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
'.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
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 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.
[](UNLICENSE)
[](https://github.com/rss-bridge/rss-bridge/releases/latest)
[](https://web.libera.chat/#rssbridge)
[](https://github.com/RSS-Bridge/rss-bridge/actions)
|||
|:-:|:-:|
|||
|||
|||
## 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
[](https://my.scalingo.com/deploy?source=https://github.com/sebsauvage/rss-bridge)
[](https://heroku.com/deploy)
[](https://www.cloudron.io/store/com.rssbridgeapp.cloudronapp.html)
[](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 = '
';
$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('/#', $content, $matches)) {
throw new \Exception('Failed to locate JS bundle tag for token extraction');
}
$jsPath = $matches[1];
$jsUrl = 'https://apps.apple.com' . $jsPath;
$this->debugLog(sprintf('Fetching JS bundle for token extraction: %s', $jsUrl));
// Fetch the JS bundle where the JWT is embedded
$jsContent = getContents($jsUrl);
// Find the JWT inside a const assignment, e.g.
// const SOME_NAME = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6.XXXX.YYYY";
// Match a const assignment that looks like a JWT
// eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6 decodes to '{"alg":"ES256","typ":"JWT","kid"'
$tokenMatches = [];
// phpcs:disable Generic.Files.LineLength
if (!preg_match('~const\s+\w+\s*=\s*[\'\"](eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)[\'\"]~', $jsContent, $tokenMatches)) {
throw new \Exception('Failed to extract JWT token from JS bundle');
}
// phpcs:enable Generic.Files.LineLength
$token = $tokenMatches[1];
$this->debugLog('Successfully extracted JWT token from JS bundle: ' . $token);
$url = $this->makeJsonUrl();
$this->debugLog(sprintf('Fetching data from API: %s', $url));
$headers = [
'accept: */*',
'Authorization: Bearer ' . $token,
'cache-control: no-cache',
'Origin: https://apps.apple.com',
'Referer: https://apps.apple.com/',
'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36w',
];
$content = getContents($url, $headers);
try {
$json = Json::decode($content);
} catch (\Exception $e) {
throw new \Exception(sprintf('Failed to parse API response: %s', $e->getMessage()));
}
if (!isset($json['data']) || empty($json['data'])) {
throw new \Exception('No app data found in API response');
}
$this->debugLog('Successfully retrieved app data from API');
return $json['data'][0];
}
private function extractAppDetails($data)
{
if (isset($data['attributes'])) {
$this->name = $data['attributes']['name'] ?? null;
$author = $data['attributes']['artistName'] ?? null;
$this->debugLog(sprintf('Found app details in attributes: %s by %s', $this->name, $author));
return [$this->name, $author];
}
// Fallback to default values if not found
$this->name = sprintf('App %s', $this->getInput('id'));
$this->debugLog(sprintf('App details not found, using default: %s', $this->name));
return [$this->name, 'Unknown Developer'];
}
private function getVersionHistory($data)
{
$platform = $this->getInput('p');
$this->debugLog(sprintf('Extracting version history for platform: %s', $platform));
// Get the mapped platform key (ios for iPhone/iPad, osx for Mac)
$platform_key = self::PLATFORM_MAPPING[$platform] ?? $platform;
$version_history = $data['attributes']['platformAttributes'][$platform_key]['versionHistory'] ?? [];
if (empty($version_history)) {
$this->debugLog(sprintf('No version history found for %s', $platform));
}
return $version_history;
}
public function collectData()
{
$this->debugLog(sprintf('Getting data for %s app', $this->getInput('p')));
$data = $this->getAppData();
// Get app name and author using array destructuring
[$name, $author] = $this->extractAppDetails($data);
// Get version history
$version_history = $this->getVersionHistory($data);
$this->debugLog(sprintf('Found %d versions for %s', count($version_history), $name));
foreach ($version_history as $entry) {
$version = $entry['versionDisplay'] ?? 'Unknown Version';
$release_notes = $entry['releaseNotes'] ?? 'No release notes available';
$release_date = $entry['releaseDate'] ?? 'Unknown Date';
$item = [];
$item['title'] = sprintf('%s - %s', $name, $version);
$item['content'] = nl2br($release_notes) ?: 'No release notes available';
$item['timestamp'] = $release_date;
$item['author'] = $author;
$item['uri'] = $this->makeHtmlUrl();
$this->items[] = $item;
}
$this->debugLog(sprintf('Successfully collected %d items', count($this->items)));
}
}
================================================
FILE: bridges/AppleMusicBridge.php
================================================
[
'name' => 'Artist ID',
'exampleValue' => '909253',
'required' => true,
],
'limit' => [
'name' => 'Latest X Releases (max 50)',
'defaultValue' => '10',
'required' => true,
],
]];
const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours
private $title;
public function collectData()
{
$items = $this->getJson();
$artist = $this->getArtist($items);
$this->title = $artist->artistName;
foreach ($items as $item) {
if ($item->wrapperType === 'collection') {
$copyright = $item->copyright ?? '';
$artworkUrl500 = str_replace('/100x100', '/500x500', $item->artworkUrl100);
$artworkUrl2000 = str_replace('/100x100', '/2000x2000', $item->artworkUrl100);
$escapedCollectionName = htmlspecialchars($item->collectionName);
$this->items[] = [
'title' => $item->collectionName,
'uri' => $item->collectionViewUrl,
'timestamp' => $item->releaseDate,
'enclosures' => $artworkUrl500,
'author' => $item->artistName,
'content' => "
artworkUrl60 60w, $item->artworkUrl100 100w, $artworkUrl500 500w, $artworkUrl2000 2000w\"
sizes=\"100%\" src=\"$artworkUrl2000\"
alt=\"Cover of $escapedCollectionName\"
style=\"display: block; margin: 0 auto;\" />
from artistLinkUrl\">$item->artistName
$copyright
",
];
}
}
}
private function getJson()
{
# Limit the amount of releases to 50
if ($this->getInput('limit') > 50) {
$limit = 50;
} else {
$limit = $this->getInput('limit');
}
$url = 'https://itunes.apple.com/lookup?id=' . $this->getInput('artist') . '&entity=album&limit=' . $limit . '&sort=recent';
$html = getContents($url);
$json = json_decode($html);
$result = $json->results;
if (!is_array($result) || count($result) == 0) {
throwServerException('There is no artist with id "' . $this->getInput('artist') . '".');
}
return $result;
}
private function getArtist($json)
{
$nameArray = array_filter($json, function ($obj) {
return $obj->wrapperType == 'artist';
});
if (count($nameArray) === 1) {
return $nameArray[0];
}
return parent::getName();
}
public function getName()
{
if (isset($this->title)) {
return $this->title;
}
return parent::getName();
}
public function getIcon()
{
if (empty($this->getInput('artist'))) {
return parent::getIcon();
}
// it isn't necessary to set the correct artist name into the url
$url = 'https://music.apple.com/us/artist/jon-bellion/' . $this->getInput('artist');
$html = getSimpleHTMLDOMCached($url);
$image = $html->find('meta[property="og:image"]', 0)->content;
$imageUpdatedSize = preg_replace('/\/\d*x\d*cw/i', '/144x144-999', $image);
return $imageUpdatedSize;
}
}
================================================
FILE: bridges/ArsTechnicaBridge.php
================================================
[
'name' => 'Site section',
'type' => 'list',
'defaultValue' => 'index',
'values' => [
'All' => 'index',
'Apple' => 'apple',
'Board Games' => 'cardboard',
'Cars' => 'cars',
'Features' => 'features',
'Gaming' => 'gaming',
'Information Technology' => 'technology-lab',
'Science' => 'science',
'Staff Blogs' => 'staff-blogs',
'Tech Policy' => 'tech-policy',
'Tech' => 'gadgets',
]
]
]];
public function collectData()
{
$url = 'https://feeds.arstechnica.com/arstechnica/' . $this->getInput('section');
$this->collectExpandableDatas($url, 10);
}
protected function parseItem(array $item)
{
$item_html = getSimpleHTMLDOMCached($item['uri']);
$item_html = defaultLinkTo($item_html, self::URI);
$content = '';
$header = $item_html->find('article header', 0);
$leading = $header->find('p[class*=leading]', 0);
if ($leading != null) {
$content .= '
' . $leading->innertext . '
';
}
$intro_image = $header->find('img.intro-image', 0);
if ($intro_image != null) {
$content .= '' . $intro_image;
$image_caption = $header->find('.caption .caption-content', 0);
if ($image_caption != null) {
$content .= '' . $image_caption->innertext . ' ';
}
$content .= ' ';
}
foreach ($item_html->find('.post-content') as $content_tag) {
$content .= $content_tag->innertext;
}
$item['content'] = str_get_html($content);
$parsely = $item_html->find('[name="parsely-page"]', 0);
$parsely_json = json_decode(html_entity_decode($parsely->content), true);
$item['categories'] = $parsely_json['tags'];
// Some lightboxes are nested in figures. I'd guess that's a
// bug in the website
foreach ($item['content']->find('figure div div.ars-lightbox') as $weird_lightbox) {
$weird_lightbox->parent->parent->outertext = $weird_lightbox;
}
// It's easier to reconstruct the whole thing than remove
// duplicate reactive tags
foreach ($item['content']->find('.ars-lightbox') as $lightbox) {
$lightbox_content = '';
foreach ($lightbox->find('.ars-lightbox-item') as $lightbox_item) {
$img = $lightbox_item->find('img', 0);
if ($img != null) {
$lightbox_content .= '' . $img;
$caption = $lightbox_item->find('div.pswp-caption-content', 0);
if ($caption != null) {
$credit = $lightbox_item->find('div.ars-gallery-caption-credit', 0);
if ($credit != null) {
$credit->innertext = 'Credit: ' . $credit->innertext;
}
$lightbox_content .= '' . $caption->innertext . ' ';
}
$lightbox_content .= ' ';
}
}
$lightbox->innertext = $lightbox_content;
}
// remove various ars advertising
foreach ($item['content']->find('.ars-interlude-container') as $ad) {
$ad->remove();
}
foreach ($item['content']->find('.toc-container') as $toc) {
$toc->remove();
}
// Mostly YouTube videos
$iframes = $item['content']->find('iframe');
foreach ($iframes as $iframe) {
$iframe->outertext = '' . $iframe->src . '';
}
// This fixed padding around the former iframes and actual inline videos
foreach ($item['content']->find('div[style*=aspect-ratio]') as $styled) {
$styled->removeAttribute('style');
}
$item['content'] = backgroundToImg($item['content']);
$item['uid'] = strval($parsely_json['post_id']);
return $item;
}
}
================================================
FILE: bridges/ArtStationBridge.php
================================================
[
'q' => [
'name' => 'Search term',
'required' => true,
'exampleValue' => 'bird'
]
]
];
public function getIcon()
{
return 'https://www.artstation.com/assets/favicon-58653022bc38c1905ac7aa1b10bffa6b.ico';
}
public function getName()
{
return self::NAME . ': ' . $this->getInput('q');
}
private function fetchSearch($searchQuery)
{
$data = '{"query":"' . $searchQuery . '","page":1,"per_page":50,"sorting":"date",';
$data .= '"pro_first":"1","filters":[],"additional_fields":[]}';
$header = [
'Content-Type: application/json',
'Accept: application/json'
];
$opts = [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $data,
CURLOPT_RETURNTRANSFER => true
];
$jsonSearchURL = self::URI . '/api/v2/search/projects.json';
$jsonSearchStr = getContents($jsonSearchURL, $header, $opts);
return json_decode($jsonSearchStr);
}
private function fetchProject($hashID)
{
$jsonProjectURL = self::URI . '/projects/' . $hashID . '.json';
$jsonProjectStr = getContents($jsonProjectURL);
return json_decode($jsonProjectStr);
}
public function collectData()
{
$searchTerm = $this->getInput('q');
$jsonQuery = $this->fetchSearch($searchTerm);
foreach ($jsonQuery->data as $media) {
// get detailed info about media item
$jsonProject = $this->fetchProject($media->hash_id);
// create item
$item = [];
$item['title'] = $media->title;
$item['uri'] = $media->url;
$item['timestamp'] = strtotime($jsonProject->published_at);
$item['author'] = $media->user->full_name;
$item['categories'] = implode(',', $jsonProject->tags);
$item['content'] = '
'
. $jsonProject->description
. '
';
$numAssets = count($jsonProject->assets);
if ($numAssets > 1) {
$item['content'] .= 'Project contains '
. ($numAssets - 1)
. ' more item(s).
';
}
$this->items[] = $item;
if (count($this->items) >= 10) {
break;
}
}
}
}
================================================
FILE: bridges/Arte7Bridge.php
================================================
[
'sort_by' => [
'type' => 'list',
'name' => 'Sort by',
'required' => false,
'defaultValue' => null,
'values' => [
'Default' => null,
'Video rights start date' => 'videoRightsBegin',
'Video rights end date' => 'videoRightsEnd',
'Brodcast date' => 'broadcastBegin',
'Creation date' => 'creationDate',
'Last modified' => 'lastModified',
'Number of views' => 'views',
'Number of views per period' => 'viewsPeriod',
'Available screens' => 'availableScreens',
'Episode' => 'episode'
],
],
'sort_direction' => [
'type' => 'list',
'name' => 'Sort direction',
'required' => false,
'defaultValue' => 'DESC',
'values' => [
'Ascending' => 'ASC',
'Descending' => 'DESC'
],
],
'exclude_trailers' => [
'name' => 'Exclude trailers',
'type' => 'checkbox',
'required' => false,
'defaultValue' => false
],
],
'Category' => [
'lang' => [
'type' => 'list',
'name' => 'Language',
'values' => [
'Français' => 'fr',
'Deutsch' => 'de',
'English' => 'en',
'Español' => 'es',
'Polski' => 'pl',
'Italiano' => 'it'
],
],
'cat' => [
'type' => 'list',
'name' => 'Category',
'values' => [
'All videos' => null,
'News & society' => 'ACT',
'Series & fiction' => 'SER',
'Cinema' => 'CIN',
'Culture' => 'ARS',
'Culture pop' => 'CPO',
'Discovery' => 'DEC',
'History' => 'HIST',
'Science' => 'SCI',
'Other' => 'AUT'
]
],
],
'Collection' => [
'lang' => [
'type' => 'list',
'name' => 'Language',
'values' => [
'Français' => 'fr',
'Deutsch' => 'de',
'English' => 'en',
'Español' => 'es',
'Polski' => 'pl',
'Italiano' => 'it'
]
],
'col' => [
'name' => 'Collection id',
'required' => true,
'title' => 'ex. RC-014095 pour https://www.arte.tv/de/videos/RC-014095/blow-up/',
'exampleValue' => 'RC-014095'
]
]
];
public function collectData()
{
switch ($this->queriedContext) {
case 'Category':
$category = $this->getInput('cat');
$collectionId = null;
break;
case 'Collection':
$collectionId = $this->getInput('col');
$category = null;
break;
}
$lang = $this->getInput('lang');
$sort_by = $this->getInput('sort_by');
$sort_direction = $this->getInput('sort_direction') == 'ASC' ? '' : '-';
$url = 'https://api.arte.tv/api/opa/v3/videos?limit=15&language='
. $lang
. ($sort_by != null ? '&sort=' . $sort_direction . $sort_by : '')
. ($category != null ? '&category.code=' . $category : '')
. ($collectionId != null ? '&collections.collectionId=' . $collectionId : '');
$header = [
'Authorization: Bearer ' . self::API_TOKEN
];
$input = getContents($url, $header);
$input_json = json_decode($input, true);
foreach ($input_json['videos'] as $element) {
if ($this->getInput('exclude_trailers') && $element['platform'] == 'EXTRAIT') {
continue;
}
$durationSeconds = $element['durationSeconds'];
$item = [];
$item['uri'] = $element['url'];
$item['id'] = $element['id'];
$item['timestamp'] = strtotime($element['videoRightsBegin']);
$item['title'] = $element['title'];
if (!empty($element['subtitle'])) {
$item['title'] = $element['title'] . ' | ' . $element['subtitle'];
}
$durationMinutes = round((int)$durationSeconds / 60);
$item['content'] = $element['teaserText']
. '
'
. $durationMinutes
. 'min
';
$item['itunes'] = [
'duration' => $durationSeconds,
];
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/AsahiShimbunAJWBridge.php
================================================
[
'type' => 'list',
'name' => 'Section',
'values' => [
'Japan » Social Affairs' => 'japan/social',
'Japan » People' => 'japan/people',
'Japan » 3/11 Disaster' => 'japan/0311disaster',
'Japan » Sci & Tech' => 'japan/sci_tech',
'Politics' => 'politics',
'Business' => 'business',
'Culture » Style' => 'culture/style',
'Culture » Movies' => 'culture/movies',
'Culture » Manga & Anime' => 'culture/manga_anime',
'Asia » China' => 'asia_world/china',
'Asia » Korean Peninsula' => 'asia_world/korean_peninsula',
'Asia » Around Asia' => 'asia_world/around_asia',
'Asia » World' => 'asia_world/world',
'Opinion » Editorial' => 'opinion/editorial',
'Opinion » Vox Populi' => 'opinion/vox',
],
'defaultValue' => 'politics',
]
]
];
private function getSectionURI($section)
{
return $this->getURI() . $section . '/';
}
public function collectData()
{
$html = getSimpleHTMLDOM($this->getSectionURI($this->getInput('section')));
foreach ($html->find('#MainInner li a') as $element) {
if ($element->parent()->class == 'HeadlineTopImage-S') {
continue;
}
$item = [];
$item['uri'] = self::BASE_URI . $element->href;
$e_lead = $element->find('span.Lead', 0);
if ($e_lead) {
$item['content'] = $e_lead->innertext;
$e_lead->outertext = '';
} else {
$item['content'] = $element->innertext;
}
$e_date = $element->find('span.EnDate', 0);
if ($e_date) {
$item['timestamp'] = strtotime($e_date->innertext);
$e_date->outertext = '';
}
$e_video = $element->find('span.EnVideo', 0);
if ($e_video) {
$e_video->outertext = '';
$element->innertext = "VIDEO: $element->innertext";
}
$e_title = $element->find('.title', 0);
if ($e_title) {
$item['title'] = $e_title->innertext;
} else {
$item['title'] = $element->innertext;
}
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/AssociatedPressNewsBridge.php
================================================
[
'topic' => [
'name' => 'Topic',
'type' => 'list',
'values' => [
'AP Top News' => 'apf-topnews',
'Sports' => 'apf-sports',
'Entertainment' => 'apf-entertainment',
'Oddities' => 'apf-oddities',
'Travel' => 'apf-Travel',
'Technology' => 'apf-technology',
'Lifestyle' => 'apf-lifestyle',
'Business' => 'apf-business',
'U.S. News' => 'apf-usnews',
'Health' => 'apf-Health',
'Science' => 'apf-science',
'World News' => 'apf-WorldNews',
'Politics' => 'apf-politics',
'Religion' => 'apf-religion',
'Photo Galleries' => 'PhotoGalleries',
'Fact Checks' => 'APFactCheck',
'Videos' => 'apf-videos',
],
'defaultValue' => 'apf-topnews',
],
],
'Custom Topic' => [
'topic' => [
'name' => 'Topic',
'type' => 'text',
'required' => true,
'exampleValue' => 'europe'
],
]
];
const CACHE_TIMEOUT = 900; // 15 mins
private $detectParamRegex = '/^https?:\/\/(?:www\.)?apnews\.com\/(?:[tag|hub]+\/)?([\w-]+)$/';
private $tagEndpoint = 'https://afs-prod.appspot.com/api/v2/feed/tag?tags=';
private $feedName = '';
public function detectParameters($url)
{
$params = [];
if (preg_match($this->detectParamRegex, $url, $matches) > 0) {
$params['topic'] = $matches[1];
$params['context'] = 'Custom Topic';
return $params;
}
return null;
}
public function collectData()
{
switch ($this->getInput('topic')) {
case 'Podcasts':
throwClientException('Podcasts topic feed is not supported');
break;
case 'PressReleases':
throwClientException('PressReleases topic feed is not supported');
break;
default:
$this->collectCardData();
}
}
public function getURI()
{
if (!is_null($this->getInput('topic'))) {
return self::URI . $this->getInput('topic');
}
return parent::getURI();
}
public function getName()
{
if (!empty($this->feedName)) {
return $this->feedName . ' - Associated Press';
}
return parent::getName();
}
private function getTagURI()
{
if (!is_null($this->getInput('topic'))) {
return $this->tagEndpoint . $this->getInput('topic');
}
return parent::getURI();
}
private function collectCardData()
{
$json = getContents($this->getTagURI());
$tagContents = json_decode($json, true);
if (empty($tagContents['tagObjs'])) {
throwClientException('Topic not found: ' . $this->getInput('topic'));
}
$this->feedName = $tagContents['tagObjs'][0]['name'];
foreach ($tagContents['cards'] as $card) {
$item = [];
// skip hub peeks & Notifications
if ($card['cardType'] == 'Hub Peek' || $card['cardType'] == 'Notification') {
continue;
}
$storyContent = $card['contents'][0];
switch ($storyContent['contentType']) {
case 'web': // Skip link only content
continue 2;
case 'video':
$html = $this->processVideo($storyContent);
$item['enclosures'][] = 'https://storage.googleapis.com/afs-prod/media/'
. $storyContent['media'][0]['id'] . '/800.jpeg';
break;
default:
if (empty($storyContent['storyHTML'])) { // Skip if no storyHTML
continue 2;
}
$html = defaultLinkTo($storyContent['storyHTML'], self::URI);
$html = str_get_html($html);
$this->processMediaPlaceholders($html, $storyContent['id']);
$this->processHubLinks($html, $storyContent);
$this->processIframes($html);
if (!is_null($storyContent['leadPhotoId'])) {
$leadPhotoUrl = sprintf('https://storage.googleapis.com/afs-prod/media/%s/800.jpeg', $storyContent['leadPhotoId']);
$leadPhotoImageTag = sprintf('
', $leadPhotoUrl);
// Move the image to the beginning of the content
$html = $leadPhotoImageTag . $html;
// Explicitly not adding it to the item's enclosures!
}
}
$item['title'] = $card['contents'][0]['headline'];
$item['uri'] = self::URI . $card['shortId'];
if ($card['contents'][0]['localLinkUrl']) {
$item['uri'] = $card['contents'][0]['localLinkUrl'];
}
$item['timestamp'] = $storyContent['published'];
if (is_null($storyContent['bylines']) === false) {
// Remove 'By' from the bylines
if (substr($storyContent['bylines'], 0, 2) == 'By') {
$item['author'] = ltrim($storyContent['bylines'], 'By ');
} else {
$item['author'] = $storyContent['bylines'];
}
}
$item['content'] = $html;
foreach ($storyContent['tagObjs'] as $tag) {
$item['categories'][] = $tag['name'];
}
$this->items[] = $item;
if (count($this->items) >= 15) {
break;
}
}
}
private function processMediaPlaceholders($html, $id)
{
if ($html->find('div.media-placeholder', 0)) {
// Fetch page content
$json = getContents('https://afs-prod.appspot.com/api/v2/content/' . $id);
$storyContent = json_decode($json, true);
foreach ($html->find('div.media-placeholder') as $div) {
$key = array_search($div->id, $storyContent['mediumIds']);
if (!isset($storyContent['media'][$key])) {
continue;
}
$media = $storyContent['media'][$key];
if ($media['type'] === 'Photo') {
$mediaUrl = $media['gcsBaseUrl'] . $media['imageRenderedSizes'][0] . $media['imageFileExtension'];
$mediaCaption = $media['caption'];
$div->outertext = <<
{$mediaCaption}
EOD;
}
if ($media['type'] === 'YouTube') {
$div->outertext = handleYoutube($media['externalId']);
}
}
}
}
/*
Create full coverage links (HubLinks)
*/
private function processHubLinks($html, $storyContent)
{
if (!empty($storyContent['richEmbeds'])) {
foreach ($storyContent['richEmbeds'] as $embed) {
if ($embed['type'] === 'Hub Link') {
$url = self::URI . $embed['tag']['id'];
$div = $html->find('div[id=' . $embed['id'] . ']', 0);
if ($div) {
$div->outertext = <<{$embed['calloutText']} {$embed['displayName']}
EOD;
}
}
}
}
}
private function processVideo($storyContent)
{
$video = $storyContent['media'][0];
if ($video['type'] === 'YouTube') {
$html = handleYoutube($video['externalId']);
} else {
$html = <<
EOD;
}
return $html;
}
// Remove datawrapper.dwcdn.net iframes and related javaScript
private function processIframes($html)
{
foreach ($html->find('iframe') as $index => $iframe) {
if (preg_match('/datawrapper\.dwcdn\.net/', $iframe->src)) {
$iframe->outertext = '';
if ($html->find('script', $index)) {
$html->find('script', $index)->outertext = '';
}
}
}
}
}
================================================
FILE: bridges/AstrophysicsDataSystemBridge.php
================================================
[
'query' => [
'name' => 'query',
'title' => 'Same format as the search bar on the website',
'exampleValue' => 'author:"huchra, john"',
'required' => true
]
]];
private $feedTitle;
public function getName()
{
if ($this->queriedContext === 'Publications') {
return $this->feedTitle;
}
return parent::getName();
}
public function getURI()
{
if ($this->queriedContext === 'Publications') {
return self::URI . '/search/?q=' . urlencode($this->getInput('query'));
}
return parent::getURI();
}
public function collectData()
{
$headers = [
'Cookie: core=always;'
];
$html = str_get_html(defaultLinkTo(getContents($this->getURI(), $headers), self::URI));
$this->feedTitle = html_entity_decode($html->find('title', 0)->plaintext);
foreach ($html->find('div.row > ul > li') as $pub) {
$item = [];
$item['title'] = $pub->find('h3.s-results-title', 0)->plaintext;
$item['content'] = $pub->find('div.s-results-links', 0);
$item['uri'] = $pub->find('a.abs-redirect-link', 0)->href;
$item['author'] = rtrim($pub->find('li.article-author', 0)->plaintext, ' ;');
$item['timestamp'] = $pub->find('div[aria-label="date published"]', 0)->plaintext;
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/AtmoNouvelleAquitaineBridge.php
================================================
[
'name' => 'Choisir une ville',
'type' => 'list',
'values' => self::CITIES
]
]];
const CACHE_TIMEOUT = 7200;
private $dom;
private function getClosest($search, $arr)
{
$closest = null;
foreach ($arr as $key => $value) {
if ($closest === null || abs((int)$search - $closest) > abs((int)$key - (int)$search)) {
$closest = (int)$key;
}
}
return $arr[$closest];
}
public function collectData()
{
// this bridge is broken and unmaintained
return;
$uri = self::URI . '/monair/commune/' . $this->getInput('cities');
$html = getSimpleHTMLDOM($uri);
$this->dom = $html->find('#block-system-main .city-prevision-map', 0);
$message = $this->getIndexMessage() . ' ' . $this->getQualityMessage();
$message .= ' ' . $this->getTomorrowTrendIndexMessage() . ' ' . $this->getTomorrowTrendQualityMessage();
$item['uri'] = $uri;
$today = date('d/m/Y');
$item['title'] = "Bulletin de l'air du $today pour la région Nouvelle Aquitaine.";
$item['title'] .= ' Retrouvez plus d\'informations en allant sur atmo-nouvelleaquitaine.org #QualiteAir.';
$item['author'] = 'floviolleau';
$item['content'] = $message;
$item['uid'] = hash('sha256', $item['title']);
$this->items[] = $item;
}
private function getIndex()
{
$index = $this->dom->find('.indice', 0)->innertext;
if ($index == 'XX') {
return -1;
}
return $index;
}
private function getMaxIndexText()
{
// will return '/100'
return $this->dom->find('.pourcent', 0)->innertext;
}
private function getQualityText($index, $indexes)
{
if ($index == -1) {
if (array_key_exists('no-available', $indexes)) {
return $indexes['no-available'];
}
return 'Aucune donnée';
}
return $this->getClosest($index, $indexes);
}
private function getLegendIndexes()
{
$rawIndexes = $this->dom->find('.prevision-legend .prevision-legend-label');
$indexes = [];
for ($i = 0; $i < count($rawIndexes); $i++) {
if ($rawIndexes[$i]->hasAttribute('data-color')) {
$indexes[$rawIndexes[$i]->getAttribute('data-color')] = $rawIndexes[$i]->innertext;
}
}
return $indexes;
}
private function getTomorrowTrendIndex()
{
$tomorrowTrendDomNode = $this->dom
->find('.day-controls.raster-controls .list-raster-controls .raster-control', 2);
$tomorrowTrendIndexNode = null;
if ($tomorrowTrendDomNode) {
$tomorrowTrendIndexNode = $tomorrowTrendDomNode->find('.raster-control-link', 0);
}
if ($tomorrowTrendIndexNode && $tomorrowTrendIndexNode->hasAttribute('data-index')) {
$tomorrowTrendIndex = $tomorrowTrendIndexNode->getAttribute('data-index');
} else {
return -1;
}
return $tomorrowTrendIndex;
}
private function getTomorrowTrendQualityText($trendIndex, $indexes)
{
if ($trendIndex == -1) {
if (array_key_exists('no-available', $indexes)) {
return $indexes['no-available'];
}
return 'Aucune donnée';
}
return $this->getClosest($trendIndex, $indexes);
}
private function getIndexMessage()
{
$index = $this->getIndex();
$maxIndexText = $this->getMaxIndexText();
if ($index == -1) {
return 'Aucune donnée pour l\'indice.';
}
return "L'indice d'aujourd'hui est $index$maxIndexText.";
}
private function getQualityMessage()
{
$index = $index = $this->getIndex();
$indexes = $this->getLegendIndexes();
$quality = $this->getQualityText($index, $indexes);
if ($index == -1) {
return 'Aucune donnée pour la qualité de l\'air.';
}
return "La qualité de l'air est $quality.";
}
private function getTomorrowTrendIndexMessage()
{
$trendIndex = $this->getTomorrowTrendIndex();
$maxIndexText = $this->getMaxIndexText();
if ($trendIndex == -1) {
return 'Aucune donnée pour l\'indice prévu demain.';
}
return "L'indice prévu pour demain est $trendIndex$maxIndexText.";
}
private function getTomorrowTrendQualityMessage()
{
$trendIndex = $this->getTomorrowTrendIndex();
$indexes = $this->getLegendIndexes();
$trendQuality = $this->getTomorrowTrendQualityText($trendIndex, $indexes);
if ($trendIndex == -1) {
return 'Aucune donnée pour la qualité de l\'air de demain.';
}
return "La qualite de l'air pour demain sera $trendQuality.";
}
const CITIES = [
'Aast (64460)' => '64001',
'Abère (64160)' => '64002',
'Abidos (64150)' => '64003',
'Abitain (64390)' => '64004',
'Abjat-sur-Bandiat (24300)' => '24001',
'Abos (64360)' => '64005',
'Abzac (16500)' => '16001',
'Abzac (33230)' => '33001',
'Accous (64490)' => '64006',
'Adilly (79200)' => '79002',
'Adriers (86430)' => '86001',
'Affieux (19260)' => '19001',
'Agen (47000)' => '47001',
'Agmé (47350)' => '47002',
'Agnac (47800)' => '47003',
'Agnos (64400)' => '64007',
'Agonac (24460)' => '24002',
'Agris (16110)' => '16003',
'Agudelle (17500)' => '17002',
'Ahaxe-Alciette-Bascassan (64220)' => '64008',
'Ahetze (64210)' => '64009',
'Ahun (23150)' => '23001',
'Aïcirits-Camou-Suhast (64120)' => '64010',
'Aiffres (79230)' => '79003',
'Aignes-et-Puypéroux (16190)' => '16004',
'Aigonnay (79370)' => '79004',
'Aigre (16140)' => '16005',
'Aigrefeuille-d\'Aunis (17290)' => '17003',
'Aiguillon (47190)' => '47004',
'Aillas (33124)' => '33002',
'Aincille (64220)' => '64011',
'Ainharp (64130)' => '64012',
'Ainhice-Mongelos (64220)' => '64013',
'Ainhoa (64250)' => '64014',
'Aire-sur-l\'Adour (40800)' => '40001',
'Airvault (79600)' => '79005',
'Aix (19200)' => '19002',
'Aixe-sur-Vienne (87700)' => '87001',
'Ajain (23380)' => '23002',
'Ajat (24210)' => '24004',
'Albignac (19190)' => '19003',
'Albussac (19380)' => '19004',
'Alçay-Alçabéhéty-Sunharette (64470)' => '64015',
'Aldudes (64430)' => '64016',
'Allas-Bocage (17150)' => '17005',
'Allas-Champagne (17500)' => '17006',
'Allas-les-Mines (24220)' => '24006',
'Allassac (19240)' => '19005',
'Allemans (24600)' => '24007',
'Allemans-du-Dropt (47800)' => '47005',
'Alles-sur-Dordogne (24480)' => '24005',
'Alleyrat (19200)' => '19006',
'Alleyrat (23200)' => '23003',
'Allez-et-Cazeneuve (47110)' => '47006',
'Allonne (79130)' => '79007',
'Allons (47420)' => '47007',
'Alloue (16490)' => '16007',
'Alos-Sibas-Abense (64470)' => '64017',
'Altillac (19120)' => '19007',
'Amailloux (79350)' => '79008',
'Ambarès-et-Lagrave (33440)' => '33003',
'Ambazac (87240)' => '87002',
'Ambérac (16140)' => '16008',
'Ambernac (16490)' => '16009',
'Amberre (86110)' => '86002',
'Ambès (33810)' => '33004',
'Ambleville (16300)' => '16010',
'Ambrugeat (19250)' => '19008',
'Ambrus (47160)' => '47008',
'Amendeuix-Oneix (64120)' => '64018',
'Amorots-Succos (64120)' => '64019',
'Amou (40330)' => '40002',
'Amuré (79210)' => '79009',
'Anais (16560)' => '16011',
'Anais (17540)' => '17007',
'Ance (64570)' => '64020',
'Anché (86700)' => '86003',
'Andernos-les-Bains (33510)' => '33005',
'Andilly (17230)' => '17008',
'Andiran (47170)' => '47009',
'Andoins (64420)' => '64021',
'Andrein (64390)' => '64022',
'Angaïs (64510)' => '64023',
'Angeac-Champagne (16130)' => '16012',
'Angeac-Charente (16120)' => '16013',
'Angeduc (16300)' => '16014',
'Anglade (33390)' => '33006',
'Angles-sur-l\'Anglin (86260)' => '86004',
'Anglet (64600)' => '64024',
'Angliers (17540)' => '17009',
'Angliers (86330)' => '86005',
'Angoisse (24270)' => '24008',
'Angoulême (16000)' => '16015',
'Angoulins (17690)' => '17010',
'Angoumé (40990)' => '40003',
'Angous (64190)' => '64025',
'Angresse (40150)' => '40004',
'Anhaux (64220)' => '64026',
'Anlhiac (24160)' => '24009',
'Annepont (17350)' => '17011',
'Annesse-et-Beaulieu (24430)' => '24010',
'Annezay (17380)' => '17012',
'Anos (64160)' => '64027',
'Anoye (64350)' => '64028',
'Ansac-sur-Vienne (16500)' => '16016',
'Antagnac (47700)' => '47010',
'Antezant-la-Chapelle (17400)' => '17013',
'Anthé (47370)' => '47011',
'Antigny (86310)' => '86006',
'Antonne-et-Trigonant (24420)' => '24011',
'Antran (86100)' => '86007',
'Anville (16170)' => '16017',
'Anzême (23000)' => '23004',
'Anzex (47700)' => '47012',
'Aramits (64570)' => '64029',
'Arancou (64270)' => '64031',
'Araujuzon (64190)' => '64032',
'Araux (64190)' => '64033',
'Arbanats (33640)' => '33007',
'Arbérats-Sillègue (64120)' => '64034',
'Arbis (33760)' => '33008',
'Arbonne (64210)' => '64035',
'Arboucave (40320)' => '40005',
'Arbouet-Sussaute (64120)' => '64036',
'Arbus (64230)' => '64037',
'Arcachon (33120)' => '33009',
'Arçais (79210)' => '79010',
'Arcangues (64200)' => '64038',
'Arçay (86200)' => '86008',
'Arces (17120)' => '17015',
'Archiac (17520)' => '17016',
'Archignac (24590)' => '24012',
'Archigny (86210)' => '86009',
'Archingeay (17380)' => '17017',
'Arcins (33460)' => '33010',
'Ardilleux (79110)' => '79011',
'Ardillières (17290)' => '17018',
'Ardin (79160)' => '79012',
'Aren (64400)' => '64039',
'Arengosse (40110)' => '40006',
'Arès (33740)' => '33011',
'Aressy (64320)' => '64041',
'Arette (64570)' => '64040',
'Arfeuille-Châtain (23700)' => '23005',
'Argagnon (64300)' => '64042',
'Argelos (40700)' => '40007',
'Argelos (64450)' => '64043',
'Argelouse (40430)' => '40008',
'Argentat (19400)' => '19010',
'Argenton (47250)' => '47013',
'Argenton-l\'Église (79290)' => '79014',
'Argentonnay (79150)' => '79013',
'Arget (64410)' => '64044',
'Arhansus (64120)' => '64045',
'Arjuzanx (40110)' => '40009',
'Armendarits (64640)' => '64046',
'Armillac (47800)' => '47014',
'Arnac-la-Poste (87160)' => '87003',
'Arnac-Pompadour (19230)' => '19011',
'Arnéguy (64220)' => '64047',
'Arnos (64370)' => '64048',
'Aroue-Ithorots-Olhaïby (64120)' => '64049',
'Arrast-Larrebieu (64130)' => '64050',
'Arraute-Charritte (64120)' => '64051',
'Arrènes (23210)' => '23006',
'Arricau-Bordes (64350)' => '64052',
'Arrien (64420)' => '64053',
'Arros-de-Nay (64800)' => '64054',
'Arrosès (64350)' => '64056',
'Ars (16130)' => '16018',
'Ars (23480)' => '23007',
'Ars-en-Ré (17590)' => '17019',
'Arsac (33460)' => '33012',
'Arsague (40330)' => '40011',
'Artassenx (40090)' => '40012',
'Arthenac (17520)' => '17020',
'Arthez-d\'Armagnac (40190)' => '40013',
'Arthez-d\'Asson (64800)' => '64058',
'Arthez-de-Béarn (64370)' => '64057',
'Artigueloutan (64420)' => '64059',
'Artiguelouve (64230)' => '64060',
'Artigues-près-Bordeaux (33370)' => '33013',
'Artix (64170)' => '64061',
'Arudy (64260)' => '64062',
'Arue (40120)' => '40014',
'Arvert (17530)' => '17021',
'Arveyres (33500)' => '33015',
'Arx (40310)' => '40015',
'Arzacq-Arraziguet (64410)' => '64063',
'Asasp-Arros (64660)' => '64064',
'Ascain (64310)' => '64065',
'Ascarat (64220)' => '64066',
'Aslonnes (86340)' => '86010',
'Asnières-en-Poitou (79170)' => '79015',
'Asnières-la-Giraud (17400)' => '17022',
'Asnières-sur-Blour (86430)' => '86011',
'Asnières-sur-Nouère (16290)' => '16019',
'Asnois (86250)' => '86012',
'Asques (33240)' => '33016',
'Assais-les-Jumeaux (79600)' => '79016',
'Assat (64510)' => '64067',
'Asson (64800)' => '64068',
'Astaffort (47220)' => '47015',
'Astaillac (19120)' => '19012',
'Aste-Béon (64260)' => '64069',
'Astis (64450)' => '64070',
'Athos-Aspis (64390)' => '64071',
'Aubagnan (40700)' => '40016',
'Aubas (24290)' => '24014',
'Aubazines (19190)' => '19013',
'Aubertin (64290)' => '64072',
'Aubeterre-sur-Dronne (16390)' => '16020',
'Aubiac (33430)' => '33017',
'Aubiac (47310)' => '47016',
'Aubigné (79110)' => '79018',
'Aubigny (79390)' => '79019',
'Aubin (64230)' => '64073',
'Aubous (64330)' => '64074',
'Aubusson (23200)' => '23008',
'Audaux (64190)' => '64075',
'Audenge (33980)' => '33019',
'Audignon (40500)' => '40017',
'Audon (40400)' => '40018',
'Audrix (24260)' => '24015',
'Auga (64450)' => '64077',
'Auge (23170)' => '23009',
'Augé (79400)' => '79020',
'Auge-Saint-Médard (16170)' => '16339',
'Augères (23210)' => '23010',
'Augignac (24300)' => '24016',
'Augne (87120)' => '87004',
'Aujac (17770)' => '17023',
'Aulnay (17470)' => '17024',
'Aulnay (86330)' => '86013',
'Aulon (23210)' => '23011',
'Aumagne (17770)' => '17025',
'Aunac (16460)' => '16023',
'Auradou (47140)' => '47017',
'Aureil (87220)' => '87005',
'Aureilhan (40200)' => '40019',
'Auriac (19220)' => '19014',
'Auriac (64450)' => '64078',
'Auriac-du-Périgord (24290)' => '24018',
'Auriac-sur-Dropt (47120)' => '47018',
'Auriat (23400)' => '23012',
'Aurice (40500)' => '40020',
'Auriolles (33790)' => '33020',
'Aurions-Idernes (64350)' => '64079',
'Auros (33124)' => '33021',
'Aussac-Vadalle (16560)' => '16024',
'Aussevielle (64230)' => '64080',
'Aussurucq (64130)' => '64081',
'Auterrive (64270)' => '64082',
'Autevielle-Saint-Martin-Bideren (64390)' => '64083',
'Authon-Ébéon (17770)' => '17026',
'Auzances (23700)' => '23013',
'Availles-en-Châtellerault (86530)' => '86014',
'Availles-Limouzine (86460)' => '86015',
'Availles-Thouarsais (79600)' => '79022',
'Avanton (86170)' => '86016',
'Avensan (33480)' => '33022',
'Avon (79800)' => '79023',
'Avy (17800)' => '17027',
'Aydie (64330)' => '64084',
'Aydius (64490)' => '64085',
'Ayen (19310)' => '19015',
'Ayguemorte-les-Graves (33640)' => '33023',
'Ayherre (64240)' => '64086',
'Ayron (86190)' => '86017',
'Aytré (17440)' => '17028',
'Azat-Châtenet (23210)' => '23014',
'Azat-le-Ris (87360)' => '87006',
'Azay-le-Brûlé (79400)' => '79024',
'Azay-sur-Thouet (79130)' => '79025',
'Azerables (23160)' => '23015',
'Azerat (24210)' => '24019',
'Azur (40140)' => '40021',
'Badefols-d\'Ans (24390)' => '24021',
'Badefols-sur-Dordogne (24150)' => '24022',
'Bagas (33190)' => '33024',
'Bagnizeau (17160)' => '17029',
'Bahus-Soubiran (40320)' => '40022',
'Baigneaux (33760)' => '33025',
'Baignes-Sainte-Radegonde (16360)' => '16025',
'Baigts (40380)' => '40023',
'Baigts-de-Béarn (64300)' => '64087',
'Bajamont (47480)' => '47019',
'Balansun (64300)' => '64088',
'Balanzac (17600)' => '17030',
'Baleix (64460)' => '64089',
'Baleyssagues (47120)' => '47020',
'Baliracq-Maumusson (64330)' => '64090',
'Baliros (64510)' => '64091',
'Balizac (33730)' => '33026',
'Ballans (17160)' => '17031',
'Balledent (87290)' => '87007',
'Ballon (17290)' => '17032',
'Balzac (16430)' => '16026',
'Banca (64430)' => '64092',
'Baneuil (24150)' => '24023',
'Banize (23120)' => '23016',
'Banos (40500)' => '40024',
'Bar (19800)' => '19016',
'Barbaste (47230)' => '47021',
'Barbezières (16140)' => '16027',
'Barbezieux-Saint-Hilaire (16300)' => '16028',
'Barcus (64130)' => '64093',
'Bardenac (16210)' => '16029',
'Bardos (64520)' => '64094',
'Bardou (24560)' => '24024',
'Barie (33190)' => '33027',
'Barinque (64160)' => '64095',
'Baron (33750)' => '33028',
'Barraute-Camu (64390)' => '64096',
'Barret (16300)' => '16030',
'Barro (16700)' => '16031',
'Bars (24210)' => '24025',
'Barsac (33720)' => '33030',
'Barzan (17120)' => '17034',
'Barzun (64530)' => '64097',
'Bas-Mauco (40500)' => '40026',
'Bascons (40090)' => '40025',
'Bassac (16120)' => '16032',
'Bassanne (33190)' => '33031',
'Bassens (33530)' => '33032',
'Bassercles (40700)' => '40027',
'Basses (86200)' => '86018',
'Bassignac-le-Bas (19430)' => '19017',
'Bassignac-le-Haut (19220)' => '19018',
'Bassillac (24330)' => '24026',
'Bassillon-Vauzé (64350)' => '64098',
'Bassussarry (64200)' => '64100',
'Bastanès (64190)' => '64099',
'Bastennes (40360)' => '40028',
'Basville (23260)' => '23017',
'Bats (40320)' => '40029',
'Baudignan (40310)' => '40030',
'Baudreix (64800)' => '64101',
'Baurech (33880)' => '33033',
'Bayac (24150)' => '24027',
'Bayas (33230)' => '33034',
'Bayers (16460)' => '16033',
'Bayon-sur-Gironde (33710)' => '33035',
'Bayonne (64100)' => '64102',
'Bazac (16210)' => '16034',
'Bazas (33430)' => '33036',
'Bazauges (17490)' => '17035',
'Bazelat (23160)' => '23018',
'Bazens (47130)' => '47022',
'Beaugas (47290)' => '47023',
'Beaugeay (17620)' => '17036',
'Beaulieu-sous-Parthenay (79420)' => '79029',
'Beaulieu-sur-Dordogne (19120)' => '19019',
'Beaulieu-sur-Sonnette (16450)' => '16035',
'Beaumont (19390)' => '19020',
'Beaumont (86490)' => '86019',
'Beaumont-du-Lac (87120)' => '87009',
'Beaumontois en Périgord (24440)' => '24028',
'Beaupouyet (24400)' => '24029',
'Beaupuy (47200)' => '47024',
'Beauregard-de-Terrasson (24120)' => '24030',
'Beauregard-et-Bassac (24140)' => '24031',
'Beauronne (24400)' => '24032',
'Beaussac (24340)' => '24033',
'Beaussais-Vitré (79370)' => '79030',
'Beautiran (33640)' => '33037',
'Beauvais-sur-Matha (17490)' => '17037',
'Beauville (47470)' => '47025',
'Beauvoir-sur-Niort (79360)' => '79031',
'Beauziac (47700)' => '47026',
'Béceleuf (79160)' => '79032',
'Bécheresse (16250)' => '16036',
'Bédeille (64460)' => '64103',
'Bedenac (17210)' => '17038',
'Bedous (64490)' => '64104',
'Bégaar (40400)' => '40031',
'Bégadan (33340)' => '33038',
'Bègles (33130)' => '33039',
'Béguey (33410)' => '33040',
'Béguios (64120)' => '64105',
'Béhasque-Lapiste (64120)' => '64106',
'Béhorléguy (64220)' => '64107',
'Beissat (23260)' => '23019',
'Beleymas (24140)' => '24034',
'Belhade (40410)' => '40032',
'Belin-Béliet (33830)' => '33042',
'Bélis (40120)' => '40033',
'Bellac (87300)' => '87011',
'Bellebat (33760)' => '33043',
'Bellechassagne (19290)' => '19021',
'Bellefond (33760)' => '33044',
'Bellefonds (86210)' => '86020',
'Bellegarde-en-Marche (23190)' => '23020',
'Belleville (79360)' => '79033',
'Bellocq (64270)' => '64108',
'Bellon (16210)' => '16037',
'Belluire (17800)' => '17039',
'Bélus (40300)' => '40034',
'Belvès-de-Castillon (33350)' => '33045',
'Benassay (86470)' => '86021',
'Benayes (19510)' => '19022',
'Bénéjacq (64800)' => '64109',
'Bénesse-lès-Dax (40180)' => '40035',
'Bénesse-Maremne (40230)' => '40036',
'Benest (16350)' => '16038',
'Bénévent-l\'Abbaye (23210)' => '23021',
'Benon (17170)' => '17041',
'Benquet (40280)' => '40037',
'Bentayou-Sérée (64460)' => '64111',
'Béost (64440)' => '64110',
'Berbiguières (24220)' => '24036',
'Bercloux (17770)' => '17042',
'Bérenx (64300)' => '64112',
'Bergerac (24100)' => '24037',
'Bergouey (40250)' => '40038',
'Bergouey-Viellenave (64270)' => '64113',
'Bernac (16700)' => '16039',
'Bernadets (64160)' => '64114',
'Bernay-Saint-Martin (17330)' => '17043',
'Berneuil (16480)' => '16040',
'Berneuil (17460)' => '17044',
'Berneuil (87300)' => '87012',
'Bernos-Beaulac (33430)' => '33046',
'Berrie (86120)' => '86022',
'Berrogain-Laruns (64130)' => '64115',
'Bersac-sur-Rivalier (87370)' => '87013',
'Berson (33390)' => '33047',
'Berthegon (86420)' => '86023',
'Berthez (33124)' => '33048',
'Bertric-Burée (24320)' => '24038',
'Béruges (86190)' => '86024',
'Bescat (64260)' => '64116',
'Bésingrand (64150)' => '64117',
'Bessac (16250)' => '16041',
'Bessé (16140)' => '16042',
'Besse (24550)' => '24039',
'Bessines (79000)' => '79034',
'Bessines-sur-Gartempe (87250)' => '87014',
'Betbezer-d\'Armagnac (40240)' => '40039',
'Bétête (23270)' => '23022',
'Béthines (86310)' => '86025',
'Bétracq (64350)' => '64118',
'Beurlay (17250)' => '17045',
'Beuste (64800)' => '64119',
'Beuxes (86120)' => '86026',
'Beychac-et-Caillau (33750)' => '33049',
'Beylongue (40370)' => '40040',
'Beynac (87700)' => '87015',
'Beynac-et-Cazenac (24220)' => '24040',
'Beynat (19190)' => '19023',
'Beyrie-en-Béarn (64230)' => '64121',
'Beyrie-sur-Joyeuse (64120)' => '64120',
'Beyries (40700)' => '40041',
'Beyssac (19230)' => '19024',
'Beyssenac (19230)' => '19025',
'Bézenac (24220)' => '24041',
'Biard (86580)' => '86027',
'Biarritz (64200)' => '64122',
'Biarrotte (40390)' => '40042',
'Bias (40170)' => '40043',
'Bias (47300)' => '47027',
'Biaudos (40390)' => '40044',
'Bidache (64520)' => '64123',
'Bidarray (64780)' => '64124',
'Bidart (64210)' => '64125',
'Bidos (64400)' => '64126',
'Bielle (64260)' => '64127',
'Bieujac (33210)' => '33050',
'Biganos (33380)' => '33051',
'Bignay (17400)' => '17046',
'Bignoux (86800)' => '86028',
'Bilhac (19120)' => '19026',
'Bilhères (64260)' => '64128',
'Billère (64140)' => '64129',
'Bioussac (16700)' => '16044',
'Birac (16120)' => '16045',
'Birac (33430)' => '33053',
'Birac-sur-Trec (47200)' => '47028',
'Biras (24310)' => '24042',
'Biriatou (64700)' => '64130',
'Biron (17800)' => '17047',
'Biron (24540)' => '24043',
'Biron (64300)' => '64131',
'Biscarrosse (40600)' => '40046',
'Bizanos (64320)' => '64132',
'Blaignac (33190)' => '33054',
'Blaignan (33340)' => '33055',
'Blanquefort (33290)' => '33056',
'Blanquefort-sur-Briolance (47500)' => '47029',
'Blanzac (87300)' => '87017',
'Blanzac-lès-Matha (17160)' => '17048',
'Blanzac-Porcheresse (16250)' => '16046',
'Blanzaguet-Saint-Cybard (16320)' => '16047',
'Blanzay (86400)' => '86029',
'Blanzay-sur-Boutonne (17470)' => '17049',
'Blasimon (33540)' => '33057',
'Blaslay (86170)' => '86030',
'Blaudeix (23140)' => '23023',
'Blaye (33390)' => '33058',
'Blaymont (47470)' => '47030',
'Blésignac (33670)' => '33059',
'Blessac (23200)' => '23024',
'Blis-et-Born (24330)' => '24044',
'Blond (87300)' => '87018',
'Boé (47550)' => '47031',
'Boeil-Bezing (64510)' => '64133',
'Bois (17240)' => '17050',
'Boisbreteau (16480)' => '16048',
'Boismé (79300)' => '79038',
'Boisné-La Tude (16320)' => '16082',
'Boisredon (17150)' => '17052',
'Boisse (24560)' => '24045',
'Boisserolles (79360)' => '79039',
'Boisseuil (87220)' => '87019',
'Boisseuilh (24390)' => '24046',
'Bommes (33210)' => '33060',
'Bon-Encontre (47240)' => '47032',
'Bonloc (64240)' => '64134',
'Bonnac-la-Côte (87270)' => '87020',
'Bonnat (23220)' => '23025',
'Bonnefond (19170)' => '19027',
'Bonnegarde (40330)' => '40047',
'Bonnes (16390)' => '16049',
'Bonnes (86300)' => '86031',
'Bonnetan (33370)' => '33061',
'Bonneuil (16120)' => '16050',
'Bonneuil-Matours (86210)' => '86032',
'Bonneville (16170)' => '16051',
'Bonneville-et-Saint-Avit-de-Fumadières (24230)' => '24048',
'Bonnut (64300)' => '64135',
'Bonzac (33910)' => '33062',
'Boos (40370)' => '40048',
'Borce (64490)' => '64136',
'Bord-Saint-Georges (23230)' => '23026',
'Bordeaux (33000)' => '33063',
'Bordères (64800)' => '64137',
'Bordères-et-Lamensans (40270)' => '40049',
'Bordes (64510)' => '64138',
'Bords (17430)' => '17053',
'Boresse-et-Martron (17270)' => '17054',
'Borrèze (24590)' => '24050',
'Bors (Canton de Baignes-Sainte-Radegonde) (16360)' => '16053',
'Bors (Canton de Montmoreau-Saint-Cybard) (16190)' => '16052',
'Bort-les-Orgues (19110)' => '19028',
'Boscamnant (17360)' => '17055',
'Bosdarros (64290)' => '64139',
'Bosmie-l\'Aiguille (87110)' => '87021',
'Bosmoreau-les-Mines (23400)' => '23027',
'Bosroger (23200)' => '23028',
'Bosset (24130)' => '24051',
'Bossugan (33350)' => '33064',
'Bostens (40090)' => '40050',
'Boucau (64340)' => '64140',
'Boudy-de-Beauregard (47290)' => '47033',
'Boueilh-Boueilho-Lasque (64330)' => '64141',
'Bouëx (16410)' => '16055',
'Bougarber (64230)' => '64142',
'Bouglon (47250)' => '47034',
'Bougneau (17800)' => '17056',
'Bougon (79800)' => '79042',
'Bougue (40090)' => '40051',
'Bouhet (17540)' => '17057',
'Bouillac (24480)' => '24052',
'Bouillé-Loretz (79290)' => '79043',
'Bouillé-Saint-Paul (79290)' => '79044',
'Bouillon (64410)' => '64143',
'Bouin (79110)' => '79045',
'Boulazac Isle Manoire (24750)' => '24053',
'Bouliac (33270)' => '33065',
'Boumourt (64370)' => '64144',
'Bouniagues (24560)' => '24054',
'Bourcefranc-le-Chapus (17560)' => '17058',
'Bourdalat (40190)' => '40052',
'Bourdeilles (24310)' => '24055',
'Bourdelles (33190)' => '33066',
'Bourdettes (64800)' => '64145',
'Bouresse (86410)' => '86034',
'Bourg (33710)' => '33067',
'Bourg-Archambault (86390)' => '86035',
'Bourg-Charente (16200)' => '16056',
'Bourg-des-Maisons (24320)' => '24057',
'Bourg-du-Bost (24600)' => '24058',
'Bourganeuf (23400)' => '23030',
'Bourgnac (24400)' => '24059',
'Bourgneuf (17220)' => '17059',
'Bourgougnague (47410)' => '47035',
'Bourideys (33113)' => '33068',
'Bourlens (47370)' => '47036',
'Bournand (86120)' => '86036',
'Bournel (47210)' => '47037',
'Bourniquel (24150)' => '24060',
'Bournos (64450)' => '64146',
'Bourran (47320)' => '47038',
'Bourriot-Bergonce (40120)' => '40053',
'Bourrou (24110)' => '24061',
'Boussac (23600)' => '23031',
'Boussac-Bourg (23600)' => '23032',
'Boussais (79600)' => '79047',
'Boussès (47420)' => '47039',
'Bouteilles-Saint-Sébastien (24320)' => '24062',
'Boutenac-Touvent (17120)' => '17060',
'Bouteville (16120)' => '16057',
'Boutiers-Saint-Trojan (16100)' => '16058',
'Bouzic (24250)' => '24063',
'Brach (33480)' => '33070',
'Bran (17210)' => '17061',
'Branceilles (19500)' => '19029',
'Branne (33420)' => '33071',
'Brannens (33124)' => '33072',
'Brantôme en Périgord (24310)' => '24064',
'Brassempouy (40330)' => '40054',
'Braud-et-Saint-Louis (33820)' => '33073',
'Brax (47310)' => '47040',
'Bresdon (17490)' => '17062',
'Bressuire (79300)' => '79049',
'Bretagne-de-Marsan (40280)' => '40055',
'Bretignolles (79140)' => '79050',
'Brettes (16240)' => '16059',
'Breuil-la-Réorte (17700)' => '17063',
'Breuil-Magné (17870)' => '17065',
'Breuilaufa (87300)' => '87022',
'Breuilh (24380)' => '24065',
'Breuillet (17920)' => '17064',
'Bréville (16370)' => '16060',
'Brie (16590)' => '16061',
'Brie (79100)' => '79054',
'Brie-sous-Archiac (17520)' => '17066',
'Brie-sous-Barbezieux (16300)' => '16062',
'Brie-sous-Chalais (16210)' => '16063',
'Brie-sous-Matha (17160)' => '17067',
'Brie-sous-Mortagne (17120)' => '17068',
'Brieuil-sur-Chizé (79170)' => '79055',
'Brignac-la-Plaine (19310)' => '19030',
'Brigueil-le-Chantre (86290)' => '86037',
'Brigueuil (16420)' => '16064',
'Brillac (16500)' => '16065',
'Brion (86160)' => '86038',
'Brion-près-Thouet (79290)' => '79056',
'Brioux-sur-Boutonne (79170)' => '79057',
'Briscous (64240)' => '64147',
'Brive-la-Gaillarde (19100)' => '19031',
'Brives-sur-Charente (17800)' => '17069',
'Brivezac (19120)' => '19032',
'Brizambourg (17770)' => '17070',
'Brocas (40420)' => '40056',
'Brossac (16480)' => '16066',
'Brouchaud (24210)' => '24066',
'Brouqueyran (33124)' => '33074',
'Brousse (23700)' => '23034',
'Bruch (47130)' => '47041',
'Bruges (33520)' => '33075',
'Bruges-Capbis-Mifaget (64800)' => '64148',
'Brugnac (47260)' => '47042',
'Brûlain (79230)' => '79058',
'Brux (86510)' => '86039',
'Buanes (40320)' => '40057',
'Budelière (23170)' => '23035',
'Budos (33720)' => '33076',
'Bugeat (19170)' => '19033',
'Bugnein (64190)' => '64149',
'Bujaleuf (87460)' => '87024',
'Bunus (64120)' => '64150',
'Bunzac (16110)' => '16067',
'Burgaronne (64390)' => '64151',
'Burgnac (87800)' => '87025',
'Burie (17770)' => '17072',
'Buros (64160)' => '64152',
'Burosse-Mendousse (64330)' => '64153',
'Bussac (24350)' => '24069',
'Bussac-Forêt (17210)' => '17074',
'Bussac-sur-Charente (17100)' => '17073',
'Busserolles (24360)' => '24070',
'Bussière-Badil (24360)' => '24071',
'Bussière-Dunoise (23320)' => '23036',
'Bussière-Galant (87230)' => '87027',
'Bussière-Nouvelle (23700)' => '23037',
'Bussière-Poitevine (87320)' => '87028',
'Bussière-Saint-Georges (23600)' => '23038',
'Bussunarits-Sarrasquette (64220)' => '64154',
'Bustince-Iriberry (64220)' => '64155',
'Buxerolles (86180)' => '86041',
'Buxeuil (37160)' => '86042',
'Buzet-sur-Baïse (47160)' => '47043',
'Buziet (64680)' => '64156',
'Buzy (64260)' => '64157',
'Cabanac-et-Villagrains (33650)' => '33077',
'Cabara (33420)' => '33078',
'Cabariot (17430)' => '17075',
'Cabidos (64410)' => '64158',
'Cachen (40120)' => '40058',
'Cadarsac (33750)' => '33079',
'Cadaujac (33140)' => '33080',
'Cadillac (33410)' => '33081',
'Cadillac-en-Fronsadais (33240)' => '33082',
'Cadillon (64330)' => '64159',
'Cagnotte (40300)' => '40059',
'Cahuzac (47330)' => '47044',
'Calès (24150)' => '24073',
'Calignac (47600)' => '47045',
'Callen (40430)' => '40060',
'Calonges (47430)' => '47046',
'Calviac-en-Périgord (24370)' => '24074',
'Camarsac (33750)' => '33083',
'Cambes (33880)' => '33084',
'Cambes (47350)' => '47047',
'Camblanes-et-Meynac (33360)' => '33085',
'Cambo-les-Bains (64250)' => '64160',
'Came (64520)' => '64161',
'Camiac-et-Saint-Denis (33420)' => '33086',
'Camiran (33190)' => '33087',
'Camou-Cihigue (64470)' => '64162',
'Campagnac-lès-Quercy (24550)' => '24075',
'Campagne (24260)' => '24076',
'Campagne (40090)' => '40061',
'Campet-et-Lamolère (40090)' => '40062',
'Camps-Saint-Mathurin-Léobazel (19430)' => '19034',
'Camps-sur-l\'Isle (33660)' => '33088',
'Campsegret (24140)' => '24077',
'Campugnan (33390)' => '33089',
'Cancon (47290)' => '47048',
'Candresse (40180)' => '40063',
'Canéjan (33610)' => '33090',
'Canenx-et-Réaut (40090)' => '40064',
'Cantenac (33460)' => '33091',
'Cantillac (24530)' => '24079',
'Cantois (33760)' => '33092',
'Capbreton (40130)' => '40065',
'Capdrot (24540)' => '24080',
'Capian (33550)' => '33093',
'Caplong (33220)' => '33094',
'Captieux (33840)' => '33095',
'Carbon-Blanc (33560)' => '33096',
'Carcans (33121)' => '33097',
'Carcarès-Sainte-Croix (40400)' => '40066',
'Carcen-Ponson (40400)' => '40067',
'Cardan (33410)' => '33098',
'Cardesse (64360)' => '64165',
'Carignan-de-Bordeaux (33360)' => '33099',
'Carlux (24370)' => '24081',
'Caro (64220)' => '64166',
'Carrère (64160)' => '64167',
'Carresse-Cassaber (64270)' => '64168',
'Cars (33390)' => '33100',
'Carsac-Aillac (24200)' => '24082',
'Carsac-de-Gurson (24610)' => '24083',
'Cartelègue (33390)' => '33101',
'Carves (24170)' => '24084',
'Cassen (40380)' => '40068',
'Casseneuil (47440)' => '47049',
'Casseuil (33190)' => '33102',
'Cassignas (47340)' => '47050',
'Castagnède (64270)' => '64170',
'Castaignos-Souslens (40700)' => '40069',
'Castandet (40270)' => '40070',
'Casteide-Cami (64170)' => '64171',
'Casteide-Candau (64370)' => '64172',
'Casteide-Doat (64460)' => '64173',
'Castel-Sarrazin (40330)' => '40074',
'Castelculier (47240)' => '47051',
'Casteljaloux (47700)' => '47052',
'Castella (47340)' => '47053',
'Castelmoron-d\'Albret (33540)' => '33103',
'Castelmoron-sur-Lot (47260)' => '47054',
'Castelnau-Chalosse (40360)' => '40071',
'Castelnau-de-Médoc (33480)' => '33104',
'Castelnau-sur-Gupie (47180)' => '47056',
'Castelnau-Tursan (40320)' => '40072',
'Castelnaud-de-Gratecambe (47290)' => '47055',
'Castelnaud-la-Chapelle (24250)' => '24086',
'Castelner (40700)' => '40073',
'Castels (24220)' => '24087',
'Castelviel (33540)' => '33105',
'Castéra-Loubix (64460)' => '64174',
'Castet (64260)' => '64175',
'Castetbon (64190)' => '64176',
'Castétis (64300)' => '64177',
'Castetnau-Camblong (64190)' => '64178',
'Castetner (64300)' => '64179',
'Castetpugon (64330)' => '64180',
'Castets (40260)' => '40075',
'Castets-en-Dorthe (33210)' => '33106',
'Castillon (Canton d\'Arthez-de-Béarn) (64370)' => '64181',
'Castillon (Canton de Lembeye) (64350)' => '64182',
'Castillon-de-Castets (33210)' => '33107',
'Castillon-la-Bataille (33350)' => '33108',
'Castillonnès (47330)' => '47057',
'Castres-Gironde (33640)' => '33109',
'Caubeyres (47160)' => '47058',
'Caubios-Loos (64230)' => '64183',
'Caubon-Saint-Sauveur (47120)' => '47059',
'Caudecoste (47220)' => '47060',
'Caudrot (33490)' => '33111',
'Caumont (33540)' => '33112',
'Caumont-sur-Garonne (47430)' => '47061',
'Cauna (40500)' => '40076',
'Caunay (79190)' => '79060',
'Cauneille (40300)' => '40077',
'Caupenne (40250)' => '40078',
'Cause-de-Clérans (24150)' => '24088',
'Cauvignac (33690)' => '33113',
'Cauzac (47470)' => '47062',
'Cavarc (47330)' => '47063',
'Cavignac (33620)' => '33114',
'Cazalis (33113)' => '33115',
'Cazalis (40700)' => '40079',
'Cazats (33430)' => '33116',
'Cazaugitat (33790)' => '33117',
'Cazères-sur-l\'Adour (40270)' => '40080',
'Cazideroque (47370)' => '47064',
'Cazoulès (24370)' => '24089',
'Ceaux-en-Couhé (86700)' => '86043',
'Ceaux-en-Loudun (86200)' => '86044',
'Celle-Lévescault (86600)' => '86045',
'Cellefrouin (16260)' => '16068',
'Celles (17520)' => '17076',
'Celles (24600)' => '24090',
'Celles-sur-Belle (79370)' => '79061',
'Cellettes (16230)' => '16069',
'Cénac (33360)' => '33118',
'Cénac-et-Saint-Julien (24250)' => '24091',
'Cendrieux (24380)' => '24092',
'Cenon (33150)' => '33119',
'Cenon-sur-Vienne (86530)' => '86046',
'Cercles (24320)' => '24093',
'Cercoux (17270)' => '17077',
'Cère (40090)' => '40081',
'Cerizay (79140)' => '79062',
'Cernay (86140)' => '86047',
'Cérons (33720)' => '33120',
'Cersay (79290)' => '79063',
'Cescau (64170)' => '64184',
'Cessac (33760)' => '33121',
'Cestas (33610)' => '33122',
'Cette-Eygun (64490)' => '64185',
'Ceyroux (23210)' => '23042',
'Cézac (33620)' => '33123',
'Chabanais (16150)' => '16070',
'Chabournay (86380)' => '86048',
'Chabrac (16150)' => '16071',
'Chabrignac (19350)' => '19035',
'Chadenac (17800)' => '17078',
'Chadurie (16250)' => '16072',
'Chail (79500)' => '79064',
'Chaillac-sur-Vienne (87200)' => '87030',
'Chaillevette (17890)' => '17079',
'Chalagnac (24380)' => '24094',
'Chalais (16210)' => '16073',
'Chalais (24800)' => '24095',
'Chalais (86200)' => '86049',
'Chalandray (86190)' => '86050',
'Challignac (16300)' => '16074',
'Châlus (87230)' => '87032',
'Chamadelle (33230)' => '33124',
'Chamberaud (23480)' => '23043',
'Chamberet (19370)' => '19036',
'Chambon (17290)' => '17080',
'Chambon-Sainte-Croix (23220)' => '23044',
'Chambon-sur-Voueize (23170)' => '23045',
'Chambonchard (23110)' => '23046',
'Chamborand (23240)' => '23047',
'Chamboret (87140)' => '87033',
'Chamboulive (19450)' => '19037',
'Chameyrat (19330)' => '19038',
'Chamouillac (17130)' => '17081',
'Champagnac (17500)' => '17082',
'Champagnac-de-Belair (24530)' => '24096',
'Champagnac-la-Noaille (19320)' => '19039',
'Champagnac-la-Prune (19320)' => '19040',
'Champagnac-la-Rivière (87150)' => '87034',
'Champagnat (23190)' => '23048',
'Champagne (17620)' => '17083',
'Champagne-et-Fontaine (24320)' => '24097',
'Champagné-le-Sec (86510)' => '86051',
'Champagne-Mouton (16350)' => '16076',
'Champagné-Saint-Hilaire (86160)' => '86052',
'Champagne-Vigny (16250)' => '16075',
'Champagnolles (17240)' => '17084',
'Champcevinel (24750)' => '24098',
'Champdeniers-Saint-Denis (79220)' => '79066',
'Champdolent (17430)' => '17085',
'Champeaux-et-la-Chapelle-Pommier (24340)' => '24099',
'Champigny-le-Sec (86170)' => '86053',
'Champmillon (16290)' => '16077',
'Champnétery (87400)' => '87035',
'Champniers (16430)' => '16078',
'Champniers (86400)' => '86054',
'Champniers-et-Reilhac (24360)' => '24100',
'Champs-Romain (24470)' => '24101',
'Champsac (87230)' => '87036',
'Champsanglard (23220)' => '23049',
'Chanac-les-Mines (19150)' => '19041',
'Chancelade (24650)' => '24102',
'Chaniers (17610)' => '17086',
'Chantecorps (79340)' => '79068',
'Chanteix (19330)' => '19042',
'Chanteloup (79320)' => '79069',
'Chantemerle-sur-la-Soie (17380)' => '17087',
'Chantérac (24190)' => '24104',
'Chantillac (16360)' => '16079',
'Chapdeuil (24320)' => '24105',
'Chapelle-Spinasse (19300)' => '19046',
'Chapelle-Viviers (86300)' => '86059',
'Chaptelat (87270)' => '87038',
'Chard (23700)' => '23053',
'Charmé (16140)' => '16083',
'Charrais (86170)' => '86060',
'Charras (16380)' => '16084',
'Charre (64190)' => '64186',
'Charritte-de-Bas (64130)' => '64187',
'Charron (17230)' => '17091',
'Charron (23700)' => '23054',
'Charroux (86250)' => '86061',
'Chartrier-Ferrière (19600)' => '19047',
'Chartuzac (17130)' => '17092',
'Chassaignes (24600)' => '24114',
'Chasseneuil-du-Poitou (86360)' => '86062',
'Chasseneuil-sur-Bonnieure (16260)' => '16085',
'Chassenon (16150)' => '16086',
'Chassiecq (16350)' => '16087',
'Chassors (16200)' => '16088',
'Chasteaux (19600)' => '19049',
'Chatain (86250)' => '86063',
'Château-Chervix (87380)' => '87039',
'Château-Garnier (86350)' => '86064',
'Château-l\'Évêque (24460)' => '24115',
'Château-Larcher (86370)' => '86065',
'Châteaubernard (16100)' => '16089',
'Châteauneuf-la-Forêt (87130)' => '87040',
'Châteauneuf-sur-Charente (16120)' => '16090',
'Châteauponsac (87290)' => '87041',
'Châtelaillon-Plage (17340)' => '17094',
'Châtelard (23700)' => '23055',
'Châtellerault (86100)' => '86066',
'Châtelus-le-Marcheix (23430)' => '23056',
'Châtelus-Malvaleix (23270)' => '23057',
'Chatenet (17210)' => '17095',
'Châtignac (16480)' => '16091',
'Châtillon (86700)' => '86067',
'Châtillon-sur-Thouet (79200)' => '79080',
'Châtres (24120)' => '24116',
'Chauffour-sur-Vell (19500)' => '19050',
'Chaumeil (19390)' => '19051',
'Chaunac (17130)' => '17096',
'Chaunay (86510)' => '86068',
'Chauray (79180)' => '79081',
'Chauvigny (86300)' => '86070',
'Chavagnac (24120)' => '24117',
'Chavanac (19290)' => '19052',
'Chavanat (23250)' => '23060',
'Chaveroche (19200)' => '19053',
'Chazelles (16380)' => '16093',
'Chef-Boutonne (79110)' => '79083',
'Cheissoux (87460)' => '87043',
'Chenac-Saint-Seurin-d\'Uzet (17120)' => '17098',
'Chenailler-Mascheix (19120)' => '19054',
'Chenay (79120)' => '79084',
'Cheneché (86380)' => '86071',
'Chénérailles (23130)' => '23061',
'Chenevelles (86450)' => '86072',
'Chéniers (23220)' => '23062',
'Chenommet (16460)' => '16094',
'Chenon (16460)' => '16095',
'Chepniers (17210)' => '17099',
'Chérac (17610)' => '17100',
'Chéraute (64130)' => '64188',
'Cherbonnières (17470)' => '17101',
'Chérigné (79170)' => '79085',
'Chermignac (17460)' => '17102',
'Chéronnac (87600)' => '87044',
'Cherval (24320)' => '24119',
'Cherveix-Cubas (24390)' => '24120',
'Cherves (86170)' => '86073',
'Cherves-Châtelars (16310)' => '16096',
'Cherves-Richemont (16370)' => '16097',
'Chervettes (17380)' => '17103',
'Cherveux (79410)' => '79086',
'Chevanceaux (17210)' => '17104',
'Chey (79120)' => '79087',
'Chiché (79350)' => '79088',
'Chillac (16480)' => '16099',
'Chirac (16150)' => '16100',
'Chirac-Bellevue (19160)' => '19055',
'Chiré-en-Montreuil (86190)' => '86074',
'Chives (17510)' => '17105',
'Chizé (79170)' => '79090',
'Chouppes (86110)' => '86075',
'Chourgnac (24640)' => '24121',
'Ciboure (64500)' => '64189',
'Cierzac (17520)' => '17106',
'Cieux (87520)' => '87045',
'Ciré-d\'Aunis (17290)' => '17107',
'Cirières (79140)' => '79091',
'Cissac-Médoc (33250)' => '33125',
'Cissé (86170)' => '86076',
'Civaux (86320)' => '86077',
'Civrac-de-Blaye (33920)' => '33126',
'Civrac-en-Médoc (33340)' => '33128',
'Civrac-sur-Dordogne (33350)' => '33127',
'Civray (86400)' => '86078',
'Cladech (24170)' => '24122',
'Clairac (47320)' => '47065',
'Clairavaux (23500)' => '23063',
'Claix (16440)' => '16101',
'Clam (17500)' => '17108',
'Claracq (64330)' => '64190',
'Classun (40320)' => '40082',
'Clavé (79420)' => '79092',
'Clavette (17220)' => '17109',
'Clèdes (40320)' => '40083',
'Clérac (17270)' => '17110',
'Clergoux (19320)' => '19056',
'Clermont (40180)' => '40084',
'Clermont-d\'Excideuil (24160)' => '24124',
'Clermont-de-Beauregard (24140)' => '24123',
'Clermont-Dessous (47130)' => '47066',
'Clermont-Soubiran (47270)' => '47067',
'Clessé (79350)' => '79094',
'Cleyrac (33540)' => '33129',
'Clion (17240)' => '17111',
'Cloué (86600)' => '86080',
'Clugnat (23270)' => '23064',
'Clussais-la-Pommeraie (79190)' => '79095',
'Coarraze (64800)' => '64191',
'Cocumont (47250)' => '47068',
'Cognac (16100)' => '16102',
'Cognac-la-Forêt (87310)' => '87046',
'Coimères (33210)' => '33130',
'Coirac (33540)' => '33131',
'Coivert (17330)' => '17114',
'Colayrac-Saint-Cirq (47450)' => '47069',
'Collonges-la-Rouge (19500)' => '19057',
'Colombier (24560)' => '24126',
'Colombiers (17460)' => '17115',
'Colombiers (86490)' => '86081',
'Colondannes (23800)' => '23065',
'Coly (24120)' => '24127',
'Comberanche-et-Épeluche (24600)' => '24128',
'Combiers (16320)' => '16103',
'Combrand (79140)' => '79096',
'Combressol (19250)' => '19058',
'Commensacq (40210)' => '40085',
'Compreignac (87140)' => '87047',
'Comps (33710)' => '33132',
'Concèze (19350)' => '19059',
'Conchez-de-Béarn (64330)' => '64192',
'Condac (16700)' => '16104',
'Condat-sur-Ganaveix (19140)' => '19060',
'Condat-sur-Trincou (24530)' => '24129',
'Condat-sur-Vézère (24570)' => '24130',
'Condat-sur-Vienne (87920)' => '87048',
'Condéon (16360)' => '16105',
'Condezaygues (47500)' => '47070',
'Confolens (16500)' => '16106',
'Confolent-Port-Dieu (19200)' => '19167',
'Conne-de-Labarde (24560)' => '24132',
'Connezac (24300)' => '24131',
'Consac (17150)' => '17116',
'Contré (17470)' => '17117',
'Corbère-Abères (64350)' => '64193',
'Corgnac-sur-l\'Isle (24800)' => '24134',
'Corignac (17130)' => '17118',
'Corme-Écluse (17600)' => '17119',
'Corme-Royal (17600)' => '17120',
'Cornil (19150)' => '19061',
'Cornille (24750)' => '24135',
'Corrèze (19800)' => '19062',
'Coslédaà-Lube-Boast (64160)' => '64194',
'Cosnac (19360)' => '19063',
'Coubeyrac (33890)' => '33133',
'Coubjours (24390)' => '24136',
'Coublucq (64410)' => '64195',
'Coudures (40500)' => '40086',
'Couffy-sur-Sarsonne (19340)' => '19064',
'Couhé (86700)' => '86082',
'Coulaures (24420)' => '24137',
'Coulgens (16560)' => '16107',
'Coulombiers (86600)' => '86083',
'Coulon (79510)' => '79100',
'Coulonges (16330)' => '16108',
'Coulonges (17800)' => '17122',
'Coulonges (86290)' => '86084',
'Coulonges-sur-l\'Autize (79160)' => '79101',
'Coulonges-Thouarsais (79330)' => '79102',
'Coulounieix-Chamiers (24660)' => '24138',
'Coulx (47260)' => '47071',
'Couquèques (33340)' => '33134',
'Courant (17330)' => '17124',
'Courbiac (47370)' => '47072',
'Courbillac (16200)' => '16109',
'Courcelles (17400)' => '17125',
'Courcerac (17160)' => '17126',
'Courcôme (16240)' => '16110',
'Courçon (17170)' => '17127',
'Courcoury (17100)' => '17128',
'Courgeac (16190)' => '16111',
'Courlac (16210)' => '16112',
'Courlay (79440)' => '79103',
'Courpiac (33760)' => '33135',
'Courpignac (17130)' => '17129',
'Cours (47360)' => '47073',
'Cours (79220)' => '79104',
'Cours-de-Monségur (33580)' => '33136',
'Cours-de-Pile (24520)' => '24140',
'Cours-les-Bains (33690)' => '33137',
'Coursac (24430)' => '24139',
'Courteix (19340)' => '19065',
'Coussac-Bonneval (87500)' => '87049',
'Coussay (86110)' => '86085',
'Coussay-les-Bois (86270)' => '86086',
'Couthures-sur-Garonne (47180)' => '47074',
'Coutières (79340)' => '79105',
'Coutras (33230)' => '33138',
'Couture (16460)' => '16114',
'Couture-d\'Argenson (79110)' => '79106',
'Coutures (24320)' => '24141',
'Coutures (33580)' => '33139',
'Coux (17130)' => '17130',
'Coux et Bigaroque-Mouzens (24220)' => '24142',
'Couze-et-Saint-Front (24150)' => '24143',
'Couzeix (87270)' => '87050',
'Cozes (17120)' => '17131',
'Cramchaban (17170)' => '17132',
'Craon (86110)' => '86087',
'Cravans (17260)' => '17133',
'Crazannes (17350)' => '17134',
'Créon (33670)' => '33140',
'Créon-d\'Armagnac (40240)' => '40087',
'Cressac-Saint-Genis (16250)' => '16115',
'Cressat (23140)' => '23068',
'Cressé (17160)' => '17135',
'Creyssac (24350)' => '24144',
'Creysse (24100)' => '24145',
'Creyssensac-et-Pissot (24380)' => '24146',
'Crézières (79110)' => '79107',
'Criteuil-la-Magdeleine (16300)' => '16116',
'Crocq (23260)' => '23069',
'Croignon (33750)' => '33141',
'Croix-Chapeau (17220)' => '17136',
'Cromac (87160)' => '87053',
'Crouseilles (64350)' => '64196',
'Croutelle (86240)' => '86088',
'Crozant (23160)' => '23070',
'Croze (23500)' => '23071',
'Cubjac (24640)' => '24147',
'Cublac (19520)' => '19066',
'Cubnezais (33620)' => '33142',
'Cubzac-les-Ponts (33240)' => '33143',
'Cudos (33430)' => '33144',
'Cuhon (86110)' => '86089',
'Cunèges (24240)' => '24148',
'Cuq (47220)' => '47076',
'Cuqueron (64360)' => '64197',
'Curac (16210)' => '16117',
'Curçay-sur-Dive (86120)' => '86090',
'Curemonte (19500)' => '19067',
'Cursan (33670)' => '33145',
'Curzay-sur-Vonne (86600)' => '86091',
'Cussac (87150)' => '87054',
'Cussac-Fort-Médoc (33460)' => '33146',
'Cuzorn (47500)' => '47077',
'Daglan (24250)' => '24150',
'Daignac (33420)' => '33147',
'Damazan (47160)' => '47078',
'Dampierre-sur-Boutonne (17470)' => '17138',
'Dampniat (19360)' => '19068',
'Dangé-Saint-Romain (86220)' => '86092',
'Darazac (19220)' => '19069',
'Dardenac (33420)' => '33148',
'Darnac (87320)' => '87055',
'Darnets (19300)' => '19070',
'Daubèze (33540)' => '33149',
'Dausse (47140)' => '47079',
'Davignac (19250)' => '19071',
'Dax (40100)' => '40088',
'Denguin (64230)' => '64198',
'Dercé (86420)' => '86093',
'Deviat (16190)' => '16118',
'Dévillac (47210)' => '47080',
'Dienné (86410)' => '86094',
'Dieulivol (33580)' => '33150',
'Dignac (16410)' => '16119',
'Dinsac (87210)' => '87056',
'Dirac (16410)' => '16120',
'Dissay (86130)' => '86095',
'Diusse (64330)' => '64199',
'Doazit (40700)' => '40089',
'Doazon (64370)' => '64200',
'Doeuil-sur-le-Mignon (17330)' => '17139',
'Dognen (64190)' => '64201',
'Doissat (24170)' => '24151',
'Dolmayrac (47110)' => '47081',
'Dolus-d\'Oléron (17550)' => '17140',
'Domeyrot (23140)' => '23072',
'Domezain-Berraute (64120)' => '64202',
'Domme (24250)' => '24152',
'Dompierre-les-Églises (87190)' => '87057',
'Dompierre-sur-Charente (17610)' => '17141',
'Dompierre-sur-Mer (17139)' => '17142',
'Domps (87120)' => '87058',
'Dondas (47470)' => '47082',
'Donnezac (33860)' => '33151',
'Dontreix (23700)' => '23073',
'Donzac (33410)' => '33152',
'Donzacq (40360)' => '40090',
'Donzenac (19270)' => '19072',
'Douchapt (24350)' => '24154',
'Doudrac (47210)' => '47083',
'Doulezon (33350)' => '33153',
'Doumy (64450)' => '64203',
'Dournazac (87230)' => '87060',
'Doussay (86140)' => '86096',
'Douville (24140)' => '24155',
'Doux (79390)' => '79108',
'Douzains (47330)' => '47084',
'Douzat (16290)' => '16121',
'Douzillac (24190)' => '24157',
'Droux (87190)' => '87061',
'Duhort-Bachen (40800)' => '40091',
'Dumes (40500)' => '40092',
'Dun-le-Palestel (23800)' => '23075',
'Durance (47420)' => '47085',
'Duras (47120)' => '47086',
'Dussac (24270)' => '24158',
'Eaux-Bonnes (64440)' => '64204',
'Ébréon (16140)' => '16122',
'Échallat (16170)' => '16123',
'Échebrune (17800)' => '17145',
'Échillais (17620)' => '17146',
'Échiré (79410)' => '79109',
'Échourgnac (24410)' => '24159',
'Écoyeux (17770)' => '17147',
'Écuras (16220)' => '16124',
'Écurat (17810)' => '17148',
'Édon (16320)' => '16125',
'Égletons (19300)' => '19073',
'Église-Neuve-d\'Issac (24400)' => '24161',
'Église-Neuve-de-Vergt (24380)' => '24160',
'Empuré (16240)' => '16127',
'Engayrac (47470)' => '47087',
'Ensigné (79170)' => '79111',
'Épannes (79270)' => '79112',
'Épargnes (17120)' => '17152',
'Épenède (16490)' => '16128',
'Éraville (16120)' => '16129',
'Escalans (40310)' => '40093',
'Escassefort (47350)' => '47088',
'Escaudes (33840)' => '33155',
'Escaunets (65500)' => '65160',
'Esclottes (47120)' => '47089',
'Escoire (24420)' => '24162',
'Escos (64270)' => '64205',
'Escot (64490)' => '64206',
'Escou (64870)' => '64207',
'Escoubès (64160)' => '64208',
'Escource (40210)' => '40094',
'Escoussans (33760)' => '33156',
'Escout (64870)' => '64209',
'Escurès (64350)' => '64210',
'Eslourenties-Daban (64420)' => '64211',
'Esnandes (17137)' => '17153',
'Espagnac (19150)' => '19075',
'Espartignac (19140)' => '19076',
'Espéchède (64160)' => '64212',
'Espelette (64250)' => '64213',
'Espès-Undurein (64130)' => '64214',
'Espiens (47600)' => '47090',
'Espiet (33420)' => '33157',
'Espiute (64390)' => '64215',
'Espoey (64420)' => '64216',
'Esquiule (64400)' => '64217',
'Esse (16500)' => '16131',
'Essouvert (17400)' => '17277',
'Estérençuby (64220)' => '64218',
'Estialescq (64290)' => '64219',
'Estibeaux (40290)' => '40095',
'Estigarde (40240)' => '40096',
'Estillac (47310)' => '47091',
'Estivals (19600)' => '19077',
'Estivaux (19410)' => '19078',
'Estos (64400)' => '64220',
'Étagnac (16150)' => '16132',
'Étaules (17750)' => '17155',
'Étauliers (33820)' => '33159',
'Etcharry (64120)' => '64221',
'Etchebar (64470)' => '64222',
'Étouars (24360)' => '24163',
'Étriac (16250)' => '16133',
'Etsaut (64490)' => '64223',
'Eugénie-les-Bains (40320)' => '40097',
'Évaux-les-Bains (23110)' => '23076',
'Excideuil (24160)' => '24164',
'Exideuil (16150)' => '16134',
'Exireuil (79400)' => '79114',
'Exoudun (79800)' => '79115',
'Expiremont (17130)' => '17156',
'Eybouleuf (87400)' => '87062',
'Eyburie (19140)' => '19079',
'Eygurande (19340)' => '19080',
'Eygurande-et-Gardedeuil (24700)' => '24165',
'Eyjeaux (87220)' => '87063',
'Eyliac (24330)' => '24166',
'Eymet (24500)' => '24167',
'Eymouthiers (16220)' => '16135',
'Eymoutiers (87120)' => '87064',
'Eynesse (33220)' => '33160',
'Eyrans (33390)' => '33161',
'Eyrein (19800)' => '19081',
'Eyres-Moncube (40500)' => '40098',
'Eysines (33320)' => '33162',
'Eysus (64400)' => '64224',
'Eyvirat (24460)' => '24170',
'Eyzerac (24800)' => '24171',
'Faleyras (33760)' => '33163',
'Fals (47220)' => '47092',
'Fanlac (24290)' => '24174',
'Fargues (33210)' => '33164',
'Fargues (40500)' => '40099',
'Fargues-Saint-Hilaire (33370)' => '33165',
'Fargues-sur-Ourbise (47700)' => '47093',
'Fauguerolles (47400)' => '47094',
'Fauillet (47400)' => '47095',
'Faurilles (24560)' => '24176',
'Faux (24560)' => '24177',
'Faux-la-Montagne (23340)' => '23077',
'Faux-Mazuras (23400)' => '23078',
'Favars (19330)' => '19082',
'Faye-l\'Abbesse (79350)' => '79116',
'Faye-sur-Ardin (79160)' => '79117',
'Féas (64570)' => '64225',
'Felletin (23500)' => '23079',
'Fénery (79450)' => '79118',
'Féniers (23100)' => '23080',
'Fenioux (17350)' => '17157',
'Fenioux (79160)' => '79119',
'Ferrensac (47330)' => '47096',
'Ferrières (17170)' => '17158',
'Festalemps (24410)' => '24178',
'Feugarolles (47230)' => '47097',
'Feuillade (16380)' => '16137',
'Feyt (19340)' => '19083',
'Feytiat (87220)' => '87065',
'Fichous-Riumayou (64410)' => '64226',
'Fieux (47600)' => '47098',
'Firbeix (24450)' => '24180',
'Flaugeac (24240)' => '24181',
'Flaujagues (33350)' => '33168',
'Flavignac (87230)' => '87066',
'Flayat (23260)' => '23081',
'Fléac (16730)' => '16138',
'Fléac-sur-Seugne (17800)' => '17159',
'Fleix (86300)' => '86098',
'Fleurac (16200)' => '16139',
'Fleurac (24580)' => '24183',
'Fleurat (23320)' => '23082',
'Fleuré (86340)' => '86099',
'Floirac (17120)' => '17160',
'Floirac (33270)' => '33167',
'Florimont-Gaumier (24250)' => '24184',
'Floudès (33190)' => '33169',
'Folles (87250)' => '87067',
'Fomperron (79340)' => '79121',
'Fongrave (47260)' => '47099',
'Fonroque (24500)' => '24186',
'Fontaine-Chalendray (17510)' => '17162',
'Fontaine-le-Comte (86240)' => '86100',
'Fontaines-d\'Ozillac (17500)' => '17163',
'Fontanières (23110)' => '23083',
'Fontclaireau (16230)' => '16140',
'Fontcouverte (17100)' => '17164',
'Fontenet (17400)' => '17165',
'Fontenille (16230)' => '16141',
'Fontenille-Saint-Martin-d\'Entraigues (79110)' => '79122',
'Fontet (33190)' => '33170',
'Forges (17290)' => '17166',
'Forgès (19380)' => '19084',
'Fors (79230)' => '79125',
'Fossemagne (24210)' => '24188',
'Fossès-et-Baleyssac (33190)' => '33171',
'Fougueyrolles (33220)' => '24189',
'Foulayronnes (47510)' => '47100',
'Fouleix (24380)' => '24190',
'Fouquebrune (16410)' => '16143',
'Fouqueure (16140)' => '16144',
'Fouras (17450)' => '17168',
'Fourques-sur-Garonne (47200)' => '47101',
'Fours (33390)' => '33172',
'Foussignac (16200)' => '16145',
'Fraisse (24130)' => '24191',
'Francescas (47600)' => '47102',
'François (79260)' => '79128',
'Francs (33570)' => '33173',
'Fransèches (23480)' => '23086',
'Fréchou (47600)' => '47103',
'Frégimont (47360)' => '47104',
'Frespech (47140)' => '47105',
'Fresselines (23450)' => '23087',
'Fressines (79370)' => '79129',
'Fromental (87250)' => '87068',
'Fronsac (33126)' => '33174',
'Frontenac (33760)' => '33175',
'Frontenay-Rohan-Rohan (79270)' => '79130',
'Frozes (86190)' => '86102',
'Fumel (47500)' => '47106',
'Gaas (40350)' => '40101',
'Gabarnac (33410)' => '33176',
'Gabarret (40310)' => '40102',
'Gabaston (64160)' => '64227',
'Gabat (64120)' => '64228',
'Gabillou (24210)' => '24192',
'Gageac-et-Rouillac (24240)' => '24193',
'Gaillan-en-Médoc (33340)' => '33177',
'Gaillères (40090)' => '40103',
'Gajac (33430)' => '33178',
'Gajoubert (87330)' => '87069',
'Galapian (47190)' => '47107',
'Galgon (33133)' => '33179',
'Gamarde-les-Bains (40380)' => '40104',
'Gamarthe (64220)' => '64229',
'Gan (64290)' => '64230',
'Gans (33430)' => '33180',
'Garat (16410)' => '16146',
'Gardegan-et-Tourtirac (33350)' => '33181',
'Gardères (65320)' => '65185',
'Gardes-le-Pontaroux (16320)' => '16147',
'Gardonne (24680)' => '24194',
'Garein (40420)' => '40105',
'Garindein (64130)' => '64231',
'Garlède-Mondebat (64450)' => '64232',
'Garlin (64330)' => '64233',
'Garos (64410)' => '64234',
'Garrey (40180)' => '40106',
'Garris (64120)' => '64235',
'Garrosse (40110)' => '40107',
'Gartempe (23320)' => '23088',
'Gastes (40160)' => '40108',
'Gaugeac (24540)' => '24195',
'Gaujac (47200)' => '47108',
'Gaujacq (40330)' => '40109',
'Gauriac (33710)' => '33182',
'Gauriaguet (33240)' => '33183',
'Gavaudun (47150)' => '47109',
'Gayon (64350)' => '64236',
'Geaune (40320)' => '40110',
'Geay (17250)' => '17171',
'Geay (79330)' => '79131',
'Gelos (64110)' => '64237',
'Geloux (40090)' => '40111',
'Gémozac (17260)' => '17172',
'Genac-Bignac (16170)' => '16148',
'Gençay (86160)' => '86103',
'Générac (33920)' => '33184',
'Génis (24160)' => '24196',
'Génissac (33420)' => '33185',
'Genneton (79150)' => '79132',
'Genouillac (16270)' => '16149',
'Genouillac (23350)' => '23089',
'Genouillé (17430)' => '17174',
'Genouillé (86250)' => '86104',
'Gensac (33890)' => '33186',
'Gensac-la-Pallue (16130)' => '16150',
'Genté (16130)' => '16151',
'Gentioux-Pigerolles (23340)' => '23090',
'Ger (64530)' => '64238',
'Gerderest (64160)' => '64239',
'Gère-Bélesten (64260)' => '64240',
'Germignac (17520)' => '17175',
'Germond-Rouvre (79220)' => '79133',
'Géronce (64400)' => '64241',
'Gestas (64190)' => '64242',
'Géus-d\'Arzacq (64370)' => '64243',
'Geüs-d\'Oloron (64400)' => '64244',
'Gibourne (17160)' => '17176',
'Gibret (40380)' => '40112',
'Gimel-les-Cascades (19800)' => '19085',
'Gimeux (16130)' => '16152',
'Ginestet (24130)' => '24197',
'Gioux (23500)' => '23091',
'Gironde-sur-Dropt (33190)' => '33187',
'Giscos (33840)' => '33188',
'Givrezac (17260)' => '17178',
'Gizay (86340)' => '86105',
'Glandon (87500)' => '87071',
'Glanges (87380)' => '87072',
'Glénay (79330)' => '79134',
'Glénic (23380)' => '23092',
'Glénouze (86200)' => '86106',
'Goès (64400)' => '64245',
'Gomer (64420)' => '64246',
'Gond-Pontouvre (16160)' => '16154',
'Gondeville (16200)' => '16153',
'Gontaud-de-Nogaret (47400)' => '47110',
'Goos (40180)' => '40113',
'Gornac (33540)' => '33189',
'Gorre (87310)' => '87073',
'Gotein-Libarrenx (64130)' => '64247',
'Goualade (33840)' => '33190',
'Gouex (86320)' => '86107',
'Goulles (19430)' => '19086',
'Gourbera (40990)' => '40114',
'Gourdon-Murat (19170)' => '19087',
'Gourgé (79200)' => '79135',
'Gournay-Loizé (79110)' => '79136',
'Gours (33660)' => '33191',
'Gourville (16170)' => '16156',
'Gourvillette (17490)' => '17180',
'Gousse (40465)' => '40115',
'Gout-Rossignol (24320)' => '24199',
'Gouts (40400)' => '40116',
'Gouzon (23230)' => '23093',
'Gradignan (33170)' => '33192',
'Grand-Brassac (24350)' => '24200',
'Grandjean (17350)' => '17181',
'Grandsaigne (19300)' => '19088',
'Granges-d\'Ans (24390)' => '24202',
'Granges-sur-Lot (47260)' => '47111',
'Granzay-Gript (79360)' => '79137',
'Grassac (16380)' => '16158',
'Grateloup-Saint-Gayrand (47400)' => '47112',
'Graves-Saint-Amant (16120)' => '16297',
'Grayan-et-l\'Hôpital (33590)' => '33193',
'Grayssas (47270)' => '47113',
'Grenade-sur-l\'Adour (40270)' => '40117',
'Grézac (17120)' => '17183',
'Grèzes (24120)' => '24204',
'Grézet-Cavagnan (47250)' => '47114',
'Grézillac (33420)' => '33194',
'Grignols (24110)' => '24205',
'Grignols (33690)' => '33195',
'Grives (24170)' => '24206',
'Groléjac (24250)' => '24207',
'Gros-Chastang (19320)' => '19089',
'Grun-Bordas (24380)' => '24208',
'Guéret (23000)' => '23096',
'Guérin (47250)' => '47115',
'Guesnes (86420)' => '86109',
'Guéthary (64210)' => '64249',
'Guiche (64520)' => '64250',
'Guillac (33420)' => '33196',
'Guillos (33720)' => '33197',
'Guimps (16300)' => '16160',
'Guinarthe-Parenties (64390)' => '64251',
'Guitinières (17500)' => '17187',
'Guîtres (33230)' => '33198',
'Guizengeard (16480)' => '16161',
'Gujan-Mestras (33470)' => '33199',
'Gumond (19320)' => '19090',
'Gurat (16320)' => '16162',
'Gurmençon (64400)' => '64252',
'Gurs (64190)' => '64253',
'Habas (40290)' => '40118',
'Hagetaubin (64370)' => '64254',
'Hagetmau (40700)' => '40119',
'Haimps (17160)' => '17188',
'Haims (86310)' => '86110',
'Halsou (64480)' => '64255',
'Hanc (79110)' => '79140',
'Hasparren (64240)' => '64256',
'Hastingues (40300)' => '40120',
'Hauriet (40250)' => '40121',
'Haut-de-Bosdarros (64800)' => '64257',
'Haut-Mauco (40280)' => '40122',
'Hautefage (19400)' => '19091',
'Hautefage-la-Tour (47340)' => '47117',
'Hautefaye (24300)' => '24209',
'Hautefort (24390)' => '24210',
'Hautesvignes (47400)' => '47118',
'Haux (33550)' => '33201',
'Haux (64470)' => '64258',
'Hélette (64640)' => '64259',
'Hendaye (64700)' => '64260',
'Herm (40990)' => '40123',
'Herré (40310)' => '40124',
'Herrère (64680)' => '64261',
'Heugas (40180)' => '40125',
'Hiers-Brouage (17320)' => '17189',
'Hiersac (16290)' => '16163',
'Hiesse (16490)' => '16164',
'Higuères-Souye (64160)' => '64262',
'Hinx (40180)' => '40126',
'Hontanx (40190)' => '40127',
'Horsarrieu (40700)' => '40128',
'Hosta (64120)' => '64265',
'Hostens (33125)' => '33202',
'Houeillès (47420)' => '47119',
'Houlette (16200)' => '16165',
'Hours (64420)' => '64266',
'Hourtin (33990)' => '33203',
'Hure (33190)' => '33204',
'Ibarrolle (64120)' => '64267',
'Idaux-Mendy (64130)' => '64268',
'Idron (64320)' => '64269',
'Igon (64800)' => '64270',
'Iholdy (64640)' => '64271',
'Île-d\'Aix (17123)' => '17004',
'Ilharre (64120)' => '64272',
'Illats (33720)' => '33205',
'Ingrandes (86220)' => '86111',
'Irais (79600)' => '79141',
'Irissarry (64780)' => '64273',
'Irouléguy (64220)' => '64274',
'Isle (87170)' => '87075',
'Isle-Saint-Georges (33640)' => '33206',
'Ispoure (64220)' => '64275',
'Issac (24400)' => '24211',
'Issigeac (24560)' => '24212',
'Issor (64570)' => '64276',
'Issoudun-Létrieix (23130)' => '23097',
'Isturits (64240)' => '64277',
'Iteuil (86240)' => '86113',
'Itxassou (64250)' => '64279',
'Izeste (64260)' => '64280',
'Izon (33450)' => '33207',
'Jabreilles-les-Bordes (87370)' => '87076',
'Jalesches (23270)' => '23098',
'Janailhac (87800)' => '87077',
'Janaillat (23250)' => '23099',
'Jardres (86800)' => '86114',
'Jarnac (16200)' => '16167',
'Jarnac-Champagne (17520)' => '17192',
'Jarnages (23140)' => '23100',
'Jasses (64190)' => '64281',
'Jatxou (64480)' => '64282',
'Jau-Dignac-et-Loirac (33590)' => '33208',
'Jauldes (16560)' => '16168',
'Jaunay-Clan (86130)' => '86115',
'Jaure (24140)' => '24213',
'Javerdat (87520)' => '87078',
'Javerlhac-et-la-Chapelle-Saint-Robert (24300)' => '24214',
'Javrezac (16100)' => '16169',
'Jaxu (64220)' => '64283',
'Jayac (24590)' => '24215',
'Jazeneuil (86600)' => '86116',
'Jazennes (17260)' => '17196',
'Jonzac (17500)' => '17197',
'Josse (40230)' => '40129',
'Jouac (87890)' => '87080',
'Jouhet (86500)' => '86117',
'Jouillat (23220)' => '23101',
'Jourgnac (87800)' => '87081',
'Journet (86290)' => '86118',
'Journiac (24260)' => '24217',
'Joussé (86350)' => '86119',
'Jugazan (33420)' => '33209',
'Jugeals-Nazareth (19500)' => '19093',
'Juicq (17770)' => '17198',
'Juignac (16190)' => '16170',
'Juillac (19350)' => '19094',
'Juillac (33890)' => '33210',
'Juillac-le-Coq (16130)' => '16171',
'Juillé (16230)' => '16173',
'Juillé (79170)' => '79142',
'Julienne (16200)' => '16174',
'Jumilhac-le-Grand (24630)' => '24218',
'Jurançon (64110)' => '64284',
'Juscorps (79230)' => '79144',
'Jusix (47180)' => '47120',
'Jussas (17130)' => '17199',
'Juxue (64120)' => '64285',
'L\'Absie (79240)' => '79001',
'L\'Église-aux-Bois (19170)' => '19074',
'L\'Éguille (17600)' => '17151',
'L\'Hôpital-d\'Orion (64270)' => '64263',
'L\'Hôpital-Saint-Blaise (64130)' => '64264',
'L\'Houmeau (17137)' => '17190',
'L\'Isle-d\'Espagnac (16340)' => '16166',
'L\'Isle-Jourdain (86150)' => '86112',
'La Bachellerie (24210)' => '24020',
'La Barde (17360)' => '17033',
'La Bastide-Clairence (64240)' => '64289',
'La Bataille (79110)' => '79027',
'La Bazeuge (87210)' => '87008',
'La Boissière-d\'Ans (24640)' => '24047',
'La Boissière-en-Gâtine (79310)' => '79040',
'La Brède (33650)' => '33213',
'La Brée-les-Bains (17840)' => '17486',
'La Brionne (23000)' => '23033',
'La Brousse (17160)' => '17071',
'La Bussière (86310)' => '86040',
'La Cassagne (24120)' => '24085',
'La Celle-Dunoise (23800)' => '23039',
'La Celle-sous-Gouzon (23230)' => '23040',
'La Cellette (23350)' => '23041',
'La Chapelle (16140)' => '16081',
'La Chapelle-Aubareil (24290)' => '24106',
'La Chapelle-aux-Brocs (19360)' => '19043',
'La Chapelle-aux-Saints (19120)' => '19044',
'La Chapelle-Baloue (23160)' => '23050',
'La Chapelle-Bâton (79220)' => '79070',
'La Chapelle-Bâton (86250)' => '86055',
'La Chapelle-Bertrand (79200)' => '79071',
'La Chapelle-des-Pots (17100)' => '17089',
'La Chapelle-Faucher (24530)' => '24107',
'La Chapelle-Gonaguet (24350)' => '24108',
'La Chapelle-Grésignac (24320)' => '24109',
'La Chapelle-Montabourlet (24320)' => '24110',
'La Chapelle-Montbrandeix (87440)' => '87037',
'La Chapelle-Montmoreau (24300)' => '24111',
'La Chapelle-Montreuil (86470)' => '86056',
'La Chapelle-Moulière (86210)' => '86058',
'La Chapelle-Pouilloux (79190)' => '79074',
'La Chapelle-Saint-Étienne (79240)' => '79075',
'La Chapelle-Saint-Géraud (19430)' => '19045',
'La Chapelle-Saint-Jean (24390)' => '24113',
'La Chapelle-Saint-Laurent (79430)' => '79076',
'La Chapelle-Saint-Martial (23250)' => '23051',
'La Chapelle-Taillefert (23000)' => '23052',
'La Chapelle-Thireuil (79160)' => '79077',
'La Chaussade (23200)' => '23059',
'La Chaussée (86330)' => '86069',
'La Chèvrerie (16240)' => '16098',
'La Clisse (17600)' => '17112',
'La Clotte (17360)' => '17113',
'La Coquille (24450)' => '24133',
'La Couarde (79800)' => '79098',
'La Couarde-sur-Mer (17670)' => '17121',
'La Couronne (16400)' => '16113',
'La Courtine (23100)' => '23067',
'La Crèche (79260)' => '79048',
'La Croisille-sur-Briance (87130)' => '87051',
'La Croix-Blanche (47340)' => '47075',
'La Croix-Comtesse (17330)' => '17137',
'La Croix-sur-Gartempe (87210)' => '87052',
'La Dornac (24120)' => '24153',
'La Douze (24330)' => '24156',
'La Faye (16700)' => '16136',
'La Ferrière-Airoux (86160)' => '86097',
'La Ferrière-en-Parthenay (79390)' => '79120',
'La Feuillade (24120)' => '24179',
'La Flotte (17630)' => '17161',
'La Force (24130)' => '24222',
'La Forêt-de-Tessé (16240)' => '16142',
'La Forêt-du-Temple (23360)' => '23084',
'La Forêt-sur-Sèvre (79380)' => '79123',
'La Foye-Monjault (79360)' => '79127',
'La Frédière (17770)' => '17169',
'La Genétouze (17360)' => '17173',
'La Geneytouse (87400)' => '87070',
'La Gonterie-Boulouneix (24310)' => '24198',
'La Grève-sur-Mignon (17170)' => '17182',
'La Grimaudière (86330)' => '86108',
'La Gripperie-Saint-Symphorien (17620)' => '17184',
'La Jard (17460)' => '17191',
'La Jarne (17220)' => '17193',
'La Jarrie (17220)' => '17194',
'La Jarrie-Audouin (17330)' => '17195',
'La Jemaye (24410)' => '24216',
'La Jonchère-Saint-Maurice (87340)' => '87079',
'La Laigne (17170)' => '17201',
'La Lande-de-Fronsac (33240)' => '33219',
'La Magdeleine (16240)' => '16197',
'La Mazière-aux-Bons-Hommes (23260)' => '23129',
'La Meyze (87800)' => '87096',
'La Mothe-Saint-Héray (79800)' => '79184',
'La Nouaille (23500)' => '23144',
'La Péruse (16270)' => '16259',
'La Petite-Boissière (79700)' => '79207',
'La Peyratte (79200)' => '79208',
'La Porcherie (87380)' => '87120',
'La Pouge (23250)' => '23157',
'La Puye (86260)' => '86202',
'La Réole (33190)' => '33352',
'La Réunion (47700)' => '47222',
'La Rivière (33126)' => '33356',
'La Roche-Canillac (19320)' => '19174',
'La Roche-Chalais (24490)' => '24354',
'La Roche-l\'Abeille (87800)' => '87127',
'La Roche-Posay (86270)' => '86207',
'La Roche-Rigault (86200)' => '86079',
'La Rochebeaucourt-et-Argentine (24340)' => '24353',
'La Rochefoucauld (16110)' => '16281',
'La Rochelle (17000)' => '17300',
'La Rochénard (79270)' => '79229',
'La Rochette (16110)' => '16282',
'La Ronde (17170)' => '17303',
'La Roque-Gageac (24250)' => '24355',
'La Roquille (33220)' => '33360',
'La Saunière (23000)' => '23169',
'La Sauve (33670)' => '33505',
'La Sauvetat-de-Savères (47270)' => '47289',
'La Sauvetat-du-Dropt (47800)' => '47290',
'La Sauvetat-sur-Lède (47150)' => '47291',
'La Serre-Bussière-Vieille (23190)' => '23172',
'La Souterraine (23300)' => '23176',
'La Tâche (16260)' => '16377',
'La Teste-de-Buch (33260)' => '33529',
'La Tour-Blanche (24320)' => '24554',
'La Tremblade (17390)' => '17452',
'La Trimouille (86290)' => '86273',
'La Vallée (17250)' => '17455',
'La Vergne (17400)' => '17465',
'La Villedieu (17470)' => '17471',
'La Villedieu (23340)' => '23264',
'La Villedieu-du-Clain (86340)' => '86290',
'La Villeneuve (23260)' => '23265',
'La Villetelle (23260)' => '23266',
'Laà-Mondrans (64300)' => '64286',
'Laàs (64390)' => '64287',
'Labarde (33460)' => '33211',
'Labastide-Castel-Amouroux (47250)' => '47121',
'Labastide-Cézéracq (64170)' => '64288',
'Labastide-Chalosse (40700)' => '40130',
'Labastide-d\'Armagnac (40240)' => '40131',
'Labastide-Monréjeau (64170)' => '64290',
'Labastide-Villefranche (64270)' => '64291',
'Labatmale (64530)' => '64292',
'Labatut (40300)' => '40132',
'Labatut (64460)' => '64293',
'Labenne (40530)' => '40133',
'Labescau (33690)' => '33212',
'Labets-Biscay (64120)' => '64294',
'Labeyrie (64300)' => '64295',
'Labouheyre (40210)' => '40134',
'Labretonie (47350)' => '47122',
'Labrit (40420)' => '40135',
'Lacadée (64300)' => '64296',
'Lacajunte (40320)' => '40136',
'Lacanau (33680)' => '33214',
'Lacapelle-Biron (47150)' => '47123',
'Lacarre (64220)' => '64297',
'Lacarry-Arhan-Charritte-de-Haut (64470)' => '64298',
'Lacaussade (47150)' => '47124',
'Lacelle (19170)' => '19095',
'Lacépède (47360)' => '47125',
'Lachaise (16300)' => '16176',
'Lachapelle (47350)' => '47126',
'Lacommande (64360)' => '64299',
'Lacq (64170)' => '64300',
'Lacquy (40120)' => '40137',
'Lacrabe (40700)' => '40138',
'Lacropte (24380)' => '24220',
'Ladapeyre (23270)' => '23102',
'Ladaux (33760)' => '33215',
'Ladignac-le-Long (87500)' => '87082',
'Ladignac-sur-Rondelles (19150)' => '19096',
'Ladiville (16120)' => '16177',
'Lados (33124)' => '33216',
'Lafage-sur-Sombre (19320)' => '19097',
'Lafat (23800)' => '23103',
'Lafitte-sur-Lot (47320)' => '47127',
'Lafox (47240)' => '47128',
'Lagarde-Enval (19150)' => '19098',
'Lagarde-sur-le-Né (16300)' => '16178',
'Lagarrigue (47190)' => '47129',
'Lageon (79200)' => '79145',
'Lagleygeolle (19500)' => '19099',
'Laglorieuse (40090)' => '40139',
'Lagor (64150)' => '64301',
'Lagorce (33230)' => '33218',
'Lagord (17140)' => '17200',
'Lagos (64800)' => '64302',
'Lagrange (40240)' => '40140',
'Lagraulière (19700)' => '19100',
'Lagruère (47400)' => '47130',
'Laguenne (19150)' => '19101',
'Laguinge-Restoue (64470)' => '64303',
'Lagupie (47180)' => '47131',
'Lahonce (64990)' => '64304',
'Lahontan (64270)' => '64305',
'Lahosse (40250)' => '40141',
'Lahourcade (64150)' => '64306',
'Lalande-de-Pomerol (33500)' => '33222',
'Lalandusse (47330)' => '47132',
'Lalinde (24150)' => '24223',
'Lalongue (64350)' => '64307',
'Lalonquette (64450)' => '64308',
'Laluque (40465)' => '40142',
'Lamarque (33460)' => '33220',
'Lamayou (64460)' => '64309',
'Lamazière-Basse (19160)' => '19102',
'Lamazière-Haute (19340)' => '19103',
'Lamongerie (19510)' => '19104',
'Lamontjoie (47310)' => '47133',
'Lamonzie-Montastruc (24520)' => '24224',
'Lamonzie-Saint-Martin (24680)' => '24225',
'Lamothe (40250)' => '40143',
'Lamothe-Landerron (33190)' => '33221',
'Lamothe-Montravel (24230)' => '24226',
'Landerrouat (33790)' => '33223',
'Landerrouet-sur-Ségur (33540)' => '33224',
'Landes (17380)' => '17202',
'Landiras (33720)' => '33225',
'Landrais (17290)' => '17203',
'Langoiran (33550)' => '33226',
'Langon (33210)' => '33227',
'Lanne-en-Barétous (64570)' => '64310',
'Lannecaube (64350)' => '64311',
'Lanneplaà (64300)' => '64312',
'Lannes (47170)' => '47134',
'Lanouaille (24270)' => '24227',
'Lanquais (24150)' => '24228',
'Lansac (33710)' => '33228',
'Lantabat (64640)' => '64313',
'Lanteuil (19190)' => '19105',
'Lanton (33138)' => '33229',
'Laparade (47260)' => '47135',
'Laperche (47800)' => '47136',
'Lapleau (19550)' => '19106',
'Laplume (47310)' => '47137',
'Lapouyade (33620)' => '33230',
'Laprade (16390)' => '16180',
'Larbey (40250)' => '40144',
'Larceveau-Arros-Cibits (64120)' => '64314',
'Larche (19600)' => '19107',
'Largeasse (79240)' => '79147',
'Laroche-près-Feyt (19340)' => '19108',
'Laroin (64110)' => '64315',
'Laroque (33410)' => '33231',
'Laroque-Timbaut (47340)' => '47138',
'Larrau (64560)' => '64316',
'Larressore (64480)' => '64317',
'Larreule (64410)' => '64318',
'Larribar-Sorhapuru (64120)' => '64319',
'Larrivière-Saint-Savin (40270)' => '40145',
'Lartigue (33840)' => '33232',
'Laruns (64440)' => '64320',
'Laruscade (33620)' => '33233',
'Larzac (24170)' => '24230',
'Lascaux (19130)' => '19109',
'Lasclaveries (64450)' => '64321',
'Lasse (64220)' => '64322',
'Lasserre (47600)' => '47139',
'Lasserre (64350)' => '64323',
'Lasseube (64290)' => '64324',
'Lasseubetat (64290)' => '64325',
'Lathus-Saint-Rémy (86390)' => '86120',
'Latillé (86190)' => '86121',
'Latresne (33360)' => '33234',
'Latrille (40800)' => '40146',
'Latronche (19160)' => '19110',
'Laugnac (47360)' => '47140',
'Laurède (40250)' => '40147',
'Lauret (40320)' => '40148',
'Laurière (87370)' => '87083',
'Laussou (47150)' => '47141',
'Lauthiers (86300)' => '86122',
'Lauzun (47410)' => '47142',
'Laval-sur-Luzège (19550)' => '19111',
'Lavalade (24540)' => '24231',
'Lavardac (47230)' => '47143',
'Lavaufranche (23600)' => '23104',
'Lavaur (24550)' => '24232',
'Lavausseau (86470)' => '86123',
'Lavaveix-les-Mines (23150)' => '23105',
'Lavazan (33690)' => '33235',
'Lavergne (47800)' => '47144',
'Laveyssière (24130)' => '24233',
'Lavignac (87230)' => '87084',
'Lavoux (86800)' => '86124',
'Lay-Lamidou (64190)' => '64326',
'Layrac (47390)' => '47145',
'Le Barp (33114)' => '33029',
'Le Beugnon (79130)' => '79035',
'Le Bois-Plage-en-Ré (17580)' => '17051',
'Le Bouchage (16350)' => '16054',
'Le Bourdeix (24300)' => '24056',
'Le Bourdet (79210)' => '79046',
'Le Bourg-d\'Hem (23220)' => '23029',
'Le Bouscat (33110)' => '33069',
'Le Breuil-Bernard (79320)' => '79051',
'Le Bugue (24260)' => '24067',
'Le Buis (87140)' => '87023',
'Le Buisson-de-Cadouin (24480)' => '24068',
'Le Busseau (79240)' => '79059',
'Le Chalard (87500)' => '87031',
'Le Change (24640)' => '24103',
'Le Chastang (19190)' => '19048',
'Le Château-d\'Oléron (17480)' => '17093',
'Le Châtenet-en-Dognon (87400)' => '87042',
'Le Chauchet (23130)' => '23058',
'Le Chay (17600)' => '17097',
'Le Chillou (79600)' => '79089',
'Le Compas (23700)' => '23066',
'Le Donzeil (23480)' => '23074',
'Le Dorat (87210)' => '87059',
'Le Douhet (17100)' => '17143',
'Le Fieu (33230)' => '33166',
'Le Fleix (24130)' => '24182',
'Le Fouilloux (17270)' => '17167',
'Le Frêche (40190)' => '40100',
'Le Gicq (17160)' => '17177',
'Le Grand-Bourg (23240)' => '23095',
'Le Grand-Madieu (16450)' => '16157',
'Le Grand-Village-Plage (17370)' => '17485',
'Le Gua (17600)' => '17185',
'Le Gué-d\'Alleré (17540)' => '17186',
'Le Haillan (33185)' => '33200',
'Le Jardin (19300)' => '19092',
'Le Lardin-Saint-Lazare (24570)' => '24229',
'Le Leuy (40250)' => '40153',
'Le Lindois (16310)' => '16188',
'Le Lonzac (19470)' => '19118',
'Le Mas-d\'Agenais (47430)' => '47159',
'Le Mas-d\'Artige (23100)' => '23125',
'Le Monteil-au-Vicomte (23460)' => '23134',
'Le Mung (17350)' => '17252',
'Le Nizan (33430)' => '33305',
'Le Palais-sur-Vienne (87410)' => '87113',
'Le Passage (47520)' => '47201',
'Le Pescher (19190)' => '19163',
'Le Pian-Médoc (33290)' => '33322',
'Le Pian-sur-Garonne (33490)' => '33323',
'Le Pin (17210)' => '17276',
'Le Pin (79140)' => '79210',
'Le Pizou (24700)' => '24329',
'Le Porge (33680)' => '33333',
'Le Pout (33670)' => '33335',
'Le Puy (33580)' => '33345',
'Le Retail (79130)' => '79226',
'Le Rochereau (86170)' => '86208',
'Le Sen (40420)' => '40297',
'Le Seure (17770)' => '17426',
'Le Taillan-Médoc (33320)' => '33519',
'Le Tallud (79200)' => '79322',
'Le Tâtre (16360)' => '16380',
'Le Teich (33470)' => '33527',
'Le Temple (33680)' => '33528',
'Le Temple-sur-Lot (47110)' => '47306',
'Le Thou (17290)' => '17447',
'Le Tourne (33550)' => '33534',
'Le Tuzan (33125)' => '33536',
'Le Vanneau-Irleau (79270)' => '79337',
'Le Verdon-sur-Mer (33123)' => '33544',
'Le Vert (79170)' => '79346',
'Le Vieux-Cérier (16350)' => '16403',
'Le Vigeant (86150)' => '86289',
'Le Vigen (87110)' => '87205',
'Le Vignau (40270)' => '40329',
'Lecumberry (64220)' => '64327',
'Lédat (47300)' => '47146',
'Ledeuix (64400)' => '64328',
'Lée (64320)' => '64329',
'Lées-Athas (64490)' => '64330',
'Lège-Cap-Ferret (33950)' => '33236',
'Léguillac-de-Cercles (24340)' => '24235',
'Léguillac-de-l\'Auche (24110)' => '24236',
'Leigné-les-Bois (86450)' => '86125',
'Leigné-sur-Usseau (86230)' => '86127',
'Leignes-sur-Fontaine (86300)' => '86126',
'Lembeye (64350)' => '64331',
'Lembras (24100)' => '24237',
'Lème (64450)' => '64332',
'Lempzours (24800)' => '24238',
'Lencloître (86140)' => '86128',
'Lencouacq (40120)' => '40149',
'Léogeats (33210)' => '33237',
'Léognan (33850)' => '33238',
'Léon (40550)' => '40150',
'Léoville (17500)' => '17204',
'Lépaud (23170)' => '23106',
'Lépinas (23150)' => '23107',
'Léren (64270)' => '64334',
'Lerm-et-Musset (33840)' => '33239',
'Les Adjots (16700)' => '16002',
'Les Alleuds (79190)' => '79006',
'Les Angles-sur-Corrèze (19000)' => '19009',
'Les Artigues-de-Lussac (33570)' => '33014',
'Les Billanges (87340)' => '87016',
'Les Billaux (33500)' => '33052',
'Les Cars (87230)' => '87029',
'Les Éduts (17510)' => '17149',
'Les Églises-d\'Argenteuil (17400)' => '17150',
'Les Églisottes-et-Chalaures (33230)' => '33154',
'Les Essards (16210)' => '16130',
'Les Essards (17250)' => '17154',
'Les Esseintes (33190)' => '33158',
'Les Eyzies-de-Tayac-Sireuil (24620)' => '24172',
'Les Farges (24290)' => '24175',
'Les Forges (79340)' => '79124',
'Les Fosses (79360)' => '79126',
'Les Gonds (17100)' => '17179',
'Les Gours (16140)' => '16155',
'Les Grands-Chézeaux (87160)' => '87074',
'Les Graulges (24340)' => '24203',
'Les Groseillers (79220)' => '79139',
'Les Lèches (24400)' => '24234',
'Les Lèves-et-Thoumeyragues (33220)' => '33242',
'Les Mars (23700)' => '23123',
'Les Mathes (17570)' => '17225',
'Les Métairies (16200)' => '16220',
'Les Nouillers (17380)' => '17266',
'Les Ormes (86220)' => '86183',
'Les Peintures (33230)' => '33315',
'Les Pins (16260)' => '16261',
'Les Portes-en-Ré (17880)' => '17286',
'Les Salles-de-Castillon (33350)' => '33499',
'Les Salles-Lavauguyon (87440)' => '87189',
'Les Touches-de-Périgny (17160)' => '17451',
'Les Trois-Moutiers (86120)' => '86274',
'Lescar (64230)' => '64335',
'Lescun (64490)' => '64336',
'Lesgor (40400)' => '40151',
'Lésignac-Durand (16310)' => '16183',
'Lésigny (86270)' => '86129',
'Lesparre-Médoc (33340)' => '33240',
'Lesperon (40260)' => '40152',
'Lespielle (64350)' => '64337',
'Lespourcy (64160)' => '64338',
'Lessac (16500)' => '16181',
'Lestards (19170)' => '19112',
'Lestelle-Bétharram (64800)' => '64339',
'Lesterps (16420)' => '16182',
'Lestiac-sur-Garonne (33550)' => '33241',
'Leugny (86220)' => '86130',
'Lévignac-de-Guyenne (47120)' => '47147',
'Lévignacq (40170)' => '40154',
'Leyrat (23600)' => '23108',
'Leyritz-Moncassin (47700)' => '47148',
'Lezay (79120)' => '79148',
'Lhommaizé (86410)' => '86131',
'Lhoumois (79390)' => '79149',
'Libourne (33500)' => '33243',
'Lichans-Sunhar (64470)' => '64340',
'Lichères (16460)' => '16184',
'Lichos (64130)' => '64341',
'Licq-Athérey (64560)' => '64342',
'Liginiac (19160)' => '19113',
'Liglet (86290)' => '86132',
'Lignan-de-Bazas (33430)' => '33244',
'Lignan-de-Bordeaux (33360)' => '33245',
'Lignareix (19200)' => '19114',
'Ligné (16140)' => '16185',
'Ligneyrac (19500)' => '19115',
'Lignières-Sonneville (16130)' => '16186',
'Ligueux (33220)' => '33246',
'Ligugé (86240)' => '86133',
'Limalonges (79190)' => '79150',
'Limendous (64420)' => '64343',
'Limeuil (24510)' => '24240',
'Limeyrat (24210)' => '24241',
'Limoges (87000)' => '87085',
'Linard (23220)' => '23109',
'Linards (87130)' => '87086',
'Linars (16730)' => '16187',
'Linazay (86400)' => '86134',
'Liniers (86800)' => '86135',
'Linxe (40260)' => '40155',
'Liorac-sur-Louyre (24520)' => '24242',
'Liourdres (19120)' => '19116',
'Lioux-les-Monges (23700)' => '23110',
'Liposthey (40410)' => '40156',
'Lisle (24350)' => '24243',
'Lissac-sur-Couze (19600)' => '19117',
'Listrac-de-Durèze (33790)' => '33247',
'Listrac-Médoc (33480)' => '33248',
'Lit-et-Mixe (40170)' => '40157',
'Livron (64530)' => '64344',
'Lizant (86400)' => '86136',
'Lizières (23240)' => '23111',
'Lohitzun-Oyhercq (64120)' => '64345',
'Loire-les-Marais (17870)' => '17205',
'Loiré-sur-Nie (17470)' => '17206',
'Loix (17111)' => '17207',
'Lolme (24540)' => '24244',
'Lombia (64160)' => '64346',
'Lonçon (64410)' => '64347',
'Londigny (16700)' => '16189',
'Longèves (17230)' => '17208',
'Longré (16240)' => '16190',
'Longueville (47200)' => '47150',
'Lonnes (16230)' => '16191',
'Lons (64140)' => '64348',
'Lonzac (17520)' => '17209',
'Lorignac (17240)' => '17210',
'Lorigné (79190)' => '79152',
'Lormont (33310)' => '33249',
'Losse (40240)' => '40158',
'Lostanges (19500)' => '19119',
'Loubejac (24550)' => '24245',
'Loubens (33190)' => '33250',
'Loubès-Bernac (47120)' => '47151',
'Loubieng (64300)' => '64349',
'Loubigné (79110)' => '79153',
'Loubillé (79110)' => '79154',
'Louchats (33125)' => '33251',
'Loudun (86200)' => '86137',
'Louer (40380)' => '40159',
'Lougratte (47290)' => '47152',
'Louhossoa (64250)' => '64350',
'Louignac (19310)' => '19120',
'Louin (79600)' => '79156',
'Loulay (17330)' => '17211',
'Loupes (33370)' => '33252',
'Loupiac (33410)' => '33253',
'Loupiac-de-la-Réole (33190)' => '33254',
'Lourdios-Ichère (64570)' => '64351',
'Lourdoueix-Saint-Pierre (23360)' => '23112',
'Lourenties (64420)' => '64352',
'Lourquen (40250)' => '40160',
'Louvie-Juzon (64260)' => '64353',
'Louvie-Soubiron (64440)' => '64354',
'Louvigny (64410)' => '64355',
'Louzac-Saint-André (16100)' => '16193',
'Louzignac (17160)' => '17212',
'Louzy (79100)' => '79157',
'Lozay (17330)' => '17213',
'Lubbon (40240)' => '40161',
'Lubersac (19210)' => '19121',
'Luc-Armau (64350)' => '64356',
'Lucarré (64350)' => '64357',
'Lucbardez-et-Bargues (40090)' => '40162',
'Lucgarier (64420)' => '64358',
'Luchapt (86430)' => '86138',
'Luchat (17600)' => '17214',
'Luché-sur-Brioux (79170)' => '79158',
'Luché-Thouarsais (79330)' => '79159',
'Lucmau (33840)' => '33255',
'Lucq-de-Béarn (64360)' => '64359',
'Ludon-Médoc (33290)' => '33256',
'Lüe (40210)' => '40163',
'Lugaignac (33420)' => '33257',
'Lugasson (33760)' => '33258',
'Luglon (40630)' => '40165',
'Lugon-et-l\'Île-du-Carnay (33240)' => '33259',
'Lugos (33830)' => '33260',
'Lunas (24130)' => '24246',
'Lupersat (23190)' => '23113',
'Lupsault (16140)' => '16194',
'Luquet (65320)' => '65292',
'Lurbe-Saint-Christau (64660)' => '64360',
'Lusignac (24320)' => '24247',
'Lusignan (86600)' => '86139',
'Lusignan-Petit (47360)' => '47154',
'Lussac (16450)' => '16195',
'Lussac (17500)' => '17215',
'Lussac (33570)' => '33261',
'Lussac-les-Châteaux (86320)' => '86140',
'Lussac-les-Églises (87360)' => '87087',
'Lussagnet (40270)' => '40166',
'Lussagnet-Lusson (64160)' => '64361',
'Lussant (17430)' => '17216',
'Lussas-et-Nontronneau (24300)' => '24248',
'Lussat (23170)' => '23114',
'Lusseray (79170)' => '79160',
'Luxé (16230)' => '16196',
'Luxe-Sumberraute (64120)' => '64362',
'Luxey (40430)' => '40167',
'Luzay (79100)' => '79161',
'Lys (64260)' => '64363',
'Macau (33460)' => '33262',
'Macaye (64240)' => '64364',
'Macqueville (17490)' => '17217',
'Madaillan (47360)' => '47155',
'Madirac (33670)' => '33263',
'Madranges (19470)' => '19122',
'Magescq (40140)' => '40168',
'Magnac-Bourg (87380)' => '87088',
'Magnac-Laval (87190)' => '87089',
'Magnac-Lavalette-Villars (16320)' => '16198',
'Magnac-sur-Touvre (16600)' => '16199',
'Magnat-l\'Étrange (23260)' => '23115',
'Magné (79460)' => '79162',
'Magné (86160)' => '86141',
'Mailhac-sur-Benaize (87160)' => '87090',
'Maillas (40120)' => '40169',
'Maillé (86190)' => '86142',
'Maillères (40120)' => '40170',
'Maine-de-Boixe (16230)' => '16200',
'Mainsat (23700)' => '23116',
'Mainxe (16200)' => '16202',
'Mainzac (16380)' => '16203',
'Mairé (86270)' => '86143',
'Mairé-Levescault (79190)' => '79163',
'Maison-Feyne (23800)' => '23117',
'Maisonnais-sur-Tardoire (87440)' => '87091',
'Maisonnay (79500)' => '79164',
'Maisonneuve (86170)' => '86144',
'Maisonnisses (23150)' => '23118',
'Maisontiers (79600)' => '79165',
'Malaussanne (64410)' => '64365',
'Malaville (16120)' => '16204',
'Malemort (19360)' => '19123',
'Malleret (23260)' => '23119',
'Malleret-Boussac (23600)' => '23120',
'Malval (23220)' => '23121',
'Manaurie (24620)' => '24249',
'Mano (40410)' => '40171',
'Manot (16500)' => '16205',
'Mansac (19520)' => '19124',
'Mansat-la-Courrière (23400)' => '23122',
'Mansle (16230)' => '16206',
'Mant (40700)' => '40172',
'Manzac-sur-Vern (24110)' => '24251',
'Marans (17230)' => '17218',
'Maransin (33230)' => '33264',
'Marc-la-Tour (19150)' => '19127',
'Marçay (86370)' => '86145',
'Marcellus (47200)' => '47156',
'Marcenais (33620)' => '33266',
'Marcheprime (33380)' => '33555',
'Marcillac (33860)' => '33267',
'Marcillac-la-Croisille (19320)' => '19125',
'Marcillac-la-Croze (19500)' => '19126',
'Marcillac-Lanville (16140)' => '16207',
'Marcillac-Saint-Quentin (24200)' => '24252',
'Marennes (17320)' => '17219',
'Mareuil (16170)' => '16208',
'Mareuil (24340)' => '24253',
'Margaux (33460)' => '33268',
'Margerides (19200)' => '19128',
'Margueron (33220)' => '33269',
'Marignac (17800)' => '17220',
'Marigny (79360)' => '79166',
'Marigny-Brizay (86380)' => '86146',
'Marigny-Chemereau (86370)' => '86147',
'Marillac-le-Franc (16110)' => '16209',
'Marimbault (33430)' => '33270',
'Marions (33690)' => '33271',
'Marmande (47200)' => '47157',
'Marmont-Pachas (47220)' => '47158',
'Marnac (24220)' => '24254',
'Marnay (86160)' => '86148',
'Marnes (79600)' => '79167',
'Marpaps (40330)' => '40173',
'Marquay (24620)' => '24255',
'Marsac (16570)' => '16210',
'Marsac (23210)' => '23124',
'Marsac-sur-l\'Isle (24430)' => '24256',
'Marsais (17700)' => '17221',
'Marsalès (24540)' => '24257',
'Marsaneix (24750)' => '24258',
'Marsas (33620)' => '33272',
'Marsilly (17137)' => '17222',
'Martaizé (86330)' => '86149',
'Marthon (16380)' => '16211',
'Martignas-sur-Jalle (33127)' => '33273',
'Martillac (33650)' => '33274',
'Martres (33760)' => '33275',
'Marval (87440)' => '87092',
'Masbaraud-Mérignat (23400)' => '23126',
'Mascaraàs-Haron (64330)' => '64366',
'Maslacq (64300)' => '64367',
'Masléon (87130)' => '87093',
'Masparraute (64120)' => '64368',
'Maspie-Lalonquère-Juillacq (64350)' => '64369',
'Masquières (47370)' => '47160',
'Massac (17490)' => '17223',
'Massais (79150)' => '79168',
'Masseilles (33690)' => '33276',
'Massels (47140)' => '47161',
'Masseret (19510)' => '19129',
'Massignac (16310)' => '16212',
'Massognes (86170)' => '86150',
'Massoulès (47140)' => '47162',
'Massugas (33790)' => '33277',
'Matha (17160)' => '17224',
'Maucor (64160)' => '64370',
'Maulay (86200)' => '86151',
'Mauléon (79700)' => '79079',
'Mauléon-Licharre (64130)' => '64371',
'Mauprévoir (86460)' => '86152',
'Maure (64460)' => '64372',
'Maurens (24140)' => '24259',
'Mauriac (33540)' => '33278',
'Mauries (40320)' => '40174',
'Maurrin (40270)' => '40175',
'Maussac (19250)' => '19130',
'Mautes (23190)' => '23127',
'Mauvezin-d\'Armagnac (40240)' => '40176',
'Mauvezin-sur-Gupie (47200)' => '47163',
'Mauzac-et-Grand-Castang (24150)' => '24260',
'Mauzé-sur-le-Mignon (79210)' => '79170',
'Mauzé-Thouarsais (79100)' => '79171',
'Mauzens-et-Miremont (24260)' => '24261',
'Mayac (24420)' => '24262',
'Maylis (40250)' => '40177',
'Mazeirat (23150)' => '23128',
'Mazeray (17400)' => '17226',
'Mazères (33210)' => '33279',
'Mazères-Lezons (64110)' => '64373',
'Mazerolles (16310)' => '16213',
'Mazerolles (17800)' => '17227',
'Mazerolles (40090)' => '40178',
'Mazerolles (64230)' => '64374',
'Mazerolles (86320)' => '86153',
'Mazeuil (86110)' => '86154',
'Mazeyrolles (24550)' => '24263',
'Mazières (16270)' => '16214',
'Mazières-en-Gâtine (79310)' => '79172',
'Mazières-Naresse (47210)' => '47164',
'Mazières-sur-Béronne (79500)' => '79173',
'Mazion (33390)' => '33280',
'Méasnes (23360)' => '23130',
'Médillac (16210)' => '16215',
'Médis (17600)' => '17228',
'Mées (40990)' => '40179',
'Méharin (64120)' => '64375',
'Meilhac (87800)' => '87094',
'Meilhan (40400)' => '40180',
'Meilhan-sur-Garonne (47180)' => '47165',
'Meilhards (19510)' => '19131',
'Meillon (64510)' => '64376',
'Melle (79500)' => '79174',
'Melleran (79190)' => '79175',
'Mendionde (64240)' => '64377',
'Menditte (64130)' => '64378',
'Mendive (64220)' => '64379',
'Ménesplet (24700)' => '24264',
'Ménigoute (79340)' => '79176',
'Ménoire (19190)' => '19132',
'Mensignac (24350)' => '24266',
'Méracq (64410)' => '64380',
'Mercoeur (19430)' => '19133',
'Mérignac (16200)' => '16216',
'Mérignac (17210)' => '17229',
'Mérignac (33700)' => '33281',
'Mérignas (33350)' => '33282',
'Mérinchal (23420)' => '23131',
'Méritein (64190)' => '64381',
'Merlines (19340)' => '19134',
'Merpins (16100)' => '16217',
'Meschers-sur-Gironde (17132)' => '17230',
'Mescoules (24240)' => '24267',
'Mesnac (16370)' => '16218',
'Mesplède (64370)' => '64382',
'Messac (17130)' => '17231',
'Messanges (40660)' => '40181',
'Messé (79120)' => '79177',
'Messemé (86200)' => '86156',
'Mesterrieux (33540)' => '33283',
'Mestes (19200)' => '19135',
'Meursac (17120)' => '17232',
'Meux (17500)' => '17233',
'Meuzac (87380)' => '87095',
'Meymac (19250)' => '19136',
'Meyrals (24220)' => '24268',
'Meyrignac-l\'Église (19800)' => '19137',
'Meyssac (19500)' => '19138',
'Mézin (47170)' => '47167',
'Mézos (40170)' => '40182',
'Mialet (24450)' => '24269',
'Mialos (64410)' => '64383',
'Mignaloux-Beauvoir (86550)' => '86157',
'Migné-Auxances (86440)' => '86158',
'Migré (17330)' => '17234',
'Migron (17770)' => '17235',
'Milhac-d\'Auberoche (24330)' => '24270',
'Milhac-de-Nontron (24470)' => '24271',
'Millac (86150)' => '86159',
'Millevaches (19290)' => '19139',
'Mimbaste (40350)' => '40183',
'Mimizan (40200)' => '40184',
'Minzac (24610)' => '24272',
'Mios (33380)' => '33284',
'Miossens-Lanusse (64450)' => '64385',
'Mirambeau (17150)' => '17236',
'Miramont-de-Guyenne (47800)' => '47168',
'Miramont-Sensacq (40320)' => '40185',
'Mirebeau (86110)' => '86160',
'Mirepeix (64800)' => '64386',
'Missé (79100)' => '79178',
'Misson (40290)' => '40186',
'Moëze (17780)' => '17237',
'Moirax (47310)' => '47169',
'Moissannes (87400)' => '87099',
'Molières (24480)' => '24273',
'Moliets-et-Maa (40660)' => '40187',
'Momas (64230)' => '64387',
'Mombrier (33710)' => '33285',
'Momuy (40700)' => '40188',
'Momy (64350)' => '64388',
'Monassut-Audiracq (64160)' => '64389',
'Monbahus (47290)' => '47170',
'Monbalen (47340)' => '47171',
'Monbazillac (24240)' => '24274',
'Moncaup (64350)' => '64390',
'Moncaut (47310)' => '47172',
'Moncayolle-Larrory-Mendibieu (64130)' => '64391',
'Monceaux-sur-Dordogne (19400)' => '19140',
'Moncla (64330)' => '64392',
'Monclar (47380)' => '47173',
'Moncontour (86330)' => '86161',
'Moncoutant (79320)' => '79179',
'Moncrabeau (47600)' => '47174',
'Mondion (86230)' => '86162',
'Monein (64360)' => '64393',
'Monestier (24240)' => '24276',
'Monestier-Merlines (19340)' => '19141',
'Monestier-Port-Dieu (19110)' => '19142',
'Monfaucon (24130)' => '24277',
'Monflanquin (47150)' => '47175',
'Mongaillard (47230)' => '47176',
'Mongauzy (33190)' => '33287',
'Monget (40700)' => '40189',
'Monheurt (47160)' => '47177',
'Monmadalès (24560)' => '24278',
'Monmarvès (24560)' => '24279',
'Monpazier (24540)' => '24280',
'Monpezat (64350)' => '64394',
'Monplaisant (24170)' => '24293',
'Monprimblanc (33410)' => '33288',
'Mons (16140)' => '16221',
'Mons (17160)' => '17239',
'Monsac (24440)' => '24281',
'Monsaguel (24560)' => '24282',
'Monsec (24340)' => '24283',
'Monségur (33580)' => '33289',
'Monségur (40700)' => '40190',
'Monségur (47150)' => '47178',
'Monségur (64460)' => '64395',
'Monsempron-Libos (47500)' => '47179',
'Mont (64300)' => '64396',
'Mont-de-Marsan (40000)' => '40192',
'Mont-Disse (64330)' => '64401',
'Montagnac-d\'Auberoche (24210)' => '24284',
'Montagnac-la-Crempse (24140)' => '24285',
'Montagnac-sur-Auvignon (47600)' => '47180',
'Montagnac-sur-Lède (47150)' => '47181',
'Montagne (33570)' => '33290',
'Montagoudin (33190)' => '33291',
'Montagrier (24350)' => '24286',
'Montagut (64410)' => '64397',
'Montaignac-Saint-Hippolyte (19300)' => '19143',
'Montaigut-le-Blanc (23320)' => '23132',
'Montalembert (79190)' => '79180',
'Montamisé (86360)' => '86163',
'Montaner (64460)' => '64398',
'Montardon (64121)' => '64399',
'Montastruc (47380)' => '47182',
'Montauriol (47330)' => '47183',
'Montaut (24560)' => '24287',
'Montaut (40500)' => '40191',
'Montaut (47210)' => '47184',
'Montaut (64800)' => '64400',
'Montayral (47500)' => '47185',
'Montazeau (24230)' => '24288',
'Montboucher (23400)' => '23133',
'Montboyer (16620)' => '16222',
'Montbron (16220)' => '16223',
'Montcaret (24230)' => '24289',
'Montégut (40190)' => '40193',
'Montemboeuf (16310)' => '16225',
'Montendre (17130)' => '17240',
'Montesquieu (47130)' => '47186',
'Monteton (47120)' => '47187',
'Montferrand-du-Périgord (24440)' => '24290',
'Montfort (64190)' => '64403',
'Montfort-en-Chalosse (40380)' => '40194',
'Montgaillard (40500)' => '40195',
'Montgibaud (19210)' => '19144',
'Montguyon (17270)' => '17241',
'Monthoiron (86210)' => '86164',
'Montignac (24290)' => '24291',
'Montignac (33760)' => '33292',
'Montignac-Charente (16330)' => '16226',
'Montignac-de-Lauzun (47800)' => '47188',
'Montignac-le-Coq (16390)' => '16227',
'Montignac-Toupinerie (47350)' => '47189',
'Montigné (16170)' => '16228',
'Montils (17800)' => '17242',
'Montjean (16240)' => '16229',
'Montlieu-la-Garde (17210)' => '17243',
'Montmérac (16300)' => '16224',
'Montmoreau-Saint-Cybard (16190)' => '16230',
'Montmorillon (86500)' => '86165',
'Montory (64470)' => '64404',
'Montpellier-de-Médillan (17260)' => '17244',
'Montpeyroux (24610)' => '24292',
'Montpezat (47360)' => '47190',
'Montpon-Ménestérol (24700)' => '24294',
'Montpouillan (47200)' => '47191',
'Montravers (79140)' => '79183',
'Montrem (24110)' => '24295',
'Montreuil-Bonnin (86470)' => '86166',
'Montrol-Sénard (87330)' => '87100',
'Montrollet (16420)' => '16231',
'Montroy (17220)' => '17245',
'Monts-sur-Guesnes (86420)' => '86167',
'Montsoué (40500)' => '40196',
'Montussan (33450)' => '33293',
'Monviel (47290)' => '47192',
'Moragne (17430)' => '17246',
'Morcenx (40110)' => '40197',
'Morganx (40700)' => '40198',
'Morizès (33190)' => '33294',
'Morlaàs (64160)' => '64405',
'Morlanne (64370)' => '64406',
'Mornac (16600)' => '16232',
'Mornac-sur-Seudre (17113)' => '17247',
'Mortagne-sur-Gironde (17120)' => '17248',
'Mortemart (87330)' => '87101',
'Mortiers (17500)' => '17249',
'Morton (86120)' => '86169',
'Mortroux (23220)' => '23136',
'Mosnac (16120)' => '16233',
'Mosnac (17240)' => '17250',
'Mougon (79370)' => '79185',
'Mouguerre (64990)' => '64407',
'Mouhous (64330)' => '64408',
'Mouillac (33240)' => '33295',
'Mouleydier (24520)' => '24296',
'Moulidars (16290)' => '16234',
'Mouliets-et-Villemartin (33350)' => '33296',
'Moulin-Neuf (24700)' => '24297',
'Moulinet (47290)' => '47193',
'Moulis-en-Médoc (33480)' => '33297',
'Moulismes (86500)' => '86170',
'Moulon (33420)' => '33298',
'Moumour (64400)' => '64409',
'Mourens (33410)' => '33299',
'Mourenx (64150)' => '64410',
'Mourioux-Vieilleville (23210)' => '23137',
'Mouscardès (40290)' => '40199',
'Moussac (86150)' => '86171',
'Moustey (40410)' => '40200',
'Moustier (47800)' => '47194',
'Moustier-Ventadour (19300)' => '19145',
'Mouterre-Silly (86200)' => '86173',
'Mouterre-sur-Blourde (86430)' => '86172',
'Mouthiers-sur-Boëme (16440)' => '16236',
'Moutier-d\'Ahun (23150)' => '23138',
'Moutier-Malcard (23220)' => '23139',
'Moutier-Rozeille (23200)' => '23140',
'Moutiers-sous-Chantemerle (79320)' => '79188',
'Mouton (16460)' => '16237',
'Moutonneau (16460)' => '16238',
'Mouzon (16310)' => '16239',
'Mugron (40250)' => '40201',
'Muron (17430)' => '17253',
'Musculdy (64130)' => '64411',
'Mussidan (24400)' => '24299',
'Nabas (64190)' => '64412',
'Nabinaud (16390)' => '16240',
'Nabirat (24250)' => '24300',
'Nachamps (17380)' => '17254',
'Nadaillac (24590)' => '24301',
'Nailhac (24390)' => '24302',
'Naillat (23800)' => '23141',
'Naintré (86530)' => '86174',
'Nalliers (86310)' => '86175',
'Nanclars (16230)' => '16241',
'Nancras (17600)' => '17255',
'Nanteuil (79400)' => '79189',
'Nanteuil-Auriac-de-Bourzac (24320)' => '24303',
'Nanteuil-en-Vallée (16700)' => '16242',
'Nantheuil (24800)' => '24304',
'Nanthiat (24800)' => '24305',
'Nantiat (87140)' => '87103',
'Nantillé (17770)' => '17256',
'Narcastet (64510)' => '64413',
'Narp (64190)' => '64414',
'Narrosse (40180)' => '40202',
'Nassiet (40330)' => '40203',
'Nastringues (24230)' => '24306',
'Naujac-sur-Mer (33990)' => '33300',
'Naujan-et-Postiac (33420)' => '33301',
'Naussannes (24440)' => '24307',
'Navailles-Angos (64450)' => '64415',
'Navarrenx (64190)' => '64416',
'Naves (19460)' => '19146',
'Nay (64800)' => '64417',
'Néac (33500)' => '33302',
'Nedde (87120)' => '87104',
'Négrondes (24460)' => '24308',
'Néoux (23200)' => '23142',
'Nérac (47600)' => '47195',
'Nerbis (40250)' => '40204',
'Nercillac (16200)' => '16243',
'Néré (17510)' => '17257',
'Nérigean (33750)' => '33303',
'Nérignac (86150)' => '86176',
'Nersac (16440)' => '16244',
'Nespouls (19600)' => '19147',
'Neuffons (33580)' => '33304',
'Neuillac (17520)' => '17258',
'Neulles (17500)' => '17259',
'Neuvic (19160)' => '19148',
'Neuvic (24190)' => '24309',
'Neuvic-Entier (87130)' => '87105',
'Neuvicq (17270)' => '17260',
'Neuvicq-le-Château (17490)' => '17261',
'Neuville (19380)' => '19149',
'Neuville-de-Poitou (86170)' => '86177',
'Neuvy-Bouin (79130)' => '79190',
'Nexon (87800)' => '87106',
'Nicole (47190)' => '47196',
'Nieuil (16270)' => '16245',
'Nieuil-l\'Espoir (86340)' => '86178',
'Nieul (87510)' => '87107',
'Nieul-le-Virouil (17150)' => '17263',
'Nieul-lès-Saintes (17810)' => '17262',
'Nieul-sur-Mer (17137)' => '17264',
'Nieulle-sur-Seudre (17600)' => '17265',
'Niort (79000)' => '79191',
'Noailhac (19500)' => '19150',
'Noaillac (33190)' => '33306',
'Noaillan (33730)' => '33307',
'Noailles (19600)' => '19151',
'Noguères (64150)' => '64418',
'Nomdieu (47600)' => '47197',
'Nonac (16190)' => '16246',
'Nonards (19120)' => '19152',
'Nonaville (16120)' => '16247',
'Nontron (24300)' => '24311',
'Noth (23300)' => '23143',
'Notre-Dame-de-Sanilhac (24660)' => '24312',
'Nouaillé-Maupertuis (86340)' => '86180',
'Nouhant (23170)' => '23145',
'Nouic (87330)' => '87108',
'Nousse (40380)' => '40205',
'Nousty (64420)' => '64419',
'Nouzerines (23600)' => '23146',
'Nouzerolles (23360)' => '23147',
'Nouziers (23350)' => '23148',
'Nuaillé-d\'Aunis (17540)' => '17267',
'Nuaillé-sur-Boutonne (17470)' => '17268',
'Nueil-les-Aubiers (79250)' => '79195',
'Nueil-sous-Faye (86200)' => '86181',
'Objat (19130)' => '19153',
'Oeyregave (40300)' => '40206',
'Oeyreluy (40180)' => '40207',
'Ogenne-Camptort (64190)' => '64420',
'Ogeu-les-Bains (64680)' => '64421',
'Oiron (79100)' => '79196',
'Oloron-Sainte-Marie (64400)' => '64422',
'Omet (33410)' => '33308',
'Onard (40380)' => '40208',
'Ondres (40440)' => '40209',
'Onesse-Laharie (40110)' => '40210',
'Oraàs (64390)' => '64423',
'Oradour (16140)' => '16248',
'Oradour-Fanais (16500)' => '16249',
'Oradour-Saint-Genest (87210)' => '87109',
'Oradour-sur-Glane (87520)' => '87110',
'Oradour-sur-Vayres (87150)' => '87111',
'Orches (86230)' => '86182',
'Ordiarp (64130)' => '64424',
'Ordonnac (33340)' => '33309',
'Orègue (64120)' => '64425',
'Orgedeuil (16220)' => '16250',
'Orgnac-sur-Vézère (19410)' => '19154',
'Origne (33113)' => '33310',
'Orignolles (17210)' => '17269',
'Orin (64400)' => '64426',
'Oriolles (16480)' => '16251',
'Orion (64390)' => '64427',
'Orist (40300)' => '40211',
'Orival (16210)' => '16252',
'Orliac (24170)' => '24313',
'Orliac-de-Bar (19390)' => '19155',
'Orliaguet (24370)' => '24314',
'Oroux (79390)' => '79197',
'Orriule (64390)' => '64428',
'Orsanco (64120)' => '64429',
'Orthevielle (40300)' => '40212',
'Orthez (64300)' => '64430',
'Orx (40230)' => '40213',
'Os-Marsillon (64150)' => '64431',
'Ossages (40290)' => '40214',
'Ossas-Suhare (64470)' => '64432',
'Osse-en-Aspe (64490)' => '64433',
'Ossenx (64190)' => '64434',
'Osserain-Rivareyte (64390)' => '64435',
'Ossès (64780)' => '64436',
'Ostabat-Asme (64120)' => '64437',
'Ouillon (64160)' => '64438',
'Ousse (64320)' => '64439',
'Ousse-Suzan (40110)' => '40215',
'Ouzilly (86380)' => '86184',
'Oyré (86220)' => '86186',
'Ozenx-Montestrucq (64300)' => '64440',
'Ozillac (17500)' => '17270',
'Ozourt (40380)' => '40216',
'Pageas (87230)' => '87112',
'Pagolle (64120)' => '64441',
'Paillé (17470)' => '17271',
'Paillet (33550)' => '33311',
'Pailloles (47440)' => '47198',
'Paizay-le-Chapt (79170)' => '79198',
'Paizay-le-Sec (86300)' => '86187',
'Paizay-le-Tort (79500)' => '79199',
'Paizay-Naudouin-Embourie (16240)' => '16253',
'Palazinges (19190)' => '19156',
'Palisse (19160)' => '19157',
'Palluaud (16390)' => '16254',
'Pamplie (79220)' => '79200',
'Pamproux (79800)' => '79201',
'Panazol (87350)' => '87114',
'Pandrignes (19150)' => '19158',
'Parbayse (64360)' => '64442',
'Parcoul-Chenaud (24410)' => '24316',
'Pardaillan (47120)' => '47199',
'Pardies (64150)' => '64443',
'Pardies-Piétat (64800)' => '64444',
'Parempuyre (33290)' => '33312',
'Parentis-en-Born (40160)' => '40217',
'Parleboscq (40310)' => '40218',
'Parranquet (47210)' => '47200',
'Parsac-Rimondeix (23140)' => '23149',
'Parthenay (79200)' => '79202',
'Parzac (16450)' => '16255',
'Pas-de-Jeu (79100)' => '79203',
'Passirac (16480)' => '16256',
'Pau (64000)' => '64445',
'Pauillac (33250)' => '33314',
'Paulhiac (47150)' => '47202',
'Paulin (24590)' => '24317',
'Paunat (24510)' => '24318',
'Paussac-et-Saint-Vivien (24310)' => '24319',
'Payré (86700)' => '86188',
'Payros-Cazautets (40320)' => '40219',
'Payroux (86350)' => '86189',
'Pays de Belvès (24170)' => '24035',
'Payzac (24270)' => '24320',
'Pazayac (24120)' => '24321',
'Pécorade (40320)' => '40220',
'Pellegrue (33790)' => '33316',
'Penne-d\'Agenais (47140)' => '47203',
'Pensol (87440)' => '87115',
'Péré (17700)' => '17272',
'Péret-Bel-Air (19300)' => '19159',
'Pérignac (16250)' => '16258',
'Pérignac (17800)' => '17273',
'Périgné (79170)' => '79204',
'Périgny (17180)' => '17274',
'Périgueux (24000)' => '24322',
'Périssac (33240)' => '33317',
'Pérols-sur-Vézère (19170)' => '19160',
'Perpezac-le-Blanc (19310)' => '19161',
'Perpezac-le-Noir (19410)' => '19162',
'Perquie (40190)' => '40221',
'Pers (79190)' => '79205',
'Persac (86320)' => '86190',
'Pessac (33600)' => '33318',
'Pessac-sur-Dordogne (33890)' => '33319',
'Pessines (17810)' => '17275',
'Petit-Bersac (24600)' => '24323',
'Petit-Palais-et-Cornemps (33570)' => '33320',
'Peujard (33240)' => '33321',
'Pey (40300)' => '40222',
'Peyrabout (23000)' => '23150',
'Peyrat-de-Bellac (87300)' => '87116',
'Peyrat-la-Nonière (23130)' => '23151',
'Peyrat-le-Château (87470)' => '87117',
'Peyre (40700)' => '40223',
'Peyrehorade (40300)' => '40224',
'Peyrelevade (19290)' => '19164',
'Peyrelongue-Abos (64350)' => '64446',
'Peyrière (47350)' => '47204',
'Peyrignac (24210)' => '24324',
'Peyrilhac (87510)' => '87118',
'Peyrillac-et-Millac (24370)' => '24325',
'Peyrissac (19260)' => '19165',
'Peyzac-le-Moustier (24620)' => '24326',
'Pezuls (24510)' => '24327',
'Philondenx (40320)' => '40225',
'Piégut-Pluviers (24360)' => '24328',
'Pierre-Buffière (87260)' => '87119',
'Pierrefitte (19450)' => '19166',
'Pierrefitte (23130)' => '23152',
'Pierrefitte (79330)' => '79209',
'Piets-Plasence-Moustrou (64410)' => '64447',
'Pillac (16390)' => '16260',
'Pimbo (40320)' => '40226',
'Pindères (47700)' => '47205',
'Pindray (86500)' => '86191',
'Pinel-Hauterive (47380)' => '47206',
'Pineuilh (33220)' => '33324',
'Pionnat (23140)' => '23154',
'Pioussay (79110)' => '79211',
'Pisany (17600)' => '17278',
'Pissos (40410)' => '40227',
'Plaisance (24560)' => '24168',
'Plaisance (86500)' => '86192',
'Plassac (17240)' => '17279',
'Plassac (33390)' => '33325',
'Plassac-Rouffiac (16250)' => '16263',
'Plassay (17250)' => '17280',
'Plazac (24580)' => '24330',
'Pleine-Selve (33820)' => '33326',
'Pleumartin (86450)' => '86193',
'Pleuville (16490)' => '16264',
'Pliboux (79190)' => '79212',
'Podensac (33720)' => '33327',
'Poey-d\'Oloron (64400)' => '64449',
'Poey-de-Lescar (64230)' => '64448',
'Poitiers (86000)' => '86194',
'Polignac (17210)' => '17281',
'Pomarez (40360)' => '40228',
'Pomerol (33500)' => '33328',
'Pommiers-Moulons (17130)' => '17282',
'Pompaire (79200)' => '79213',
'Pompéjac (33730)' => '33329',
'Pompiey (47230)' => '47207',
'Pompignac (33370)' => '33330',
'Pompogne (47420)' => '47208',
'Pomport (24240)' => '24331',
'Pomps (64370)' => '64450',
'Pondaurat (33190)' => '33331',
'Pons (17800)' => '17283',
'Ponson-Debat-Pouts (64460)' => '64451',
'Ponson-Dessus (64460)' => '64452',
'Pont-du-Casse (47480)' => '47209',
'Pont-l\'Abbé-d\'Arnoult (17250)' => '17284',
'Pontacq (64530)' => '64453',
'Pontarion (23250)' => '23155',
'Pontcharraud (23260)' => '23156',
'Pontenx-les-Forges (40200)' => '40229',
'Ponteyraud (24410)' => '24333',
'Pontiacq-Viellepinte (64460)' => '64454',
'Pontonx-sur-l\'Adour (40465)' => '40230',
'Pontours (24150)' => '24334',
'Porchères (33660)' => '33332',
'Port-d\'Envaux (17350)' => '17285',
'Port-de-Lanne (40300)' => '40231',
'Port-de-Piles (86220)' => '86195',
'Port-des-Barques (17730)' => '17484',
'Port-Sainte-Foy-et-Ponchapt (33220)' => '24335',
'Port-Sainte-Marie (47130)' => '47210',
'Portet (64330)' => '64455',
'Portets (33640)' => '33334',
'Pouançay (86120)' => '86196',
'Pouant (86200)' => '86197',
'Poudenas (47170)' => '47211',
'Poudenx (40700)' => '40232',
'Pouffonds (79500)' => '79214',
'Pougne-Hérisson (79130)' => '79215',
'Pouillac (17210)' => '17287',
'Pouillé (86800)' => '86198',
'Pouillon (40350)' => '40233',
'Pouliacq (64410)' => '64456',
'Poullignac (16190)' => '16267',
'Poursac (16700)' => '16268',
'Poursay-Garnaud (17400)' => '17288',
'Poursiugues-Boucoue (64410)' => '64457',
'Poussanges (23500)' => '23158',
'Poussignac (47700)' => '47212',
'Pouydesseaux (40120)' => '40234',
'Poyanne (40380)' => '40235',
'Poyartin (40380)' => '40236',
'Pradines (19170)' => '19168',
'Prahecq (79230)' => '79216',
'Prailles (79370)' => '79217',
'Pranzac (16110)' => '16269',
'Prats-de-Carlux (24370)' => '24336',
'Prats-du-Périgord (24550)' => '24337',
'Prayssas (47360)' => '47213',
'Préchac (33730)' => '33336',
'Préchacq-Josbaig (64190)' => '64458',
'Préchacq-les-Bains (40465)' => '40237',
'Préchacq-Navarrenx (64190)' => '64459',
'Précilhon (64400)' => '64460',
'Préguillac (17460)' => '17289',
'Preignac (33210)' => '33337',
'Pressac (86460)' => '86200',
'Pressignac (16150)' => '16270',
'Pressignac-Vicq (24150)' => '24338',
'Pressigny (79390)' => '79218',
'Preyssac-d\'Excideuil (24160)' => '24339',
'Priaires (79210)' => '79219',
'Prignac (17160)' => '17290',
'Prignac-en-Médoc (33340)' => '33338',
'Prignac-et-Marcamps (33710)' => '33339',
'Prigonrieux (24130)' => '24340',
'Prin-Deyrançon (79210)' => '79220',
'Prinçay (86420)' => '86201',
'Prissé-la-Charrière (79360)' => '79078',
'Proissans (24200)' => '24341',
'Puch-d\'Agenais (47160)' => '47214',
'Pugnac (33710)' => '33341',
'Pugny (79320)' => '79222',
'Puihardy (79160)' => '79223',
'Puilboreau (17138)' => '17291',
'Puisseguin (33570)' => '33342',
'Pujo-le-Plan (40190)' => '40238',
'Pujols (33350)' => '33344',
'Pujols (47300)' => '47215',
'Pujols-sur-Ciron (33210)' => '33343',
'Puy-d\'Arnac (19120)' => '19169',
'Puy-du-Lac (17380)' => '17292',
'Puy-Malsignat (23130)' => '23159',
'Puybarban (33190)' => '33346',
'Puymiclan (47350)' => '47216',
'Puymirol (47270)' => '47217',
'Puymoyen (16400)' => '16271',
'Puynormand (33660)' => '33347',
'Puyol-Cazalet (40320)' => '40239',
'Puyoô (64270)' => '64461',
'Puyravault (17700)' => '17293',
'Puyréaux (16230)' => '16272',
'Puyrenier (24340)' => '24344',
'Puyrolland (17380)' => '17294',
'Puysserampion (47800)' => '47218',
'Queaux (86150)' => '86203',
'Queyrac (33340)' => '33348',
'Queyssac (24140)' => '24345',
'Queyssac-les-Vignes (19120)' => '19170',
'Quinçay (86190)' => '86204',
'Quinsac (24530)' => '24346',
'Quinsac (33360)' => '33349',
'Raix (16240)' => '16273',
'Ramous (64270)' => '64462',
'Rampieux (24440)' => '24347',
'Rancogne (16110)' => '16274',
'Rancon (87290)' => '87121',
'Ranton (86200)' => '86205',
'Ranville-Breuillaud (16140)' => '16275',
'Raslay (86120)' => '86206',
'Rauzan (33420)' => '33350',
'Rayet (47210)' => '47219',
'Razac-d\'Eymet (24500)' => '24348',
'Razac-de-Saussignac (24240)' => '24349',
'Razac-sur-l\'Isle (24430)' => '24350',
'Razès (87640)' => '87122',
'Razimet (47160)' => '47220',
'Réaup-Lisse (47170)' => '47221',
'Réaux sur Trèfle (17500)' => '17295',
'Rébénacq (64260)' => '64463',
'Reffannes (79420)' => '79225',
'Reignac (16360)' => '16276',
'Reignac (33860)' => '33351',
'Rempnat (87120)' => '87123',
'Renung (40270)' => '40240',
'Réparsac (16200)' => '16277',
'Rétaud (17460)' => '17296',
'Reterre (23110)' => '23160',
'Retjons (40120)' => '40164',
'Reygade (19430)' => '19171',
'Ribagnac (24240)' => '24351',
'Ribarrouy (64330)' => '64464',
'Ribérac (24600)' => '24352',
'Rilhac-Lastours (87800)' => '87124',
'Rilhac-Rancon (87570)' => '87125',
'Rilhac-Treignac (19260)' => '19172',
'Rilhac-Xaintrie (19220)' => '19173',
'Rimbez-et-Baudiets (40310)' => '40242',
'Rimons (33580)' => '33353',
'Riocaud (33220)' => '33354',
'Rion-des-Landes (40370)' => '40243',
'Rions (33410)' => '33355',
'Rioux (17460)' => '17298',
'Rioux-Martin (16210)' => '16279',
'Riupeyrous (64160)' => '64465',
'Rivedoux-Plage (17940)' => '17297',
'Rivehaute (64190)' => '64466',
'Rives (47210)' => '47223',
'Rivière-Saas-et-Gourby (40180)' => '40244',
'Rivières (16110)' => '16280',
'Roaillan (33210)' => '33357',
'Roche-le-Peyroux (19160)' => '19175',
'Rochechouart (87600)' => '87126',
'Rochefort (17300)' => '17299',
'Roches (23270)' => '23162',
'Roches-Prémarie-Andillé (86340)' => '86209',
'Roiffé (86120)' => '86210',
'Rom (79120)' => '79230',
'Romagne (33760)' => '33358',
'Romagne (86700)' => '86211',
'Romans (79260)' => '79231',
'Romazières (17510)' => '17301',
'Romegoux (17250)' => '17302',
'Romestaing (47250)' => '47224',
'Ronsenac (16320)' => '16283',
'Rontignon (64110)' => '64467',
'Roquebrune (33580)' => '33359',
'Roquefort (40120)' => '40245',
'Roquefort (47310)' => '47225',
'Roquiague (64130)' => '64468',
'Rosiers-d\'Égletons (19300)' => '19176',
'Rosiers-de-Juillac (19350)' => '19177',
'Rouffiac (16210)' => '16284',
'Rouffiac (17800)' => '17304',
'Rouffignac (17130)' => '17305',
'Rouffignac-de-Sigoulès (24240)' => '24357',
'Rouffignac-Saint-Cernin-de-Reilhac (24580)' => '24356',
'Rougnac (16320)' => '16285',
'Rougnat (23700)' => '23164',
'Rouillac (16170)' => '16286',
'Rouillé (86480)' => '86213',
'Roullet-Saint-Estèphe (16440)' => '16287',
'Roumagne (47800)' => '47226',
'Roumazières-Loubert (16270)' => '16192',
'Roussac (87140)' => '87128',
'Roussines (16310)' => '16289',
'Rouzède (16220)' => '16290',
'Royan (17200)' => '17306',
'Royère-de-Vassivière (23460)' => '23165',
'Royères (87400)' => '87129',
'Roziers-Saint-Georges (87130)' => '87130',
'Ruch (33350)' => '33361',
'Rudeau-Ladosse (24340)' => '24221',
'Ruelle-sur-Touvre (16600)' => '16291',
'Ruffec (16700)' => '16292',
'Ruffiac (47700)' => '47227',
'Sablonceaux (17600)' => '17307',
'Sablons (33910)' => '33362',
'Sabres (40630)' => '40246',
'Sadillac (24500)' => '24359',
'Sadirac (33670)' => '33363',
'Sadroc (19270)' => '19178',
'Sagelat (24170)' => '24360',
'Sagnat (23800)' => '23166',
'Saillac (19500)' => '19179',
'Saillans (33141)' => '33364',
'Saillat-sur-Vienne (87720)' => '87131',
'Saint Aulaye-Puymangou (24410)' => '24376',
'Saint Maurice Étusson (79150)' => '79280',
'Saint-Abit (64800)' => '64469',
'Saint-Adjutory (16310)' => '16293',
'Saint-Agnant (17620)' => '17308',
'Saint-Agnant-de-Versillat (23300)' => '23177',
'Saint-Agnant-près-Crocq (23260)' => '23178',
'Saint-Agne (24520)' => '24361',
'Saint-Agnet (40800)' => '40247',
'Saint-Aignan (33126)' => '33365',
'Saint-Aigulin (17360)' => '17309',
'Saint-Alpinien (23200)' => '23179',
'Saint-Amand (23200)' => '23180',
'Saint-Amand-de-Coly (24290)' => '24364',
'Saint-Amand-de-Vergt (24380)' => '24365',
'Saint-Amand-Jartoudeix (23400)' => '23181',
'Saint-Amand-le-Petit (87120)' => '87132',
'Saint-Amand-Magnazeix (87290)' => '87133',
'Saint-Amand-sur-Sèvre (79700)' => '79235',
'Saint-Amant-de-Boixe (16330)' => '16295',
'Saint-Amant-de-Bonnieure (16230)' => '16296',
'Saint-Amant-de-Montmoreau (16190)' => '16294',
'Saint-Amant-de-Nouère (16170)' => '16298',
'Saint-André-d\'Allas (24200)' => '24366',
'Saint-André-de-Cubzac (33240)' => '33366',
'Saint-André-de-Double (24190)' => '24367',
'Saint-André-de-Lidon (17260)' => '17310',
'Saint-André-de-Seignanx (40390)' => '40248',
'Saint-André-du-Bois (33490)' => '33367',
'Saint-André-et-Appelles (33220)' => '33369',
'Saint-André-sur-Sèvre (79380)' => '79236',
'Saint-Androny (33390)' => '33370',
'Saint-Angeau (16230)' => '16300',
'Saint-Angel (19200)' => '19180',
'Saint-Antoine-Cumond (24410)' => '24368',
'Saint-Antoine-d\'Auberoche (24330)' => '24369',
'Saint-Antoine-de-Breuilh (24230)' => '24370',
'Saint-Antoine-de-Ficalba (47340)' => '47228',
'Saint-Antoine-du-Queyret (33790)' => '33372',
'Saint-Antoine-sur-l\'Isle (33660)' => '33373',
'Saint-Aquilin (24110)' => '24371',
'Saint-Armou (64160)' => '64470',
'Saint-Astier (24110)' => '24372',
'Saint-Astier (47120)' => '47229',
'Saint-Aubin (40250)' => '40249',
'Saint-Aubin (47150)' => '47230',
'Saint-Aubin-de-Blaye (33820)' => '33374',
'Saint-Aubin-de-Branne (33420)' => '33375',
'Saint-Aubin-de-Cadelech (24500)' => '24373',
'Saint-Aubin-de-Lanquais (24560)' => '24374',
'Saint-Aubin-de-Médoc (33160)' => '33376',
'Saint-Aubin-de-Nabirat (24250)' => '24375',
'Saint-Aubin-du-Plain (79300)' => '79238',
'Saint-Aubin-le-Cloud (79450)' => '79239',
'Saint-Augustin (17570)' => '17311',
'Saint-Augustin (19390)' => '19181',
'Saint-Aulaire (19130)' => '19182',
'Saint-Aulais-la-Chapelle (16300)' => '16301',
'Saint-Auvent (87310)' => '87135',
'Saint-Avit (16210)' => '16302',
'Saint-Avit (40090)' => '40250',
'Saint-Avit (47350)' => '47231',
'Saint-Avit-de-Soulège (33220)' => '33377',
'Saint-Avit-de-Tardes (23200)' => '23182',
'Saint-Avit-de-Vialard (24260)' => '24377',
'Saint-Avit-le-Pauvre (23480)' => '23183',
'Saint-Avit-Rivière (24540)' => '24378',
'Saint-Avit-Saint-Nazaire (33220)' => '33378',
'Saint-Avit-Sénieur (24440)' => '24379',
'Saint-Barbant (87330)' => '87136',
'Saint-Bard (23260)' => '23184',
'Saint-Barthélemy (40390)' => '40251',
'Saint-Barthélemy-d\'Agenais (47350)' => '47232',
'Saint-Barthélemy-de-Bellegarde (24700)' => '24380',
'Saint-Barthélemy-de-Bussière (24360)' => '24381',
'Saint-Bazile (87150)' => '87137',
'Saint-Bazile-de-la-Roche (19320)' => '19183',
'Saint-Bazile-de-Meyssac (19500)' => '19184',
'Saint-Benoît (86280)' => '86214',
'Saint-Boès (64300)' => '64471',
'Saint-Bonnet (16300)' => '16303',
'Saint-Bonnet-Avalouze (19150)' => '19185',
'Saint-Bonnet-Briance (87260)' => '87138',
'Saint-Bonnet-de-Bellac (87300)' => '87139',
'Saint-Bonnet-Elvert (19380)' => '19186',
'Saint-Bonnet-l\'Enfantier (19410)' => '19188',
'Saint-Bonnet-la-Rivière (19130)' => '19187',
'Saint-Bonnet-les-Tours-de-Merle (19430)' => '19189',
'Saint-Bonnet-près-Bort (19200)' => '19190',
'Saint-Bonnet-sur-Gironde (17150)' => '17312',
'Saint-Brice (16100)' => '16304',
'Saint-Brice (33540)' => '33379',
'Saint-Brice-sur-Vienne (87200)' => '87140',
'Saint-Bris-des-Bois (17770)' => '17313',
'Saint-Caprais-de-Blaye (33820)' => '33380',
'Saint-Caprais-de-Bordeaux (33880)' => '33381',
'Saint-Caprais-de-Lerm (47270)' => '47234',
'Saint-Capraise-d\'Eymet (24500)' => '24383',
'Saint-Capraise-de-Lalinde (24150)' => '24382',
'Saint-Cassien (24540)' => '24384',
'Saint-Castin (64160)' => '64472',
'Saint-Cernin-de-l\'Herm (24550)' => '24386',
'Saint-Cernin-de-Labarde (24560)' => '24385',
'Saint-Cernin-de-Larche (19600)' => '19191',
'Saint-Césaire (17770)' => '17314',
'Saint-Chabrais (23130)' => '23185',
'Saint-Chamant (19380)' => '19192',
'Saint-Chamassy (24260)' => '24388',
'Saint-Christoly-de-Blaye (33920)' => '33382',
'Saint-Christoly-Médoc (33340)' => '33383',
'Saint-Christophe (16420)' => '16306',
'Saint-Christophe (17220)' => '17315',
'Saint-Christophe (23000)' => '23186',
'Saint-Christophe (86230)' => '86217',
'Saint-Christophe-de-Double (33230)' => '33385',
'Saint-Christophe-des-Bardes (33330)' => '33384',
'Saint-Christophe-sur-Roc (79220)' => '79241',
'Saint-Cibard (33570)' => '33386',
'Saint-Ciers-Champagne (17520)' => '17316',
'Saint-Ciers-d\'Abzac (33910)' => '33387',
'Saint-Ciers-de-Canesse (33710)' => '33388',
'Saint-Ciers-du-Taillon (17240)' => '17317',
'Saint-Ciers-sur-Bonnieure (16230)' => '16307',
'Saint-Ciers-sur-Gironde (33820)' => '33389',
'Saint-Cirgues-la-Loutre (19220)' => '19193',
'Saint-Cirq (24260)' => '24389',
'Saint-Clair (86330)' => '86218',
'Saint-Claud (16450)' => '16308',
'Saint-Clément (19700)' => '19194',
'Saint-Clément-des-Baleines (17590)' => '17318',
'Saint-Colomb-de-Lauzun (47410)' => '47235',
'Saint-Côme (33430)' => '33391',
'Saint-Coutant (16350)' => '16310',
'Saint-Coutant (79120)' => '79243',
'Saint-Coutant-le-Grand (17430)' => '17320',
'Saint-Crépin (17380)' => '17321',
'Saint-Crépin-d\'Auberoche (24330)' => '24390',
'Saint-Crépin-de-Richemont (24310)' => '24391',
'Saint-Crépin-et-Carlucet (24590)' => '24392',
'Saint-Cricq-Chalosse (40700)' => '40253',
'Saint-Cricq-du-Gave (40300)' => '40254',
'Saint-Cricq-Villeneuve (40190)' => '40255',
'Saint-Cybardeaux (16170)' => '16312',
'Saint-Cybranet (24250)' => '24395',
'Saint-Cyprien (19130)' => '19195',
'Saint-Cyprien (24220)' => '24396',
'Saint-Cyr (86130)' => '86219',
'Saint-Cyr (87310)' => '87141',
'Saint-Cyr-du-Doret (17170)' => '17322',
'Saint-Cyr-la-Lande (79100)' => '79244',
'Saint-Cyr-la-Roche (19130)' => '19196',
'Saint-Cyr-les-Champagnes (24270)' => '24397',
'Saint-Denis-d\'Oléron (17650)' => '17323',
'Saint-Denis-de-Pile (33910)' => '33393',
'Saint-Denis-des-Murs (87400)' => '87142',
'Saint-Dizant-du-Bois (17150)' => '17324',
'Saint-Dizant-du-Gua (17240)' => '17325',
'Saint-Dizier-la-Tour (23130)' => '23187',
'Saint-Dizier-les-Domaines (23270)' => '23188',
'Saint-Dizier-Leyrenne (23400)' => '23189',
'Saint-Domet (23190)' => '23190',
'Saint-Dos (64270)' => '64474',
'Saint-Éloi (23000)' => '23191',
'Saint-Éloy-les-Tuileries (19210)' => '19198',
'Saint-Émilion (33330)' => '33394',
'Saint-Esteben (64640)' => '64476',
'Saint-Estèphe (24360)' => '24398',
'Saint-Estèphe (33180)' => '33395',
'Saint-Étienne-aux-Clos (19200)' => '19199',
'Saint-Étienne-d\'Orthe (40300)' => '40256',
'Saint-Étienne-de-Baïgorry (64430)' => '64477',
'Saint-Étienne-de-Fougères (47380)' => '47239',
'Saint-Étienne-de-Fursac (23290)' => '23192',
'Saint-Étienne-de-Lisse (33330)' => '33396',
'Saint-Étienne-de-Puycorbier (24400)' => '24399',
'Saint-Étienne-de-Villeréal (47210)' => '47240',
'Saint-Étienne-la-Cigogne (79360)' => '79247',
'Saint-Étienne-la-Geneste (19160)' => '19200',
'Saint-Eugène (17520)' => '17326',
'Saint-Eutrope (16190)' => '16314',
'Saint-Eutrope-de-Born (47210)' => '47241',
'Saint-Exupéry (33190)' => '33398',
'Saint-Exupéry-les-Roches (19200)' => '19201',
'Saint-Faust (64110)' => '64478',
'Saint-Félix (16480)' => '16315',
'Saint-Félix (17330)' => '17327',
'Saint-Félix-de-Bourdeilles (24340)' => '24403',
'Saint-Félix-de-Foncaude (33540)' => '33399',
'Saint-Félix-de-Reillac-et-Mortemart (24260)' => '24404',
'Saint-Félix-de-Villadeix (24510)' => '24405',
'Saint-Ferme (33580)' => '33400',
'Saint-Fiel (23000)' => '23195',
'Saint-Fort-sur-Gironde (17240)' => '17328',
'Saint-Fort-sur-le-Né (16130)' => '16316',
'Saint-Fraigne (16140)' => '16317',
'Saint-Fréjoux (19200)' => '19204',
'Saint-Frion (23500)' => '23196',
'Saint-Front (16460)' => '16318',
'Saint-Front-d\'Alemps (24460)' => '24408',
'Saint-Front-de-Pradoux (24400)' => '24409',
'Saint-Front-la-Rivière (24300)' => '24410',
'Saint-Front-sur-Lémance (47500)' => '47242',
'Saint-Front-sur-Nizonne (24300)' => '24411',
'Saint-Froult (17780)' => '17329',
'Saint-Gaudent (86400)' => '86220',
'Saint-Gein (40190)' => '40259',
'Saint-Gelais (79410)' => '79249',
'Saint-Génard (79500)' => '79251',
'Saint-Gence (87510)' => '87143',
'Saint-Généroux (79600)' => '79252',
'Saint-Genès-de-Blaye (33390)' => '33405',
'Saint-Genès-de-Castillon (33350)' => '33406',
'Saint-Genès-de-Fronsac (33240)' => '33407',
'Saint-Genès-de-Lombaud (33670)' => '33408',
'Saint-Genest-d\'Ambière (86140)' => '86221',
'Saint-Genest-sur-Roselle (87260)' => '87144',
'Saint-Geniès (24590)' => '24412',
'Saint-Geniez-ô-Merle (19220)' => '19205',
'Saint-Genis-d\'Hiersac (16570)' => '16320',
'Saint-Genis-de-Saintonge (17240)' => '17331',
'Saint-Genis-du-Bois (33760)' => '33409',
'Saint-Georges (16700)' => '16321',
'Saint-Georges (47370)' => '47328',
'Saint-Georges-Antignac (17240)' => '17332',
'Saint-Georges-Blancaneix (24130)' => '24413',
'Saint-Georges-d\'Oléron (17190)' => '17337',
'Saint-Georges-de-Didonne (17110)' => '17333',
'Saint-Georges-de-Longuepierre (17470)' => '17334',
'Saint-Georges-de-Montclard (24140)' => '24414',
'Saint-Georges-de-Noisné (79400)' => '79253',
'Saint-Georges-de-Rex (79210)' => '79254',
'Saint-Georges-des-Agoûts (17150)' => '17335',
'Saint-Georges-des-Coteaux (17810)' => '17336',
'Saint-Georges-du-Bois (17700)' => '17338',
'Saint-Georges-la-Pouge (23250)' => '23197',
'Saint-Georges-lès-Baillargeaux (86130)' => '86222',
'Saint-Georges-les-Landes (87160)' => '87145',
'Saint-Georges-Nigremont (23500)' => '23198',
'Saint-Geours-d\'Auribat (40380)' => '40260',
'Saint-Geours-de-Maremne (40230)' => '40261',
'Saint-Géraud (47120)' => '47245',
'Saint-Géraud-de-Corps (24700)' => '24415',
'Saint-Germain (86310)' => '86223',
'Saint-Germain-Beaupré (23160)' => '23199',
'Saint-Germain-d\'Esteuil (33340)' => '33412',
'Saint-Germain-de-Belvès (24170)' => '24416',
'Saint-Germain-de-Grave (33490)' => '33411',
'Saint-Germain-de-la-Rivière (33240)' => '33414',
'Saint-Germain-de-Longue-Chaume (79200)' => '79255',
'Saint-Germain-de-Lusignan (17500)' => '17339',
'Saint-Germain-de-Marencennes (17700)' => '17340',
'Saint-Germain-de-Montbron (16380)' => '16323',
'Saint-Germain-de-Vibrac (17500)' => '17341',
'Saint-Germain-des-Prés (24160)' => '24417',
'Saint-Germain-du-Puch (33750)' => '33413',
'Saint-Germain-du-Salembre (24190)' => '24418',
'Saint-Germain-du-Seudre (17240)' => '17342',
'Saint-Germain-et-Mons (24520)' => '24419',
'Saint-Germain-Lavolps (19290)' => '19206',
'Saint-Germain-les-Belles (87380)' => '87146',
'Saint-Germain-les-Vergnes (19330)' => '19207',
'Saint-Germier (79340)' => '79256',
'Saint-Gervais (33240)' => '33415',
'Saint-Gervais-les-Trois-Clochers (86230)' => '86224',
'Saint-Géry (24400)' => '24420',
'Saint-Geyrac (24330)' => '24421',
'Saint-Gilles-les-Forêts (87130)' => '87147',
'Saint-Girons-d\'Aiguevives (33920)' => '33416',
'Saint-Girons-en-Béarn (64300)' => '64479',
'Saint-Gladie-Arrive-Munein (64390)' => '64480',
'Saint-Goin (64400)' => '64481',
'Saint-Gor (40120)' => '40262',
'Saint-Gourson (16700)' => '16325',
'Saint-Goussaud (23430)' => '23200',
'Saint-Grégoire-d\'Ardennes (17240)' => '17343',
'Saint-Groux (16230)' => '16326',
'Saint-Hilaire-Bonneval (87260)' => '87148',
'Saint-Hilaire-d\'Estissac (24140)' => '24422',
'Saint-Hilaire-de-la-Noaille (33190)' => '33418',
'Saint-Hilaire-de-Lusignan (47450)' => '47246',
'Saint-Hilaire-de-Villefranche (17770)' => '17344',
'Saint-Hilaire-du-Bois (17500)' => '17345',
'Saint-Hilaire-du-Bois (33540)' => '33419',
'Saint-Hilaire-Foissac (19550)' => '19208',
'Saint-Hilaire-la-Palud (79210)' => '79257',
'Saint-Hilaire-la-Plaine (23150)' => '23201',
'Saint-Hilaire-la-Treille (87190)' => '87149',
'Saint-Hilaire-le-Château (23250)' => '23202',
'Saint-Hilaire-les-Courbes (19170)' => '19209',
'Saint-Hilaire-les-Places (87800)' => '87150',
'Saint-Hilaire-Luc (19160)' => '19210',
'Saint-Hilaire-Peyroux (19560)' => '19211',
'Saint-Hilaire-Taurieux (19400)' => '19212',
'Saint-Hippolyte (17430)' => '17346',
'Saint-Hippolyte (33330)' => '33420',
'Saint-Jacques-de-Thouars (79100)' => '79258',
'Saint-Jal (19700)' => '19213',
'Saint-Jammes (64160)' => '64482',
'Saint-Jean-d\'Angély (17400)' => '17347',
'Saint-Jean-d\'Angle (17620)' => '17348',
'Saint-Jean-d\'Ataux (24190)' => '24424',
'Saint-Jean-d\'Estissac (24140)' => '24426',
'Saint-Jean-d\'Eyraud (24140)' => '24427',
'Saint-Jean-d\'Illac (33127)' => '33422',
'Saint-Jean-de-Blaignac (33420)' => '33421',
'Saint-Jean-de-Côle (24800)' => '24425',
'Saint-Jean-de-Duras (47120)' => '47247',
'Saint-Jean-de-Lier (40380)' => '40263',
'Saint-Jean-de-Liversay (17170)' => '17349',
'Saint-Jean-de-Luz (64500)' => '64483',
'Saint-Jean-de-Marsacq (40230)' => '40264',
'Saint-Jean-de-Sauves (86330)' => '86225',
'Saint-Jean-de-Thouars (79100)' => '79259',
'Saint-Jean-de-Thurac (47270)' => '47248',
'Saint-Jean-le-Vieux (64220)' => '64484',
'Saint-Jean-Ligoure (87260)' => '87151',
'Saint-Jean-Pied-de-Port (64220)' => '64485',
'Saint-Jean-Poudge (64330)' => '64486',
'Saint-Jory-de-Chalais (24800)' => '24428',
'Saint-Jory-las-Bloux (24160)' => '24429',
'Saint-Jouin-de-Marnes (79600)' => '79260',
'Saint-Jouin-de-Milly (79380)' => '79261',
'Saint-Jouvent (87510)' => '87152',
'Saint-Julien-aux-Bois (19220)' => '19214',
'Saint-Julien-Beychevelle (33250)' => '33423',
'Saint-Julien-d\'Armagnac (40240)' => '40265',
'Saint-Julien-d\'Eymet (24500)' => '24433',
'Saint-Julien-de-Crempse (24140)' => '24431',
'Saint-Julien-de-l\'Escap (17400)' => '17350',
'Saint-Julien-de-Lampon (24370)' => '24432',
'Saint-Julien-en-Born (40170)' => '40266',
'Saint-Julien-l\'Ars (86800)' => '86226',
'Saint-Julien-la-Genête (23110)' => '23203',
'Saint-Julien-le-Châtel (23130)' => '23204',
'Saint-Julien-le-Pèlerin (19430)' => '19215',
'Saint-Julien-le-Petit (87460)' => '87153',
'Saint-Julien-le-Vendômois (19210)' => '19216',
'Saint-Julien-Maumont (19500)' => '19217',
'Saint-Julien-près-Bort (19110)' => '19218',
'Saint-Junien (87200)' => '87154',
'Saint-Junien-la-Bregère (23400)' => '23205',
'Saint-Junien-les-Combes (87300)' => '87155',
'Saint-Just (24320)' => '24434',
'Saint-Just-Ibarre (64120)' => '64487',
'Saint-Just-le-Martel (87590)' => '87156',
'Saint-Just-Luzac (17320)' => '17351',
'Saint-Justin (40240)' => '40267',
'Saint-Laon (86200)' => '86227',
'Saint-Laurent (23000)' => '23206',
'Saint-Laurent (47130)' => '47249',
'Saint-Laurent-Bretagne (64160)' => '64488',
'Saint-Laurent-d\'Arce (33240)' => '33425',
'Saint-Laurent-de-Belzagot (16190)' => '16328',
'Saint-Laurent-de-Céris (16450)' => '16329',
'Saint-Laurent-de-Cognac (16100)' => '16330',
'Saint-Laurent-de-Gosse (40390)' => '40268',
'Saint-Laurent-de-Jourdes (86410)' => '86228',
'Saint-Laurent-de-la-Barrière (17380)' => '17352',
'Saint-Laurent-de-la-Prée (17450)' => '17353',
'Saint-Laurent-des-Combes (16480)' => '16331',
'Saint-Laurent-des-Combes (33330)' => '33426',
'Saint-Laurent-des-Hommes (24400)' => '24436',
'Saint-Laurent-des-Vignes (24100)' => '24437',
'Saint-Laurent-du-Bois (33540)' => '33427',
'Saint-Laurent-du-Plan (33190)' => '33428',
'Saint-Laurent-la-Vallée (24170)' => '24438',
'Saint-Laurent-les-Églises (87240)' => '87157',
'Saint-Laurent-Médoc (33112)' => '33424',
'Saint-Laurent-sur-Gorre (87310)' => '87158',
'Saint-Laurs (79160)' => '79263',
'Saint-Léger (16250)' => '16332',
'Saint-Léger (17800)' => '17354',
'Saint-Léger (47160)' => '47250',
'Saint-Léger-Bridereix (23300)' => '23207',
'Saint-Léger-de-Balson (33113)' => '33429',
'Saint-Léger-de-la-Martinière (79500)' => '79264',
'Saint-Léger-de-Montbrillais (86120)' => '86229',
'Saint-Léger-de-Montbrun (79100)' => '79265',
'Saint-Léger-la-Montagne (87340)' => '87159',
'Saint-Léger-le-Guérétois (23000)' => '23208',
'Saint-Léger-Magnazeix (87190)' => '87160',
'Saint-Léomer (86290)' => '86230',
'Saint-Léon (33670)' => '33431',
'Saint-Léon (47160)' => '47251',
'Saint-Léon-d\'Issigeac (24560)' => '24441',
'Saint-Léon-sur-l\'Isle (24110)' => '24442',
'Saint-Léon-sur-Vézère (24290)' => '24443',
'Saint-Léonard-de-Noblat (87400)' => '87161',
'Saint-Lin (79420)' => '79267',
'Saint-Lon-les-Mines (40300)' => '40269',
'Saint-Loubert (33210)' => '33432',
'Saint-Loubès (33450)' => '33433',
'Saint-Loubouer (40320)' => '40270',
'Saint-Louis-de-Montferrand (33440)' => '33434',
'Saint-Louis-en-l\'Isle (24400)' => '24444',
'Saint-Loup (17380)' => '17356',
'Saint-Loup (23130)' => '23209',
'Saint-Loup-Lamairé (79600)' => '79268',
'Saint-Macaire (33490)' => '33435',
'Saint-Macoux (86400)' => '86231',
'Saint-Magne (33125)' => '33436',
'Saint-Magne-de-Castillon (33350)' => '33437',
'Saint-Maigrin (17520)' => '17357',
'Saint-Maime-de-Péreyrol (24380)' => '24459',
'Saint-Maixant (23200)' => '23210',
'Saint-Maixant (33490)' => '33438',
'Saint-Maixent-de-Beugné (79160)' => '79269',
'Saint-Maixent-l\'École (79400)' => '79270',
'Saint-Mandé-sur-Brédoire (17470)' => '17358',
'Saint-Marc-à-Frongier (23200)' => '23211',
'Saint-Marc-à-Loubaud (23460)' => '23212',
'Saint-Marc-la-Lande (79310)' => '79271',
'Saint-Marcel-du-Périgord (24510)' => '24445',
'Saint-Marcory (24540)' => '24446',
'Saint-Mard (17700)' => '17359',
'Saint-Marien (23600)' => '23213',
'Saint-Mariens (33620)' => '33439',
'Saint-Martial (16190)' => '16334',
'Saint-Martial (17330)' => '17361',
'Saint-Martial (33490)' => '33440',
'Saint-Martial-d\'Albarède (24160)' => '24448',
'Saint-Martial-d\'Artenset (24700)' => '24449',
'Saint-Martial-de-Gimel (19150)' => '19220',
'Saint-Martial-de-Mirambeau (17150)' => '17362',
'Saint-Martial-de-Nabirat (24250)' => '24450',
'Saint-Martial-de-Valette (24300)' => '24451',
'Saint-Martial-de-Vitaterne (17500)' => '17363',
'Saint-Martial-Entraygues (19400)' => '19221',
'Saint-Martial-le-Mont (23150)' => '23214',
'Saint-Martial-le-Vieux (23100)' => '23215',
'Saint-Martial-sur-Isop (87330)' => '87163',
'Saint-Martial-sur-Né (17520)' => '17364',
'Saint-Martial-Viveyrol (24320)' => '24452',
'Saint-Martin-Château (23460)' => '23216',
'Saint-Martin-Curton (47700)' => '47254',
'Saint-Martin-d\'Arberoue (64640)' => '64489',
'Saint-Martin-d\'Arrossa (64780)' => '64490',
'Saint-Martin-d\'Ary (17270)' => '17365',
'Saint-Martin-d\'Oney (40090)' => '40274',
'Saint-Martin-de-Beauville (47270)' => '47255',
'Saint-Martin-de-Bernegoue (79230)' => '79273',
'Saint-Martin-de-Coux (17360)' => '17366',
'Saint-Martin-de-Fressengeas (24800)' => '24453',
'Saint-Martin-de-Gurson (24610)' => '24454',
'Saint-Martin-de-Hinx (40390)' => '40272',
'Saint-Martin-de-Juillers (17400)' => '17367',
'Saint-Martin-de-Jussac (87200)' => '87164',
'Saint-Martin-de-Laye (33910)' => '33442',
'Saint-Martin-de-Lerm (33540)' => '33443',
'Saint-Martin-de-Mâcon (79100)' => '79274',
'Saint-Martin-de-Ré (17410)' => '17369',
'Saint-Martin-de-Ribérac (24600)' => '24455',
'Saint-Martin-de-Saint-Maixent (79400)' => '79276',
'Saint-Martin-de-Sanzay (79290)' => '79277',
'Saint-Martin-de-Seignanx (40390)' => '40273',
'Saint-Martin-de-Sescas (33490)' => '33444',
'Saint-Martin-de-Villeréal (47210)' => '47256',
'Saint-Martin-des-Combes (24140)' => '24456',
'Saint-Martin-du-Bois (33910)' => '33445',
'Saint-Martin-du-Clocher (16700)' => '16335',
'Saint-Martin-du-Fouilloux (79420)' => '79278',
'Saint-Martin-du-Puy (33540)' => '33446',
'Saint-Martin-l\'Ars (86350)' => '86234',
'Saint-Martin-l\'Astier (24400)' => '24457',
'Saint-Martin-la-Méanne (19320)' => '19222',
'Saint-Martin-Lacaussade (33390)' => '33441',
'Saint-Martin-le-Mault (87360)' => '87165',
'Saint-Martin-le-Pin (24300)' => '24458',
'Saint-Martin-le-Vieux (87700)' => '87166',
'Saint-Martin-lès-Melle (79500)' => '79279',
'Saint-Martin-Petit (47180)' => '47257',
'Saint-Martin-Sainte-Catherine (23430)' => '23217',
'Saint-Martin-Sepert (19210)' => '19223',
'Saint-Martin-Terressus (87400)' => '87167',
'Saint-Mary (16260)' => '16336',
'Saint-Mathieu (87440)' => '87168',
'Saint-Maurice-de-Lestapel (47290)' => '47259',
'Saint-Maurice-des-Lions (16500)' => '16337',
'Saint-Maurice-la-Clouère (86160)' => '86235',
'Saint-Maurice-la-Souterraine (23300)' => '23219',
'Saint-Maurice-les-Brousses (87800)' => '87169',
'Saint-Maurice-près-Crocq (23260)' => '23218',
'Saint-Maurice-sur-Adour (40270)' => '40275',
'Saint-Maurin (47270)' => '47260',
'Saint-Maxire (79410)' => '79281',
'Saint-Méard (87130)' => '87170',
'Saint-Méard-de-Drône (24600)' => '24460',
'Saint-Méard-de-Gurçon (24610)' => '24461',
'Saint-Médard (16300)' => '16338',
'Saint-Médard (17500)' => '17372',
'Saint-Médard (64370)' => '64491',
'Saint-Médard (79370)' => '79282',
'Saint-Médard-d\'Aunis (17220)' => '17373',
'Saint-Médard-d\'Excideuil (24160)' => '24463',
'Saint-Médard-d\'Eyrans (33650)' => '33448',
'Saint-Médard-de-Guizières (33230)' => '33447',
'Saint-Médard-de-Mussidan (24400)' => '24462',
'Saint-Médard-en-Jalles (33160)' => '33449',
'Saint-Médard-la-Rochette (23200)' => '23220',
'Saint-Même-les-Carrières (16720)' => '16340',
'Saint-Merd-de-Lapleau (19320)' => '19225',
'Saint-Merd-la-Breuille (23100)' => '23221',
'Saint-Merd-les-Oussines (19170)' => '19226',
'Saint-Mesmin (24270)' => '24464',
'Saint-Mexant (19330)' => '19227',
'Saint-Michel (16470)' => '16341',
'Saint-Michel (64220)' => '64492',
'Saint-Michel-de-Castelnau (33840)' => '33450',
'Saint-Michel-de-Double (24400)' => '24465',
'Saint-Michel-de-Fronsac (33126)' => '33451',
'Saint-Michel-de-Lapujade (33190)' => '33453',
'Saint-Michel-de-Montaigne (24230)' => '24466',
'Saint-Michel-de-Rieufret (33720)' => '33452',
'Saint-Michel-de-Veisse (23480)' => '23222',
'Saint-Michel-de-Villadeix (24380)' => '24468',
'Saint-Michel-Escalus (40550)' => '40276',
'Saint-Moreil (23400)' => '23223',
'Saint-Morillon (33650)' => '33454',
'Saint-Nazaire-sur-Charente (17780)' => '17375',
'Saint-Nexans (24520)' => '24472',
'Saint-Nicolas-de-la-Balerme (47220)' => '47262',
'Saint-Oradoux-de-Chirouze (23100)' => '23224',
'Saint-Oradoux-près-Crocq (23260)' => '23225',
'Saint-Ouen-d\'Aunis (17230)' => '17376',
'Saint-Ouen-la-Thène (17490)' => '17377',
'Saint-Ouen-sur-Gartempe (87300)' => '87172',
'Saint-Palais (33820)' => '33456',
'Saint-Palais (64120)' => '64493',
'Saint-Palais-de-Négrignac (17210)' => '17378',
'Saint-Palais-de-Phiolin (17800)' => '17379',
'Saint-Palais-du-Né (16300)' => '16342',
'Saint-Palais-sur-Mer (17420)' => '17380',
'Saint-Pancrace (24530)' => '24474',
'Saint-Pandelon (40180)' => '40277',
'Saint-Pantaléon-de-Lapleau (19160)' => '19228',
'Saint-Pantaléon-de-Larche (19600)' => '19229',
'Saint-Pantaly-d\'Ans (24640)' => '24475',
'Saint-Pantaly-d\'Excideuil (24160)' => '24476',
'Saint-Pardon-de-Conques (33210)' => '33457',
'Saint-Pardoult (17400)' => '17381',
'Saint-Pardoux (79310)' => '79285',
'Saint-Pardoux (87250)' => '87173',
'Saint-Pardoux-Corbier (19210)' => '19230',
'Saint-Pardoux-d\'Arnet (23260)' => '23226',
'Saint-Pardoux-de-Drône (24600)' => '24477',
'Saint-Pardoux-du-Breuil (47200)' => '47263',
'Saint-Pardoux-et-Vielvic (24170)' => '24478',
'Saint-Pardoux-Isaac (47800)' => '47264',
'Saint-Pardoux-l\'Ortigier (19270)' => '19234',
'Saint-Pardoux-la-Croisille (19320)' => '19231',
'Saint-Pardoux-la-Rivière (24470)' => '24479',
'Saint-Pardoux-le-Neuf (19200)' => '19232',
'Saint-Pardoux-le-Neuf (23200)' => '23228',
'Saint-Pardoux-le-Vieux (19200)' => '19233',
'Saint-Pardoux-les-Cards (23150)' => '23229',
'Saint-Pardoux-Morterolles (23400)' => '23227',
'Saint-Pastour (47290)' => '47265',
'Saint-Paul (19150)' => '19235',
'Saint-Paul (33390)' => '33458',
'Saint-Paul (87260)' => '87174',
'Saint-Paul-de-Serre (24380)' => '24480',
'Saint-Paul-en-Born (40200)' => '40278',
'Saint-Paul-en-Gâtine (79240)' => '79286',
'Saint-Paul-la-Roche (24800)' => '24481',
'Saint-Paul-lès-Dax (40990)' => '40279',
'Saint-Paul-Lizonne (24320)' => '24482',
'Saint-Pé-de-Léren (64270)' => '64494',
'Saint-Pé-Saint-Simon (47170)' => '47266',
'Saint-Pée-sur-Nivelle (64310)' => '64495',
'Saint-Perdon (40090)' => '40280',
'Saint-Perdoux (24560)' => '24483',
'Saint-Pey-d\'Armens (33330)' => '33459',
'Saint-Pey-de-Castets (33350)' => '33460',
'Saint-Philippe-d\'Aiguille (33350)' => '33461',
'Saint-Philippe-du-Seignal (33220)' => '33462',
'Saint-Pierre-Bellevue (23460)' => '23232',
'Saint-Pierre-Chérignat (23430)' => '23230',
'Saint-Pierre-d\'Amilly (17700)' => '17382',
'Saint-Pierre-d\'Aurillac (33490)' => '33463',
'Saint-Pierre-d\'Exideuil (86400)' => '86237',
'Saint-Pierre-d\'Eyraud (24130)' => '24487',
'Saint-Pierre-d\'Irube (64990)' => '64496',
'Saint-Pierre-d\'Oléron (17310)' => '17385',
'Saint-Pierre-de-Bat (33760)' => '33464',
'Saint-Pierre-de-Buzet (47160)' => '47267',
'Saint-Pierre-de-Chignac (24330)' => '24484',
'Saint-Pierre-de-Clairac (47270)' => '47269',
'Saint-Pierre-de-Côle (24800)' => '24485',
'Saint-Pierre-de-Frugie (24450)' => '24486',
'Saint-Pierre-de-Fursac (23290)' => '23231',
'Saint-Pierre-de-Juillers (17400)' => '17383',
'Saint-Pierre-de-l\'Isle (17330)' => '17384',
'Saint-Pierre-de-Maillé (86260)' => '86236',
'Saint-Pierre-de-Mons (33210)' => '33465',
'Saint-Pierre-des-Échaubrognes (79700)' => '79289',
'Saint-Pierre-du-Mont (40280)' => '40281',
'Saint-Pierre-du-Palais (17270)' => '17386',
'Saint-Pierre-le-Bost (23600)' => '23233',
'Saint-Pierre-sur-Dropt (47120)' => '47271',
'Saint-Pompain (79160)' => '79290',
'Saint-Pompont (24170)' => '24488',
'Saint-Porchaire (17250)' => '17387',
'Saint-Preuil (16130)' => '16343',
'Saint-Priest (23110)' => '23234',
'Saint-Priest-de-Gimel (19800)' => '19236',
'Saint-Priest-la-Feuille (23300)' => '23235',
'Saint-Priest-la-Plaine (23240)' => '23236',
'Saint-Priest-les-Fougères (24450)' => '24489',
'Saint-Priest-Ligoure (87800)' => '87176',
'Saint-Priest-Palus (23400)' => '23237',
'Saint-Priest-sous-Aixe (87700)' => '87177',
'Saint-Priest-Taurion (87480)' => '87178',
'Saint-Privat (19220)' => '19237',
'Saint-Privat-des-Prés (24410)' => '24490',
'Saint-Projet-Saint-Constant (16110)' => '16344',
'Saint-Quantin-de-Rançanne (17800)' => '17388',
'Saint-Quentin-de-Baron (33750)' => '33466',
'Saint-Quentin-de-Caplong (33220)' => '33467',
'Saint-Quentin-de-Chalais (16210)' => '16346',
'Saint-Quentin-du-Dropt (47330)' => '47272',
'Saint-Quentin-la-Chabanne (23500)' => '23238',
'Saint-Quentin-sur-Charente (16150)' => '16345',
'Saint-Rabier (24210)' => '24491',
'Saint-Raphaël (24160)' => '24493',
'Saint-Rémy (19290)' => '19238',
'Saint-Rémy (24700)' => '24494',
'Saint-Rémy (79410)' => '79293',
'Saint-Rémy-sur-Creuse (86220)' => '86241',
'Saint-Robert (19310)' => '19239',
'Saint-Robert (47340)' => '47273',
'Saint-Rogatien (17220)' => '17391',
'Saint-Romain (16210)' => '16347',
'Saint-Romain (86250)' => '86242',
'Saint-Romain-de-Benet (17600)' => '17393',
'Saint-Romain-de-Monpazier (24540)' => '24495',
'Saint-Romain-et-Saint-Clément (24800)' => '24496',
'Saint-Romain-la-Virvée (33240)' => '33470',
'Saint-Romain-le-Noble (47270)' => '47274',
'Saint-Romain-sur-Gironde (17240)' => '17392',
'Saint-Romans-des-Champs (79230)' => '79294',
'Saint-Romans-lès-Melle (79500)' => '79295',
'Saint-Salvadour (19700)' => '19240',
'Saint-Salvy (47360)' => '47275',
'Saint-Sardos (47360)' => '47276',
'Saint-Saturnin (16290)' => '16348',
'Saint-Saturnin-du-Bois (17700)' => '17394',
'Saint-Saud-Lacoussière (24470)' => '24498',
'Saint-Sauvant (17610)' => '17395',
'Saint-Sauvant (86600)' => '86244',
'Saint-Sauveur (24520)' => '24499',
'Saint-Sauveur (33250)' => '33471',
'Saint-Sauveur-d\'Aunis (17540)' => '17396',
'Saint-Sauveur-de-Meilhan (47180)' => '47277',
'Saint-Sauveur-de-Puynormand (33660)' => '33472',
'Saint-Sauveur-Lalande (24700)' => '24500',
'Saint-Savin (33920)' => '33473',
'Saint-Savin (86310)' => '86246',
'Saint-Savinien (17350)' => '17397',
'Saint-Saviol (86400)' => '86247',
'Saint-Sébastien (23160)' => '23239',
'Saint-Secondin (86350)' => '86248',
'Saint-Selve (33650)' => '33474',
'Saint-Sernin (47120)' => '47278',
'Saint-Setiers (19290)' => '19241',
'Saint-Seurin-de-Bourg (33710)' => '33475',
'Saint-Seurin-de-Cadourne (33180)' => '33476',
'Saint-Seurin-de-Cursac (33390)' => '33477',
'Saint-Seurin-de-Palenne (17800)' => '17398',
'Saint-Seurin-de-Prats (24230)' => '24501',
'Saint-Seurin-sur-l\'Isle (33660)' => '33478',
'Saint-Sève (33190)' => '33479',
'Saint-Sever (40500)' => '40282',
'Saint-Sever-de-Saintonge (17800)' => '17400',
'Saint-Séverin (16390)' => '16350',
'Saint-Séverin-d\'Estissac (24190)' => '24502',
'Saint-Séverin-sur-Boutonne (17330)' => '17401',
'Saint-Sigismond-de-Clermont (17240)' => '17402',
'Saint-Silvain-Bas-le-Roc (23600)' => '23240',
'Saint-Silvain-Bellegarde (23190)' => '23241',
'Saint-Silvain-Montaigut (23320)' => '23242',
'Saint-Silvain-sous-Toulx (23140)' => '23243',
'Saint-Simeux (16120)' => '16351',
'Saint-Simon (16120)' => '16352',
'Saint-Simon-de-Bordes (17500)' => '17403',
'Saint-Simon-de-Pellouaille (17260)' => '17404',
'Saint-Sixte (47220)' => '47279',
'Saint-Solve (19130)' => '19242',
'Saint-Sorlin-de-Conac (17150)' => '17405',
'Saint-Sornin (16220)' => '16353',
'Saint-Sornin (17600)' => '17406',
'Saint-Sornin-la-Marche (87210)' => '87179',
'Saint-Sornin-Lavolps (19230)' => '19243',
'Saint-Sornin-Leulac (87290)' => '87180',
'Saint-Sulpice-d\'Arnoult (17250)' => '17408',
'Saint-Sulpice-d\'Excideuil (24800)' => '24505',
'Saint-Sulpice-de-Cognac (16370)' => '16355',
'Saint-Sulpice-de-Faleyrens (33330)' => '33480',
'Saint-Sulpice-de-Guilleragues (33580)' => '33481',
'Saint-Sulpice-de-Mareuil (24340)' => '24503',
'Saint-Sulpice-de-Pommiers (33540)' => '33482',
'Saint-Sulpice-de-Roumagnac (24600)' => '24504',
'Saint-Sulpice-de-Royan (17200)' => '17409',
'Saint-Sulpice-de-Ruffec (16460)' => '16356',
'Saint-Sulpice-et-Cameyrac (33450)' => '33483',
'Saint-Sulpice-Laurière (87370)' => '87181',
'Saint-Sulpice-le-Dunois (23800)' => '23244',
'Saint-Sulpice-le-Guérétois (23000)' => '23245',
'Saint-Sulpice-les-Bois (19250)' => '19244',
'Saint-Sulpice-les-Champs (23480)' => '23246',
'Saint-Sulpice-les-Feuilles (87160)' => '87182',
'Saint-Sylvain (19380)' => '19245',
'Saint-Sylvestre (87240)' => '87183',
'Saint-Sylvestre-sur-Lot (47140)' => '47280',
'Saint-Symphorien (33113)' => '33484',
'Saint-Symphorien (79270)' => '79298',
'Saint-Symphorien-sur-Couze (87140)' => '87184',
'Saint-Thomas-de-Conac (17150)' => '17410',
'Saint-Trojan (33710)' => '33486',
'Saint-Trojan-les-Bains (17370)' => '17411',
'Saint-Urcisse (47270)' => '47281',
'Saint-Vaize (17100)' => '17412',
'Saint-Vallier (16480)' => '16357',
'Saint-Varent (79330)' => '79299',
'Saint-Vaury (23320)' => '23247',
'Saint-Viance (19240)' => '19246',
'Saint-Victor (24350)' => '24508',
'Saint-Victor-en-Marche (23000)' => '23248',
'Saint-Victour (19200)' => '19247',
'Saint-Victurnien (87420)' => '87185',
'Saint-Vincent (64800)' => '64498',
'Saint-Vincent-de-Connezac (24190)' => '24509',
'Saint-Vincent-de-Cosse (24220)' => '24510',
'Saint-Vincent-de-Lamontjoie (47310)' => '47282',
'Saint-Vincent-de-Paul (33440)' => '33487',
'Saint-Vincent-de-Paul (40990)' => '40283',
'Saint-Vincent-de-Pertignas (33420)' => '33488',
'Saint-Vincent-de-Tyrosse (40230)' => '40284',
'Saint-Vincent-Jalmoutiers (24410)' => '24511',
'Saint-Vincent-la-Châtre (79500)' => '79301',
'Saint-Vincent-le-Paluel (24200)' => '24512',
'Saint-Vincent-sur-l\'Isle (24420)' => '24513',
'Saint-Vite (47500)' => '47283',
'Saint-Vitte-sur-Briance (87380)' => '87186',
'Saint-Vivien (17220)' => '17413',
'Saint-Vivien (24230)' => '24514',
'Saint-Vivien-de-Blaye (33920)' => '33489',
'Saint-Vivien-de-Médoc (33590)' => '33490',
'Saint-Vivien-de-Monségur (33580)' => '33491',
'Saint-Xandre (17138)' => '17414',
'Saint-Yaguen (40400)' => '40285',
'Saint-Ybard (19140)' => '19248',
'Saint-Yrieix-la-Montagne (23460)' => '23249',
'Saint-Yrieix-la-Perche (87500)' => '87187',
'Saint-Yrieix-le-Déjalat (19300)' => '19249',
'Saint-Yrieix-les-Bois (23150)' => '23250',
'Saint-Yrieix-sous-Aixe (87700)' => '87188',
'Saint-Yrieix-sur-Charente (16710)' => '16358',
'Saint-Yzan-de-Soudiac (33920)' => '33492',
'Saint-Yzans-de-Médoc (33340)' => '33493',
'Sainte-Alvère-Saint-Laurent Les Bâtons (24510)' => '24362',
'Sainte-Anne-Saint-Priest (87120)' => '87134',
'Sainte-Bazeille (47180)' => '47233',
'Sainte-Blandine (79370)' => '79240',
'Sainte-Colombe (16230)' => '16309',
'Sainte-Colombe (17210)' => '17319',
'Sainte-Colombe (33350)' => '33390',
'Sainte-Colombe (40700)' => '40252',
'Sainte-Colombe-de-Duras (47120)' => '47236',
'Sainte-Colombe-de-Villeneuve (47300)' => '47237',
'Sainte-Colombe-en-Bruilhois (47310)' => '47238',
'Sainte-Colome (64260)' => '64473',
'Sainte-Croix (24440)' => '24393',
'Sainte-Croix-de-Mareuil (24340)' => '24394',
'Sainte-Croix-du-Mont (33410)' => '33392',
'Sainte-Eanne (79800)' => '79246',
'Sainte-Engrâce (64560)' => '64475',
'Sainte-Eulalie (33560)' => '33397',
'Sainte-Eulalie-d\'Ans (24640)' => '24401',
'Sainte-Eulalie-d\'Eymet (24500)' => '24402',
'Sainte-Eulalie-en-Born (40200)' => '40257',
'Sainte-Féréole (19270)' => '19202',
'Sainte-Feyre (23000)' => '23193',
'Sainte-Feyre-la-Montagne (23500)' => '23194',
'Sainte-Florence (33350)' => '33401',
'Sainte-Fortunade (19490)' => '19203',
'Sainte-Foy (40190)' => '40258',
'Sainte-Foy-de-Belvès (24170)' => '24406',
'Sainte-Foy-de-Longas (24510)' => '24407',
'Sainte-Foy-la-Grande (33220)' => '33402',
'Sainte-Foy-la-Longue (33490)' => '33403',
'Sainte-Gemme (17250)' => '17330',
'Sainte-Gemme (33580)' => '33404',
'Sainte-Gemme (79330)' => '79250',
'Sainte-Gemme-Martaillac (47250)' => '47244',
'Sainte-Hélène (33480)' => '33417',
'Sainte-Innocence (24500)' => '24423',
'Sainte-Lheurine (17520)' => '17355',
'Sainte-Livrade-sur-Lot (47110)' => '47252',
'Sainte-Marie-de-Chignac (24330)' => '24447',
'Sainte-Marie-de-Gosse (40390)' => '40271',
'Sainte-Marie-de-Ré (17740)' => '17360',
'Sainte-Marie-de-Vaux (87420)' => '87162',
'Sainte-Marie-Lapanouze (19160)' => '19219',
'Sainte-Marthe (47430)' => '47253',
'Sainte-Maure-de-Peyriac (47170)' => '47258',
'Sainte-Même (17770)' => '17374',
'Sainte-Mondane (24370)' => '24470',
'Sainte-Nathalène (24200)' => '24471',
'Sainte-Néomaye (79260)' => '79283',
'Sainte-Orse (24210)' => '24473',
'Sainte-Ouenne (79220)' => '79284',
'Sainte-Radegonde (17250)' => '17389',
'Sainte-Radegonde (24560)' => '24492',
'Sainte-Radegonde (33350)' => '33468',
'Sainte-Radegonde (79100)' => '79292',
'Sainte-Radégonde (86300)' => '86239',
'Sainte-Ramée (17240)' => '17390',
'Sainte-Sévère (16200)' => '16349',
'Sainte-Soline (79120)' => '79297',
'Sainte-Souline (16480)' => '16354',
'Sainte-Soulle (17220)' => '17407',
'Sainte-Terre (33350)' => '33485',
'Sainte-Trie (24160)' => '24507',
'Sainte-Verge (79100)' => '79300',
'Saintes (17100)' => '17415',
'Saires (86420)' => '86249',
'Saivres (79400)' => '79302',
'Saix (86120)' => '86250',
'Salagnac (24160)' => '24515',
'Salaunes (33160)' => '33494',
'Saleignes (17510)' => '17416',
'Salies-de-Béarn (64270)' => '64499',
'Salignac-de-Mirambeau (17130)' => '17417',
'Salignac-Eyvigues (24590)' => '24516',
'Salignac-sur-Charente (17800)' => '17418',
'Salleboeuf (33370)' => '33496',
'Salles (33770)' => '33498',
'Salles (47150)' => '47284',
'Salles (79800)' => '79303',
'Salles-d\'Angles (16130)' => '16359',
'Salles-de-Barbezieux (16300)' => '16360',
'Salles-de-Belvès (24170)' => '24517',
'Salles-de-Villefagnan (16700)' => '16361',
'Salles-Lavalette (16190)' => '16362',
'Salles-Mongiscard (64300)' => '64500',
'Salles-sur-Mer (17220)' => '17420',
'Sallespisse (64300)' => '64501',
'Salon (24380)' => '24518',
'Salon-la-Tour (19510)' => '19250',
'Samadet (40320)' => '40286',
'Samazan (47250)' => '47285',
'Sames (64520)' => '64502',
'Sammarçolles (86200)' => '86252',
'Samonac (33710)' => '33500',
'Samsons-Lion (64350)' => '64503',
'Sanguinet (40460)' => '40287',
'Sannat (23110)' => '23167',
'Sansais (79270)' => '79304',
'Sanxay (86600)' => '86253',
'Sarbazan (40120)' => '40288',
'Sardent (23250)' => '23168',
'Sare (64310)' => '64504',
'Sarlande (24270)' => '24519',
'Sarlat-la-Canéda (24200)' => '24520',
'Sarliac-sur-l\'Isle (24420)' => '24521',
'Sarpourenx (64300)' => '64505',
'Sarran (19800)' => '19251',
'Sarrance (64490)' => '64506',
'Sarrazac (24800)' => '24522',
'Sarraziet (40500)' => '40289',
'Sarron (40800)' => '40290',
'Sarroux (19110)' => '19252',
'Saubion (40230)' => '40291',
'Saubole (64420)' => '64507',
'Saubrigues (40230)' => '40292',
'Saubusse (40180)' => '40293',
'Saucats (33650)' => '33501',
'Saucède (64400)' => '64508',
'Saugnac-et-Cambran (40180)' => '40294',
'Saugnacq-et-Muret (40410)' => '40295',
'Saugon (33920)' => '33502',
'Sauguis-Saint-Étienne (64470)' => '64509',
'Saujon (17600)' => '17421',
'Saulgé (86500)' => '86254',
'Saulgond (16420)' => '16363',
'Sault-de-Navailles (64300)' => '64510',
'Sauméjan (47420)' => '47286',
'Saumont (47600)' => '47287',
'Saumos (33680)' => '33503',
'Saurais (79200)' => '79306',
'Saussignac (24240)' => '24523',
'Sauternes (33210)' => '33504',
'Sauvagnac (16310)' => '16364',
'Sauvagnas (47340)' => '47288',
'Sauvagnon (64230)' => '64511',
'Sauvelade (64150)' => '64512',
'Sauveterre-de-Béarn (64390)' => '64513',
'Sauveterre-de-Guyenne (33540)' => '33506',
'Sauveterre-la-Lémance (47500)' => '47292',
'Sauveterre-Saint-Denis (47220)' => '47293',
'Sauviac (33430)' => '33507',
'Sauviat-sur-Vige (87400)' => '87190',
'Sauvignac (16480)' => '16365',
'Sauzé-Vaussais (79190)' => '79307',
'Savennes (23000)' => '23170',
'Savignac (33124)' => '33508',
'Savignac-de-Duras (47120)' => '47294',
'Savignac-de-l\'Isle (33910)' => '33509',
'Savignac-de-Miremont (24260)' => '24524',
'Savignac-de-Nontron (24300)' => '24525',
'Savignac-Lédrier (24270)' => '24526',
'Savignac-les-Églises (24420)' => '24527',
'Savignac-sur-Leyze (47150)' => '47295',
'Savigné (86400)' => '86255',
'Savigny-Lévescault (86800)' => '86256',
'Savigny-sous-Faye (86140)' => '86257',
'Sceau-Saint-Angel (24300)' => '24528',
'Sciecq (79000)' => '79308',
'Scillé (79240)' => '79309',
'Scorbé-Clairvaux (86140)' => '86258',
'Séby (64410)' => '64514',
'Secondigné-sur-Belle (79170)' => '79310',
'Secondigny (79130)' => '79311',
'Sedze-Maubecq (64160)' => '64515',
'Sedzère (64160)' => '64516',
'Ségalas (47410)' => '47296',
'Segonzac (16130)' => '16366',
'Segonzac (19310)' => '19253',
'Segonzac (24600)' => '24529',
'Ségur-le-Château (19230)' => '19254',
'Seigné (17510)' => '17422',
'Seignosse (40510)' => '40296',
'Seilhac (19700)' => '19255',
'Séligné (79170)' => '79312',
'Sembas (47360)' => '47297',
'Séméacq-Blachon (64350)' => '64517',
'Semens (33490)' => '33510',
'Semillac (17150)' => '17423',
'Semoussac (17150)' => '17424',
'Semussac (17120)' => '17425',
'Sencenac-Puy-de-Fourches (24310)' => '24530',
'Sendets (33690)' => '33511',
'Sendets (64320)' => '64518',
'Sénestis (47430)' => '47298',
'Senillé-Saint-Sauveur (86100)' => '86245',
'Sepvret (79120)' => '79313',
'Sérandon (19160)' => '19256',
'Séreilhac (87620)' => '87191',
'Sergeac (24290)' => '24531',
'Sérignac-Péboudou (47410)' => '47299',
'Sérignac-sur-Garonne (47310)' => '47300',
'Sérigny (86230)' => '86260',
'Sérilhac (19190)' => '19257',
'Sermur (23700)' => '23171',
'Séron (65320)' => '65422',
'Serres-Castet (64121)' => '64519',
'Serres-et-Montguyard (24500)' => '24532',
'Serres-Gaston (40700)' => '40298',
'Serres-Morlaàs (64160)' => '64520',
'Serres-Sainte-Marie (64170)' => '64521',
'Serreslous-et-Arribans (40700)' => '40299',
'Sers (16410)' => '16368',
'Servanches (24410)' => '24533',
'Servières-le-Château (19220)' => '19258',
'Sévignacq (64160)' => '64523',
'Sévignacq-Meyracq (64260)' => '64522',
'Sèvres-Anxaumont (86800)' => '86261',
'Sexcles (19430)' => '19259',
'Seyches (47350)' => '47301',
'Seyresse (40180)' => '40300',
'Siecq (17490)' => '17427',
'Siest (40180)' => '40301',
'Sigalens (33690)' => '33512',
'Sigogne (16200)' => '16369',
'Sigoulès (24240)' => '24534',
'Sillars (86320)' => '86262',
'Sillas (33690)' => '33513',
'Simacourbe (64350)' => '64524',
'Simeyrols (24370)' => '24535',
'Sindères (40110)' => '40302',
'Singleyrac (24500)' => '24536',
'Sioniac (19120)' => '19260',
'Siorac-de-Ribérac (24600)' => '24537',
'Siorac-en-Périgord (24170)' => '24538',
'Sireuil (16440)' => '16370',
'Siros (64230)' => '64525',
'Smarves (86240)' => '86263',
'Solférino (40210)' => '40303',
'Solignac (87110)' => '87192',
'Sommières-du-Clain (86160)' => '86264',
'Sompt (79110)' => '79314',
'Sonnac (17160)' => '17428',
'Soorts-Hossegor (40150)' => '40304',
'Sorbets (40320)' => '40305',
'Sorde-l\'Abbaye (40300)' => '40306',
'Sore (40430)' => '40307',
'Sorges et Ligueux en Périgord (24420)' => '24540',
'Sornac (19290)' => '19261',
'Sort-en-Chalosse (40180)' => '40308',
'Sos (47170)' => '47302',
'Sossais (86230)' => '86265',
'Soubise (17780)' => '17429',
'Soubran (17150)' => '17430',
'Soubrebost (23250)' => '23173',
'Soudaine-Lavinadière (19370)' => '19262',
'Soudan (79800)' => '79316',
'Soudat (24360)' => '24541',
'Soudeilles (19300)' => '19263',
'Souffrignac (16380)' => '16372',
'Soulac-sur-Mer (33780)' => '33514',
'Soulaures (24540)' => '24542',
'Soulignac (33760)' => '33515',
'Soulignonne (17250)' => '17431',
'Soumans (23600)' => '23174',
'Soumensac (47120)' => '47303',
'Souméras (17130)' => '17432',
'Soumoulou (64420)' => '64526',
'Souprosse (40250)' => '40309',
'Souraïde (64250)' => '64527',
'Soursac (19550)' => '19264',
'Sourzac (24400)' => '24543',
'Sous-Parsat (23150)' => '23175',
'Sousmoulins (17130)' => '17433',
'Soussac (33790)' => '33516',
'Soussans (33460)' => '33517',
'Soustons (40140)' => '40310',
'Soutiers (79310)' => '79318',
'Souvigné (16240)' => '16373',
'Souvigné (79800)' => '79319',
'Soyaux (16800)' => '16374',
'Suaux (16260)' => '16375',
'Suhescun (64780)' => '64528',
'Surdoux (87130)' => '87193',
'Surgères (17700)' => '17434',
'Surin (79220)' => '79320',
'Surin (86250)' => '86266',
'Suris (16270)' => '16376',
'Sus (64190)' => '64529',
'Susmiou (64190)' => '64530',
'Sussac (87130)' => '87194',
'Tabaille-Usquain (64190)' => '64531',
'Tabanac (33550)' => '33518',
'Tadousse-Ussau (64330)' => '64532',
'Taillant (17350)' => '17435',
'Taillebourg (17350)' => '17436',
'Taillebourg (47200)' => '47304',
'Taillecavat (33580)' => '33520',
'Taizé (79100)' => '79321',
'Taizé-Aizie (16700)' => '16378',
'Talais (33590)' => '33521',
'Talence (33400)' => '33522',
'Taller (40260)' => '40311',
'Talmont-sur-Gironde (17120)' => '17437',
'Tamniès (24620)' => '24544',
'Tanzac (17260)' => '17438',
'Taponnat-Fleurignac (16110)' => '16379',
'Tardes (23170)' => '23251',
'Tardets-Sorholus (64470)' => '64533',
'Targon (33760)' => '33523',
'Tarnac (19170)' => '19265',
'Tarnès (33240)' => '33524',
'Tarnos (40220)' => '40312',
'Taron-Sadirac-Viellenave (64330)' => '64534',
'Tarsacq (64360)' => '64535',
'Tartas (40400)' => '40313',
'Taugon (17170)' => '17439',
'Tauriac (33710)' => '33525',
'Tayac (33570)' => '33526',
'Tayrac (47270)' => '47305',
'Teillots (24390)' => '24545',
'Temple-Laguyon (24390)' => '24546',
'Tercé (86800)' => '86268',
'Tercillat (23350)' => '23252',
'Tercis-les-Bains (40180)' => '40314',
'Ternant (17400)' => '17440',
'Ternay (86120)' => '86269',
'Terrasson-Lavilledieu (24120)' => '24547',
'Tersannes (87360)' => '87195',
'Tesson (17460)' => '17441',
'Tessonnière (79600)' => '79325',
'Téthieu (40990)' => '40315',
'Teuillac (33710)' => '33530',
'Teyjat (24300)' => '24548',
'Thaims (17120)' => '17442',
'Thairé (17290)' => '17443',
'Thalamy (19200)' => '19266',
'Thauron (23250)' => '23253',
'Theil-Rabier (16240)' => '16381',
'Thénac (17460)' => '17444',
'Thénac (24240)' => '24549',
'Thénezay (79390)' => '79326',
'Thenon (24210)' => '24550',
'Thézac (17600)' => '17445',
'Thézac (47370)' => '47307',
'Thèze (64450)' => '64536',
'Thiat (87320)' => '87196',
'Thiviers (24800)' => '24551',
'Thollet (86290)' => '86270',
'Thonac (24290)' => '24552',
'Thorigné (79370)' => '79327',
'Thorigny-sur-le-Mignon (79360)' => '79328',
'Thors (17160)' => '17446',
'Thouars (79100)' => '79329',
'Thouars-sur-Garonne (47230)' => '47308',
'Thouron (87140)' => '87197',
'Thurageau (86110)' => '86271',
'Thuré (86540)' => '86272',
'Tilh (40360)' => '40316',
'Tillou (79110)' => '79330',
'Tizac-de-Curton (33420)' => '33531',
'Tizac-de-Lapouyade (33620)' => '33532',
'Tocane-Saint-Apre (24350)' => '24553',
'Tombeboeuf (47380)' => '47309',
'Tonnay-Boutonne (17380)' => '17448',
'Tonnay-Charente (17430)' => '17449',
'Tonneins (47400)' => '47310',
'Torsac (16410)' => '16382',
'Torxé (17380)' => '17450',
'Tosse (40230)' => '40317',
'Toulenne (33210)' => '33533',
'Toulouzette (40250)' => '40318',
'Toulx-Sainte-Croix (23600)' => '23254',
'Tourliac (47210)' => '47311',
'Tournon-d\'Agenais (47370)' => '47312',
'Tourriers (16560)' => '16383',
'Tourtenay (79100)' => '79331',
'Tourtoirac (24390)' => '24555',
'Tourtrès (47380)' => '47313',
'Touvérac (16360)' => '16384',
'Touvre (16600)' => '16385',
'Touzac (16120)' => '16386',
'Toy-Viam (19170)' => '19268',
'Trayes (79240)' => '79332',
'Treignac (19260)' => '19269',
'Trélissac (24750)' => '24557',
'Trémolat (24510)' => '24558',
'Trémons (47140)' => '47314',
'Trensacq (40630)' => '40319',
'Trentels (47140)' => '47315',
'Tresses (33370)' => '33535',
'Triac-Lautrait (16200)' => '16387',
'Trizay (17250)' => '17453',
'Troche (19230)' => '19270',
'Trois-Fonds (23230)' => '23255',
'Trois-Palis (16730)' => '16388',
'Trois-Villes (64470)' => '64537',
'Tudeils (19120)' => '19271',
'Tugéras-Saint-Maurice (17130)' => '17454',
'Tulle (19000)' => '19272',
'Turenne (19500)' => '19273',
'Turgon (16350)' => '16389',
'Tursac (24620)' => '24559',
'Tusson (16140)' => '16390',
'Tuzie (16700)' => '16391',
'Uchacq-et-Parentis (40090)' => '40320',
'Uhart-Cize (64220)' => '64538',
'Uhart-Mixe (64120)' => '64539',
'Urcuit (64990)' => '64540',
'Urdès (64370)' => '64541',
'Urdos (64490)' => '64542',
'Urepel (64430)' => '64543',
'Urgons (40320)' => '40321',
'Urost (64160)' => '64544',
'Urrugne (64122)' => '64545',
'Urt (64240)' => '64546',
'Urval (24480)' => '24560',
'Ussac (19270)' => '19274',
'Usseau (79210)' => '79334',
'Usseau (86230)' => '86275',
'Ussel (19200)' => '19275',
'Usson-du-Poitou (86350)' => '86276',
'Ustaritz (64480)' => '64547',
'Uza (40170)' => '40322',
'Uzan (64370)' => '64548',
'Uzein (64230)' => '64549',
'Uzerche (19140)' => '19276',
'Uzeste (33730)' => '33537',
'Uzos (64110)' => '64550',
'Val d\'Issoire (87330)' => '87097',
'Val de Virvée (33240)' => '33018',
'Val des Vignes (16250)' => '16175',
'Valdivienne (86300)' => '86233',
'Valence (16460)' => '16392',
'Valeuil (24310)' => '24561',
'Valeyrac (33340)' => '33538',
'Valiergues (19200)' => '19277',
'Vallans (79270)' => '79335',
'Vallereuil (24190)' => '24562',
'Vallière (23120)' => '23257',
'Valojoulx (24290)' => '24563',
'Vançais (79120)' => '79336',
'Vandré (17700)' => '17457',
'Vanxains (24600)' => '24564',
'Vanzac (17500)' => '17458',
'Vanzay (79120)' => '79338',
'Varaignes (24360)' => '24565',
'Varaize (17400)' => '17459',
'Vareilles (23300)' => '23258',
'Varennes (24150)' => '24566',
'Varennes (86110)' => '86277',
'Varès (47400)' => '47316',
'Varetz (19240)' => '19278',
'Vars (16330)' => '16393',
'Vars-sur-Roseix (19130)' => '19279',
'Varzay (17460)' => '17460',
'Vasles (79340)' => '79339',
'Vaulry (87140)' => '87198',
'Vaunac (24800)' => '24567',
'Vausseroux (79420)' => '79340',
'Vautebis (79420)' => '79341',
'Vaux (86700)' => '86278',
'Vaux-Lavalette (16320)' => '16394',
'Vaux-Rouillac (16170)' => '16395',
'Vaux-sur-Mer (17640)' => '17461',
'Vaux-sur-Vienne (86220)' => '86279',
'Vayres (33870)' => '33539',
'Vayres (87600)' => '87199',
'Végennes (19120)' => '19280',
'Veix (19260)' => '19281',
'Vélines (24230)' => '24568',
'Vellèches (86230)' => '86280',
'Vendays-Montalivet (33930)' => '33540',
'Vendeuvre-du-Poitou (86380)' => '86281',
'Vendoire (24320)' => '24569',
'Vénérand (17100)' => '17462',
'Vensac (33590)' => '33541',
'Ventouse (16460)' => '16396',
'Vérac (33240)' => '33542',
'Verdelais (33490)' => '33543',
'Verdets (64400)' => '64551',
'Verdille (16140)' => '16397',
'Verdon (24520)' => '24570',
'Vergeroux (17300)' => '17463',
'Vergné (17330)' => '17464',
'Vergt (24380)' => '24571',
'Vergt-de-Biron (24540)' => '24572',
'Vérines (17540)' => '17466',
'Verneiges (23170)' => '23259',
'Verneuil (16310)' => '16398',
'Verneuil-Moustiers (87360)' => '87200',
'Verneuil-sur-Vienne (87430)' => '87201',
'Vernon (86340)' => '86284',
'Vernoux-en-Gâtine (79240)' => '79342',
'Vernoux-sur-Boutonne (79170)' => '79343',
'Verrières (16130)' => '16399',
'Verrières (86410)' => '86285',
'Verrue (86420)' => '86286',
'Verruyes (79310)' => '79345',
'Vert (40420)' => '40323',
'Verteillac (24320)' => '24573',
'Verteuil-d\'Agenais (47260)' => '47317',
'Verteuil-sur-Charente (16510)' => '16400',
'Vertheuil (33180)' => '33545',
'Vervant (16330)' => '16401',
'Vervant (17400)' => '17467',
'Veyrac (87520)' => '87202',
'Veyrières (19200)' => '19283',
'Veyrignac (24370)' => '24574',
'Veyrines-de-Domme (24250)' => '24575',
'Veyrines-de-Vergt (24380)' => '24576',
'Vézac (24220)' => '24577',
'Vézières (86120)' => '86287',
'Vialer (64330)' => '64552',
'Viam (19170)' => '19284',
'Vianne (47230)' => '47318',
'Vibrac (16120)' => '16402',
'Vibrac (17130)' => '17468',
'Vicq-d\'Auribat (40380)' => '40324',
'Vicq-sur-Breuilh (87260)' => '87203',
'Vicq-sur-Gartempe (86260)' => '86288',
'Vidaillat (23250)' => '23260',
'Videix (87600)' => '87204',
'Vielle-Saint-Girons (40560)' => '40326',
'Vielle-Soubiran (40240)' => '40327',
'Vielle-Tursan (40320)' => '40325',
'Viellenave-d\'Arthez (64170)' => '64554',
'Viellenave-de-Navarrenx (64190)' => '64555',
'Vielleségure (64150)' => '64556',
'Viennay (79200)' => '79347',
'Viersat (23170)' => '23261',
'Vieux-Boucau-les-Bains (40480)' => '40328',
'Vieux-Mareuil (24340)' => '24579',
'Vieux-Ruffec (16350)' => '16404',
'Vigeois (19410)' => '19285',
'Vigeville (23140)' => '23262',
'Vignes (64410)' => '64557',
'Vignolles (16300)' => '16405',
'Vignols (19130)' => '19286',
'Vignonet (33330)' => '33546',
'Vilhonneur (16220)' => '16406',
'Villac (24120)' => '24580',
'Villamblard (24140)' => '24581',
'Villandraut (33730)' => '33547',
'Villard (23800)' => '23263',
'Villars (24530)' => '24582',
'Villars-en-Pons (17260)' => '17469',
'Villars-les-Bois (17770)' => '17470',
'Villebois-Lavalette (16320)' => '16408',
'Villebramar (47380)' => '47319',
'Villedoux (17230)' => '17472',
'Villefagnan (16240)' => '16409',
'Villefavard (87190)' => '87206',
'Villefollet (79170)' => '79348',
'Villefranche-de-Lonchat (24610)' => '24584',
'Villefranche-du-Périgord (24550)' => '24585',
'Villefranche-du-Queyran (47160)' => '47320',
'Villefranque (64990)' => '64558',
'Villegats (16700)' => '16410',
'Villegouge (33141)' => '33548',
'Villejésus (16140)' => '16411',
'Villejoubert (16560)' => '16412',
'Villemain (79110)' => '79349',
'Villemorin (17470)' => '17473',
'Villemort (86310)' => '86291',
'Villenave (40110)' => '40330',
'Villenave-d\'Ornon (33140)' => '33550',
'Villenave-de-Rions (33550)' => '33549',
'Villenave-près-Béarn (65500)' => '65476',
'Villeneuve (33710)' => '33551',
'Villeneuve-de-Duras (47120)' => '47321',
'Villeneuve-de-Marsan (40190)' => '40331',
'Villeneuve-la-Comtesse (17330)' => '17474',
'Villeneuve-sur-Lot (47300)' => '47323',
'Villeréal (47210)' => '47324',
'Villeton (47400)' => '47325',
'Villetoureix (24600)' => '24586',
'Villexavier (17500)' => '17476',
'Villiers (86190)' => '86292',
'Villiers-Couture (17510)' => '17477',
'Villiers-en-Bois (79360)' => '79350',
'Villiers-en-Plaine (79160)' => '79351',
'Villiers-le-Roux (16240)' => '16413',
'Villiers-sur-Chizé (79170)' => '79352',
'Villognon (16230)' => '16414',
'Vinax (17510)' => '17478',
'Vindelle (16430)' => '16415',
'Viodos-Abense-de-Bas (64130)' => '64559',
'Virazeil (47200)' => '47326',
'Virelade (33720)' => '33552',
'Virollet (17260)' => '17479',
'Virsac (33240)' => '33553',
'Virson (17290)' => '17480',
'Vitrac (24200)' => '24587',
'Vitrac-Saint-Vincent (16310)' => '16416',
'Vitrac-sur-Montane (19800)' => '19287',
'Viven (64450)' => '64560',
'Viville (16120)' => '16417',
'Vivonne (86370)' => '86293',
'Voeuil-et-Giget (16400)' => '16418',
'Voissay (17400)' => '17481',
'Vouharte (16330)' => '16419',
'Vouhé (17700)' => '17482',
'Vouhé (79310)' => '79354',
'Vouillé (79230)' => '79355',
'Vouillé (86190)' => '86294',
'Voulême (86400)' => '86295',
'Voulgézac (16250)' => '16420',
'Voulmentin (79150)' => '79242',
'Voulon (86700)' => '86296',
'Vouneuil-sous-Biard (86580)' => '86297',
'Vouneuil-sur-Vienne (86210)' => '86298',
'Voutezac (19130)' => '19288',
'Vouthon (16220)' => '16421',
'Vouzailles (86170)' => '86299',
'Vouzan (16410)' => '16422',
'Xaintrailles (47230)' => '47327',
'Xaintray (79220)' => '79357',
'Xambes (16330)' => '16423',
'Ychoux (40160)' => '40332',
'Ygos-Saint-Saturnin (40110)' => '40333',
'Yssandon (19310)' => '19289',
'Yversay (86170)' => '86300',
'Yves (17340)' => '17483',
'Yviers (16210)' => '16424',
'Yvrac (33370)' => '33554',
'Yvrac-et-Malleyrand (16110)' => '16425',
'Yzosse (40180)' => '40334'
];
}
================================================
FILE: bridges/AtmoOccitanieBridge.php
================================================
[
'name' => 'Ville',
'required' => true,
'exampleValue' => 'cahors'
]
]];
const CACHE_TIMEOUT = 7200;
public function collectData()
{
$uri = self::URI . $this->getInput('city');
$html = getSimpleHTMLDOM($uri);
$generalMessage = $html->find('.landing-ville .city-banner .iqa-avertissement', 0)->innertext;
$recommendationsDom = $html->find('.landing-ville .recommandations', 0);
$recommendationsItemDom = $recommendationsDom->find('.recommandation-item .label');
$recommendationsMessage = '';
$i = 0;
$len = count($recommendationsItemDom);
foreach ($recommendationsItemDom as $key => $value) {
if ($i == 0) {
$recommendationsMessage .= trim($value->innertext) . '.';
} else {
$recommendationsMessage .= ' ' . trim($value->innertext) . '.';
}
$i++;
}
$lastRecommendationsDom = $recommendationsDom->find('.col-md-6', -1);
$informationHeaderMessage = $lastRecommendationsDom->find('.heading', 0)->innertext;
$indice = $lastRecommendationsDom->find('.current-indice .indice div', 0)->innertext;
$informationDescriptionMessage = $lastRecommendationsDom->find('.current-indice .description p', 0)->innertext;
$message = "$generalMessage L'indice est de " . (6 - $indice) . "/6. $informationDescriptionMessage. $recommendationsMessage";
$city = $this->getInput('city');
$item['uri'] = $uri;
$today = date('d/m/Y');
$item['title'] = "Bulletin de l'air du $today pour la ville : $city.";
$item['title'] .= ' #QualiteAir. ' . $message;
$item['author'] = 'floviolleau';
$item['content'] = $message;
$item['uid'] = hash('sha256', $item['title']);
$this->items[] = $item;
}
}
================================================
FILE: bridges/AuctionetBridge.php
================================================
[
'name' => 'Category',
'type' => 'list',
'values' => [
'All categories' => '',
'Art' => [
'All' => '25-art',
'Drawings' => '119-drawings',
'Engravings & Prints' => '27-engravings-prints',
'Other' => '30-other',
'Paintings' => '28-paintings',
'Photography' => '26-photography',
'Sculptures & Bronzes' => '29-sculptures-bronzes',
],
'Asiatica' => [
'All' => '117-asiatica',
],
'Books, Maps & Manuscripts' => [
'All' => '50-books-maps-manuscripts',
'Autographs & Manuscripts' => '206-autographs-manuscripts',
'Books' => '204-books',
'Maps' => '205-maps',
'Other' => '207-other',
],
'Carpets & Textiles' => [
'All' => '35-carpets-textiles',
'Carpets' => '36-carpets',
'Textiles' => '37-textiles',
],
'Ceramics & Porcelain' => [
'All' => '9-ceramics-porcelain',
'European' => '10-european',
'Oriental' => '11-oriental',
'Rest of the world' => '12-rest-of-the-world',
'Tableware' => '210-tableware',
],
'Clocks & Watches' => [
'All' => '31-clocks-watches',
'Carriage & Miniature Clocks' => '258-carriage-miniature-clocks',
'Longcase clocks' => '32-longcase-clocks',
'Mantel clocks' => '33-mantel-clocks',
'Other clocks' => '34-other-clocks',
'Pocket & Stop Watches' => '110-pocket-stop-watches',
'Wall Clocks' => '127-wall-clocks',
'Wristwatches' => '15-wristwatches',
],
'Coins, Medals & Stamps' => [
'All' => '46-coins-medals-stamps',
'Coins' => '128-coins',
'Orders & Medals' => '135-orders-medals',
'Other' => '131-other',
'Stamps' => '136-stamps',
],
'Folk art' => [
'All' => '58-folk-art',
'Bowls & Boxes' => '121-bowls-boxes',
'Furniture' => '122-furniture',
'Other' => '123-other',
'Tools & Gears' => '120-tools-gears',
],
'Furniture' => [
'All' => '16-furniture',
'Armchairs & Chairs' => '18-armchairs-chairs',
'Chests of drawers' => '24-chests-of-drawers',
'Cupboards, Cabinets & Shelves' => '23-cupboards-cabinets-shelves',
'Dining room furniture' => '22-dining-room-furniture',
'Garden' => '21-garden',
'Other' => '17-other',
'Sofas & seatings' => '20-sofas-seatings',
'Tables' => '19-tables',
],
'Glass' => [
'All' => '6-glass',
'Art glass' => '208-art-glass',
'Other' => '8-other',
'Tableware' => '7-tableware',
'Utility glass' => '209-utility-glass',
],
'Jewellery & Gemstones' => [
'All' => '13-jewellery-gemstones',
'Alliance rings' => '113-alliance-rings',
'Bracelets' => '106-bracelets',
'Brooches & Pendants' => '107-brooches-pendants',
'Costume Jewellery' => '259-costume-jewellery',
'Cufflinks & Tie Pins' => '111-cufflinks-tie-pins',
'Ear studs' => '116-ear-studs',
'Earrings' => '115-earrings',
'Gemstones' => '48-gemstones',
'Jewellery' => '14-jewellery',
'Jewellery Suites' => '109-jewellery-suites',
'Necklace' => '104-necklace',
'Other' => '118-other',
'Rings' => '112-rings',
'Signet rings' => '105-signet-rings',
'Solitaire rings' => '114-solitaire-rings',
],
'Licence weapons' => [
'All' => '59-licence-weapons',
'Combi/Combo' => '63-combi-combo',
'Double express rifles' => '60-double-express-rifles',
'Rifles' => '61-rifles',
'Shotguns' => '62-shotguns',
],
'Lighting & Lamps' => [
'All' => '1-lighting-lamps',
'Candlesticks' => '4-candlesticks',
'Ceiling lights' => '3-ceiling-lights',
'Chandeliers' => '203-chandeliers',
'Floor lights' => '2-floor-lights',
'Other lighting' => '5-other-lighting',
'Table Lamps' => '125-table-lamps',
'Wall Lights' => '124-wall-lights',
],
'Mirrors' => [
'All' => '42-mirrors',
],
'Miscellaneous' => [
'All' => '43-miscellaneous',
'Fishing equipment' => '54-fishing-equipment',
'Miscellaneous' => '47-miscellaneous',
'Modern Tools' => '133-modern-tools',
'Modern consumer electronics' => '52-modern-consumer-electronics',
'Musical instruments' => '51-musical-instruments',
'Technica & Nautica' => '45-technica-nautica',
],
'Photo, Cameras & Lenses' => [
'All' => '57-photo-cameras-lenses',
'Cameras & accessories' => '71-cameras-accessories',
'Optics' => '66-optics',
'Other' => '72-other',
],
'Silver & Metals' => [
'All' => '38-silver-metals',
'Other metals' => '40-other-metals',
'Pewter, Brass & Copper' => '41-pewter-brass-copper',
'Silver' => '39-silver',
'Silver plated' => '213-silver-plated',
],
'Toys' => [
'All' => '44-toys',
'Comics' => '211-comics',
'Toys' => '212-toys',
],
'Tribal art' => [
'All' => '134-tribal-art',
],
'Vehicles, Boats & Parts' => [
'All' => '249-vehicles-boats-parts',
'Automobilia & Transport' => '255-automobilia-transport',
'Bicycles' => '132-bicycles',
'Boats & Accessories' => '250-boats-accessories',
'Car parts' => '253-car-parts',
'Cars' => '215-cars',
'Moped parts' => '254-moped-parts',
'Mopeds' => '216-mopeds',
'Motorcycle parts' => '252-motorcycle-parts',
'Motorcycles' => '251-motorcycles',
'Other' => '256-other',
],
'Vintage & Designer Fashion' => [
'All' => '49-vintage-designer-fashion',
],
'Weapons & Militaria' => [
'All' => '137-weapons-militaria',
'Airguns' => '257-airguns',
'Armour & Uniform' => '138-armour-uniform',
'Edged weapons' => '130-edged-weapons',
'Guns & Rifles' => '129-guns-rifles',
'Other' => '214-other',
],
'Wine, Port & Spirits' => [
'All' => '170-wine-port-spirits',
],
]
],
'sort_order' => [
'name' => 'Sort order',
'type' => 'list',
'values' => [
'Most bids' => 'bids_count_desc',
'Lowest bid' => 'bid_asc',
'Highest bid' => 'bid_desc',
'Last bid on' => 'bid_on',
'Ending soonest' => 'end_asc_active',
'Lowest estimate' => 'estimate_asc',
'Highest estimate' => 'estimate_desc',
'Recently added' => 'recent'
],
],
'country' => [
'name' => 'Country',
'type' => 'list',
'values' => [
'All' => '',
'Denmark' => 'DK',
'Finland' => 'FI',
'Germany' => 'DE',
'Spain' => 'ES',
'Sweden' => 'SE',
'United Kingdom' => 'GB'
]
],
'language' => [
'name' => 'Language',
'type' => 'list',
'values' => [
'English' => 'en',
'Español' => 'es',
'Deutsch' => 'de',
'Svenska' => 'sv',
'Dansk' => 'da',
'Suomi' => 'fi',
],
],
]];
const CACHE_TIMEOUT = 3600; // 1 hour
private $title;
public function collectData()
{
// Each page contains 48 auctions
// So we fetch 10 pages so we decrease the likelihood
// of missing auctions between feed refreshes
// Fetch first page and use that to get title
{
$url = $this->getUrl(1);
$data = getContents($url);
$title = $this->getDocumentTitle($data);
$this->items = array_merge($this->items, $this->parsePageData($data));
}
// Fetch remaining pages
for ($page = 2; $page <= 10; $page++) {
$url = $this->getUrl($page);
$data = getContents($url);
$this->items = array_merge($this->items, $this->parsePageData($data));
}
}
public function getName()
{
return $this->title ?: parent::getName();
}
/* HELPERS */
private function getUrl($page)
{
$category = $this->getInput('category');
$language = $this->getInput('language');
$sort_order = $this->getInput('sort_order');
$country = $this->getInput('country');
$url = self::URI . '/' . $language . '/search';
if ($category) {
$url = $url . '/' . $category;
}
$query = [];
$query['page'] = $page;
if ($sort_order) {
$query['order'] = $sort_order;
}
if ($country) {
$query['country_code'] = $country;
}
if (count($query) > 0) {
$url = $url . '?' . http_build_query($query);
}
return $url;
}
private function getDocumentTitle($data)
{
$title_elem = '';
$title_elem_length = strlen($title_elem);
$title_start = strpos($data, $title_elem);
$title_end = strpos($data, ' ', $title_start);
$title_length = $title_end - $title_start + strlen($title_elem);
$title = substr($data, $title_start + strlen($title_elem), $title_length);
return $title;
}
/**
* The auction items data is included in the HTML document
* as a HTML entities encoded JSON structure
* which is used to hydrate the React component for the list of auctions
*/
private function parsePageData($data)
{
$key = 'data-react-props="';
$keyLength = strlen($key);
$start = strpos($data, $key);
$end = strpos($data, '"', $start + strlen($key));
$length = $end - ($start + $keyLength);
$jsonString = substr($data, $start + $keyLength, $length);
$jsonData = json_decode(htmlspecialchars_decode($jsonString), false);
$items = [];
foreach ($jsonData->{'items'} as $item) {
$title = $item->{'longTitle'};
$relative_url = $item->{'url'};
$images = $item->{'imageUrls'};
$id = $item->{'auctionId'};
$items[] = [
'title' => $title,
'uri' => self::URI . $relative_url,
'uid' => $id,
'content' => count($images) > 0 ? "
$title" : $title,
'enclosures' => array_slice($images, 1),
];
}
return $items;
}
}
================================================
FILE: bridges/AutoJMBridge.php
================================================
[
'url' => [
'name' => 'URL de la page de recherche',
'type' => 'text',
'required' => true,
'title' => 'URL d\'une recherche avec filtre de véhicules sans le http://www.autojm.fr/',
'exampleValue' => 'recherche?brands[]=PEUGEOT&ranges[]=PEUGEOT 308'
],
]
];
const CACHE_TIMEOUT = 3600;
const TEST_DETECT_PARAMETERS = [
'https://www.autojm.fr/recherche?brands%5B%5D=PEUGEOT&ranges%5B%5D=PEUGEOT%20308'
=> ['url' => 'recherche?brands%5B%5D=PEUGEOT&ranges%5B%5D=PEUGEOT%20308',
'context' => 'Afficher les offres de véhicules disponible sur la recheche AutoJM'
]
];
public function getIcon()
{
return self::URI . 'favicon.ico';
}
public function getName()
{
switch ($this->queriedContext) {
case 'Afficher les offres de véhicules disponible sur la recheche AutoJM':
return 'AutoJM | Recherche de véhicules';
break;
default:
return parent::getName();
}
}
public function getURI()
{
switch ($this->queriedContext) {
case 'Afficher les offres de véhicules disponible sur la recheche AutoJM':
return self::URI . $this->getInput('url');
break;
default:
return self::URI;
}
}
public function collectData()
{
// Get the number of result for this search
$search_url = self::URI . $this->getInput('url') . '&open=energy&onlyFilters=false';
// Set the header 'X-Requested-With' like the website does it
$header = [
'X-Requested-With: XMLHttpRequest'
];
// Get the JSON content of the form
$json = getContents($search_url, $header);
// Extract the HTML content from the JSON result
$data = json_decode($json);
$nb_results = $data->nbResults;
$total_pages = ceil($nb_results / 14);
// Limit the number of page to analyse to 10
for ($page = 1; $page <= $total_pages && $page <= 10; $page++) {
// Get the result the next page
$html = $this->getResults($page);
// Go through every car of the search
$list = $html->find('div[class*=card-car card-car--listing]');
foreach ($list as $car) {
// Get the info about the car offer
$image = $car->find('div[class=card-car__header__img]', 0)->find('img', 0)->src;
// Decode HTML attribute JSON data
$car_data = json_decode(html_entity_decode($car->{'data-layer'}));
$car_model = $car_data->title;
$availability = $car->find('div[class*=card-car__modalites]', 0)->find('div[class=col]', 0)->plaintext;
$warranty = $car->find('div[data-type=WarrantyCard]', 0)->plaintext;
$discount_html = $car->find('div[class=subtext vehicle_reference_element]', 0);
// Check if there is any discount info displayed
if ($discount_html != null) {
$reference_price_value = $discount_html->find('span[data-cfg=vehicle__reference_price]', 0)->plaintext;
$discount_percent_value = $discount_html->find('span[data-cfg=vehicle__discount_percent]', 0)->plaintext;
$reference_price = 'Prix de référence : ' . $reference_price_value . ' ';
$discount_percent = 'Réduction : ' . $discount_percent_value . ' % ';
} else {
$reference_price = '';
$discount_percent = '';
}
$price = $car_data->price;
$kilometer = $car->find('span[data-cfg=vehicle__kilometer]', 0)->plaintext;
$energy = $car->find('span[data-cfg=vehicle__energy__label]', 0)->plaintext;
$power = $car->find('span[data-cfg=vehicle__tax_horse_power]', 0)->plaintext;
$seats = $car->find('span[data-cfg=vehicle__seats]', 0)->plaintext;
$doors = $car->find('span[data-cfg=vehicle__door__label]', 0)->plaintext;
$transmission = $car->find('span[data-cfg=vehicle__transmission]', 0)->plaintext;
$loa_html = $car->find('span[data-cfg=vehicle__loa]', 0);
// Check if any LOA price is displayed
if ($loa_html != null) {
$loa_value = $car->find('span[data-cfg=vehicle__loa]', 0)->plaintext;
$loa = 'LOA : à partir de ' . $loa_value . ' / mois ';
} else {
$loa = '';
}
// Construct the new item
$item = [];
$item['title'] = $car_model;
$item['content'] = '
'
. $car_model . '
';
$item['content'] .= '- Disponibilité : ' . $availability . '
';
$item['content'] .= '- Prix : ' . $price . ' €
';
$item['content'] .= $reference_price;
$item['content'] .= $loa;
$item['content'] .= $discount_percent;
$item['content'] .= '- Garantie : ' . $warranty . '
';
$item['content'] .= '- Kilométrage : ' . $kilometer . ' km
';
$item['content'] .= '- Energie : ' . $energy . '
';
$item['content'] .= '- Puissance: ' . $power . ' CV Fiscaux
';
$item['content'] .= '- Nombre de Places : ' . $seats . ' place(s)
';
$item['content'] .= '- Nombre de portes : ' . $doors . '
';
$item['content'] .= '- Boite de vitesse : ' . $transmission . '
';
$item['uri'] = $car_data->{'uri'};
$item['uid'] = hash('md5', $item['content']);
$this->items[] = $item;
}
}
}
private function getResults(int $page)
{
$user_input = $this->getInput('url');
$search_data = preg_replace('#(recherche|recherche/[0-9]{1,10})\?#', 'recherche/' . $page . '?', $user_input);
$search_url = self::URI . $search_data . '&open=energy&onlyFilters=false';
// Get the HTML content of the page
$html = getSimpleHTMLDOMCached($search_url);
return $html;
}
public function detectParameters($url)
{
$params = [];
$regex = '/^(https?:\/\/)?(www\.|)autojm.fr\/(recherche\?.*|recherche\/[0-9]{1,10}\?.*)$/m';
if (preg_match($regex, $url, $matches) > 0) {
$url = preg_replace('#(recherche|recherche/[0-9]{1,10})#', 'recherche', $matches[3]);
$params['url'] = $url;
$params['context'] = 'Afficher les offres de véhicules disponible sur la recheche AutoJM';
return $params;
}
}
}
================================================
FILE: bridges/AwwwardsBridge.php
================================================
fetchSites();
foreach ($this->sites as $site) {
$item = [];
$item['title'] = $site['title'];
$item['timestamp'] = $site['createdAt'];
$item['categories'] = $site['tags'];
$item['content'] = '
';
$item['uri'] = self::SITEURI . $site['slug'];
$this->items[] = $item;
if (count($this->items) >= 10) {
break;
}
}
}
public function getIcon()
{
return 'https://www.awwwards.com/favicon.ico';
}
private function fetchSites()
{
$sites = getSimpleHTMLDOM(self::SITESURI);
foreach ($sites->find('.grid-sites li') as $li) {
$encodedJson = $li->attr['data-collectable-model-value'] ?? null;
if (!$encodedJson) {
continue;
}
$json = html_entity_decode($encodedJson, ENT_QUOTES, 'utf-8');
$site = Json::decode($json);
$this->sites[] = $site;
}
}
}
================================================
FILE: bridges/BAEBridge.php
================================================
[
'name' => 'Filtrer par mots clés',
'title' => 'Entrez le mot clé à filtrer ici'
],
'type' => [
'name' => 'Type de recherche',
'title' => 'Afficher seuleument un certain type d\'annonce',
'type' => 'list',
'values' => [
'Toutes les annonces' => false,
'Les embarquements' => 'boat',
'Les skippers' => 'skipper',
'Les équipiers' => 'crew'
]
]
]
];
public function collectData()
{
$url = $this->getURI();
$html = getSimpleHTMLDOM($url);
$annonces = $html->find('main article');
foreach ($annonces as $annonce) {
$detail = $annonce->find('footer a', 0);
$htmlDetail = getSimpleHTMLDOMCached(parent::getURI() . $detail->href);
if (!$htmlDetail) {
continue;
}
$item = [];
$item['title'] = $annonce->find('header h2', 0)->plaintext;
$item['uri'] = parent::getURI() . $detail->href;
$content = $htmlDetail->find('article p', 0)->innertext;
if (!empty($this->getInput('keyword'))) {
$keyword = $this->removeAccents(strtolower($this->getInput('keyword')));
$cleanTitle = $this->removeAccents(strtolower($item['title']));
if (strpos($cleanTitle, $keyword) === false) {
$cleanContent = $this->removeAccents(strtolower($content));
if (strpos($cleanContent, $keyword) === false) {
continue;
}
}
}
$content .= '
';
$content .= $htmlDetail->find('section', 0)->innertext;
$item['content'] = defaultLinkTo($content, parent::getURI());
$image = $htmlDetail->find('#zoom', 0);
if ($image) {
$item['enclosures'] = [parent::getURI() . $image->getAttribute('src')];
}
$this->items[] = $item;
}
}
public function getURI()
{
$uri = parent::getURI();
if (!empty($this->getInput('type'))) {
if ($this->getInput('type') == 'boat') {
$uri .= '/embarquements.html';
} elseif ($this->getInput('type') == 'skipper') {
$uri .= '/skippers.html';
} else {
$uri .= '/equipiers.html';
}
}
return $uri;
}
private function removeAccents($string)
{
$chars = [
// Decompositions for Latin-1 Supplement
'ª' => 'a', 'º' => 'o',
'À' => 'A', 'Á' => 'A',
'Â' => 'A', 'Ã' => 'A',
'Ä' => 'A', 'Å' => 'A',
'Æ' => 'AE', 'Ç' => 'C',
'È' => 'E', 'É' => 'E',
'Ê' => 'E', 'Ë' => 'E',
'Ì' => 'I', 'Í' => 'I',
'Î' => 'I', 'Ï' => 'I',
'Ð' => 'D', 'Ñ' => 'N',
'Ò' => 'O', 'Ó' => 'O',
'Ô' => 'O', 'Õ' => 'O',
'Ö' => 'O', 'Ù' => 'U',
'Ú' => 'U', 'Û' => 'U',
'Ü' => 'U', 'Ý' => 'Y',
'Þ' => 'TH', 'ß' => 's',
'à' => 'a', 'á' => 'a',
'â' => 'a', 'ã' => 'a',
'ä' => 'a', 'å' => 'a',
'æ' => 'ae', 'ç' => 'c',
'è' => 'e', 'é' => 'e',
'ê' => 'e', 'ë' => 'e',
'ì' => 'i', 'í' => 'i',
'î' => 'i', 'ï' => 'i',
'ð' => 'd', 'ñ' => 'n',
'ò' => 'o', 'ó' => 'o',
'ô' => 'o', 'õ' => 'o',
'ö' => 'o', 'ø' => 'o',
'ù' => 'u', 'ú' => 'u',
'û' => 'u', 'ü' => 'u',
'ý' => 'y', 'þ' => 'th',
'ÿ' => 'y', 'Ø' => 'O',
// Decompositions for Latin Extended-A
'Ā' => 'A', 'ā' => 'a',
'Ă' => 'A', 'ă' => 'a',
'Ą' => 'A', 'ą' => 'a',
'Ć' => 'C', 'ć' => 'c',
'Ĉ' => 'C', 'ĉ' => 'c',
'Ċ' => 'C', 'ċ' => 'c',
'Č' => 'C', 'č' => 'c',
'Ď' => 'D', 'ď' => 'd',
'Đ' => 'D', 'đ' => 'd',
'Ē' => 'E', 'ē' => 'e',
'Ĕ' => 'E', 'ĕ' => 'e',
'Ė' => 'E', 'ė' => 'e',
'Ę' => 'E', 'ę' => 'e',
'Ě' => 'E', 'ě' => 'e',
'Ĝ' => 'G', 'ĝ' => 'g',
'Ğ' => 'G', 'ğ' => 'g',
'Ġ' => 'G', 'ġ' => 'g',
'Ģ' => 'G', 'ģ' => 'g',
'Ĥ' => 'H', 'ĥ' => 'h',
'Ħ' => 'H', 'ħ' => 'h',
'Ĩ' => 'I', 'ĩ' => 'i',
'Ī' => 'I', 'ī' => 'i',
'Ĭ' => 'I', 'ĭ' => 'i',
'Į' => 'I', 'į' => 'i',
'İ' => 'I', 'ı' => 'i',
'IJ' => 'IJ', 'ij' => 'ij',
'Ĵ' => 'J', 'ĵ' => 'j',
'Ķ' => 'K', 'ķ' => 'k',
'ĸ' => 'k', 'Ĺ' => 'L',
'ĺ' => 'l', 'Ļ' => 'L',
'ļ' => 'l', 'Ľ' => 'L',
'ľ' => 'l', 'Ŀ' => 'L',
'ŀ' => 'l', 'Ł' => 'L',
'ł' => 'l', 'Ń' => 'N',
'ń' => 'n', 'Ņ' => 'N',
'ņ' => 'n', 'Ň' => 'N',
'ň' => 'n', 'ʼn' => 'n',
'Ŋ' => 'N', 'ŋ' => 'n',
'Ō' => 'O', 'ō' => 'o',
'Ŏ' => 'O', 'ŏ' => 'o',
'Ő' => 'O', 'ő' => 'o',
'Œ' => 'OE', 'œ' => 'oe',
'Ŕ' => 'R', 'ŕ' => 'r',
'Ŗ' => 'R', 'ŗ' => 'r',
'Ř' => 'R', 'ř' => 'r',
'Ś' => 'S', 'ś' => 's',
'Ŝ' => 'S', 'ŝ' => 's',
'Ş' => 'S', 'ş' => 's',
'Š' => 'S', 'š' => 's',
'Ţ' => 'T', 'ţ' => 't',
'Ť' => 'T', 'ť' => 't',
'Ŧ' => 'T', 'ŧ' => 't',
'Ũ' => 'U', 'ũ' => 'u',
'Ū' => 'U', 'ū' => 'u',
'Ŭ' => 'U', 'ŭ' => 'u',
'Ů' => 'U', 'ů' => 'u',
'Ű' => 'U', 'ű' => 'u',
'Ų' => 'U', 'ų' => 'u',
'Ŵ' => 'W', 'ŵ' => 'w',
'Ŷ' => 'Y', 'ŷ' => 'y',
'Ÿ' => 'Y', 'Ź' => 'Z',
'ź' => 'z', 'Ż' => 'Z',
'ż' => 'z', 'Ž' => 'Z',
'ž' => 'z', 'ſ' => 's',
// Decompositions for Latin Extended-B
'Ș' => 'S', 'ș' => 's',
'Ț' => 'T', 'ț' => 't',
// Euro Sign
'€' => 'E',
// GBP (Pound) Sign
'£' => '',
// Vowels with diacritic (Vietnamese)
// unmarked
'Ơ' => 'O', 'ơ' => 'o',
'Ư' => 'U', 'ư' => 'u',
// grave accent
'Ầ' => 'A', 'ầ' => 'a',
'Ằ' => 'A', 'ằ' => 'a',
'Ề' => 'E', 'ề' => 'e',
'Ồ' => 'O', 'ồ' => 'o',
'Ờ' => 'O', 'ờ' => 'o',
'Ừ' => 'U', 'ừ' => 'u',
'Ỳ' => 'Y', 'ỳ' => 'y',
// hook
'Ả' => 'A', 'ả' => 'a',
'Ẩ' => 'A', 'ẩ' => 'a',
'Ẳ' => 'A', 'ẳ' => 'a',
'Ẻ' => 'E', 'ẻ' => 'e',
'Ể' => 'E', 'ể' => 'e',
'Ỉ' => 'I', 'ỉ' => 'i',
'Ỏ' => 'O', 'ỏ' => 'o',
'Ổ' => 'O', 'ổ' => 'o',
'Ở' => 'O', 'ở' => 'o',
'Ủ' => 'U', 'ủ' => 'u',
'Ử' => 'U', 'ử' => 'u',
'Ỷ' => 'Y', 'ỷ' => 'y',
// tilde
'Ẫ' => 'A', 'ẫ' => 'a',
'Ẵ' => 'A', 'ẵ' => 'a',
'Ẽ' => 'E', 'ẽ' => 'e',
'Ễ' => 'E', 'ễ' => 'e',
'Ỗ' => 'O', 'ỗ' => 'o',
'Ỡ' => 'O', 'ỡ' => 'o',
'Ữ' => 'U', 'ữ' => 'u',
'Ỹ' => 'Y', 'ỹ' => 'y',
// acute accent
'Ấ' => 'A', 'ấ' => 'a',
'Ắ' => 'A', 'ắ' => 'a',
'Ế' => 'E', 'ế' => 'e',
'Ố' => 'O', 'ố' => 'o',
'Ớ' => 'O', 'ớ' => 'o',
'Ứ' => 'U', 'ứ' => 'u',
// dot below
'Ạ' => 'A', 'ạ' => 'a',
'Ậ' => 'A', 'ậ' => 'a',
'Ặ' => 'A', 'ặ' => 'a',
'Ẹ' => 'E', 'ẹ' => 'e',
'Ệ' => 'E', 'ệ' => 'e',
'Ị' => 'I', 'ị' => 'i',
'Ọ' => 'O', 'ọ' => 'o',
'Ộ' => 'O', 'ộ' => 'o',
'Ợ' => 'O', 'ợ' => 'o',
'Ụ' => 'U', 'ụ' => 'u',
'Ự' => 'U', 'ự' => 'u',
'Ỵ' => 'Y', 'ỵ' => 'y',
// Vowels with diacritic (Chinese, Hanyu Pinyin)
'ɑ' => 'a',
// macron
'Ǖ' => 'U', 'ǖ' => 'u',
// acute accent
'Ǘ' => 'U', 'ǘ' => 'u',
// caron
'Ǎ' => 'A', 'ǎ' => 'a',
'Ǐ' => 'I', 'ǐ' => 'i',
'Ǒ' => 'O', 'ǒ' => 'o',
'Ǔ' => 'U', 'ǔ' => 'u',
'Ǚ' => 'U', 'ǚ' => 'u',
// grave accent
'Ǜ' => 'U', 'ǜ' => 'u',
];
$string = strtr($string, $chars);
return $string;
}
}
================================================
FILE: bridges/BMDSystemhausBlogBridge.php
================================================
'{data_img} {data_content}
',
'clir' => '{data_content} {data_img}
',
'itcb' => '{data_img}
{data_content}',
'ctib' => '{data_content}
{data_img}',
'co' => '{data_content}',
'io' => '{data_img}'
];
const PARAMETERS = [
'Blog' => [
'country' => [
'name' => 'Country',
'type' => 'list',
'values' => [
'Österreich' => 'at',
'Deutschland' => 'de',
'Schweiz' => 'ch',
'Slovensko' => 'sk',
'Cesko' => 'cz',
'Hungary' => 'hu',
],
'defaultValue' => 'at',
],
'style' => [
'name' => 'Style',
'type' => 'list',
'values' => [
'Image left, content right' => 'ilcr',
'Content left, image right' => 'clir',
'Image top, content bottom' => 'itcb',
'Content top, image bottom' => 'ctib',
'Content only' => 'co',
'Image only' => 'io',
],
'defaultValue' => 'ilcr',
]
]
];
//-----------------------------------------------------
public function collectData()
{
// get website content
$html = getSimpleHTMLDOM($this->getURI());
// Convert relative links in HTML into absolute links
$html = defaultLinkTo($html, self::URI);
// Convert lazy-loading images and frames (video embeds) into static elements
$html = convertLazyLoading($html);
foreach ($html->find('div#bmdNewsList div#bmdNewsList-Item') as $element) {
$itemScope = $element->find('div[itemscope=itemscope]', 0);
$item = [];
// set base article data
$item['title'] = $this->getMetaItemPropContent($itemScope, 'headline');
$item['timestamp'] = strtotime($this->getMetaItemPropContent($itemScope, 'datePublished'));
$item['author'] = $this->getMetaItemPropContent($itemScope->find('div[itemprop=author]', 0), 'name');
// find article image
$imageTag = '';
$image = $element->find('div.mediaelement.mediaelement-image img', 0);
if ((!is_null($image)) and ($image->src != '')) {
$item['enclosures'] = [$image->src];
$imageTag = '
';
}
// begin with right style
$content = self::ITEMSTYLE[$this->getInput('style')];
// render placeholder
$content = str_replace('{data_content}', $this->getMetaItemPropContent($itemScope, 'description'), $content);
$content = str_replace('{data_img}', $imageTag, $content);
// set finished content
$item['content'] = $content;
// get link to article
$link = $element->find('div#bmdNewsList-Text div#bmdNewsList-Title a', 0);
if (!is_null($link)) {
$item['uri'] = $link->href;
}
// init categories
$categories = [];
$tmpOne = [];
$tmpTwo = [];
// search first categorie span
$catElem = $element->find('div#bmdNewsList-Text div#bmdNewsList-Category span.news-list-category', 0);
$txt = trim($catElem->innertext);
$tmpOne = explode('/', $txt);
// split by 2 spaces
foreach ($tmpOne as $tmpElem) {
$tmpElem = trim($tmpElem);
$tmpData = preg_split('/ /', $tmpElem);
$tmpTwo = array_merge($tmpTwo, $tmpData);
}
// split by tabulator
foreach ($tmpTwo as $tmpElem) {
$tmpElem = trim($tmpElem);
$tmpData = preg_split('/\t+/', $tmpElem);
$categories = array_merge($categories, $tmpData);
}
// trim each categorie entries
$categories = array_map('trim', $categories);
// remove empty entries
$categories = array_filter($categories, function ($value) {
return !is_null($value) && $value !== '';
});
// set categories
if (count($categories) > 0) {
$item['categories'] = $categories;
}
// add item
if (($item['title'] != '') and ($item['content'] != '') and ($item['uri'] != '')) {
$this->items[] = $item;
}
}
}
//-----------------------------------------------------
public function detectParameters($url)
{
try {
$parsedUrl = Url::fromString($url);
} catch (UrlException $e) {
return null;
}
if (!in_array($parsedUrl->getHost(), ['www.bmd.com', 'bmd.com'])) {
return null;
}
$lang = '';
// extract language from url
$path = explode('/', $parsedUrl->getPath());
if (count($path) > 1) {
$lang = $path[1];
// validate data
if ($this->getURIbyCountry($lang) == '') {
$lang = '';
}
}
// if no country available, find language by browser
if ($lang == '') {
$srvLanguages = explode(';', $_SERVER['HTTP_ACCEPT_LANGUAGE']);
if (count($srvLanguages) > 0) {
$languages = explode(',', $srvLanguages[0]);
if (count($languages) > 0) {
for ($i = 0; $i < count($languages); $i++) {
$langDetails = explode('-', $languages[$i]);
if (count($langDetails) > 1) {
$lang = $langDetails[1];
} else {
$lang = substr($srvLanguages[0], 0, 2);
}
// validate data
if ($this->getURIbyCountry($lang) == '') {
$lang = '';
}
if ($lang != '') {
break;
}
}
}
}
}
// if no URL found by language, use AT as default
if ($this->getURIbyCountry($lang) == '') {
$lang = 'at';
}
$params = [];
$params['country'] = strtolower($lang);
return $params;
}
//-----------------------------------------------------
public function getURI()
{
$country = $this->getInput('country') ?? '';
$lURI = $this->getURIbyCountry($country);
return $lURI != '' ? $lURI : parent::getURI();
}
//-----------------------------------------------------
public function getIcon()
{
return self::BMD_FAV_ICON;
}
//-----------------------------------------------------
private function getMetaItemPropContent($elem, $key)
{
if (($key != '') and (!is_null($elem))) {
$metaElem = $elem->find('meta[itemprop=' . $key . ']', 0);
if (!is_null($metaElem)) {
return $metaElem->getAttribute('content');
}
}
return '';
}
//-----------------------------------------------------
private function getURIbyCountry($country)
{
switch (strtolower($country)) {
case 'at':
return 'https://www.bmd.com/at/ueber-bmd/blog-ohne-filter.html';
case 'de':
return 'https://www.bmd.com/de/das-ist-bmd/blog.html';
case 'ch':
return 'https://www.bmd.com/ch/das-ist-bmd/blog.html';
case 'sk':
return 'https://www.bmd.com/sk/firma/blog.html';
case 'cz':
return 'https://www.bmd.com/cz/firma/news-blog.html';
case 'hu':
return 'https://www.bmd.com/hu/rolunk/hirek.html';
default:
return '';
}
}
}
================================================
FILE: bridges/BadDragonBridge.php
================================================
[
],
'Clearance' => [
'ready_made' => [
'name' => 'Ready Made',
'type' => 'checkbox'
],
'flop' => [
'name' => 'Flops',
'type' => 'checkbox'
],
'skus' => [
'name' => 'Products',
'exampleValue' => 'chanceflared, crackers',
'title' => 'Comma separated list of product SKUs'
],
'onesize' => [
'name' => 'One-Size',
'type' => 'checkbox'
],
'mini' => [
'name' => 'Mini',
'type' => 'checkbox'
],
'small' => [
'name' => 'Small',
'type' => 'checkbox'
],
'medium' => [
'name' => 'Medium',
'type' => 'checkbox'
],
'large' => [
'name' => 'Large',
'type' => 'checkbox'
],
'extralarge' => [
'name' => 'Extra Large',
'type' => 'checkbox'
],
'category' => [
'name' => 'Category',
'type' => 'list',
'values' => [
'All' => 'all',
'Accessories' => 'accessories',
'Merchandise' => 'merchandise',
'Dildos' => 'insertable',
'Masturbators' => 'penetrable',
'Packers' => 'packer',
'Lil\' Squirts' => 'shooter',
'Lil\' Vibes' => 'vibrator',
'Wearables' => 'wearable'
],
'defaultValue' => 'all',
],
'soft' => [
'name' => 'Soft Firmness',
'type' => 'checkbox'
],
'med_firm' => [
'name' => 'Medium Firmness',
'type' => 'checkbox'
],
'firm' => [
'name' => 'Firm',
'type' => 'checkbox'
],
'split' => [
'name' => 'Split Firmness',
'type' => 'checkbox'
],
'maxprice' => [
'name' => 'Max Price',
'type' => 'number',
'required' => true,
'defaultValue' => 300
],
'minprice' => [
'name' => 'Min Price',
'type' => 'number',
'defaultValue' => 0
],
'cumtube' => [
'name' => 'Cumtube',
'type' => 'checkbox'
],
'suctionCup' => [
'name' => 'Suction Cup',
'type' => 'checkbox'
],
'noAccessories' => [
'name' => 'No Accessories',
'type' => 'checkbox'
]
]
];
/*
* This sets index $strFrom (or $strTo if set) in $outArr to 'on' if
* $inArr[$param] contains $strFrom.
* It is used for translating BD's shop filter URLs into something we can use.
*
* For the query '?type[]=ready_made&type[]=flop' we would have an array like:
* Array (
* [type] => Array (
* [0] => ready_made
* [1] => flop
* )
* )
* which could be translated into:
* Array (
* [ready_made] => on
* [flop] => on
* )
* */
private function setParam($inArr, &$outArr, $param, $strFrom, $strTo = null)
{
if (isset($inArr[$param]) && in_array($strFrom, $inArr[$param])) {
$outArr[($strTo ?: $strFrom)] = 'on';
}
}
public function detectParameters($url)
{
$params = [];
// Sale
$regex = '/^(https?:\/\/)?bad-dragon\.com\/sales/';
if (preg_match($regex, $url, $matches) > 0) {
$params['context'] = 'Sales';
return $params;
}
// Clearance
$regex = '/^(https?:\/\/)?bad-dragon\.com\/shop\/clearance/';
if (preg_match($regex, $url, $matches) > 0) {
parse_str(parse_url($url, PHP_URL_QUERY), $urlParams);
$this->setParam($urlParams, $params, 'type', 'ready_made');
$this->setParam($urlParams, $params, 'type', 'flop');
if (isset($urlParams['skus'])) {
$skus = [];
foreach ($urlParams['skus'] as $sku) {
is_string($sku) && $skus[] = $sku;
is_array($sku) && $skus[] = $sku[0];
}
$params['skus'] = implode(',', $skus);
}
$this->setParam($urlParams, $params, 'sizes', 'onesize');
$this->setParam($urlParams, $params, 'sizes', 'mini');
$this->setParam($urlParams, $params, 'sizes', 'small');
$this->setParam($urlParams, $params, 'sizes', 'medium');
$this->setParam($urlParams, $params, 'sizes', 'large');
$this->setParam($urlParams, $params, 'sizes', 'extralarge');
if (isset($urlParams['category'])) {
$params['category'] = strtolower($urlParams['category']);
} else {
$params['category'] = 'all';
}
$this->setParam($urlParams, $params, 'firmnessValues', 'soft');
$this->setParam($urlParams, $params, 'firmnessValues', 'medium', 'med_firm');
$this->setParam($urlParams, $params, 'firmnessValues', 'firm');
$this->setParam($urlParams, $params, 'firmnessValues', 'split');
if (isset($urlParams['price'])) {
isset($urlParams['price']['max'])
&& $params['maxprice'] = $urlParams['price']['max'];
isset($urlParams['price']['min'])
&& $params['minprice'] = $urlParams['price']['min'];
}
isset($urlParams['cumtube'])
&& $urlParams['cumtube'] === '1'
&& $params['cumtube'] = 'on';
isset($urlParams['suctionCup'])
&& $urlParams['suctionCup'] === '1'
&& $params['suctionCup'] = 'on';
isset($urlParams['noAccessories'])
&& $urlParams['noAccessories'] === '1'
&& $params['noAccessories'] = 'on';
$params['context'] = 'Clearance';
return $params;
}
return null;
}
public function getName()
{
switch ($this->queriedContext) {
case 'Sales':
return 'Bad Dragon Sales';
case 'Clearance':
return 'Bad Dragon Clearance Search';
default:
return parent::getName();
}
}
public function getURI()
{
switch ($this->queriedContext) {
case 'Sales':
return self::URI . 'sales';
case 'Clearance':
return $this->inputToURL();
default:
return parent::getURI();
}
}
public function collectData()
{
switch ($this->queriedContext) {
case 'Sales':
$sales = json_decode(getContents(self::URI . 'api/sales'));
foreach ($sales as $sale) {
$item = [];
$item['title'] = $sale->title;
$item['timestamp'] = strtotime($sale->startDate);
$item['uri'] = $this->getURI() . '/' . $sale->slug;
$contentHTML = '
';
if (isset($sale->endDate)) {
$contentHTML .= 'This promotion ends on '
. gmdate('M j, Y \a\t g:i A T', strtotime($sale->endDate))
. '
';
} else {
$contentHTML .= 'This promotion never ends
';
}
$ul = false;
$content = json_decode($sale->content);
foreach ($content->blocks as $block) {
switch ($block->type) {
case 'header-one':
$contentHTML .= '' . $block->text . '
';
break;
case 'header-two':
$contentHTML .= '' . $block->text . '
';
break;
case 'header-three':
$contentHTML .= '' . $block->text . '
';
break;
case 'unordered-list-item':
if (!$ul) {
$contentHTML .= '';
$ul = true;
}
$contentHTML .= '- ' . $block->text . '
';
break;
default:
if ($ul) {
$contentHTML .= '
';
$ul = false;
}
$contentHTML .= '' . $block->text . '
';
break;
}
}
$item['content'] = $contentHTML;
$this->items[] = $item;
}
break;
case 'Clearance':
$toyData = json_decode(getContents($this->inputToURL(true)));
$productList = json_decode(getContents(self::URI . 'api/inventory-toy/product-list'));
foreach ($toyData->toys as $toy) {
$item = [];
$item['uri'] = $this->getURI()
. '#'
. $toy->id;
$item['timestamp'] = strtotime($toy->created);
foreach ($productList as $product) {
if ($product->sku == $toy->sku) {
$item['title'] = $product->name;
break;
}
}
// images
$content = '';
foreach ($toy->images as $image) {
$content .= '
';
}
// price
$content .= '
Price: $'
. $toy->price
// size
. '
Size: '
. $toy->size
// color
. '
Color: '
. $toy->color
// features
. '
Features: '
. ($toy->suction_cup ? 'Suction cup' : '')
. ($toy->suction_cup && $toy->cumtube ? ', ' : '')
. ($toy->cumtube ? 'Cumtube' : '')
. ($toy->suction_cup || $toy->cumtube ? '' : 'None');
// firmness
$firmnessTexts = [
'2' => 'Extra soft',
'3' => 'Soft',
'5' => 'Medium',
'8' => 'Firm'
];
$firmnesses = explode('/', $toy->firmness);
if (count($firmnesses) === 2) {
$content .= '
Firmness: '
. $firmnessTexts[$firmnesses[0]]
. ', '
. $firmnessTexts[$firmnesses[1]];
} else {
$content .= '
Firmness: '
. $firmnessTexts[$firmnesses[0]];
}
// flop
if ($toy->type === 'flop') {
$content .= '
Flop reason: '
. $toy->flop_reason;
}
$content .= '
';
$item['content'] = $content;
$enclosures = [];
foreach ($toy->images as $image) {
$enclosures[] = $image->fullFilename;
}
$item['enclosures'] = $enclosures;
$categories = [];
$categories[] = $toy->sku;
$categories[] = $toy->type;
$categories[] = $toy->size;
if ($toy->cumtube) {
$categories[] = 'cumtube';
}
if ($toy->suction_cup) {
$categories[] = 'suction_cup';
}
$item['categories'] = $categories;
$this->items[] = $item;
}
break;
}
}
private function inputToURL($api = false)
{
$url = self::URI;
$url .= ($api ? 'api/inventory-toys?' : 'shop/clearance?');
// Default parameters
$url .= 'limit=60';
$url .= '&page=1';
$url .= '&sort[field]=created';
$url .= '&sort[direction]=desc';
// Product types
$url .= ($this->getInput('ready_made') ? '&type[]=ready_made' : '');
$url .= ($this->getInput('flop') ? '&type[]=flop' : '');
// Product names
foreach (array_filter(explode(',', $this->getInput('skus'))) as $sku) {
$url .= '&skus[]=' . urlencode(trim($sku));
}
// Size
$url .= ($this->getInput('onesize') ? '&sizes[]=onesize' : '');
$url .= ($this->getInput('mini') ? '&sizes[]=mini' : '');
$url .= ($this->getInput('small') ? '&sizes[]=small' : '');
$url .= ($this->getInput('medium') ? '&sizes[]=medium' : '');
$url .= ($this->getInput('large') ? '&sizes[]=large' : '');
$url .= ($this->getInput('extralarge') ? '&sizes[]=extralarge' : '');
// Category
$url .= ($this->getInput('category') ? '&category='
. urlencode($this->getInput('category')) : '');
// Firmness
if ($api) {
$url .= ($this->getInput('soft') ? '&firmnessValues[]=3' : '');
$url .= ($this->getInput('med_firm') ? '&firmnessValues[]=5' : '');
$url .= ($this->getInput('firm') ? '&firmnessValues[]=8' : '');
if ($this->getInput('split')) {
$url .= '&firmnessValues[]=3/5';
$url .= '&firmnessValues[]=3/8';
$url .= '&firmnessValues[]=8/3';
$url .= '&firmnessValues[]=5/8';
$url .= '&firmnessValues[]=8/5';
}
} else {
$url .= ($this->getInput('soft') ? '&firmnessValues[]=soft' : '');
$url .= ($this->getInput('med_firm') ? '&firmnessValues[]=medium' : '');
$url .= ($this->getInput('firm') ? '&firmnessValues[]=firm' : '');
$url .= ($this->getInput('split') ? '&firmnessValues[]=split' : '');
}
// Price
$url .= ($this->getInput('maxprice') ? '&price[max]='
. $this->getInput('maxprice') : '&price[max]=300');
$url .= ($this->getInput('minprice') ? '&price[min]='
. $this->getInput('minprice') : '&price[min]=0');
// Features
$url .= ($this->getInput('cumtube') ? '&cumtube=1' : '');
$url .= ($this->getInput('suctionCup') ? '&suctionCup=1' : '');
$url .= ($this->getInput('noAccessories') ? '&noAccessories=1' : '');
return $url;
}
}
================================================
FILE: bridges/BakaUpdatesMangaReleasesBridge.php
================================================
[
'series_id' => [
'name' => 'Series ID',
'type' => 'number',
'required' => true,
'exampleValue' => '188066'
]
],
'By list' => [
'list_id' => [
'name' => 'List ID and Type',
'type' => 'text',
'required' => true,
'exampleValue' => '4395&list=read'
]
]
];
const LIMIT_COLS = 5;
const LIMIT_ITEMS = 10;
const RELEASES_URL = 'https://www.mangaupdates.com/releases.html';
private $feedName = '';
public function collectData()
{
if ($this -> queriedContext == 'By series') {
$this -> collectDataBySeries();
} else { //queriedContext == 'By list'
$this -> collectDataByList();
}
}
public function getURI()
{
if ($this -> queriedContext == 'By series') {
$series_id = $this->getInput('series_id');
if (!empty($series_id)) {
return self::URI . 'releases.html?search=' . $series_id . '&stype=series';
}
} else { //queriedContext == 'By list'
return self::RELEASES_URL;
}
return self::URI;
}
public function getName()
{
if (!empty($this->feedName)) {
return $this->feedName . ' - ' . self::NAME;
}
return parent::getName();
}
private function getSanitizedHash($string)
{
return hash('sha1', preg_replace('/[^a-zA-Z0-9\-\.]/', '', ucwords(strtolower($string))));
}
private function filterText($text)
{
return rtrim($text, '* ');
}
private function filterHTML($text)
{
return $this->filterText(html_entity_decode($text));
}
private function findID($manga)
{
// sometimes new series are on the release list that have no ID. just drop them.
if (@$this -> filterHTML($manga -> find('a', 0) -> href) != null) {
preg_match('/id=([0-9]*)/', $this -> filterHTML($manga -> find('a', 0) -> href), $match);
return $match[1];
} else {
return 0;
}
}
private function collectDataBySeries()
{
$html = getSimpleHTMLDOM($this->getURI());
// content is an unstructured pile of divs, ugly to parse
$cols = $html->find('div#main_content div.row > div.text');
if (!$cols) {
throwServerException('No releases');
}
$rows = array_slice(
array_chunk($cols, self::LIMIT_COLS),
0,
self::LIMIT_ITEMS
);
if (isset($rows[0][1])) {
$this->feedName = $this->filterHTML($rows[0][1]->plaintext);
}
foreach ($rows as $cols) {
if (count($cols) < self::LIMIT_COLS) {
continue;
}
$item = [];
$title = [];
$item['content'] = '';
$objDate = $cols[0];
if ($objDate) {
$item['timestamp'] = strtotime($objDate->plaintext);
}
$objTitle = $cols[1];
if ($objTitle) {
$title[] = $this->filterHTML($objTitle->plaintext);
$item['content'] .= 'Series: ' . $this->filterText($objTitle->innertext) . '
';
}
$objVolume = $cols[2];
if ($objVolume && !empty($objVolume->plaintext)) {
$title[] = 'Vol.' . $objVolume->plaintext;
}
$objChapter = $cols[3];
if ($objChapter && !empty($objChapter->plaintext)) {
$title[] = 'Chp.' . $objChapter->plaintext;
}
$objAuthor = $cols[4];
if ($objAuthor && !empty($objAuthor->plaintext)) {
$item['author'] = $this->filterHTML($objAuthor->plaintext);
$item['content'] .= 'Groups: ' . $this->filterText($objAuthor->innertext) . '
';
}
$item['title'] = implode(' ', $title);
$item['uri'] = $this->getURI();
$item['uid'] = $this->getSanitizedHash($item['title'] . $item['author']);
$this->items[] = $item;
}
}
private function collectDataByList()
{
$this -> feedName = 'Releases';
$list = [];
$releasesHTML = getSimpleHTMLDOM(self::RELEASES_URL);
$list_id = $this -> getInput('list_id');
$listHTML = getSimpleHTMLDOM('https://www.mangaupdates.com/mylist.html?id=' . $list_id);
//get ids of the manga that the user follows,
$parts = $listHTML -> find('table#ptable tr > td.pl');
foreach ($parts as $part) {
$list[] = $this -> findID($part);
}
//similar to above, but the divs are in groups of 3.
$cols = $releasesHTML -> find('div#main_content div.row > div.pbreak');
$rows = array_slice(array_chunk($cols, 3), 0);
foreach ($rows as $cols) {
//check if current manga is in user's list.
$id = $this -> findId($cols[0]);
if (!array_search($id, $list)) {
continue;
}
$item = [];
$title = [];
$item['content'] = '';
$objTitle = $cols[0];
if ($objTitle) {
$title[] = $this->filterHTML($objTitle->plaintext);
$item['content'] .= 'Series: ' . $this->filterHTML($objTitle -> innertext) . '
';
}
$objVolChap = $cols[1];
if ($objVolChap && !empty($objVolChap->plaintext)) {
$title[] = $this -> filterHTML($objVolChap -> innertext);
}
$objAuthor = $cols[2];
if ($objAuthor && !empty($objAuthor->plaintext)) {
$item['author'] = $this->filterHTML($objAuthor -> plaintext);
$item['content'] .= 'Groups: ' . $this->filterHTML($objAuthor -> innertext) . '
';
}
$item['title'] = implode(' ', $title);
$item['uri'] = self::URI . 'releases.html?search=' . $id . '&stype=series';
$item['uid'] = $this->getSanitizedHash($item['title'] . $item['author']);
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/BandcampBridge.php
================================================
[
'tag' => [
'name' => 'tag',
'type' => 'text',
'required' => true,
'exampleValue' => 'hip-hop-rap'
]
],
'By band' => [
'band' => [
'name' => 'band',
'type' => 'text',
'title' => 'Band name as seen in the band page URL',
'required' => true,
'exampleValue' => 'aesoprock'
],
'type' => [
'name' => 'Articles are',
'type' => 'list',
'values' => [
'Releases' => 'releases',
'Releases, new one when track list changes' => 'changes',
'Individual tracks' => 'tracks'
],
'defaultValue' => 'changes'
],
'limit' => [
'name' => 'limit',
'type' => 'number',
'required' => true,
'title' => 'Number of releases to return',
'defaultValue' => 5
]
],
'By label' => [
'label' => [
'name' => 'label',
'type' => 'text',
'title' => 'label name as seen in the label page URL',
'required' => true
],
'type' => [
'name' => 'Articles are',
'type' => 'list',
'values' => [
'Releases' => 'releases',
'Releases, new one when track list changes' => 'changes',
'Individual tracks' => 'tracks'
],
'defaultValue' => 'changes'
],
'limit' => [
'name' => 'limit',
'type' => 'number',
'title' => 'Number of releases to return',
'defaultValue' => 5
]
],
'By album' => [
'band' => [
'name' => 'band',
'type' => 'text',
'title' => 'Band name as seen in the album page URL',
'required' => true,
'exampleValue' => 'aesoprock'
],
'album' => [
'name' => 'album',
'type' => 'text',
'title' => 'Album name as seen in the album page URL',
'required' => true,
'exampleValue' => 'appleseed'
],
'type' => [
'name' => 'Articles are',
'type' => 'list',
'values' => [
'Releases' => 'releases',
'Releases, new one when track list changes' => 'changes',
'Individual tracks' => 'tracks'
],
'defaultValue' => 'tracks'
]
]
];
const IMGURI = 'https://f4.bcbits.com/';
const IMGSIZE_300PX = 23;
const IMGSIZE_700PX = 16;
private $feedName;
public function getIcon()
{
return 'https://s4.bcbits.com/img/bc_favicon.ico';
}
public function collectData()
{
switch ($this->queriedContext) {
case 'By tag':
$url = self::URI . 'api/hub/1/dig_deeper';
$data = $this->buildRequestJson();
$header = [
'Content-Type: application/json',
'Content-Length: ' . strlen($data),
];
$opts = [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $data,
];
$content = getContents($url, $header, $opts);
$json = json_decode($content);
if ($json->ok !== true) {
throwServerException('Invalid response');
}
foreach ($json->items as $entry) {
$url = $entry->tralbum_url;
$artist = $entry->artist;
$title = $entry->title;
// e.g. record label is the releaser, but not the artist
$releaser = $entry->band_name !== $entry->artist ? $entry->band_name : null;
$full_title = $artist . ' - ' . $title;
$full_artist = $artist;
if (isset($releaser)) {
$full_title .= ' (' . $releaser . ')';
$full_artist .= ' (' . $releaser . ')';
}
$small_img = $this->getImageUrl($entry->art_id, self::IMGSIZE_300PX);
$img = $this->getImageUrl($entry->art_id, self::IMGSIZE_700PX);
$item = [
'uri' => $url,
'author' => $full_artist,
'title' => $full_title
];
$item['content'] = "
$full_title";
$item['enclosures'] = [$img];
$this->items[] = $item;
}
break;
case 'By band':
case 'By label':
case 'By album':
$html = getSimpleHTMLDOMCached($this->getURI(), 86400);
if ($html->find('meta[name=title]', 0)) {
$this->feedName = $html->find('meta[name=title]', 0)->content;
} else {
$this->feedName = str_replace('Music | ', '', $html->find('title', 0)->plaintext);
}
$regex = '/band_id=(\d+)/';
if (preg_match($regex, $html, $matches) == false) {
throwClientException('Unable to find band ID on: ' . $this->getURI());
}
$band_id = $matches[1];
$tralbums = [];
switch ($this->queriedContext) {
case 'By band':
case 'By label':
$query_data = [
'band_id' => $band_id
];
$band_data = $this->apiGet('mobile/22/band_details', $query_data);
$num_albums = min(count($band_data->discography), $this->getInput('limit'));
for ($i = 0; $i < $num_albums; $i++) {
$album_basic_data = $band_data->discography[$i];
// 'a' or 't' for albums and individual tracks respectively
$tralbum_type = substr($album_basic_data->item_type, 0, 1);
$query_data = [
'band_id' => $band_id,
'tralbum_type' => $tralbum_type,
'tralbum_id' => $album_basic_data->item_id
];
$tralbums[] = $this->apiGet('mobile/22/tralbum_details', $query_data);
}
break;
case 'By album':
$regex = '/album=(\d+)/';
if (preg_match($regex, $html, $matches) == false) {
throwClientException('Unable to find album ID on: ' . $this->getURI());
}
$album_id = $matches[1];
$query_data = [
'band_id' => $band_id,
'tralbum_type' => 'a',
'tralbum_id' => $album_id
];
$tralbums[] = $this->apiGet('mobile/22/tralbum_details', $query_data);
break;
}
foreach ($tralbums as $tralbum_data) {
if ($tralbum_data->type === 'a' && $this->getInput('type') === 'tracks') {
foreach ($tralbum_data->tracks as $track) {
$query_data = [
'band_id' => $band_id,
'tralbum_type' => 't',
'tralbum_id' => $track->track_id
];
$track_data = $this->apiGet('mobile/22/tralbum_details', $query_data);
$this->items[] = $this->buildTralbumItem($track_data);
}
} else {
$this->items[] = $this->buildTralbumItem($tralbum_data);
}
}
break;
}
}
private function buildTralbumItem($tralbum_data)
{
$band_data = $tralbum_data->band;
// Format title like: ARTIST - ALBUM/TRACK (OPTIONAL RELEASER)
// Format artist/author like: ARTIST (OPTIONAL RELEASER)
//
// If the album/track is released under a label/a band other than the artist
// themselves, append that releaser name to the title and artist/author.
//
// This sadly doesn't always work right for individual tracks as the artist
// of the track is always set to the releaser.
$artist = $tralbum_data->tralbum_artist;
$full_title = $artist . ' - ' . $tralbum_data->title;
$full_artist = $artist;
if (isset($tralbum_data->label)) {
$full_title .= ' (' . $tralbum_data->label . ')';
$full_artist .= ' (' . $tralbum_data->label . ')';
} elseif ($band_data->name !== $artist) {
$full_title .= ' (' . $band_data->name . ')';
$full_artist .= ' (' . $band_data->name . ')';
}
$small_img = $this->getImageUrl($tralbum_data->art_id, self::IMGSIZE_300PX);
$img = $this->getImageUrl($tralbum_data->art_id, self::IMGSIZE_700PX);
$item = [
'uri' => $tralbum_data->bandcamp_url,
'author' => $full_artist,
'title' => $full_title,
'enclosures' => [$img],
'timestamp' => $tralbum_data->release_date
];
$item['categories'] = [];
foreach ($tralbum_data->tags as $tag) {
$item['categories'][] = $tag->norm_name;
}
// Give articles a unique UID depending on its track list
// Releases should then show up as new articles when tracks are added
if ($this->getInput('type') === 'changes') {
$item['uid'] = "bandcamp/$band_data->band_id/$tralbum_data->id/";
foreach ($tralbum_data->tracks as $track) {
$item['uid'] .= $track->track_id;
}
}
$item['content'] = "
$full_title
";
if ($tralbum_data->type === 'a') {
$item['content'] .= '';
foreach ($tralbum_data->tracks as $track) {
$item['content'] .= "- $track->title
";
}
$item['content'] .= '
';
}
if (!empty($tralbum_data->about)) {
$item['content'] .= ''
. nl2br($tralbum_data->about)
. '
';
}
return $item;
}
private function buildRequestJson()
{
$requestJson = [
'tag' => $this->getInput('tag'),
'page' => 1,
'sort' => 'date'
];
return json_encode($requestJson);
}
private function getImageUrl($id, $size)
{
return self::IMGURI . 'img/a' . $id . '_' . $size . '.jpg';
}
private function apiGet($endpoint, $query_data)
{
$url = self::URI . 'api/' . $endpoint . '?' . http_build_query($query_data);
// todo: 429 Too Many Requests happens a lot
$response = getContents($url);
$data = json_decode($response);
return $data;
}
public function getURI()
{
switch ($this->queriedContext) {
case 'By tag':
if (!is_null($this->getInput('tag'))) {
return self::URI
. 'tag/'
. urlencode($this->getInput('tag'))
. '?sort_field=date';
}
break;
case 'By label':
if (!is_null($this->getInput('label'))) {
return 'https://'
. $this->getInput('label')
. '.bandcamp.com/music';
}
break;
case 'By band':
if (!is_null($this->getInput('band'))) {
return 'https://'
. $this->getInput('band')
. '.bandcamp.com/music';
}
break;
case 'By album':
if (!is_null($this->getInput('band')) && !is_null($this->getInput('album'))) {
return 'https://'
. $this->getInput('band')
. '.bandcamp.com/album/'
. $this->getInput('album');
}
break;
}
return parent::getURI();
}
public function getName()
{
switch ($this->queriedContext) {
case 'By tag':
if (!is_null($this->getInput('tag'))) {
return $this->getInput('tag') . ' - Bandcamp Tag';
}
break;
case 'By band':
if (isset($this->feedName)) {
return $this->feedName . ' - Bandcamp Band';
} elseif (!is_null($this->getInput('band'))) {
return $this->getInput('band') . ' - Bandcamp Band';
}
break;
case 'By label':
if (isset($this->feedName)) {
return $this->feedName . ' - Bandcamp Label';
} elseif (!is_null($this->getInput('label'))) {
return $this->getInput('label') . ' - Bandcamp Label';
}
break;
case 'By album':
if (isset($this->feedName)) {
return $this->feedName . ' - Bandcamp Album';
} elseif (!is_null($this->getInput('album'))) {
return $this->getInput('album') . ' - Bandcamp Album';
}
break;
}
return parent::getName();
}
public function detectParameters($url)
{
$params = [];
// By tag
$regex = '/^(https?:\/\/)?bandcamp\.com\/tag\/([^\/.&?\n]+)/';
if (preg_match($regex, $url, $matches) > 0) {
$params['context'] = 'By tag';
$params['tag'] = urldecode($matches[2]);
return $params;
}
// By band
$regex = '/^(https?:\/\/)?([^\/.&?\n]+?)\.bandcamp\.com/';
if (preg_match($regex, $url, $matches) > 0) {
$params['context'] = 'By band';
$params['band'] = urldecode($matches[2]);
return $params;
}
// By album
$regex = '/^(https?:\/\/)?([^\/.&?\n]+?)\.bandcamp\.com\/album\/([^\/.&?\n]+)/';
if (preg_match($regex, $url, $matches) > 0) {
$params['context'] = 'By album';
$params['band'] = urldecode($matches[2]);
$params['album'] = urldecode($matches[3]);
return $params;
}
return null;
}
}
================================================
FILE: bridges/BandcampDailyBridge.php
================================================
[],
'Best of' => [
'best-content' => [
'name' => 'content',
'type' => 'list',
'values' => [
'Best Ambient' => 'best-ambient',
'Best Beat Tapes' => 'best-beat-tapes',
'Best Dance 12\'s' => 'best-dance-12s',
'Best Contemporary Classical' => 'best-contemporary-classical',
'Best Electronic' => 'best-electronic',
'Best Experimental' => 'best-experimental',
'Best Hip-Hop' => 'best-hip-hop',
'Best Jazz' => 'best-jazz',
'Best Metal' => 'best-metal',
'Best Punk' => 'best-punk',
'Best Reissues' => 'best-reissues',
'Best Soul' => 'best-soul',
],
'defaultValue' => 'best-ambient',
],
],
'Genres' => [
'genres-content' => [
'name' => 'content',
'type' => 'list',
'values' => [
'Acoustic' => 'genres/acoustic',
'Alternative' => 'genres/alternative',
'Ambient' => 'genres/ambient',
'Blues' => 'genres/blues',
'Classical' => 'genres/classical',
'Comedy' => 'genres/comedy',
'Country' => 'genres/country',
'Devotional' => 'genres/devotional',
'Electronic' => 'genres/electronic',
'Experimental' => 'genres/experimental',
'Folk' => 'genres/folk',
'Funk' => 'genres/funk',
'Hip-Hop/Rap' => 'genres/hip-hop-rap',
'Jazz' => 'genres/jazz',
'Kids' => 'genres/kids',
'Latin' => 'genres/latin',
'Metal' => 'genres/metal',
'Pop' => 'genres/pop',
'Punk' => 'genres/punk',
'R&B/Soul' => 'genres/r-b-soul',
'Reggae' => 'genres/reggae',
'Rock' => 'genres/rock',
'Soundtrack' => 'genres/soundtrack',
'Spoken Word' => 'genres/spoken-word',
'World' => 'genres/world',
],
'defaultValue' => 'genres/acoustic',
],
],
'Franchises' => [
'franchises-content' => [
'name' => 'content',
'type' => 'list',
'values' => [
'Lists' => 'lists',
'Features' => 'features',
'Album of the Day' => 'album-of-the-day',
'Acid Test' => 'acid-test',
'Bandcamp Navigator' => 'bandcamp-navigator',
'Big Ups' => 'big-ups',
'Certified' => 'certified',
'Gallery' => 'gallery',
'Hidden Gems' => 'hidden-gems',
'High Scores' => 'high-scores',
'Label Profile' => 'label-profile',
'Lifetime Achievement' => 'lifetime-achievement',
'Scene Report' => 'scene-report',
'Seven Essential Releases' => 'seven-essential-releases',
'The Merch Table' => 'the-merch-table',
],
'defaultValue' => 'lists',
],
]
];
const CACHE_TIMEOUT = 3600; // 1 hour
public function collectData()
{
$html = getSimpleHTMLDOM($this->getURI());
$html = defaultLinkTo($html, self::URI);
$articles = $html->find('articles-list', 0);
foreach ($articles->find('div.list-article') as $index => $article) {
$item = [];
$articlePath = $article->find('a.title', 0)->href;
$articlePageHtml = getSimpleHTMLDOMCached($articlePath, 3600);
$item['uri'] = $articlePath;
$item['title'] = $articlePageHtml->find('article-title', 0)->innertext;
$item['author'] = $articlePageHtml->find('article-credits > a', 0)->innertext;
$item['content'] = html_entity_decode($articlePageHtml->find('meta[name="description"]', 0)->content, ENT_QUOTES);
$item['timestamp'] = $articlePageHtml->find('meta[property="article:published_time"]', 0)->content;
$item['categories'][] = $articlePageHtml->find('meta[property="article:section"]', 0)->content;
if ($articlePageHtml->find('meta[property="article:tag"]', 0)) {
$item['categories'][] = $articlePageHtml->find('meta[property="article:tag"]', 0)->content;
}
$item['enclosures'][] = $articlePageHtml->find('meta[name="twitter:image"]', 0)->content;
$this->items[] = $item;
if (count($this->items) >= 10) {
break;
}
}
}
public function getURI()
{
switch ($this->queriedContext) {
case 'Latest articles':
return self::URI . '/latest';
case 'Best of':
case 'Genres':
case 'Franchises':
// TODO Switch to array_key_first once php >= 7.3
$contentKey = key(self::PARAMETERS[$this->queriedContext]);
return self::URI . '/' . $this->getInput($contentKey);
default:
return parent::getURI();
}
}
public function getName()
{
switch ($this->queriedContext) {
case 'Latest articles':
return $this->queriedContext . ' - Bandcamp Daily';
case 'Best of':
case 'Genres':
case 'Franchises':
$contentKey = array_key_first(self::PARAMETERS[$this->queriedContext]);
$contentValues = array_flip(self::PARAMETERS[$this->queriedContext][$contentKey]['values']);
return $contentValues[$this->getInput($contentKey)] . ' - Bandcamp Daily';
default:
return parent::getName();
}
}
}
================================================
FILE: bridges/BarraqueiroBridgeAbstract.php
================================================
find('div.newsFundoGrey1, div.newsFundoGrey2');
foreach ($data as $entry) {
$item = [];
$text = $entry->find('span.text', 0)->plaintext;
$title = substr($text, 12);
$item['uri'] = $base_uri . $entry->find('a', 0)->href;
$item['title'] = $title;
$item['timestamp'] = DateTimeImmutable::createFromFormat('d-m-Y+', $text)->format('Y-m-d');
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/BarraqueiroOesteBridge.php
================================================
find('item') as $element) {
if ($limit < 10) {
$item = [];
$item['title'] = $element->find('title', 0)->innertext;
$item['uri'] = $element->find('guid', 0)->plaintext;
$item['timestamp'] = strtotime($element->find('dc:date', 0)->plaintext);
$html = getSimpleHTMLDOM($item['uri']);
$html = defaultLinkTo($html, self::URI);
$item['content'] = $html->find('div.texte', 0)->innertext;
$this->items[] = $item;
$limit++;
}
}
}
}
================================================
FILE: bridges/BazarakiBridge.php
================================================
[
'name' => 'URL',
'type' => 'text',
'required' => true,
'title' => 'Enter the URL of the Bazaraki page to fetch adverts from.',
'exampleValue' => 'https://www.bazaraki.com/real-estate-for-sale/houses/?lat=0&lng=0&radius=100000',
],
'limit' => [
'name' => 'Limit',
'type' => 'number',
'required' => false,
'title' => 'Enter the number of adverts to fetch. (max 50)',
'exampleValue' => '10',
'defaultValue' => 10,
]
]
];
public function collectData()
{
$url = $this->getInput('url');
if (! str_starts_with($url, 'https://www.bazaraki.com/')) {
throw new \Exception('Nope');
}
$html = getSimpleHTMLDOM($url);
$i = 0;
foreach ($html->find('div.advert') as $element) {
$i++;
if ($i > $this->getInput('limit') || $i > 50) {
break;
}
$item = [];
$item['uri'] = 'https://www.bazaraki.com' . $element->find('a.advert__content-title', 0)->href;
# Get the content
$advert = getSimpleHTMLDOM($item['uri']);
$price = trim($advert->find('div.announcement-price__cost', 0)->plaintext);
$name = trim($element->find('a.advert__content-title', 0)->plaintext);
$item['title'] = $name . ' - ' . $price;
$time = trim($advert->find('span.date-meta', 0)->plaintext);
$time = str_replace('Posted: ', '', $time);
$item['content'] = $this->processAdvertContent($advert);
$item['timestamp'] = $this->convertRelativeTime($time);
$item['author'] = trim($advert->find('div.author-name', 0)->plaintext);
$item['uid'] = $advert->find('span.number-announcement', 0)->plaintext;
$this->items[] = $item;
}
}
/**
* Process the advert content to clean up HTML
*
* @param simple_html_dom $advert The SimpleHTMLDOM object for the advert page
* @return string Processed HTML content
*/
private function processAdvertContent($advert)
{
// Get the content sections
$header = $advert->find('div.announcement-content-header', 0);
$characteristics = $advert->find('div.announcement-characteristics', 0);
$description = $advert->find('div.js-description', 0);
$images = $advert->find('div.announcement__images', 0);
// Remove all favorites divs
foreach ($advert->find('div.announcement-meta__favorites') as $favorites) {
$favorites->outertext = '';
}
// Replace all tags with their text content
foreach ($advert->find('a') as $a) {
$a->outertext = $a->innertext;
}
// Format the content with section headers and dividers
$formattedContent = '';
// Add header section
$formattedContent .= $header->innertext;
$formattedContent .= '
';
// Add characteristics section with header
$formattedContent .= 'Details
';
$formattedContent .= $characteristics->innertext;
$formattedContent .= '
';
// Add description section with header
$formattedContent .= 'Description
';
$formattedContent .= $description->innertext;
$formattedContent .= '
';
// Add images section with header
$formattedContent .= 'Images
';
$formattedContent .= $images->innertext;
return $formattedContent;
}
/**
* Convert relative time strings like "Yesterday 12:32" to proper timestamps
*
* @param string $timeString The relative time string from the website
* @return string Timestamp in a format compatible with strtotime()
*/
private function convertRelativeTime($timeString)
{
if (strpos($timeString, 'Yesterday') !== false) {
// Replace "Yesterday" with actual date
$time = str_replace('Yesterday', date('Y-m-d', strtotime('-1 day')), $timeString);
return date('Y-m-d H:i:s', strtotime($time));
} elseif (strpos($timeString, 'Today') !== false) {
// Replace "Today" with actual date
$time = str_replace('Today', date('Y-m-d'), $timeString);
return date('Y-m-d H:i:s', strtotime($time));
} else {
// For other formats, return as is and let strtotime handle it
return $timeString;
}
}
}
================================================
FILE: bridges/BinanceBridge.php
================================================
data->blogList as $post) {
$item = [];
$item['title'] = $post->title;
// Url slug not in json
//$item['uri'] = $uri;
$item['timestamp'] = $post->postTimeUTC / 1000;
$item['author'] = 'Binance';
$item['content'] = $post->brief;
//$item['categories'] = $category;
$item['uid'] = $post->idStr;
$this->items[] = $item;
}
}
public function getIcon()
{
return 'https://bin.bnbstatic.com/static/images/common/favicon.ico';
}
}
================================================
FILE: bridges/BlaguesDeMerdeBridge.php
================================================
find('div.blague') as $element) {
$item = [];
$item['uri'] = static::URI . '#' . $element->id;
$item['author'] = $element->find('div[class="blague-footer"] p strong', 0)->plaintext;
// Let the title be everything up to the first
$item['title'] = trim(explode("\n", $element->find('div.text', 0)->plaintext)[0]);
$item['content'] = strip_tags($element->find('div.text', 0));
// timestamp is part of:
// Par {author} le {date} dans {category}
preg_match(
'/.+le(.+)dans.*/',
$element->find('div[class="blague-footer"]', 0)->plaintext,
$matches
);
$item['timestamp'] = strtotime($matches[1]);
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/BleepingComputerBridge.php
================================================
collectExpandableDatas($feed);
}
protected function parseItem(array $item)
{
$article_html = getSimpleHTMLDOMCached($item['uri']);
if (!$article_html) {
$item['content'] .= 'Could not request ' . $this->getName() . ': ' . $item['uri'] . '
';
return $item;
}
$article_content = $article_html->find('div.articleBody', 0)->innertext;
$article_content = stripRecursiveHTMLSection($article_content, 'div', ' on home page, each one being treated as a feed item.
Use the URL selector option to select the `a` element with the
`href` to the article link. If this option is not configured, the first encountered
`a` element is used.
EOT,
'exampleValue' => 'div.article',
'required' => true
],
'url_selector' => [
'name' => '[Optional] Selector for link elements',
'title' => << 'a.article',
'defaultValue' => 'a'
],
'url_pattern' => [
'name' => '[Optional] Pattern for site URLs to keep in feed',
'title' => 'Optionally filter items by applying a regular expression on their URL',
'exampleValue' => '/blog/article/.*',
],
'limit' => self::LIMIT,
'use_article_pages' => [
'name' => 'Load article from page',
'title' => << 'checkbox'
],
'article_page_content_selector' => [
'name' => '[Optional] Selector to select article element',
'title' => 'Extract the article from its page using the provided selector',
'exampleValue' => 'article.content',
],
'content_cleanup' => [
'name' => '[Optional] Content cleanup: selector for items to remove',
'title' => 'Selector for unnecessary elements to remove inside article contents.',
'exampleValue' => 'div.ads, div.comments',
],
'title_selector' => [
'name' => '[Optional] Selector for the article title',
'title' => 'Selector to select the article title',
'defaultValue' => 'h1'
],
'category_selector' => [
'name' => '[Optional] Categories',
'title' => << 'span.category, #main-category'
],
'author_selector' => [
'name' => '[Optional] Author',
'title' => << 'span#author'
],
'time_selector' => [
'name' => '[Optional] Time selector',
'title' => << [
'name' => '[Optional] Format string for parsing time',
'title' => << [
'name' => '[Optional] Remove styling',
'title' => 'Remove class and style attributes from the page elements',
'type' => 'checkbox'
]
]
];
private $feedName = '';
public function getURI()
{
$url = $this->getInput('home_page');
if (empty($url)) {
$url = parent::getURI();
}
return $url;
}
public function getName()
{
if (!empty($this->feedName)) {
return $this->feedName;
}
return parent::getName();
}
protected function getHeaders()
{
$headers = [];
$cookie = $this->getInput('cookie');
if (!empty($cookie)) {
$headers[] = 'Cookie: ' . $cookie;
}
return $headers;
}
public function collectData()
{
$url = $this->getInput('home_page');
$headers = $this->getHeaders();
$entry_element_selector = $this->getInput('entry_element_selector');
$url_selector = $this->getInput('url_selector');
$url_pattern = $this->getInput('url_pattern');
$limit = $this->getInput('limit') ?? 10;
$use_article_pages = $this->getInput('use_article_pages');
$article_page_content_selector = $this->getInput('article_page_content_selector');
$content_cleanup = $this->getInput('content_cleanup');
$title_selector = $this->getInput('title_selector');
$title_cleanup = $this->getInput('title_cleanup');
$time_selector = $this->getInput('time_selector');
$time_format = $this->getInput('time_format');
$category_selector = $this->getInput('category_selector');
$author_selector = $this->getInput('author_selector');
$remove_styling = $this->getInput('remove_styling');
$html = defaultLinkTo(getSimpleHTMLDOM($url, $headers), $url);
$this->feedName = $this->getTitle($html, $title_cleanup);
$entry_elements = $this->htmlFindEntryElements($html, $entry_element_selector, $url_selector, $url_pattern, $limit);
if (empty($entry_elements)) {
return;
}
// Fetch the elements from the article pages.
if ($use_article_pages) {
if (empty($article_page_content_selector)) {
throwClientException('`Article selector` is required when `Load article page` is enabled');
}
foreach (array_keys($entry_elements) as $uri) {
$entry_elements[$uri] = $this->fetchArticleElementFromPage($uri, $article_page_content_selector);
}
}
foreach ($entry_elements as $uri => $element) {
$entry = $this->parseEntryElement(
$element,
$title_selector,
$author_selector,
$category_selector,
$time_selector,
$time_format,
$content_cleanup,
$this->feedName,
$remove_styling
);
$entry['uri'] = $uri;
$this->items[] = $entry;
}
}
/**
* Filter a list of URLs using a pattern and limit
* @param array $links List of URLs
* @param string $url_pattern Pattern to look for in URLs
* @param int $limit Optional maximum amount of URLs to return
* @return array Array of URLs
*/
protected function filterUrlList($links, $url_pattern, $limit = 0)
{
if (!empty($url_pattern)) {
$url_pattern = '/' . str_replace('/', '\/', $url_pattern) . '/';
$links = array_filter($links, function ($url) use ($url_pattern) {
return preg_match($url_pattern, $url) === 1;
});
}
if ($limit > 0 && count($links) > $limit) {
$links = array_slice($links, 0, $limit);
}
return $links;
}
/**
* Retrieve title from webpage URL or DOM
* @param string|object $page URL or DOM to retrieve title from
* @param string $title_cleanup optional string to remove from webpage title, e.g. " | BlogName"
* @return string Webpage title
*/
protected function getTitle($page, $title_cleanup)
{
if (is_string($page)) {
$page = getSimpleHTMLDOMCached($page, 86400, $this->getHeaders());
}
$title = html_entity_decode($page->find('title', 0)->plaintext);
if (!empty($title)) {
$title = trim(str_replace($title_cleanup, '', $title));
}
return $title;
}
/**
* Remove all elements from HTML content matching cleanup selector
* @param string|object $content HTML content as HTML object or string
* @return string|object Cleaned content (same type as input)
*/
protected function cleanArticleContent($content, $cleanup_selector, $remove_styling)
{
$string_convert = false;
if (is_string($content)) {
$string_convert = true;
$content = str_get_html($content);
}
if (!empty($cleanup_selector)) {
foreach ($content->find($cleanup_selector) as $item_to_clean) {
$item_to_clean->outertext = '';
}
}
if ($remove_styling) {
foreach (['class', 'style'] as $attribute_to_remove) {
foreach ($content->find('[' . $attribute_to_remove . ']') as $item_to_clean) {
$item_to_clean->removeAttribute($attribute_to_remove);
}
}
}
if ($string_convert) {
$content = $content->outertext;
}
return $content;
}
/**
* Retrieve first N link+element from webpage URL or DOM satisfying the specified criteria
* @param string|object $page URL or DOM to retrieve feed items from
* @param string $entry_selector DOM selector for matching HTML elements that contain article
* entries
* @param string $url_selector DOM selector for matching links
* @param string $url_pattern Optional filter to keep only links matching the pattern
* @param int $limit Optional maximum amount of URLs to return
* @return array of items { => }
*/
protected function htmlFindEntryElements($page, $entry_selector, $url_selector, $url_pattern = '', $limit = 0)
{
if (is_string($page)) {
$page = getSimpleHTMLDOM($page, $this->getHeaders());
}
$entryElements = $page->find($entry_selector);
if (empty($entryElements)) {
throwClientException('No entry elements for entry selector');
}
// Extract URIs with the associated entry element
$links_with_elements = [];
foreach ($entryElements as $entry) {
$url_element = $entry->find($url_selector, 0);
if (is_null($url_element)) {
// No `a` element found in this entry
if ($entry->tag == 'a') {
$url_element = $entry;
} else {
continue;
}
}
$links_with_elements[$url_element->href] = $entry;
}
if (empty($links_with_elements)) {
throwClientException('The provided URL selector matches some elements, but they do not
contain links.');
}
// Filter using the URL pattern
$filtered_urls = $this->filterUrlList(array_keys($links_with_elements), $url_pattern, $limit);
if (empty($filtered_urls)) {
throwClientException('No results for URL pattern');
}
$items = [];
foreach ($filtered_urls as $link) {
$items[$link] = $links_with_elements[$link];
}
return $items;
}
/**
* Retrieve article element from its URL using content selector and return the DOM element
* @param string $entry_url URL to retrieve article from
* @param string $content_selector HTML selector for extracting content, e.g. "article.content"
* @return article DOM element
*/
protected function fetchArticleElementFromPage($entry_url, $content_selector)
{
$entry_html = getSimpleHTMLDOMCached($entry_url, 86400, $this->getHeaders());
$article_content = $entry_html->find($content_selector, 0);
if (is_null($article_content)) {
throwClientException('Could not get article content at URL: ' . $entry_url);
}
$article_content = defaultLinkTo($article_content, $entry_url);
return $article_content;
}
protected function parseTimeStrAsTimestamp($timeStr, $format)
{
$date = date_parse_from_format($format, $timeStr);
if ($date['error_count'] != 0) {
throwClientException('Error while parsing time string');
}
$timestamp = mktime(
$date['hour'],
$date['minute'],
$date['second'],
$date['month'],
$date['day'],
$date['year']
);
if ($timestamp == false) {
throwClientException('Error while creating timestamp');
}
return $timestamp;
}
/**
* Retrieve article content from its URL using content selector and return a feed item
* @param object $entry_html A DOM element containing the article
* @param string $title_selector A selector to the article title from the article
* @param string $author_selector A selector to find the article author
* @param string $time_selector A selector to get the article publication time.
* @param string $time_format The format to parse the time_selector.
* @param string $content_cleanup Optional selector for removing elements, e.g. "div.ads,
* div.comments"
* @param string $title_default Optional title to use when could not extract title reliably
* @param bool $remove_styling Whether to remove class and style attributes from the HTML
* @return array Entry data: uri, title, content
*/
protected function parseEntryElement(
$entry_html,
$title_selector = null,
$author_selector = null,
$category_selector = null,
$time_selector = null,
$time_format = null,
$content_cleanup = null,
$title_default = null,
$remove_styling = false
) {
$article_content = convertLazyLoading($entry_html);
$article_title = '';
if (is_null($title_selector)) {
$article_title = $title_default;
} else {
$titleElement = $entry_html->find($title_selector, 0);
if ($titleElement) {
$article_title = trim($titleElement->innertext);
}
}
$author = null;
if (!is_null($author_selector) && $author_selector != '') {
$author = trim($entry_html->find($author_selector, 0)->innertext);
}
$categories = [];
if (!is_null($category_selector && $category_selector != '')) {
$category_elements = $entry_html->find($category_selector);
foreach ($category_elements as $category_element) {
$categories[] = trim($category_element->innertext);
}
}
$time = null;
if (!is_null($time_selector) && $time_selector != '') {
$time_element = $entry_html->find($time_selector, 0);
$time = $time_element->getAttribute('datetime');
if (empty($time)) {
$time = $time_element->innertext;
}
$time = $this->parseTimeStrAsTimestamp($time, $time_format);
}
$article_content = $this->cleanArticleContent($article_content, $content_cleanup, $remove_styling);
$item = [];
$item['title'] = $article_title;
$item['content'] = $article_content;
$item['categories'] = $categories;
$item['timestamp'] = $time;
$item['author'] = $author;
return $item;
}
}
================================================
FILE: bridges/CssSelectorFeedExpanderBridge.php
================================================
[
'name' => 'Feed: URL of truncated RSS feed',
'exampleValue' => 'https://example.com/feed.xml',
'required' => true
],
'content_selector' => [
'name' => 'Selector for each article content',
'title' => <<.
Everything inside that element becomes feed item content.
EOT,
'exampleValue' => 'article.content',
'required' => true
],
'content_cleanup' => [
'name' => '[Optional] Content cleanup: List of items to remove',
'title' => 'Selector for unnecessary elements to remove inside article contents.',
'exampleValue' => 'div.ads, div.comments',
],
'dont_expand_metadata' => [
'name' => '[Optional] Don\'t expand metadata',
'title' => "This bridge will attempt to fill missing fields using metadata from the webpage.\nCheck to disable.",
'type' => 'checkbox',
],
'discard_thumbnail' => [
'name' => '[Optional] Discard thumbnail set by site author',
'title' => 'Some sites set their logo as thumbnail for every article. Use this option to discard it.',
'type' => 'checkbox',
],
'thumbnail_as_header' => [
'name' => '[Optional] Insert thumbnail as article header',
'title' => 'Insert article main image on top of article contents.',
'type' => 'checkbox',
],
'limit' => self::LIMIT
]
];
public function collectData()
{
$url = $this->getInput('feed');
$content_selector = $this->getInput('content_selector');
$content_cleanup = $this->getInput('content_cleanup');
$dont_expand_metadata = $this->getInput('dont_expand_metadata');
$discard_thumbnail = $this->getInput('discard_thumbnail');
$thumbnail_as_header = $this->getInput('thumbnail_as_header');
$limit = $this->getInput('limit');
$feedParser = new FeedParser();
$xml = getContents($url);
$source_feed = $feedParser->parseFeed($xml);
$items = $source_feed['items'];
// Map Homepage URL (Default: Root page)
if (isset($source_feed['uri'])) {
$this->homepageUrl = $source_feed['uri'];
} else {
$this->homepageUrl = urljoin($url, '/');
}
// Map Feed Name (Default: Domain name)
if (isset($source_feed['title'])) {
$this->feedName = $source_feed['title'];
} else {
$this->feedName = explode('/', urljoin($url, '/'))[2];
}
// Apply item limit (Default: Global limit)
if ($limit > 0) {
$items = array_slice($items, 0, $limit);
}
// Expand feed items (CssSelectorBridge)
foreach ($items as $item_from_feed) {
$item_expanded = $this->expandEntryWithSelector(
$item_from_feed['uri'],
$content_selector,
$content_cleanup
);
if ($dont_expand_metadata) {
// Take feed item, only replace content from expanded data
$content = $item_expanded['content'];
$item_expanded = $item_from_feed;
$item_expanded['content'] = $content;
} else {
// Take expanded item, but give priority to metadata already in source item
foreach ($item_from_feed as $field => $val) {
if ($field !== 'content' && !empty($val)) {
$item_expanded[$field] = $val;
}
}
}
if ($discard_thumbnail && isset($item_expanded['enclosures'])) {
unset($item_expanded['enclosures']);
}
if ($thumbnail_as_header && isset($item_expanded['enclosures'][0])) {
$item_expanded['content'] = '
'
. $item_expanded['content'];
}
$this->items[] = $item_expanded;
}
}
}
================================================
FILE: bridges/CubariBridge.php
================================================
[
'name' => 'Gist/Raw Url',
'type' => 'text',
'required' => true,
'exampleValue' => 'https://raw.githubusercontent.com/kurisumx/baka/main/ikedan'
]
]];
private $mangaTitle = '';
public function getName()
{
if (!empty($this->mangaTitle)) {
return $this->mangaTitle . ' - ' . self::NAME;
} else {
return self::NAME;
}
}
public function getURI()
{
if ($this->getInput('gist') != '') {
return self::URI . '/read/gist/' . $this->getEncodedGist();
} else {
return self::URI;
}
}
/**
* The Cubari bridge.
*
* Cubari urls are base64 encodes of a given github raw or gist link described as below:
* https://cubari.moe/read/gist/${bаse64.url_encode(raw/)}/
* https://cubari.moe/read/gist/${bаse64.url_encode(gist/)}/
* https://cubari.moe/read/gist/${gitio shortcode}
*
* This bridge uses just the raw/gist and generates matching cubari urls.
*/
public function collectData()
{
// TODO: fix trivial SSRF
$json = getContents($this->getInput('gist'));
$jsonFile = Json::decode($json);
$this->mangaTitle = $jsonFile['title'];
$chapters = $jsonFile['chapters'];
foreach ($chapters as $chapnum => $chapter) {
$item = $this->getItemFromChapter($chapnum, $chapter);
$this->items[] = $item;
}
array_multisort(array_column($this->items, 'timestamp'), SORT_DESC, $this->items);
}
protected function getEncodedGist()
{
$url = $this->getInput('gist');
if (preg_match('/\/([a-z]*)\.githubusercontent.com(.*)/', $url, $matches)) {
// raw or gist is first match.
$unencoded = $matches[1] . $matches[2];
return base64_encode($unencoded);
} else {
// todo: fix this
return '';
}
}
private function getSanitizedHash($string)
{
return hash('sha1', preg_replace('/[^a-zA-Z0-9\-\.]/', '', ucwords(strtolower($string))));
}
protected function getItemFromChapter($chapnum, $chapter)
{
$item = [];
$item['uri'] = $this->getURI() . '/' . $chapnum;
$item['title'] = 'Chapter ' . $chapnum . ' - ' . $chapter['title'] . ' - ' . $this->mangaTitle;
foreach ($chapter['groups'] as $key => $value) {
$item['author'] = $key;
}
$item['timestamp'] = $chapter['last_updated'];
$item['content'] = 'Manga: ' . $this->mangaTitle . '
Chapter Number: ' . $chapnum . '
Chapter Title: ' . $chapter['title'] . '
Group: ' . $item['author'] . '
';
$item['uid'] = $this->getSanitizedHash($item['title'] . $item['author']);
return $item;
}
}
================================================
FILE: bridges/CubariProxyBridge.php
================================================
[
'name' => 'Content service',
'type' => 'list',
'defaultValue' => 'mangadex',
'values' => [
'MangAventure' => 'mangadventure',
'MangaDex' => 'mangadex',
'MangaKatana' => 'mangakatana',
'WeebCentral' => 'weebcentral',
]
],
'series' => [
'name' => 'Series ID/Name',
'exampleValue' => '8c1d7d0c-e0b7-4170-941d-29f652c3c19d', # KnH
'required' => true,
],
'fetch' => [
'name' => 'Fetch chapter page images',
'type' => 'list',
'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',
'defaultValue' => 'c',
'values' => [
'None' => 'n',
'Content' => 'c',
'Enclosure' => 'e'
]
],
'limit' => self::LIMIT
]];
private $title;
public function collectData()
{
$limit = $this->getInput('limit') ?? 10;
$url = parent::getURI() . '/read/api/' . $this->getInput('service') . '/series/' . $this->getInput('series');
$json = Json::decode(getContents($url));
$this->title = $json['title'];
$chapters = $json['chapters'];
krsort($chapters);
$count = 0;
foreach ($chapters as $number => $element) {
$item = [];
$item['uri'] = $this->getURI() . '/' . $number;
if ($element['title']) {
$item['title'] = $number . ' - ' . $element['title'];
} else {
$item['title'] = 'Volume ' . $element['volume'] . ' Chapter ' . $number;
}
$group = '1';
if (isset($element['release_date'])) {
$dates = $element['release_date'];
$date = max($dates);
$item['timestamp'] = $date;
$group = array_keys($dates, $date)[0];
}
$page = $element['groups'][$group];
$item['author'] = $json['groups'][$group];
$api = parent::getURI() . $page;
$item['uid'] = $page;
$item['comments'] = $api;
if ($this->getInput('fetch') != 'n') {
$pages = [];
try {
$jsonp = getContents($api);
$pages = Json::decode($jsonp);
} catch (HttpException $e) {
// allow error 500, as it's effectively a 429
if ($e->getCode() != 500) {
throw $e;
}
}
if ($this->getInput('fetch') == 'e') {
$item['enclosures'] = $pages;
}
if ($this->getInput('fetch') == 'c') {
$item['content'] = '';
foreach ($pages as $img) {
$item['content'] .= '
';
}
}
}
if ($count++ == $limit) {
break;
}
$this->items[] = $item;
}
}
public function getName()
{
$name = parent::getName();
if (isset($this->title)) {
$name .= ' - ' . $this->title;
}
return $name;
}
public function getURI()
{
$uri = parent::getURI();
if ($this->getInput('service')) {
$uri .= '/read/' . $this->getInput('service') . '/' . $this->getInput('series');
}
return $uri;
}
public function getIcon()
{
return parent::getURI() . '/static/favicon.png';
}
}
================================================
FILE: bridges/CybernewsBridge.php
================================================
items[] = [
'title' => $title,
'uri' => $url,
'uid' => $url,
'timestamp' => strtotime($lastmod),
'categories' => $category ? [$category] : [],
'content' => $this->fetchFullArticle($url),
];
if (count($this->items) >= self::MAX_ARTICLES) {
break;
}
}
}
private function fetchFullArticle(string $url): string
{
$html = getSimpleHTMLDOMCached($url);
if (!$html) {
return 'Unable to fetch article content';
}
$article = $html->find('article', 0);
if (!$article) {
return 'Unable to parse article content';
}
$removeSelectors = [
'script',
'style',
'div.links-bar',
'div.google-news-cta',
'div.a-wrapper',
'div.embed_youtube',
];
foreach ($removeSelectors as $selector) {
foreach ($article->find($selector) as $element) {
$element->outertext = '';
}
}
// Handle lazy-loaded images
foreach ($article->find('img') as $img) {
if (!empty($img->{'data-src'})) {
$img->src = $img->{'data-src'};
unset($img->{'data-src'});
}
}
return $article->innertext;
}
}
================================================
FILE: bridges/DRKBlutspendeBridge.php
================================================
[
'term' => [
'name' => 'PLZ / Ort',
'required' => true,
'exampleValue' => '12555',
],
'radius' => [
'name' => 'Umkreis in km',
'type' => 'number',
'exampleValue' => 10,
],
'limit_days' => [
'name' => 'Limit von Tagen',
'title' => 'Nur Termine innerhalb der nächsten x Tagen',
'type' => 'number',
'exampleValue' => 28,
],
'limit_items' => [
'name' => 'Limit von Terminen',
'title' => 'Nicht mehr als x Termine',
'type' => 'number',
'required' => true,
'defaultValue' => 20,
]
]
];
const OFFER_LOW_PRIORITIES = [
'Imbiss nach der Blutspende',
'Registrierung als Stammzellspender',
'Typisierung möglich!',
'Allgemeine Informationen',
'Krankenkassen belohnen Blutspender',
'Wer benötigt eigentlich eine Blutspende?',
'Win-Win-Situation für die Gesundheit!',
'Terminreservierung',
'Du möchtest das erste Mal Blut spenden?',
'Spende-Check',
'Sie haben Fragen vor Ihrer Blutspende?'
];
const IMAGE_PRIORITIES = [
'DRK',
'Imbiss',
'Obst',
];
public function collectData()
{
$limitItems = intval($this->getInput('limit_items'));
$this->collectExpandableDatas(self::buildAppointmentsURI(), $limitItems);
}
protected function parseItem(array $item)
{
$html = getSimpleHTMLDOMCached($item['uri']);
$detailsElement = $html->find('.details', 0);
$dateLines = self::explodeLines($detailsElement->find('.datum', 0)->plaintext);
$addressLines = self::explodeLines($detailsElement->find('.adresse', 0)->plaintext);
$infoElement = $detailsElement->find('.angebote > h4 + p', 0);
$info = $infoElement ? trim($infoElement->plaintext) : '';
$offers = self::parseOffers($detailsElement->find('.angebote .item'));
$images = self::parseImages($detailsElement->find('.fotos', 0));
usort($images, function ($imageA, $imageB): int {
list($titleA) = $imageA;
list($titleB) = $imageB;
$prioA = 0;
$prioB = 0;
foreach (self::IMAGE_PRIORITIES as $prioIndex => $prioTitleNeedle) {
if (stripos($titleA, $prioTitleNeedle) !== false) {
$prioA = $prioIndex + 1;
}
if (stripos($titleB, $prioTitleNeedle) !== false) {
$prioB = $prioIndex + 1;
}
}
return $prioA - $prioB;
});
$itemContent = <<
{$dateLines[0]} {$dateLines[1]}
{$addressLines[3]}
{$addressLines[0]}
{$addressLines[1]}
{$addressLines[2]}
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 '';
}
// 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 .= '
';
$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(
'
',
$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 = '';
} 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'] .= "";
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'] .= "";
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'] .= "url}\">
";
$item['content'] .= "{$embed->subject} ";
$item['content'] .= "{$embed->description} ";
$item['content'] .= '
';
}
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'] .= "Poll: {$poll->attributes->question_text} ";
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'] .= "{$poll_option_text} ";
}
}
$item['content'] .= '
';
}
//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, '');
$content = preg_replace('//', '', $content);
return $content;
}
public function getURI()
{
$url = $this->getInput('url');
if (empty($url)) {
$url = parent::getURI();
}
return $url;
}
}
================================================
FILE: bridges/WordPressMadaraBridge.php
================================================
[
'url' => [
'name' => 'Manga URL',
'exampleValue' => 'https://live.mangabooth.com/manga/manga-text-chapter/',
'required' => true
]
]
];
public function getName()
{
switch ($this->queriedContext) {
case 'Manga Chapters':
$mangaInfo = $this->getMangaInfo($this->getInput('url'));
return $mangaInfo['title'];
default:
return parent::getName();
}
}
public function getURI()
{
return $this->getInput('url') ?? self::URI;
}
public function collectData()
{
$html = $this->queryAjaxChapters();
// Check if the list subcategorizes by volume
$volumes = $html->find('ul.volumns', 0);
if ($volumes) {
$this->parseVolumes($volumes);
} else {
$this->parseChapterList($html, null);
}
}
protected function queryAjaxChaptersNew()
{
$uri = rtrim($this->getInput('url'), '/') . '/ajax/chapters/';
$headers = [];
$opts = [CURLOPT_POST => 1];
return str_get_html(getContents($uri, $headers, $opts));
}
protected function queryAjaxChaptersOld()
{
$mangaInfo = $this->getMangaInfo($this->getInput('url'));
$uri = rtrim($mangaInfo['root'], '/') . '/wp-admin/admin-ajax.php';
$headers = [];
$opts = [CURLOPT_POSTFIELDS => [
'action' => 'manga_get_chapters',
'manga' => $mangaInfo['id']
]];
return str_get_html(getContents($uri, $headers, $opts));
}
protected function queryAjaxChapters()
{
$new = $this->queryAjaxChaptersNew();
if ($new->find('.wp-manga-chapter')) {
return $new;
} else {
return $this->queryAjaxChaptersOld();
}
}
protected function parseVolumes($volumes)
{
foreach ($volumes->children(-1) as $volume) {
$volume_name = trim($volume->find('a.has-child', 0)->plaintext);
$this->parseChapterList($volume->find('ul', -1), $volume_name);
}
}
protected function parseChapterList($chapters, $volume)
{
$mangaInfo = $this->getMangaInfo($this->getInput('url'));
foreach ($chapters->find('li.wp-manga-chapter') as $chap) {
$link = $chap->find('a', 0);
$item = [];
$item['title'] = ($volume ?? '') . ' ' . trim($link->plaintext);
$item['uri'] = $link->href;
$item['uid'] = $link->href;
$item['timestamp'] = $chap->find('span.chapter-release-date', 0)->plaintext;
$item['author'] = $mangaInfo['author'] ?? null;
$item['categories'] = $mangaInfo['categories'] ?? null;
$this->items[] = $item;
}
}
/**
* Retrieves manga info from cache or title page.
* The returned array contains 'title', 'author', and 'categories' keys for use in feed items.
* The 'id' key contains the manga title id, used for the old ajax api.
* The 'root' key contains the website root.
*
* @param $url
* @return array
*/
protected function getMangaInfo($url)
{
$url_cache = 'TitleInfo_' . preg_replace('/[^\w]/', '.', rtrim($url, '/'));
$cache = $this->loadCacheValue($url_cache);
if ($cache) {
return $cache;
}
$info = [];
$html = getSimpleHTMLDOMCached($url);
$info['title'] = html_entity_decode($html->find('*[property=og:title]', 0)->content);
$author = $html->find('.author-content', 0);
if (!is_null($author)) {
$info['author'] = trim($author->plaintext);
}
$cats = $html->find('.genres-content', 0);
if (!is_null($cats)) {
$info['categories'] = explode(', ', trim($cats->plaintext));
}
$info['id'] = $html->find('#manga-chapters-holder', 0)->getAttribute('data-id');
// It's possible to find this from the input parameters, but it is already available here.
$info['root'] = $html->find('a.logo', 0)->href;
$this->saveCacheValue($url_cache, $info);
return $info;
}
}
================================================
FILE: bridges/WordPressPluginUpdateBridge.php
================================================
[
'name' => 'Plugin slug',
'exampleValue' => 'akismet',
'required' => true,
'title' => 'Slug or url',
]
]
];
public function collectData()
{
$input = trim($this->getInput('pluginUrl'));
if (preg_match('#https://wordpress\.org/plugins/([\w-]+)#', $input, $m)) {
$slug = $m[1];
} else {
$slug = str_replace(['/'], '', $input);
}
$pluginData = self::fetchPluginData($slug);
if ($pluginData->versions === []) {
throw new \Exception('This plugin does not have versioning data');
}
// We don't need trunk. I think it's the latest commit.
unset($pluginData->versions->trunk);
foreach ($pluginData->versions as $version => $downloadUrl) {
$this->items[] = [
'title' => $version,
'uri' => sprintf('https://wordpress.org/plugins/%s/#developers', $slug),
'uid' => $downloadUrl,
];
}
usort($this->items, function ($a, $b) {
return version_compare($b['title'], $a['title']);
});
}
/**
* Fetch plugin data from wordpress.org json api
*
* https://codex.wordpress.org/WordPress.org_API#Plugins
* https://wordpress.org/support/topic/using-the-wordpress-org-api/
*/
private static function fetchPluginData(string $slug): \stdClass
{
$api = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request[slug]=%s';
return json_decode(getContents(sprintf($api, $slug)));
}
}
================================================
FILE: bridges/WorldOfTanksBridge.php
================================================
[
'name' => 'Langue',
'type' => 'list',
'values' => [
'Français' => 'fr',
'English' => 'en',
'Español' => 'es',
'Deutsch' => 'de',
'Čeština' => 'cs',
'Polski' => 'pl',
'Türkçe' => 'tr'
]
]
]];
const POSSIBLE_ARTICLES = ['article', 'rich-article'];
public function collectData()
{
$this->collectExpandableDatas(sprintf('https://worldoftanks.eu/%s/rss/news/', $this->getInput('lang')));
}
protected function parseItem(array $item)
{
$item['content'] = $this->loadFullArticle($item['uri']);
return $item;
}
/**
* Loads the full article and returns the contents
* @param $uri The article URI
* @return The article content
*/
private function loadFullArticle($uri)
{
$html = getSimpleHTMLDOMCached($uri);
foreach (self::POSSIBLE_ARTICLES as $article_class) {
$content = $html->find('article', 0);
if ($content !== null) {
// Remove the scripts, please
foreach ($content->find('script') as $script) {
$script->outertext = '';
}
return $content->innertext;
}
}
return null;
}
}
================================================
FILE: bridges/WorldbankBridge.php
================================================
[
'name' => 'Language',
'type' => 'list',
'defaultValue' => 'English',
'values' => [
'English' => 'English',
'French' => 'French',
]
],
'limit' => [
'name' => 'limit (max 100)',
'type' => 'number',
'defaultValue' => 5,
'required' => true,
]
]
];
public function collectData()
{
$apiUrl = 'https://search.worldbank.org/api/v2/news?format=json&rows='
. min(100, $this->getInput('limit'))
. '&lang_exact=' . $this->getInput('lang');
$jsonData = json_decode(getContents($apiUrl));
// Remove unnecessary data from the original object
if (isset($jsonData->documents->facets)) {
unset($jsonData->documents->facets);
}
foreach ($jsonData->documents as $element) {
$this->items[] = [
'uid' => $element->id,
'timestamp' => $element->lnchdt,
'title' => $element->title->{'cdata!'},
'uri' => $element->url,
'content' => $element->descr->{'cdata!'},
];
}
}
}
================================================
FILE: bridges/XPathBridge.php
================================================
XPath expressions';
const MAINTAINER = 'Niehztog';
const PARAMETERS = [
'' => [
'url' => [
'name' => 'Enter web page URL',
'title' => <<<'EOL'
You can specify any website URL which serves data suited for display in RSS feeds
(for example a news blog).
EOL,
'type' => 'text',
'exampleValue' => 'https://news.blizzard.com/en-en',
'defaultValue' => 'https://news.blizzard.com/en-en',
'required' => true
],
'item' => [
'name' => 'Item selector',
'title' => <<<'EOL'
Enter an XPath expression matching a list of dom nodes, each node containing one
feed article item in total (usually a surrounding <div> or <span> tag). This will
be the context nodes for all of the following expressions. This expression usually
starts with a single forward slash.
EOL,
'type' => 'text',
'exampleValue' => '/html/body/div/div[4]/div[2]/div[2]/div/div/section/ol/li/article',
'defaultValue' => '/html/body/div/div[4]/div[2]/div[2]/div/div/section/ol/li/article',
'required' => true
],
'title' => [
'name' => 'Item title selector',
'title' => <<<'EOL'
This expression should match a node contained within each article item node
containing the article headline. It should start with a dot followed by two
forward slashes, referring to any descendant nodes of the article item node.
EOL,
'type' => 'text',
'exampleValue' => './/div/div[2]/h2',
'defaultValue' => './/div/div[2]/h2',
'required' => true
],
'content' => [
'name' => 'Item description selector',
'title' => <<<'EOL'
This expression should match a node contained within each article item node
containing the article content or description. It should start with a dot
followed by two forward slashes, referring to any descendant nodes of the
article item node.
EOL,
'type' => 'text',
'exampleValue' => './/div[@class="ArticleListItem-description"]/div[@class="h6"]',
'defaultValue' => './/div[@class="ArticleListItem-description"]/div[@class="h6"]',
'required' => false
],
'raw_content' => [
'name' => 'Use raw item description',
'title' => <<<'EOL'
Whether to use the raw item description or to replace certain characters with
special significance in HTML by HTML entities (using the PHP function htmlspecialchars).
EOL,
'type' => 'checkbox',
'defaultValue' => false,
'required' => false
],
'uri' => [
'name' => 'Item URL selector',
'title' => <<<'EOL'
This expression should match a node's attribute containing the article URL
(usually the href attribute of an <a> tag). It should start with a dot
followed by two forward slashes, referring to any descendant nodes of
the article item node. Attributes can be selected by prepending an @ char
before the attributes name.
EOL,
'type' => 'text',
'exampleValue' => './/a[@class="ArticleLink ArticleLink"]/@href',
'defaultValue' => './/a[@class="ArticleLink ArticleLink"]/@href',
'required' => false
],
'author' => [
'name' => 'Item author selector',
'title' => <<<'EOL'
This expression should match a node contained within each article item
node containing the article author's name. It should start with a dot
followed by two forward slashes, referring to any descendant nodes of
the article item node.
EOL,
'type' => 'text',
'required' => false
],
'timestamp' => [
'name' => 'Item date selector',
'title' => <<<'EOL'
This expression should match a node or node's attribute containing the
article timestamp or date (parsable by PHP's strtotime function). It
should start with a dot followed by two forward slashes, referring to
any descendant nodes of the article item node. Attributes can be
selected by prepending an @ char before the attributes name.
EOL,
'type' => 'text',
'exampleValue' => './/time[@class="ArticleListItem-footerTimestamp"]/@timestamp',
'defaultValue' => './/time[@class="ArticleListItem-footerTimestamp"]/@timestamp',
'required' => false
],
'enclosures' => [
'name' => 'Item image selector',
'title' => <<<'EOL'
This expression should match a node's attribute containing an article
image URL (usually the src attribute of an <img> tag or a style
attribute). It should start with a dot followed by two forward slashes,
referring to any descendant nodes of the article item node. Attributes
can be selected by prepending an @ char before the attributes name.
EOL,
'type' => 'text',
'exampleValue' => './/div[@class="ArticleListItem-image"]/@style',
'defaultValue' => './/div[@class="ArticleListItem-image"]/@style',
'required' => false
],
'categories' => [
'name' => 'Item category selector',
'title' => <<<'EOL'
This expression should match a node or node's attribute contained
within each article item node containing the article category. This
could be inside <div> or <span> tags or sometimes be hidden
in a data attribute. It should start with a dot followed by two
forward slashes, referring to any descendant nodes of the article
item node. Attributes can be selected by prepending an @ char
before the attributes name.
EOL,
'type' => 'text',
'exampleValue' => './/div[@class="ArticleListItem-label"]',
'defaultValue' => './/div[@class="ArticleListItem-label"]',
'required' => false
],
'fix_encoding' => [
'name' => 'Fix encoding',
'title' => <<<'EOL'
Check this to fix feed encoding by invoking PHP's utf8_decode
function on all extracted texts. Try this in case you see "broken" or
"weird" characters in your feed where you'd normally expect umlauts
or any other non-ascii characters.
EOL,
'type' => 'checkbox',
'required' => false
],
]
];
/**
* Source Web page URL (should provide either HTML or XML content)
* @return string
*/
protected function getSourceUrl(): string
{
return $this->encodeUri($this->getInput('url') ?? '');
}
/**
* XPath expression for extracting the feed items from the source page
* @return string
*/
protected function getExpressionItem(): string
{
return urldecode($this->getInput('item') ?? '');
}
/**
* XPath expression for extracting an item title from the item context
* @return string
*/
protected function getExpressionItemTitle(): string
{
return urldecode($this->getInput('title') ?? '');
}
/**
* XPath expression for extracting an item's content from the item context
* @return string
*/
protected function getExpressionItemContent(): string
{
return urldecode($this->getInput('content') ?? '');
}
/**
* Use raw item content
* @return bool
*/
protected function getSettingUseRawItemContent(): bool
{
return $this->getInput('raw_content');
}
/**
* XPath expression for extracting an item link from the item context
* @return string
*/
protected function getExpressionItemUri(): string
{
return urldecode($this->getInput('uri') ?? '');
}
/**
* XPath expression for extracting an item author from the item context
* @return string
*/
protected function getExpressionItemAuthor(): string
{
return urldecode($this->getInput('author') ?? '');
}
/**
* XPath expression for extracting an item timestamp from the item context
* @return string
*/
protected function getExpressionItemTimestamp(): string
{
return urldecode($this->getInput('timestamp') ?? '');
}
/**
* XPath expression for extracting item enclosures (media content like
* images or movies) from the item context
* @return string
*/
protected function getExpressionItemEnclosures(): string
{
return urldecode($this->getInput('enclosures') ?? '');
}
/**
* XPath expression for extracting an item category from the item context
* @return string
*/
protected function getExpressionItemCategories(): string
{
return urldecode($this->getInput('categories') ?? '');
}
/**
* Fix encoding
* @return bool
*/
protected function getSettingFixEncoding(): bool
{
return $this->getInput('fix_encoding');
}
/**
* Fixes URL encoding issues in input URL's
* @param $uri
* @return string|string[]
*/
private function encodeUri($uri)
{
$uri = $uri ?? '';
if (
strpos($uri, 'https%3A%2F%2F') === 0
|| strpos($uri, 'http%3A%2F%2F') === 0
) {
$uri = urldecode($uri);
}
$uri = str_replace('|', '%7C', $uri);
return $uri;
}
}
================================================
FILE: bridges/XbooruBridge.php
================================================
getURI() . 'thumbnails/' . $element->directory
. '/thumbnail_' . $element->hash . '.jpg';
}
}
================================================
FILE: bridges/XenForoBridge.php
================================================
[
'url' => [
'name' => 'Thread URL',
'type' => 'text',
'required' => true,
'title' => 'Insert URL to the thread for which the feed should be generated',
'exampleValue' => 'https://xenforo.com/community/threads/guide-to-suggestions.2285/'
]
],
'global' => [
'limit' => [
'name' => 'Limit',
'type' => 'number',
'required' => false,
'title' => 'Specify maximum number of elements to return in the feed',
'defaultValue' => 10
]
]
];
const CACHE_TIMEOUT = 7200; // 10 minutes
private $title = '';
private $threadurl = '';
private $version; // Holds the XenForo version
public function getName()
{
switch ($this->queriedContext) {
case self::CONTEXT_THREAD:
return $this->title . ' - ' . static::NAME;
}
return parent::getName();
}
public function getURI()
{
switch ($this->queriedContext) {
case self::CONTEXT_THREAD:
return $this->threadurl;
}
return parent::getURI();
}
public function collectData()
{
$this->threadurl = filter_var(
$this->getInput('url'),
FILTER_VALIDATE_URL,
FILTER_FLAG_PATH_REQUIRED
);
if ($this->threadurl === false) {
throwClientException('The URL you provided is invalid!');
}
$urlparts = parse_url($this->threadurl, PHP_URL_SCHEME);
// Scheme must be "http" or "https"
if (preg_match('/http[s]{0,1}/', parse_url($this->threadurl, PHP_URL_SCHEME)) == false) {
throwClientException('The URL you provided doesn\'t specify a valid scheme (http or https)!');
}
// Path cannot be root (../)
if (parse_url($this->threadurl, PHP_URL_PATH) === '/') {
throwClientException('The URL you provided doesn\'t link to a valid thread (root path)!');
}
// XenForo adds a thread ID to the URL, like "...-thread.454934283". It must be present
if (preg_match('/.+\.\d+[\/]{0,1}/', parse_URL($this->threadurl, PHP_URL_PATH)) == false) {
throwClientException('The URL you provided doesn\'t link to a valid thread (ID missing)!');
}
// We want to start at the first page in the thread. XenForo uses "../page-n" syntax
// to identify pages (except for the first page).
// Notice: XenForo uses the concept of "sentinels" to find and replace parts in the
// URL. Technically forum hosts can change the syntax!
if (preg_match('/.+\/(page-\d+.*)$/', $this->threadurl, $matches) != false) {
// before: https://xenforo.com/community/threads/guide-to-suggestions.2285/page-5
// after : https://xenforo.com/community/threads/guide-to-suggestions.2285/
$this->threadurl = str_replace($matches[1], '', $this->threadurl);
}
$html = getSimpleHTMLDOMCached($this->threadurl);
$html = defaultLinkTo($html, $this->threadurl);
// Notice: The DOM structure changes depending on the XenForo version used
if ($mainContent = $html->find('div.mainContent', 0)) {
$this->version = self::XENFORO_VERSION_1;
} elseif ($mainContent = $html->find('div[class~="p-body"]', 0)) {
$this->version = self::XENFORO_VERSION_2;
} else {
throwServerException('This forum is currently not supported!');
}
switch ($this->version) {
case self::XENFORO_VERSION_1:
$titleBar = $mainContent->find('div.titleBar > h1', 0)
or throwServerException('Error finding title bar!');
$this->title = $titleBar->plaintext;
// Store items from current page (we'll use $this->items as LIFO buffer)
$this->extractThreadPostsV1($html, $this->threadurl);
$this->extractPagesV1($html);
break;
case self::XENFORO_VERSION_2:
$titleBar = $mainContent->find('div[class~="p-title"] h1', 0)
or throwServerException('Error finding title bar!');
$this->title = $titleBar->plaintext;
$this->extractThreadPostsV2($html, $this->threadurl);
$this->extractPagesV2($html);
break;
}
usort($this->items, function ($a, $b) {
return $b['timestamp'] <=> $a['timestamp'];
});
$this->items = array_slice($this->items, 0, $this->getInput('limit'));
}
/**
* Extracts thread posts
* @param $html A simplehtmldom object
* @param $url The url from which $html was loaded
*/
private function extractThreadPostsV1($html, $url)
{
$lang = $html->find('html', 0)->lang;
// Posts are contained in an "ol"
$messageList = $html->find('#messageList > li')
or throwServerException('Error finding message list!');
foreach ($messageList as $post) {
if (!isset($post->attr['id'])) { // Skip ads
continue;
}
$item = [];
$item['uri'] = $url . '#' . $post->getAttribute('id');
$content = $post->find('.messageContent > article', 0);
// Add some style to quotes
foreach ($content->find('.bbCodeQuote') as $quote) {
$quote->style = '
color: #495566;
background-color: rgb(248,251,253);
border: 1px solid rgb(111, 140, 180);
border-color: rgb(111, 140, 180);
font-style: italic;';
}
// Remove script tags
foreach ($content->find('script') as $script) {
$script->outertext = '';
}
$item['content'] = $content->innertext;
// Remove quotes (for the title)
foreach ($content->find('.bbCodeQuote') as $quote) {
$quote->innertext = '';
}
$title = trim($content->plaintext);
if (strlen($title) > 70) {
$item['title'] = substr($title, 0, strpos($title, ' ', 70)) . '...';
} else {
$item['title'] = $title;
}
/**
* Timestamps are presented in two forms:
*
* 1) short version (for older posts?)
* 22 Oct. 2018
*
* This form has to be interpreted depending on the current language.
*
* 2) long version (for newer posts?)
* Wednesday at 18:59
*
* This form has the timestamp embedded (data-time)
*/
if ($timestamp = $post->find('abbr.DateTime', 0)) { // long version (preffered)
$item['timestamp'] = $timestamp->{'data-time'};
} elseif ($timestamp = $post->find('span.DateTime', 0)) { // short version
$item['timestamp'] = $this->fixDate($timestamp->title, $lang);
}
$item['author'] = $post->getAttribute('data-author');
// Bridge specific properties
$item['id'] = $post->getAttribute('id');
$this->items[] = $item;
}
}
private function extractThreadPostsV2($html, $url)
{
$lang = $html->find('html', 0)->lang;
$messageList = $html->find('div[class~="block-body"] article')
or throwServerException('Error finding message list!');
foreach ($messageList as $post) {
if (!isset($post->attr['id'])) { // Skip ads
continue;
}
$item = [];
$item['uri'] = $url . '#' . $post->getAttribute('id');
$title = $post->find('div[class~="message-content"] article', 0)->plaintext;
$end = strpos($title, ' ', min(70, strlen($title)));
$item['title'] = substr($title, 0, $end);
if ($post->find('time[datetime]', 0)) {
$item['timestamp'] = $post->find('time[datetime]', 0)->datetime;
} else {
$item['timestamp'] = $this->fixDate($post->find('time', 0)->title, $lang);
}
$item['author'] = $post->getAttribute('data-author');
$item['content'] = $post->find('div[class~="message-content"] article', 0);
// Bridge specific properties
$item['id'] = $post->getAttribute('id');
$this->items[] = $item;
}
}
private function extractPagesV1($html)
{
// A navigation bar becomes available if the number of posts grows too
// high. When this happens we need to load further pages (from last backwards)
if (($pageNav = $html->find('div.PageNav', 0))) {
$lastpage = $pageNav->{'data-last'};
$baseurl = $pageNav->{'data-baseurl'};
$sentinel = $pageNav->{'data-sentinel'};
$hosturl = parse_url($this->threadurl, PHP_URL_SCHEME)
. '://'
. parse_url($this->threadurl, PHP_URL_HOST)
. '/';
$page = $lastpage;
// Load at least the last page
do {
$pageurl = str_replace($sentinel, $lastpage, $baseurl);
// We can optimize performance by caching all but the last page
if ($page != $lastpage) {
$html = getSimpleHTMLDOMCached($pageurl);
} else {
$html = getSimpleHTMLDOM($pageurl);
}
$html = defaultLinkTo($html, $hosturl);
$this->extractThreadPostsV1($html, $pageurl);
$page--;
} while (count($this->items) < $this->getInput('limit') && $page != 1);
}
}
private function extractPagesV2($html)
{
// A navigation bar becomes available if the number of posts grows too
// high. When this happens we need to load further pages (from last backwards)
if (($pageNav = $html->find('div.pageNav', 0))) {
foreach ($pageNav->find('li') as $nav) {
$lastpage = $nav->plaintext;
}
// Manually extract baseurl and inject sentinel
$baseurl = $pageNav->find('li > a', -1)->href;
$baseurl = str_replace('page-' . $lastpage, 'page-{{sentinel}}', $baseurl);
$sentinel = '{{sentinel}}';
$hosturl = parse_url($this->threadurl, PHP_URL_SCHEME)
. '://'
. parse_url($this->threadurl, PHP_URL_HOST);
$page = $lastpage;
// Load at least the last page
do {
$pageurl = str_replace($sentinel, $lastpage, $baseurl);
// We can optimize performance by caching all but the last page
if ($page != $lastpage) {
$html = getSimpleHTMLDOMCached($pageurl);
} else {
$html = getSimpleHTMLDOM($pageurl);
}
$html = defaultLinkTo($html, $hosturl);
$this->extractThreadPostsV2($html, $pageurl);
$page--;
} while (count($this->items) < $this->getInput('limit') && $page != 1);
}
}
/**
* Fixes dates depending on the choosen language:
*
* de : dd.mm.yy
* en : dd.mm.yy
* it : dd/mm/yy
*
* Basically strtotime doesn't convert dates correctly due to formats
* being hard to interpret. So we use the DateTime object.
*
* We don't know the timezone, so just assume +00:00 (or whatever
* DateTime chooses)
*/
private function fixDate($date, $lang = 'en-US')
{
$mnamesen = [
'January',
'Feburary',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
switch ($lang) {
case 'en-US': // example: Jun 9, 2018 at 11:46 PM
$df = date_create_from_format('M d, Y \a\t H:i A', $date);
break;
case 'de-DE': // example: 19 Juli 2018 um 19:27 Uhr
$mnamesde = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember'
];
$mnamesdeshort = [
'Jan.',
'Feb.',
'Mär.',
'Apr.',
'Mai',
'Juni',
'Juli',
'Aug.',
'Sep.',
'Okt.',
'Nov.',
'Dez.'
];
$date = str_ireplace($mnamesde, $mnamesen, $date);
$date = str_ireplace($mnamesdeshort, $mnamesen, $date);
$df = date_create_from_format('d M Y \u\m H:i \U\h\r', $date);
break;
}
return date_format($df, 'U');
}
}
================================================
FILE: bridges/YGGTorrentBridge.php
================================================
[
'name' => 'category',
'type' => 'list',
'values' => [
'Toutes les catégories' => 'all.all',
'Film/Vidéo - Toutes les sous-catégories' => '2145.all',
'Film/Vidéo - Animation' => '2145.2178',
'Film/Vidéo - Animation Série' => '2145.2179',
'Film/Vidéo - Concert' => '2145.2180',
'Film/Vidéo - Documentaire' => '2145.2181',
'Film/Vidéo - Émission TV' => '2145.2182',
'Film/Vidéo - Film' => '2145.2183',
'Film/Vidéo - Série TV' => '2145.2184',
'Film/Vidéo - Spectacle' => '2145.2185',
'Film/Vidéo - Sport' => '2145.2186',
'Film/Vidéo - Vidéo-clips' => '2145.2186',
'Audio - Toutes les sous-catégories' => '2139.all',
'Audio - Karaoké' => '2139.2147',
'Audio - Musique' => '2139.2148',
'Audio - Podcast Radio' => '2139.2150',
'Audio - Samples' => '2139.2149',
'Jeu vidéo - Toutes les sous-catégories' => '2142.all',
'Jeu vidéo - Autre' => '2142.2167',
'Jeu vidéo - Linux' => '2142.2159',
'Jeu vidéo - MacOS' => '2142.2160',
'Jeu vidéo - Microsoft' => '2142.2162',
'Jeu vidéo - Nintendo' => '2142.2163',
'Jeu vidéo - Smartphone' => '2142.2165',
'Jeu vidéo - Sony' => '2142.2164',
'Jeu vidéo - Tablette' => '2142.2166',
'Jeu vidéo - Windows' => '2142.2161',
'eBook - Toutes les sous-catégories' => '2140.all',
'eBook - Audio' => '2140.2151',
'eBook - Bds' => '2140.2152',
'eBook - Comics' => '2140.2153',
'eBook - Livres' => '2140.2154',
'eBook - Mangas' => '2140.2155',
'eBook - Presse' => '2140.2156',
'Emulation - Toutes les sous-catégories' => '2141.all',
'Emulation - Emulateurs' => '2141.2157',
'Emulation - Roms' => '2141.2158',
'GPS - Toutes les sous-catégories' => '2141.all',
'GPS - Applications' => '2141.2168',
'GPS - Cartes' => '2141.2169',
'GPS - Divers' => '2141.2170'
]
],
'nom' => [
'name' => 'Nom',
'description' => 'Nom du torrent',
'type' => 'text',
'exampleValue' => 'matrix'
],
'description' => [
'name' => 'Description',
'description' => 'Description du torrent',
'type' => 'text'
],
'fichier' => [
'name' => 'Fichier',
'description' => 'Fichier du torrent',
'type' => 'text'
],
'uploader' => [
'name' => 'Uploader',
'description' => 'Uploader du torrent',
'type' => 'text'
],
]
];
public function collectData()
{
$catInfo = explode('.', $this->getInput('cat'));
$category = $catInfo[0];
$subcategory = $catInfo[1];
$html = getSimpleHTMLDOM(self::URI . '/engine/search?name='
. $this->getInput('nom')
. '&description='
. $this->getInput('description')
. '&file='
. $this->getInput('fichier')
. '&uploader='
. $this->getInput('uploader')
. '&category='
. $category
. '&sub_category='
. $subcategory
. '&do=search&order=desc&sort=publish_date');
$count = 0;
$results = $html->find('.results', 0);
if (!$results) {
return;
}
foreach ($results->find('tr') as $row) {
$count++;
if ($count == 1) {
continue; // Skip table header
}
if ($count == 22) {
break; // Stop processing after 21 items (20 + 1 table header)
}
$item = [];
$item['timestamp'] = $row->find('.hidden', 1)->plaintext;
$item['title'] = $row->find('a#torrent_name', 0)->plaintext;
$item['uri'] = $this->processLink($row->find('a#torrent_name', 0)->href);
$item['seeders'] = $row->find('td', 7)->plaintext;
$item['leechers'] = $row->find('td', 8)->plaintext;
$item['size'] = $row->find('td', 5)->plaintext;
$item = array_merge($item, $this->collectTorrentData($item['uri']));
$this->items[] = $item;
}
}
/**
* Convert special characters like é to %C3%A9 in the url
*/
private function processLink($url)
{
$url = explode('/', $url);
foreach ($url as $index => $value) {
// Skip https://{self::URI}/
if ($index < 3) {
continue;
}
// Decode first so that characters like + are not encoded
$url[$index] = urlencode(urldecode($value));
}
return implode('/', $url);
}
private function collectTorrentData($url)
{
$page = defaultLinkTo(getSimpleHTMLDOMCached($url), self::URI);
$author = $page->find('.informations tr', 5)->find('td', 1)->plaintext;
$content = $page->find('.default', 1);
return ['author' => $author, 'content' => $content];
}
}
================================================
FILE: bridges/YandereBridge.php
================================================
[
'name' => 'Channel URL',
'type' => 'text',
'required' => true,
'title' => 'The channel\'s URL',
'exampleValue' => 'https://dzen.ru/dream_faity_diy',
],
'limit' => [
'name' => 'Limit',
'type' => 'number',
'required' => false,
'title' => 'Number of posts to display. Max is 20.',
'exampleValue' => '20',
'defaultValue' => 20,
],
],
];
# credit: https://github.com/teromene see #1032
const _BASE_API_URL_WITH_CHANNEL_NAME = 'https://dzen.ru/api/v3/launcher/more?channel_name=';
const _BASE_API_URL_WITH_CHANNEL_ID = 'https://dzen.ru/api/v3/launcher/more?channel_id=';
const _ACCOUNT_URL_WITH_CHANNEL_ID_REGEX = '#^https?://dzen\.ru/id/(?[a-z0-9]{24})#';
const _ACCOUNT_URL_WITH_CHANNEL_NAME_REGEX = '#^https?://dzen\.ru/(?[\w\.]+)#';
private $channelRealName = null; # as shown in the webpage, not in the URL
public function collectData()
{
$channelURL = $this->getInput('channelURL');
if (preg_match(self::_ACCOUNT_URL_WITH_CHANNEL_ID_REGEX, $channelURL, $matches)) {
$channelID = $matches['channelID'];
$channelAPIURL = self::_BASE_API_URL_WITH_CHANNEL_ID . $channelID;
} elseif (preg_match(self::_ACCOUNT_URL_WITH_CHANNEL_NAME_REGEX, $channelURL, $matches)) {
$channelName = $matches['channelName'];
$channelAPIURL = self::_BASE_API_URL_WITH_CHANNEL_NAME . $channelName;
} else {
throwClientException(<<channelRealName = $APIResponse->header->title;
$limit = $this->getInput('limit');
foreach (array_slice($APIResponse->items, 0, $limit) as $post) {
$item = [];
$item['uri'] = $post->share_link;
$item['title'] = $post->title;
$publicationDateUnixTimestamp = $post->publication_date ?? null;
if ($publicationDateUnixTimestamp) {
$item['timestamp'] = date(DateTimeInterface::ATOM, $publicationDateUnixTimestamp);
}
$postImage = $post->image ?? null;
$item['content'] = $post->text;
if ($postImage) {
$item['content'] .= "
";
$item['enclosures'] = [$postImage];
}
$this->items[] = $item;
}
}
public function getURI()
{
if (is_null($this->getInput('channelURL'))) {
return parent::getURI();
}
return $this->getInput('channelURL');
}
public function getName()
{
if (is_null($this->channelRealName)) {
return parent::getName();
}
return $this->channelRealName . '\'s latest zen.yandex posts';
}
}
================================================
FILE: bridges/YeggiBridge.php
================================================
[
'name' => 'Search query',
'type' => 'text',
'required' => true,
'title' => 'Insert your search term here',
'exampleValue' => 'vase'
],
'sortby' => [
'name' => 'Sort by',
'type' => 'list',
'required' => false,
'values' => [
'Best match' => '0',
'Popular' => '1',
'Latest' => '2',
],
'defaultValue' => 'newest'
],
'show' => [
'name' => 'Show',
'type' => 'list',
'required' => false,
'values' => [
'All' => '0',
'Free' => '1',
'For sale' => '2',
],
'defaultValue' => 'all'
],
'showimage' => [
'name' => 'Show image in content',
'type' => 'checkbox',
'required' => false,
'title' => 'Activate to show the image in the content',
'defaultValue' => 'checked'
]
]
];
public function collectData()
{
$html = getSimpleHTMLDOM($this->getURI());
$results = $html->find('div.item_1_A');
foreach ($results as $result) {
$item = [];
$title = $result->find('.item_3_B_2', 0)->plaintext;
$explodeTitle = explode(' ', $title);
if (count($explodeTitle) == 2) {
$item['title'] = $explodeTitle[1];
} else {
$item['title'] = $explodeTitle[0];
}
$item['uri'] = self::URI . $result->find('a', 0)->href;
$item['author'] = 'Yeggi';
$text = $result->find('i');
$item['content'] = $text[0]->plaintext . ' on ' . $text[1]->plaintext;
$item['uid'] = hash('md5', $item['title']);
foreach ($result->find('.item_3_B_2 > a[href^=/q/]') as $tag) {
$item['tags'][] = $tag->plaintext;
}
$image = $result->find('img', 0)->src;
if ($this->getInput('showimage')) {
$item['content'] .= '
';
}
$item['enclosures'] = [$image];
$this->items[] = $item;
}
}
public function getURI()
{
if (!is_null($this->getInput('query'))) {
$uri = self::URI . '/q/' . urlencode($this->getInput('query')) . '/';
$uri .= '?o_f=' . $this->getInput('show');
$uri .= '&o_s=' . $this->getInput('sortby');
return $uri;
}
return parent::getURI();
}
}
================================================
FILE: bridges/YorushikaBridge.php
================================================
[
'lang' => [
'name' => 'Language',
'defaultValue' => 'jp',
'type' => 'list',
'values' => [
'日本語' => 'jp',
'English' => 'en',
'한국어' => 'ko',
'中文(繁體字)' => 'zh-tw',
'中文(簡体字)' => 'zh-cn',
]
],
],
'All categories' => [
],
'Only selected categories' => [
'yorushika' => [
'name' => 'Yorushika',
'type' => 'checkbox',
],
'suis' => [
'name' => 'suis',
'type' => 'checkbox',
],
'n-buna' => [
'name' => 'n-buna',
'type' => 'checkbox',
],
]
];
public function collectData()
{
switch ($this->getInput('lang')) {
case 'jp':
$url = 'https://yorushika.com/news/5/';
break;
case 'en':
$url = 'https://yorushika.com/news/5/?lang=en';
break;
case 'ko':
$url = 'https://yorushika.com/news/5/?lang=ko';
break;
case 'zh-tw':
$url = 'https://yorushika.com/news/5/?lang=zh-tw';
break;
case 'zh-cn':
$url = 'https://yorushika.com/news/5/?lang=zh-cn';
break;
default:
$url = 'https://yorushika.com/news/5/';
break;
}
$categories = [];
if ($this->queriedContext == 'All categories') {
array_push($categories, 'all');
} else if ($this->queriedContext == 'Only selected categories') {
if ($this->getInput('yorushika')) {
array_push($categories, 'ヨルシカ');
}
if ($this->getInput('suis')) {
array_push($categories, 'suis');
}
if ($this->getInput('n-buna')) {
array_push($categories, 'n-buna');
}
}
$html = getSimpleHTMLDOM($url)->find('.list--news', 0);
$html = defaultLinkTo($html, $this->getURI());
foreach ($html->find('.inview') as $art) {
$item = [];
// Get article category and check the filters
$art_category = $art->find('.category', 0)->plaintext;
if (!in_array('all', $categories) && !in_array($art_category, $categories)) {
// Filtering is enabled and the category is not selected, skipping
continue;
}
// Get article title
$title = $art->find('.tit', 0)->plaintext;
// Get article url
$url = $art->find('a.clearfix', 0)->href;
// Get article date
$date = $art->find('.date', 0)->plaintext;
preg_match('/(\d+)[\.年](\d+)[\.月](\d+)/u', $date, $matches);
$formattedDate = sprintf('%d.%02d.%02d', $matches[1], $matches[2], $matches[3]);
$date = date_create_from_format('Y.m.d', $formattedDate);
$date = date_format($date, 'd.m.Y');
// Get article info
$art_html = getSimpleHTMLDOMCached($url)->find('.text.inview', 0);
$art_html = defaultLinkTo($art_html, $this->getURI());
// Rewrite the YouTube embed with a YouTube link
$yt_embed = $art_html->find('iframe[src*="youtube.com"]', 0);
if ($yt_embed) {
$yt_embed->outertext = handleYoutube($yt_embed->outertext);
}
$item['uri'] = $url;
$item['title'] = $title . ' (' . $art_category . ')';
$item['content'] = $art_html;
$item['timestamp'] = $date;
$this->items[] = $item;
}
}
}
================================================
FILE: bridges/YouTubeCommunityTabBridge.php
================================================
[
'channel' => [
'name' => 'Channel ID',
'type' => 'text',
'required' => true,
'exampleValue' => 'UCULkRHBdLC5ZcEQBaL0oYHQ'
]
],
'By username' => [
'username' => [
'name' => 'Username',
'type' => 'text',
'required' => true,
'exampleValue' => 'YouTubeUK'
],
]
];
const CACHE_TIMEOUT = 3600; // 1 hour
private $feedUrl = '';
private $feedName = '';
private $itemTitle = '';
private $urlRegex = '/youtube\.com\/(channel|user|c)\/([\w]+)\/posts/';
private $jsonRegex = '/var ytInitialData = ([^<]*);<\/script>/';
public function detectParameters($url)
{
$params = [];
if (preg_match($this->urlRegex, $url, $matches)) {
if ($matches[1] === 'channel') {
$params['context'] = 'By channel ID';
$params['channel'] = $matches[2];
}
if ($matches[1] === 'user') {
$params['context'] = 'By username';
$params['username'] = $matches[2];
}
return $params;
}
return null;
}
public function collectData()
{
if (is_null($this->getInput('username')) === false) {
try {
$this->feedUrl = $this->buildPostsUri($this->getInput('username'), 'c');
$html = getSimpleHTMLDOM($this->feedUrl);
} catch (Exception $e) {
$this->feedUrl = $this->buildPostsUri($this->getInput('username'), 'user');
$html = getSimpleHTMLDOM($this->feedUrl);
}
} else {
$this->feedUrl = $this->buildPostsUri($this->getInput('channel'), 'channel');
$html = getSimpleHTMLDOM($this->feedUrl);
}
$json = $this->extractJson($html->find('html', 0)->innertext);
$this->feedName = $json->header->c4TabbedHeaderRenderer->title ?? null;
$this->feedName ??= $json->header->pageHeaderRenderer->pageTitle ?? null;
$this->feedName ??= $json->metadata->channelMetadataRenderer->title ?? null;
$this->feedName ??= $json->microformat->microformatDataRenderer->title ?? null;
$this->feedName ??= '';
if ($this->hasPostsTab($json) === false) {
throwServerException('Channel does not have a posts tab');
}
$posts = $this->getPosts($json);
foreach ($posts as $key => $post) {
$this->itemTitle = '';
if (!isset($post->backstagePostThreadRenderer)) {
continue;
}
if (isset($post->backstagePostThreadRenderer->post->backstagePostRenderer)) {
$details = $post->backstagePostThreadRenderer->post->backstagePostRenderer;
} elseif (isset($post->backstagePostThreadRenderer->post->sharedPostRenderer)) {
// todo: properly extract data from this shared post
$details = $post->backstagePostThreadRenderer->post->sharedPostRenderer;
} else {
continue;
}
$item = [];
$item['uri'] = self::URI . '/post/' . $details->postId;
$item['author'] = $details->authorText->runs[0]->text ?? null;
$item['content'] = $item['uri'];
if (isset($details->contentText->runs)) {
$text = $this->getText($details->contentText->runs);
$this->itemTitle = $this->ellipsisTitle($text);
$item['content'] = $text;
}
$item['content'] .= $this->getAttachments($details);
$item['title'] = $this->itemTitle;
$date = strtotime(str_replace(' (edited)', '', $details->publishedTimeText->runs[0]->text));
if (is_int($date)) {
// subtract an increasing multiple of 60 seconds to always preserve the original order
$item['timestamp'] = $date - $key * 60;
}
$this->items[] = $item;
}
}
public function getURI()
{
if (!empty($this->feedUrl)) {
return $this->feedUrl;
}
return parent::getURI();
}
public function getName()
{
if (!empty($this->feedName)) {
return $this->feedName . ' - YouTube Posts Tab';
}
return parent::getName();
}
/**
* Build Posts URI
*/
private function buildPostsUri($value, $type)
{
return self::URI . '/' . $type . '/' . $value . '/posts';
}
/**
* Extract JSON from page
*/
private function extractJson($html)
{
if (!preg_match($this->jsonRegex, $html, $parts)) {
throwServerException('Failed to extract data from page');
}
$data = json_decode($parts[1]);
if ($data === false) {
throwServerException('Failed to decode extracted data');
}
return $data;
}
/**
* Check if channel has a posts tab
*/
private function hasPostsTab($json)
{
foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {
if (
isset($tab->tabRenderer)
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'posts')
) {
return true;
}
}
return false;
}
/**
* Get posts from posts tab
*/
private function getPosts($json)
{
foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {
if (
isset($tab->tabRenderer)
&& str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'posts')
) {
return $tab->tabRenderer->content->sectionListRenderer->contents[0]->itemSectionRenderer->contents;
}
}
}
/**
* Get text content for a post
*/
private function getText($runs)
{
$text = '';
foreach ($runs as $part) {
if (isset($part->navigationEndpoint->browseEndpoint->canonicalBaseUrl)) {
$text .= $this->formatUrls($part->text, $part->navigationEndpoint->browseEndpoint->canonicalBaseUrl);
} elseif (isset($part->navigationEndpoint->urlEndpoint->url)) {
$text .= $this->formatUrls($part->text, $part->navigationEndpoint->urlEndpoint->url);
} elseif (isset($part->navigationEndpoint->commandMetadata->webCommandMetadata->url)) {
$text .= $this->formatUrls($part->text, $part->navigationEndpoint->commandMetadata->webCommandMetadata->url);
} else {
$text .= $this->formatUrls($part->text, null);
}
}
return nl2br($text);
}
/**
* Get attachments for posts
*/
private function getAttachments($details)
{
$content = '';
if (isset($details->backstageAttachment)) {
$attachments = $details->backstageAttachment;
if (isset($attachments->videoRenderer) && isset($attachments->videoRenderer->videoId)) {
// Video
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted a video';
}
$content = handleYoutube($attachments->videoRenderer->videoId);
} elseif (isset($attachments->backstageImageRenderer)) {
// Image
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted an image';
}
$lastThumb = end($attachments->backstageImageRenderer->image->thumbnails);
$content = <<
EOD;
} elseif (isset($attachments->pollRenderer)) {
// Poll
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted a poll';
}
$pollChoices = '';
foreach ($attachments->pollRenderer->choices as $choice) {
$pollChoices .= <<{$choice->text->runs[0]->text}
EOD;
}
$content = <<Poll ({$attachments->pollRenderer->totalVotes->simpleText})
{$pollChoices}
EOD;
} elseif (isset($attachments->postMultiImageRenderer->images)) {
// Multiple images
$images = $attachments->postMultiImageRenderer->images;
if (is_array($images)) {
if (empty($this->itemTitle)) {
$this->itemTitle = $this->feedName . ' posted ' . count($images) . ' images';
}
foreach ($images as $image) {
$lastThumb = end($image->backstageImageRenderer->image->thumbnails);
$content .= <<
EOD;
}
}
}
}
return $content;
}
/*
Ellipsis text for title
*/
private function ellipsisTitle($text)
{
$length = 100;
$text = strip_tags($text);
if (strlen($text) > $length) {
$text = explode('
', wordwrap($text, $length, '
'));
return $text[0] . '...';
}
return $text;
}
private function formatUrls($content, $url)
{
if (substr(strval($url), 0, 1) == '/') {
// fix relative URL
$url = 'https://www.youtube.com' . $url;
} elseif (substr(strval($url), 0, 33) == 'https://www.youtube.com/redirect?') {
// extract actual URL from YouTube redirect
parse_str(substr($url, 33), $params);
if (strpos(($params['q'] ?? ''), rtrim($content, '.')) === 0) {
$url = $params['q'];
}
}
// ensure all URLs are made clickable
$url = $url ?? $content;
if (filter_var($url, FILTER_VALIDATE_URL)) {
return '' . $content . '';
}
return $content;
}
}
================================================
FILE: bridges/YouTubeFeedExpanderBridge.php
================================================
[
'name' => 'Channel ID',
'required' => true,
// Example: vinesauce
'exampleValue' => 'UCzORJV8l3FWY4cFO8ot-F2w',
],
'embed' => [
'name' => 'Add embed to entry',
'type' => 'checkbox',
'title' => 'Add embed to entry',
'defaultValue' => 'checked',
],
'embedurl' => [
'name' => 'Use embed page as entry url',
'type' => 'checkbox',
'title' => 'Use embed page as entry url',
],
'nocookie' => [
'name' => 'Use nocookie embed page',
'type' => 'checkbox',
'title' => 'Use nocookie embed page'
],
'hideshorts' => [
'name' => 'Hide shorts',
'type' => 'checkbox',
'title' => 'Hide shorts'
]
]];
public function getIcon()
{
if ($this->getInput('channel') != null) {
$html = getSimpleHTMLDOMCached($this->getURI());
return $html->find('[itemprop="thumbnailUrl"]', 0)->href;
}
return parent::getIcon();
}
public function collectData()
{
$url = 'https://www.youtube.com/feeds/videos.xml?channel_id=' . $this->getInput('channel');
$this->collectExpandableDatas($url);
}
protected function parseItem(array $item)
{
if ($this->getInput('hideshorts') && str_contains($item['uri'], '/shorts/')) {
return;
}
$id = $item['yt']['videoId'];
$item['comments'] = $item['uri'] . '#comments';
$item['uid'] = $item['id'];
$thumbnail = sprintf('https://img.youtube.com/vi/%s/maxresdefault.jpg', $id);
$item['enclosures'] = [$thumbnail];
$item['content'] = $item['media']['group']['description'];
$item['content'] = str_replace("\n", '
', $item['content']);
unset($item['media']);
$embedURI = self::URI;
if ($this->getInput('nocookie')) {
$embedURI = 'https://www.youtube-nocookie.com/';
}
$embed = $embedURI . 'embed/' . $id;
if ($this->getInput('embed')) {
$iframe = handleYoutube($id) . '
';
$item['content'] = $iframe . $item['content'];
}
if ($this->getInput('embedurl')) {
$item['uri'] = $embed;
}
return $item;
}
}
================================================
FILE: bridges/YoutubeBridge.php
================================================
[
'u' => [
'name' => 'username',
'exampleValue' => 'LinusTechTips',
'required' => true
]
],
'By channel id' => [
'c' => [
'name' => 'channel id',
'exampleValue' => 'UCw38-8_Ibv_L6hlKChHO9dQ',
'required' => true
]
],
'By custom name' => [
'custom' => [
'name' => 'custom name',
'exampleValue' => 'LinusTechTips',
'required' => true
]
],
'By playlist Id' => [
'p' => [
'name' => 'playlist id',
'exampleValue' => 'PL8mG-RkN2uTzJc8N0EoyhdC54prvBBLpj',
'required' => true
]
],
'Search result' => [
's' => [
'name' => 'search keyword',
'exampleValue' => 'LinusTechTips',
'required' => true
],
'pa' => [
'name' => 'page',
'type' => 'number',
'title' => 'This option is not work anymore, as YouTube will always return the same page',
'exampleValue' => 1
]
],
'global' => [
'duration_min' => [
'name' => 'min. duration (minutes)',
'type' => 'number',
'title' => 'Minimum duration for the video in minutes',
'exampleValue' => 5
],
'duration_max' => [
'name' => 'max. duration (minutes)',
'type' => 'number',
'title' => 'Maximum duration for the video in minutes',
'exampleValue' => 10
]
]
];
private $feedName = '';
private $feeduri = '';
private $feedIconUrl = '';
// This took from repo BetterVideoRss of VerifiedJoseph.
const URI_REGEX = '/(https?:\/\/(?:www\.)?(?:[a-zA-Z0-9-.]{2,256}\.[a-z]{2,20})(\:[0-9]{2 ,4})?(?:\/[a-zA-Z0-9@:%_\+.,~#"\'!?&\/\/=\-*]+|\/)?)/ims'; //phpcs:ignore
public function collectData()
{
$cacheKey = 'youtube_rate_limit';
if ($this->cache->get($cacheKey)) {
throwRateLimitException();
}
try {
$this->collectDataInternal();
} catch (HttpException $e) {
if ($e->getCode() === 429) {
$this->cache->set($cacheKey, true, 60 * 16);
throwRateLimitException();
}
throw $e;
}
}
private function collectDataInternal()
{
$html = '';
$url_feed = '';
$url_listing = '';
$username = $this->getInput('u');
$channel = $this->getInput('c');
$custom = $this->getInput('custom');
$playlist = $this->getInput('p');
$search = $this->getInput('s');
$durationMin = $this->getInput('duration_min');
$durationMax = $this->getInput('duration_max');
// Whether to discriminate videos by duration
$filterByDuration = $durationMin || $durationMax;
if ($username) {
// user and channel
$url_feed = self::URI . '/feeds/videos.xml?user=' . urlencode($username);
$url_listing = self::URI . '/user/' . urlencode($username) . '/videos';
} elseif ($channel) {
$url_feed = self::URI . '/feeds/videos.xml?channel_id=' . urlencode($channel);
$url_listing = self::URI . '/channel/' . urlencode($channel) . '/videos';
} elseif ($custom) {
$url_listing = self::URI . '/' . urlencode($custom) . '/videos';
}
if ($url_feed || $url_listing) {
// user, channel or custom
$this->feeduri = $url_listing;
if ($custom) {
// Extract the feed url for the custom name
$html = $this->fetch($url_listing);
$jsonData = $this->extractJsonFromHtml($html);
// Pluck out the rss feed url
$url_feed = $jsonData->metadata->channelMetadataRenderer->rssUrl;
$this->feedIconUrl = $jsonData->metadata->channelMetadataRenderer->avatar->thumbnails[0]->url;
}
if ($filterByDuration) {
if (!$custom) {
// Fetch the html page
$html = $this->fetch($url_listing);
$jsonData = $this->extractJsonFromHtml($html);
}
$channel_id = '';
if (isset($jsonData->contents)) {
$channel_id = $jsonData->metadata->channelMetadataRenderer->externalId;
$jsonData = $jsonData->contents->twoColumnBrowseResultsRenderer->tabs[1];
$jsonData = $jsonData->tabRenderer->content->richGridRenderer->contents;
// $jsonData = $jsonData->itemSectionRenderer->contents[0]->gridRenderer->items;
$this->fetchItemsFromFromJsonData($jsonData);
} else {
throwServerException('Unable to get data from YouTube');
}
} else {
// Fetch the xml feed
$html = $this->fetch($url_feed);
$this->extractItemsFromXmlFeed($html);
}
$this->feedName = str_replace(' - YouTube', '', $html->find('title', 0)->plaintext);
} elseif ($playlist) {
// playlist
$url_feed = self::URI . '/feeds/videos.xml?playlist_id=' . urlencode($playlist);
$url_listing = self::URI . '/playlist?list=' . urlencode($playlist);
$html = $this->fetch($url_listing);
$jsonData = $this->extractJsonFromHtml($html);
// TODO: this method returns only first 100 video items
// if it has more videos, playlistVideoListRenderer will have continuationItemRenderer as last element
$jsonData = $jsonData->contents->twoColumnBrowseResultsRenderer->tabs[0] ?? null;
if (!$jsonData) {
// playlist probably doesnt exists
throw new \Exception('Unable to find playlist: ' . $url_listing);
}
$jsonData = $jsonData->tabRenderer->content->sectionListRenderer->contents[0]->itemSectionRenderer;
$jsonData = $jsonData->contents[0]->playlistVideoListRenderer->contents;
$item_count = count($jsonData);
if ($item_count > 15 || $filterByDuration) {
$this->fetchItemsFromFromJsonData($jsonData);
} else {
$xml = $this->fetch($url_feed);
$this->extractItemsFromXmlFeed($xml);
}
$this->feedName = 'Playlist: ' . str_replace(' - YouTube', '', $html->find('title', 0)->plaintext);
usort($this->items, function ($item1, $item2) {
if (!is_int($item1['timestamp']) && !is_int($item2['timestamp'])) {
$item1['timestamp'] = strtotime($item1['timestamp']);
$item2['timestamp'] = strtotime($item2['timestamp']);
}
return $item2['timestamp'] - $item1['timestamp'];
});
} elseif ($search) {
// search
$today_filter = 'EgIIAg'; // restrict the upload date to the last 24 hours
$url_listing = self::URI . '/results?sp=' . $today_filter . '&search_query=' . urlencode($search);
if (!preg_match("/\b(before|after):/i", $search)) {
// unless explicitly overridden, a special "after:yyyy-mm-dd" keyword is appended to restrict the upload date to the last 6-30 hours
$html = $this->fetch($url_listing . urlencode(' after:' . date('Y-m-d', strtotime('-6 hours'))));
} else {
$html = $this->fetch($url_listing);
}
$jsonData = $this->extractJsonFromHtml($html);
$jsonData = $jsonData->contents->twoColumnSearchResultsRenderer->primaryContents;
$jsonData = $jsonData->sectionListRenderer->contents[0]->itemSectionRenderer->contents;
$this->fetchItemsFromFromJsonData($jsonData);
$this->feeduri = $url_listing;
$this->feedName = 'Search: ' . $search;
} else {
throwClientException("You must either specify either:\n - YouTube username (?u=...)\n - Channel id (?c=...)\n - Playlist id (?p=...)\n - Search (?s=...)");
}
}
private function fetchVideoDetails($videoId, &$author, &$description, &$timestamp)
{
$url = self::URI . "/watch?v=$videoId";
$html = $this->fetch($url, true);
// Skip unavailable videos
if (strpos($html->innertext, 'IS_UNAVAILABLE_PAGE') !== false) {
return;
}
$elAuthor = $html->find('span[itemprop=author] > link[itemprop=name]', 0);
if (!is_null($elAuthor)) {
$author = $elAuthor->getAttribute('content');
}
$elDatePublished = $html->find('meta[itemprop=datePublished]', 0);
if (!is_null($elDatePublished)) {
$timestamp = strtotime($elDatePublished->getAttribute('content'));
}
$jsonData = $this->extractJsonFromHtml($html);
if (!isset($jsonData->contents)) {
return;
}
$jsonData = $jsonData->contents->twoColumnWatchNextResults->results->results->contents ?? null;
if (!$jsonData) {
throw new \Exception('Unable to find json data');
}
$videoSecondaryInfo = null;
foreach ($jsonData as $item) {
if (isset($item->videoSecondaryInfoRenderer)) {
$videoSecondaryInfo = $item->videoSecondaryInfoRenderer;
break;
}
}
if (!$videoSecondaryInfo) {
throwServerException('Could not find videoSecondaryInfoRenderer. Error at: ' . $videoId);
}
$description = $videoSecondaryInfo->attributedDescription->content ?? '';
// Default whitespace chars used by trim + non-breaking spaces (https://en.wikipedia.org/wiki/Non-breaking_space)
$whitespaceChars = " \t\n\r\0\x0B\u{A0}\u{2060}\u{202F}\u{2007}";
$descEnhancements = $this->ytBridgeGetVideoDescriptionEnhancements($videoSecondaryInfo, $description, self::URI, $whitespaceChars);
foreach ($descEnhancements as $descEnhancement) {
if (isset($descEnhancement['url'])) {
$descBefore = mb_substr($description, 0, $descEnhancement['pos']);
$descValue = mb_substr($description, $descEnhancement['pos'], $descEnhancement['len']);
$descAfter = mb_substr($description, $descEnhancement['pos'] + $descEnhancement['len'], null);
// Extended trim for the display value of internal links, e.g.:
// FAVICON • Video Name
// FAVICON / @ChannelName
$descValue = trim($descValue, $whitespaceChars . '•/');
$description = sprintf('%s%s%s', $descBefore, $descEnhancement['url'], $descValue, $descAfter);
}
}
}
private function ytBridgeGetVideoDescriptionEnhancements(
object $videoSecondaryInfo,
string $descriptionContent,
string $baseUrl,
string $whitespaceChars
): array {
$commandRuns = $videoSecondaryInfo->attributedDescription->commandRuns ?? [];
if (count($commandRuns) <= 0) {
return [];
}
$enhancements = [];
$boundaryWhitespaceChars = mb_str_split($whitespaceChars);
$boundaryStartChars = array_merge($boundaryWhitespaceChars, [':', '-', '(']);
$boundaryEndChars = array_merge($boundaryWhitespaceChars, [',', '.', "'", ')']);
$hashtagBoundaryEndChars = array_merge($boundaryEndChars, ['#', '-']);
$descriptionContentLength = mb_strlen($descriptionContent);
$minPositionOffset = 0;
$prevStartPosition = 0;
$totalLength = 0;
$maxPositionByStartIndex = [];
foreach (array_reverse($commandRuns) as $commandRun) {
$endPosition = $commandRun->startIndex + $commandRun->length;
if ($endPosition < $prevStartPosition) {
$totalLength += 1;
}
$totalLength += $commandRun->length;
$maxPositionByStartIndex[$commandRun->startIndex] = $totalLength;
$prevStartPosition = $commandRun->startIndex;
}
foreach ($commandRuns as $commandRun) {
$commandMetadata = $commandRun->onTap->innertubeCommand->commandMetadata->webCommandMetadata ?? null;
if (!isset($commandMetadata)) {
continue;
}
$enhancement = null;
/*
$commandRun->startIndex can be offset by few positions in the positive direction
when some multibyte characters (e.g. emojis, but maybe also others) are used in the plain text video description.
(probably some difference between php and javascript in handling multibyte characters)
This loop should correct the position in most cases. It searches for the next word (determined by a set of boundary chars) with the expected length.
Several safeguards ensure that the correct word is chosen. When a link can not be matched,
everything will be discarded to prevent corrupting the description.
Hashtags require a different set of boundary chars.
*/
$isHashtag = $commandMetadata->webPageType === 'WEB_PAGE_TYPE_BROWSE';
$prevEnhancement = end($enhancements);
$minPosition = $prevEnhancement === false ? 0 : $prevEnhancement['pos'] + $prevEnhancement['len'];
$maxPosition = $descriptionContentLength - $maxPositionByStartIndex[$commandRun->startIndex];
$position = min($commandRun->startIndex - $minPositionOffset, $maxPosition);
while ($position >= $minPosition) {
// The link display value can only ever include a new line at the end (which will be removed further below), never in between.
$newLinePosition = mb_strpos($descriptionContent, "\n", $position);
if ($newLinePosition !== false && $newLinePosition < $position + ($commandRun->length - 1)) {
$position = $newLinePosition - ($commandRun->length - 1);
continue;
}
$firstChar = mb_substr($descriptionContent, $position, 1);
$boundaryStart = mb_substr($descriptionContent, $position - 1, 1);
$boundaryEndIndex = $position + $commandRun->length;
$boundaryEnd = mb_substr($descriptionContent, $boundaryEndIndex, 1);
$boundaryStartIsValid = $position === 0 ||
in_array($boundaryStart, $boundaryStartChars) ||
($isHashtag && $firstChar === '#');
$boundaryEndIsValid = $boundaryEndIndex === $descriptionContentLength ||
in_array($boundaryEnd, $isHashtag ? $hashtagBoundaryEndChars : $boundaryEndChars);
if ($boundaryStartIsValid && $boundaryEndIsValid) {
$minPositionOffset = $commandRun->startIndex - $position;
$enhancement = [
'pos' => $position,
'len' => $commandRun->length,
];
break;
}
$position--;
}
if (!isset($enhancement)) {
$this->logger->debug(sprintf('Position %d cannot be corrected in "%s"', $commandRun->startIndex, substr($descriptionContent, 0, 50) . '...'));
// Skip to prevent the description from becoming corrupted
continue;
}
// $commandRun->length sometimes incorrectly includes the newline as last char
$lastChar = mb_substr($descriptionContent, $enhancement['pos'] + $enhancement['len'] - 1, 1);
if ($lastChar === "\n") {
$enhancement['len'] -= 1;
}
$commandUrl = parse_url($commandMetadata->url);
if ($commandUrl['path'] === '/redirect') {
parse_str($commandUrl['query'], $commandUrlQuery);
$enhancement['url'] = urldecode($commandUrlQuery['q']);
} elseif (isset($commandUrl['host'])) {
$enhancement['url'] = $commandMetadata->url;
} else {
$enhancement['url'] = $baseUrl . $commandMetadata->url;
}
$enhancements[] = $enhancement;
}
if (count($enhancements) !== count($commandRuns)) {
// At least one link can not be matched. Discard everything to prevent corrupting the description.
return [];
}
// Sort by position in descending order to be able to safely replace values
return array_reverse($enhancements);
}
private function extractItemsFromXmlFeed($xml)
{
$this->feedName = $this->decodeTitle($xml->find('feed > title', 0)->plaintext);
foreach ($xml->find('entry') as $element) {
$videoId = str_replace('yt:video:', '', $element->find('id', 0)->plaintext);
if (strpos($videoId, 'googleads') !== false) {
continue;
}
$title = $this->decodeTitle($element->find('title', 0)->plaintext);
$author = $element->find('name', 0)->plaintext;
$desc = $element->find('media:description', 0)->innertext;
$desc = htmlspecialchars($desc);
$desc = nl2br($desc);
$desc = preg_replace(self::URI_REGEX, '$1 ', $desc);
$time = strtotime($element->find('published', 0)->plaintext);
$this->addItem($videoId, $title, $author, $desc, $time);
}
}
private function fetch($url, bool $cache = false)
{
$header = ['Accept-Language: en-US'];
$ttl = 86400 * 3; // 3d
$stripNewlines = false;
if ($cache) {
return getSimpleHTMLDOMCached($url, $ttl, $header, [], true, true, DEFAULT_TARGET_CHARSET, $stripNewlines);
}
return getSimpleHTMLDOM($url, $header, [], true, true, DEFAULT_TARGET_CHARSET, $stripNewlines);
}
private function extractJsonFromHtml($html)
{
$scriptRegex = '/var ytInitialData = (.*?);<\/script>/';
$result = preg_match($scriptRegex, $html, $matches);
if (! $result) {
$this->logger->debug('Could not find ytInitialData');
return null;
}
$data = json_decode($matches[1]);
return $data;
}
private function fetchItemsFromFromJsonData($jsonData)
{
$minimumDurationSeconds = ($this->getInput('duration_min') ?: -1) * 60;
$maximumDurationSeconds = ($this->getInput('duration_max') ?: INF) * 60;
foreach ($jsonData as $item) {
$wrapper = null;
if (isset($item->gridVideoRenderer)) {
$wrapper = $item->gridVideoRenderer;
} elseif (isset($item->videoRenderer)) {
$wrapper = $item->videoRenderer;
} elseif (isset($item->playlistVideoRenderer)) {
$wrapper = $item->playlistVideoRenderer;
} elseif (isset($item->richItemRenderer)) {
$wrapper = $item->richItemRenderer->content->videoRenderer;
} else {
continue;
}
// 01:03:30 | 15:06 | 1:24
$lengthText = $wrapper->lengthText->simpleText ?? null;
// 6,875 views
$viewCount = $wrapper->viewCountText->simpleText ?? null;
// Dc645M8Het8
$videoId = $wrapper->videoId;
// Jumbo frames - transfer more data faster!
$title = $wrapper->title->runs[0]->text ?? $wrapper->title->accessibility->accessibilityData->label ?? null;
$author = null;
$description = $wrapper->descriptionSnippet->runs[0]->text ?? null;
// 5 days ago | 1 month ago
$publishedTimeText = $wrapper->publishedTimeText->simpleText ?? $wrapper->videoInfo->runs[2]->text ?? null;
$timestamp = null;
if ($publishedTimeText) {
try {
$publicationDate = new \DateTimeImmutable($publishedTimeText);
// Hard-code hour, minute and second
$publicationDate = $publicationDate->setTime(0, 0, 0);
$timestamp = $publicationDate->getTimestamp();
} catch (\Exception $e) {
}
}
$durationText = 0;
if ($lengthText) {
$durationText = $lengthText;
} else {
foreach ($wrapper->thumbnailOverlays as $overlay) {
if (isset($overlay->thumbnailOverlayTimeStatusRenderer)) {
$durationText = $overlay->thumbnailOverlayTimeStatusRenderer->text;
break;
}
}
}
if (is_string($durationText)) {
if (preg_match('/([\d]{1,2})\:([\d]{1,2})\:([\d]{2})/', $durationText)) {
$durationText = preg_replace('/([\d]{1,2})\:([\d]{1,2})\:([\d]{2})/', '$1:$2:$3', $durationText);
} else {
$durationText = preg_replace('/([\d]{1,2})\:([\d]{2})/', '00:$1:$2', $durationText);
}
sscanf($durationText, '%d:%d:%d', $hours, $minutes, $seconds);
$duration = $hours * 3600 + $minutes * 60 + $seconds;
if ($duration < $minimumDurationSeconds || $duration > $maximumDurationSeconds) {
continue;
}
}
if (!$description || !$timestamp) {
$this->fetchVideoDetails($videoId, $author, $description, $timestamp);
}
$this->addItem($videoId, $title, $author, $description, $timestamp);
if (count($this->items) >= 99) {
break;
}
}
}
private function addItem($videoId, $title, $author, $description, $timestamp, $thumbnail = '')
{
$description = nl2br($description);
$item = [];
// This should probably be uid?
$item['id'] = $videoId;
$item['title'] = $title;
$item['author'] = $author ?? '';
$item['timestamp'] = $timestamp;
$item['uri'] = self::URI . '/watch?v=' . $videoId;
if (!$thumbnail) {
// Fallback to default thumbnail if there aren't any provided.
$thumbnail = '0';
}
$thumbnailUri = str_replace('/www.', '/img.', self::URI) . '/vi/' . $videoId . '/' . $thumbnail . '.jpg';
$item['content'] = sprintf('
%s', $item['uri'], $thumbnailUri, $description);
$this->items[] = $item;
}
private function decodeTitle($title)
{
// convert both Ӓ and " to UTF-8
return html_entity_decode($title, ENT_QUOTES, 'UTF-8');
}
public function getURI()
{
if (!is_null($this->getInput('p'))) {
return static::URI . '/playlist?list=' . $this->getInput('p');
} elseif ($this->feeduri) {
return $this->feeduri;
}
return parent::getURI();
}
public function getName()
{
switch ($this->queriedContext) {
case 'By username':
case 'By channel id':
case 'By custom name':
case 'By playlist Id':
case 'Search result':
return htmlspecialchars_decode($this->feedName) . ' - YouTube';
default:
return parent::getName();
}
}
public function getIcon()
{
if (empty($this->feedIconUrl)) {
return parent::getIcon();
} else {
return $this->feedIconUrl;
}
}
}
================================================
FILE: bridges/ZDFMediathekBridge.php
================================================
[
'name' => 'ZDF Show URL',
'type' => 'text',
'required' => true,
'exampleValue' => 'https://www.zdf.de/magazine/zdfheute-live-102'
]
]
];
public function collectData()
{
$url = $this->getInput('path');
$data = $this->getJSON($url);
foreach ($data['value']['data']['smartCollectionByCanonical']['seasons']['nodes'][0]['episodes']['nodes'] as $episode_node) {
$item = [];
$item['title'] = $episode_node['title'];
$item['timestamp'] = strtotime($episode_node['editorialDate']);
$item['uri'] = $episode_node['sharingUrl'];
$item['uid'] = $episode_node['id'];
$description = $episode_node['teaser']['description'];
$image = $episode_node['teaser']['imageWithoutLogo']['layouts']['dim1920X1080'];
$image_desc = $episode_node['teaser']['imageWithoutLogo']['altText'];
$item['content'] = "
{$description}
";
$this->items[] = $item;
}
}
private function getJSON($url)
{
$html = getContents($url);
// Find all ');
$contents = stripWithDelimiters($contents, '');
$contents = stripWithDelimiters($contents, '