[
  {
    "path": ".devcontainer/Dockerfile",
    "content": "FROM rssbridge/rss-bridge:latest\n\nCOPY --chmod=755 post-create-command.sh /usr/local/bin/post-create-command\n\nADD https://raw.githubusercontent.com/docker-library/php/master/docker-php-ext-enable /usr/local/bin/docker-php-ext-enable\nRUN chmod u+x /usr/local/bin/docker-php-ext-enable\n\nADD https://getcomposer.org/installer /usr/local/bin/composer-installer.php\nRUN chmod u+x /usr/local/bin/composer-installer.php\nRUN php /usr/local/bin/composer-installer.php --check && \\\n    php /usr/local/bin/composer-installer.php --filename=composer --install-dir=/usr/local/bin\n\nRUN apt-get update && \\\n    apt-get install -y \\\n      git \\\n      php-dev \\\n      make \\\n      unzip\n\nRUN pecl install xdebug && \\\n    PHP_INI_DIR=/etc/php/8.2/fpm docker-php-ext-enable xdebug\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n\t\"name\": \"rss-bridge dev\",\n\t\"build\": { \"dockerfile\": \"Dockerfile\" },\n\t\"customizations\": {\n\t\t// Configure properties specific to VS Code.\n\t\t\"vscode\": {\n\t\t\t// Set *default* container specific settings.json values on container create.\n\t\t\t\"settings\": {\n\t\t\t\t\"php.validate.executablePath\": \"/usr/bin/php\",\n\t\t\t\t\"phpSniffer.executablesFolder\": \"/root/.config/composer/vendor/bin\",\n\t\t\t\t\"phpcs.executablePath\": \"/root/.config/composer/vendor/bin/phpcs\",\n\t\t\t\t\"phpcs.lintOnType\": false\n\t\t\t},\n\n\t\t\t// Add the IDs of extensions you want installed when the container is created.\n\t\t\t\"extensions\": [\n\t\t\t\t\"xdebug.php-debug\",\n\t\t\t\t\"bmewburn.vscode-intelephense-client\",\n\t\t\t\t\"philfontaine.autolaunch\",\n\t\t\t\t\"eamodio.gitlens\",\n\t\t\t\t\"shevaua.phpcs\"\n\t\t\t]\n\t\t}\n\t},\n\t\"remoteEnv\": {\n\t\t\"PATH\": \"${containerEnv:PATH}:/root/.config/composer/vendor/bin\",\n\t},\n\t\"forwardPorts\": [3100, 9000, 9003],\n\t\"postCreateCommand\": \"/usr/local/bin/post-create-command\"\n}"
  },
  {
    "path": ".devcontainer/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Listen for Xdebug\",\n            \"type\": \"php\",\n            \"request\": \"launch\",\n            \"port\": 9003,\n            \"auto\": true,\n            \"log\": true\n        },\n        {\n            \"name\": \"Launch currently open script\",\n            \"type\": \"php\",\n            \"request\": \"launch\",\n            \"program\": \"${file}\",\n            \"cwd\": \"${fileDirname}\",\n            \"port\": 0,\n            \"runtimeArgs\": [\n                \"-dxdebug.start_with_request=yes\"\n            ],\n            \"env\": {\n                \"XDEBUG_MODE\": \"debug,develop\",\n                \"XDEBUG_CONFIG\": \"client_port=${port}\"\n            }\n        },\n        {\n            \"name\": \"Launch Built-in web server\",\n            \"type\": \"php\",\n            \"request\": \"launch\",\n            \"runtimeArgs\": [\n                \"-dxdebug.mode=debug\",\n                \"-dxdebug.start_with_request=yes\",\n                \"-S\",\n                \"localhost:0\"\n            ],\n            \"program\": \"\",\n            \"cwd\": \"${workspaceRoot}\",\n            \"port\": 9003,\n            \"serverReadyAction\": {\n                \"pattern\": \"Development Server \\\\(http://localhost:([0-9]+)\\\\) started\",\n                \"uriFormat\": \"http://localhost:%s\",\n                \"action\": \"openExternally\"\n            }\n        }\n    ]\n}"
  },
  {
    "path": ".devcontainer/nginx.conf",
    "content": "server {\n    listen 3100 default_server;\n    root /workspaces/rss-bridge;\n    access_log /var/log/nginx/rssbridge.access.log;\n    error_log /var/log/nginx/rssbridge.error.log;\n    index index.php;\n\n    location ~ /(\\.|vendor|tests) {\n        deny all;\n        return 403; # Forbidden\n    }\n\n    location ~ \\.php$ {\n        include snippets/fastcgi-php.conf;\n        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;\n    }\n}\n"
  },
  {
    "path": ".devcontainer/post-create-command.sh",
    "content": "#/bin/sh\n\ncp .devcontainer/nginx.conf /etc/nginx/conf.d/default.conf\ncp .devcontainer/xdebug.ini /etc/php/8.2/fpm/conf.d/xdebug.ini\n\n#  This should download some dev-dependencies, like phpunit and the PHP code sniffers\ncomposer global require \"phpunit/phpunit:^9\"\ncomposer global require \"squizlabs/php_codesniffer:^3.6\"\ncomposer global require \"phpcompatibility/php-compatibility:^9.3\"\n\n#  We need to this manually for running the PHPCompatibility ruleset\nphpcs --config-set installed_paths /root/.config/composer/vendor/phpcompatibility/php-compatibility\n\nmkdir -p .vscode\ncp .devcontainer/launch.json .vscode\n\necho '*' > whitelist.txt \n\nchmod a+x $(pwd)\nrm -rf /var/www/html \nln -s $(pwd) /var/www/html \n\n# Solves possible issue of cache directory not being accessible\nchown www-data:www-data -R $(pwd)/cache \n\nnginx\nphp-fpm8.2 -D"
  },
  {
    "path": ".devcontainer/xdebug.ini",
    "content": "[xdebug]\nxdebug.mode=develop,debug\nxdebug.client_host=localhost\nxdebug.client_port=9003\nxdebug.start_with_request=yes\nxdebug.discover_client_host=false\nxdebug.log='/var/www/html/xdebug.log'"
  },
  {
    "path": ".dockerignore",
    "content": ".git\n!.git/HEAD\n!.git/refs/heads/*\n.gitattributes\n.github/*\n.travis.yml\ncache/*\nCONTRIBUTING.md\nDEBUG\nDockerfile\nphpcompatibility.xml\nphpcs.xml\nphpcs.xml\nscalingo.json\ntests/*\nwhitelist.txt"
  },
  {
    "path": ".git-blame-ignore-revs",
    "content": "# Reformat code base to PSR12\n4f75591060d95208a301bc6bf460d875631b29cc\n# Fix coding style missed by phpbcf\n951092eef374db048b77bac85e75e3547bfac702\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n*.sh text eol=lf\n\n# Custom for Visual Studio\n*.cs     diff=csharp\n*.sln    merge=union\n*.csproj merge=union\n*.vbproj merge=union\n*.fsproj merge=union\n*.dbproj merge=union\n\n# Standard to msysgit\n*.doc   diff=astextplain\n*.DOC   diff=astextplain\n*.docx  diff=astextplain\n*.DOCX  diff=astextplain\n*.dot   diff=astextplain\n*.DOT   diff=astextplain\n*.pdf   diff=astextplain\n*.PDF   diff=astextplain\n*.rtf   diff=astextplain\n*.RTF   diff=astextplain\n\n# Ignore files in git archive (i.e. GitHub release builds)\n\n## Docker\nDockerfile                              export-ignore\n.dockerignore                           export-ignore\n\n## Travis\n.travis.yml                             export-ignore\n\n## GitHub\n.github/                                export-ignore\n\n## Git\n.gitattributes                          export-ignore\n.gitignore                              export-ignore\n\n## Scalingo\nscalingo.json                           export-ignore\n\n## RSS-Bridge\nphpunit.xml                             export-ignore\nphpcs.xml                               export-ignore\nphpcompatibility.xml                    export-ignore\ntests/                                  export-ignore\ncache/.gitkeep                          export-ignore\n\n## Composer\n#\n# Keep the following lines commented out. Heroku does\n# not function if the composer files are ignored during\n# export. For more information see\n# https://github.com/rss-bridge/rss-bridge/issues/1165\n#\n# composer.json                         export-ignore\n# composer.lock                         export-ignore\n\n## Heroku\n#\n# Keep the following line commented out. Heroku does\n# not function if app.json is ignored during export.\n# For more information see\n# https://github.com/rss-bridge/rss-bridge/issues/1165\n#\n# app.json                              export-ignore\n"
  },
  {
    "path": ".github/.gitignore",
    "content": "# Visual Studio Code\n.vscode/*\n\n# Generated files\ncomment*.md\ncomment*.txt\n*.html\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "### Pull request policy\n\nSee 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.\n\n### Coding style\n\nSee 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.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bridge-request.md",
    "content": "---\nname: Bridge request\nabout: Use this template for requesting a new bridge\ntitle: Bridge request for ...\nlabels: Bridge-Request\nassignees: ''\n\n---\n\n# Bridge request\n\n<!--\nThis is a bridge request. Start by adding a descriptive title (i.e. `Bridge request for GitHub`). Use the \"Preview\" button to see a preview of your request. Make sure your request is complete before submitting!\n\nNotice: This comment is only visible to you while you work on your request. Please do not remove any of the lines in the template (you may add your own outside the \"<!--\" and \"- ->\" lines!)\n-->\n\n## General information\n\n<!--\nPlease describe what you expect from the bridge. Whenever possible provide sample links and screenshots (you can just paste them here) to express your expectations and help others understand your request. If possible, mark relevant areas in your screenshot. Use the following questions for reference:\n-->\n\n- _Host URI for the bridge_ (i.e. `https://github.com`):\n\n- Which information would you like to see?\n\n\n\n- How should the information be displayed/formatted?\n\n\n\n- Which of the following parameters do you expect?\n\n  - [X] Title\n  - [X] URI (link to the original article)\n  - [ ] Author\n  - [ ] Timestamp\n  - [X] Content (the content of the article)\n  - [ ] Enclosures (pictures, videos, etc...)\n  - [ ] Categories (categories, tags, etc...)\n\n## Options\n\n<!--Select options from the list below. Add your own option if one is missing:-->\n\n- [ ] Limit number of returned items\n  - _Default limit_: 5\n- [ ] Load full articles\n  - _Cache articles_ (articles are stored in a local cache on first request): yes\n  - _Cache timeout_ : 24 hours\n- [X] Balance requests (RSS-Bridge uses cached versions to reduce bandwith usage)\n  - _Timeout_ (default = 5 minutes): 5 minutes\n\n<!--Be aware that some options might not be available for your specific request due to technical limitations!-->\n\n<!--\n## Additional notes\n\nKeep in mind that opening a request does not guarantee the bridge being implemented! That depends entirely on the interest and time of others to make the bridge for you.\n\nYou can also implement your own bridge (with support of the community if needed). Find more information in the [RSS-Bridge Documentation](https://rss-bridge.github.io/rss-bridge/For_Developers/index.html) developer section.\n-->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: Bug-Report\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: Feature-Request\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/prtester-requirements.txt",
    "content": "beautifulsoup4>=4.10.0\nrequests>=2.26.0"
  },
  {
    "path": ".github/prtester.py",
    "content": "﻿import argparse\nimport requests\nimport re\nfrom bs4 import BeautifulSoup\nfrom datetime import datetime\nfrom typing import Iterable\nimport os\nimport glob\nimport urllib\n\n# This script is specifically written to be used in automation for https://github.com/RSS-Bridge/rss-bridge\n#\n# This will scrape the whitelisted bridges in the current state (port 3000) and the PR state (port 3001) of\n# RSS-Bridge, generate a feed for each of the bridges and save the output as html files.\n# It also add a <base> tag with the url of em's public instance, so viewing\n# the HTML file locally will actually work as designed.\n\nARTIFACT_FILE_EXTENSION = '.html'\n\nclass Instance:\n    name = ''\n    url = ''\n\ndef main(instances: Iterable[Instance], with_artifacts: bool, with_reduced_artifacts: bool, artifacts_directory: str, artifacts_base_url: str, title: str, output_file: str):\n    start_date = datetime.now()\n\n    for file in glob.glob(f'*{ARTIFACT_FILE_EXTENSION}', root_dir=artifacts_directory):\n        os.remove(file)\n\n    table_rows = []\n    for instance in instances:\n        page = requests.get(instance.url) # Use python requests to grab the rss-bridge main page\n        soup = BeautifulSoup(page.content, \"html.parser\") # use bs4 to turn the page into soup\n        bridge_cards = soup.select('.bridge-card') # get a soup-formatted list of all bridges on the rss-bridge page\n        table_rows += testBridges(\n            instance=instance,\n            bridge_cards=bridge_cards,\n            with_artifacts=with_artifacts,\n            with_reduced_artifacts=with_reduced_artifacts,\n            artifacts_directory=artifacts_directory,\n            artifacts_base_url=artifacts_base_url) # run the main scraping code with the list of bridges\n    with open(file=output_file, mode='w+', encoding='utf-8') as file:\n        table_rows_value = '\\n'.join(sorted(table_rows))\n        file.write(f'''\n## {title}\n| Bridge | Context | Status |\n| - | - | - |\n{table_rows_value}\n\n*last change: {start_date.strftime(\"%A %Y-%m-%d %H:%M:%S\")}*\n        '''.strip())\n\ndef testBridges(instance: Instance, bridge_cards: Iterable, with_artifacts: bool, with_reduced_artifacts: bool, artifacts_directory: str, artifacts_base_url: str) -> Iterable:\n    instance_suffix = ''\n    if instance.name:\n        instance_suffix = f' ({instance.name})'\n    table_rows = []\n    for bridge_card in bridge_cards:\n        bridgeid = bridge_card.get('id')\n        bridgeid = bridgeid.split('-')[1] # this extracts a readable bridge name from the bridge metadata\n        print(f'{bridgeid}{instance_suffix}')\n        bridge_name = bridgeid.replace('Bridge', '')\n        context_forms = bridge_card.find_all(\"form\")\n        form_number = 1\n        for context_form in context_forms:\n            # a bridge can have multiple contexts, named 'forms' in html\n            # this code will produce a fully working url that should create a working feed when called\n            # this will create an example feed for every single context, to test them all\n            context_parameters = {}\n            error_messages = []\n            context_name = '*untitled*'\n            context_name_element = context_form.find_previous_sibling('h5')\n            if context_name_element and context_name_element.text.strip() != '':\n                context_name = context_name_element.text\n            parameters = context_form.find_all(\"input\")\n            lists = context_form.find_all(\"select\")\n            # this for/if mess cycles through all available input parameters, checks if it required, then pulls\n            # the default or examplevalue and then combines it all together into the url parameters\n            # if an example or default value is missing for a required attribute, it will throw an error\n            # any non-required fields are not tested!!!\n            for parameter in parameters:\n                parameter_type = parameter.get('type')\n                parameter_name = parameter.get('name')\n                if parameter_type == 'hidden':\n                    context_parameters[parameter_name] = parameter.get('value')\n                if parameter_type == 'number' or parameter_type == 'text':\n                    if parameter.has_attr('required'):\n                        if parameter.get('placeholder') == '':\n                            if parameter.get('value') == '':\n                                error_messages.append(f'Missing example or default value for parameter \"{parameter_name}\"')\n                            else:\n                                context_parameters[parameter_name] = parameter.get('value')\n                        else:\n                            context_parameters[parameter_name] = parameter.get('placeholder')\n                # same thing, just for checkboxes. If a checkbox is checked per default, it gets added to the url parameters\n                if parameter_type == 'checkbox':\n                    if parameter.has_attr('checked'):\n                        context_parameters[parameter_name] = 'on'\n            for listing in lists:\n                selectionvalue = ''\n                listname = listing.get('name')\n                cleanlist = []\n                options = listing.find_all('option')\n                for option in options:\n                    if 'optgroup' in option.name:\n                        cleanlist.extend(option)\n                    else:\n                        cleanlist.append(option)\n                firstselectionentry = 1\n                for selectionentry in cleanlist:\n                    if firstselectionentry:\n                        selectionvalue = selectionentry.get('value')\n                        firstselectionentry = 0\n                    else:\n                        if 'selected' in selectionentry.attrs:\n                            selectionvalue = selectionentry.get('value')\n                            break\n                context_parameters[listname] = selectionvalue\n            artifact_url = 'about:blank'\n            if error_messages:\n                status = '<br>'.join(map(lambda m: f'❌ `{m}`', error_messages))\n            else:\n                # if all example/default values are present, form the full request url, run the request, add a <base> tag with\n                # the url of em's public instance to the response text (so that relative paths work, e.g. to the static css file) and\n                # then save it to a html file.\n                context_parameters.update({\n                    'action': 'display',\n                    'bridge': bridgeid,\n                    'format': 'Html',\n                })\n                request_url = f'{instance.url}/?{urllib.parse.urlencode(context_parameters)}'\n                response = requests.get(request_url)\n                page_text = response.text.replace('<head>','<head><base href=\"https://rss-bridge.org/bridge01/\" target=\"_blank\">')\n                page_text = page_text.encode(\"utf_8\")\n                soup = BeautifulSoup(page_text, \"html.parser\")\n                status_messages = []\n                if response.status_code != 200:\n                    status_messages += [f'❌ `HTTP status {response.status_code} {response.reason}`']\n                else:\n                    feed_items = soup.select('.feeditem')\n                    feed_items_length = len(feed_items)\n                    if feed_items_length <= 0:\n                        status_messages += [f'⚠️ `The feed has no items`']\n                    elif feed_items_length == 1 and len(soup.select('.error')) > 0:\n                        status_messages += [f'❌ `{getFirstLine(feed_items[0].text)}`']\n                status_messages += map(lambda e: f'❌ `{getFirstLine(e.text)}`', soup.select('.error .error-type') + soup.select('.error .error-message'))\n                for item_element in soup.select('.feeditem'): # remove all feed items to not accidentally selected <pre> tags from item content\n                    item_element.decompose()\n                status_messages += map(lambda e: f'⚠️ `{getFirstLine(e.text)}`', soup.find_all('pre'))\n                status_messages = list(dict.fromkeys(status_messages)) # remove duplicates\n                status = '<br>'.join(status_messages)\n                status_is_ok = status == '';\n                if status_is_ok:\n                    status = '✔️'\n                if with_artifacts and (not with_reduced_artifacts or not status_is_ok):\n                    filename = f'{bridge_name} {form_number}{instance_suffix}{ARTIFACT_FILE_EXTENSION}'\n                    filename = re.sub(r'[^a-z0-9 \\_\\-\\.]', '', filename, flags=re.I).replace(' ', '_')\n                    with open(file=f'{artifacts_directory}/{filename}', mode='wb') as file:\n                        file.write(page_text)\n                    artifact_url = f'{artifacts_base_url}/{filename}'\n            table_rows.append(f'| {bridge_name} | [{form_number} {context_name}{instance_suffix}]({artifact_url}) | {status} |')\n            form_number += 1\n    return table_rows\n\ndef getFirstLine(value: str) -> str:\n     # trim whitespace and remove text that can break the table or is simply unnecessary\n    clean_value = re.sub(r'^\\[[^\\]]+\\]\\s*rssbridge\\.|[\\|`]', '', value.strip())\n    first_line = next(iter(clean_value.splitlines()), '')\n    max_length = 250\n    if (len(first_line) > max_length):\n        first_line = first_line[:max_length] + '...'\n    return first_line\n\nif __name__ == '__main__':\n    parser = argparse.ArgumentParser()\n    parser.add_argument('--instances', nargs='+')\n    parser.add_argument('--no-artifacts', action='store_true')\n    parser.add_argument('--reduced-artifacts', action='store_true')\n    parser.add_argument('--artifacts-directory', default=os.getcwd())\n    parser.add_argument('--artifacts-base-url', default='')\n    parser.add_argument('--title', default='Pull request artifacts')\n    parser.add_argument('--output-file', default=os.getcwd() + '/comment.md')\n    args = parser.parse_args()\n    instances = []\n    if args.instances:\n        for instance_arg in args.instances:\n            instance_arg_parts = instance_arg.split('::')\n            instance = Instance()\n            instance.name = instance_arg_parts[1].strip() if len(instance_arg_parts) >= 2 else ''\n            instance.url = instance_arg_parts[0].strip().rstrip(\"/\")\n            instances.append(instance)\n    else:\n        instance = Instance()\n        instance.name = 'current'\n        instance.url = 'http://localhost:3000'\n        instances.append(instance)\n        instance = Instance()\n        instance.name = 'pr'\n        instance.url = 'http://localhost:3001'\n        instances.append(instance)\n    main(\n        instances=instances,\n        with_artifacts=not args.no_artifacts,\n        with_reduced_artifacts=args.reduced_artifacts and not args.no_artifacts,\n        artifacts_directory=args.artifacts_directory,\n        artifacts_base_url=args.artifacts_base_url,\n        title=args.title,\n        output_file=args.output_file\n    );\n"
  },
  {
    "path": ".github/workflows/dockerbuild.yml",
    "content": "name: Build Container Image\n\non:\n  push:\n    branches:\n      - 'master'\n    tags:\n      - '20*'\n  pull_request:\n    branches:\n      - 'master'\n    paths:\n      - .github/workflows/**\n      - Dockerfile\n\npermissions:\n  contents: read\n  packages: write\n\nenv:\n  DOCKERHUB_SLUG: rssbridge/rss-bridge\n  GHCR_SLUG: ghcr.io/rss-bridge/rss-bridge\n\njobs:\n  bake:\n    runs-on: ubuntu-24.04-arm\n    steps:\n      - name: Docker meta\n        id: docker_meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ env.DOCKERHUB_SLUG }}\n            ${{ env.GHCR_SLUG }}\n          tags: |\n            type=raw,value=latest,enable=${{ github.event_name != 'pull_request' }}\n            type=sha\n            type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/20') }}\n            type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/20') }}\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        if: ${{ github.event_name != 'pull_request' }}\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        if: ${{ github.event_name != 'pull_request' }}\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n      - name: Build and push\n        uses: docker/bake-action@v6\n        with:\n          files: |\n            ./docker-bake.hcl\n            cwd://${{ steps.docker_meta.outputs.bake-file }}\n          targets: image-all\n          push: ${{ github.event_name != 'pull_request' }}\n"
  },
  {
    "path": ".github/workflows/documentation.yml",
    "content": "name: Documentation\n\non:\n  push:\n    paths:\n    - 'docs/**'\n  pull_request:\n    branches:\n      - 'master'\n    paths:\n      - .github/workflows/**\n      - 'docs/**'\n\npermissions:\n  contents: write\n\njobs:\n  documentation:\n    runs-on: ubuntu-24.04-arm\n    steps:\n    - uses: actions/checkout@v5\n      with:\n        persist-credentials: false\n    - name: Setup PHP\n      uses: shivammathur/setup-php@v2\n      with:\n        php-version: 8.0\n    - name: Install dependencies\n      run: composer global require daux/daux.io\n    - name: Generate documentation\n      run: daux generate\n    - name: Deploy same repository 🚀\n      if: ${{ github.event_name != 'pull_request' }}\n      uses: JamesIves/github-pages-deploy-action@v4\n      with:\n        folder: \"static\"\n        branch: gh-pages\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Lint\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\npermissions:\n  contents: read\n\njobs:\n  phpcs:\n    runs-on: ubuntu-24.04-arm\n    strategy:\n      matrix:\n        php-versions: ['7.4']\n    steps:\n      - uses: actions/checkout@v5\n      - uses: shivammathur/setup-php@v2\n        with:\n          php-version: ${{ matrix.php-versions }}\n          tools: phpcs\n      - run: phpcs . --standard=phpcs.xml --warning-severity=0 --extensions=php -p\n\n  phpcompatibility:\n    runs-on: ubuntu-24.04-arm\n    strategy:\n      matrix:\n        php-versions: ['7.4']\n    steps:\n      - uses: actions/checkout@v5\n      - uses: shivammathur/setup-php@v2\n        with:\n          php-version: ${{ matrix.php-versions }}\n      - run: composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true\n      - run: composer global require dealerdirect/phpcodesniffer-composer-installer phpcompatibility/php-compatibility\n      - run: ~/.composer/vendor/bin/phpcs . --standard=phpcompatibility.xml --warning-severity=0 --extensions=php -p\n\n  executable_php_files_check:\n    runs-on: ubuntu-24.04-arm\n    steps:\n      - uses: actions/checkout@v5\n      - run: |\n          if find -name \"*.php\" -executable -type f -print -exec false {} +\n          then\n            echo 'Good, no executable php scripts found'\n          else\n            echo 'Please unmark php scripts above as non-executable'\n            exit 1\n          fi\n"
  },
  {
    "path": ".github/workflows/prhtmlgenerator.yml",
    "content": "name: 'PR Testing'\n\non:\n  pull_request_target:\n    branches: [ master ]\n\npermissions:\n  contents: read\n\njobs:\n  checks:\n    name: Check if bridges were changed\n    runs-on: ubuntu-24.04-arm\n    outputs:\n      BRIDGES: ${{ steps.check_bridges.outputs.BRIDGES }}\n      WITH_UPLOAD: ${{ steps.check_upload.outputs.WITH_UPLOAD }}\n    steps:\n      - name: Check number of bridges\n        id: check_bridges\n        run: |\n          PR=${{ github.event.number || 'none' }};\n          wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch;\n          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);\n          echo \"BRIDGES=$bridgeamount\" >> \"$GITHUB_OUTPUT\"\n      - name: \"Check upload token secret RSSTESTER_SSH_KEY is set\"\n        id: check_upload\n        run: |\n          echo \"WITH_UPLOAD=$([ -n \"${{ secrets.RSSTESTER_SSH_KEY }}\" ] && echo \"true\" || echo \"false\")\" >> \"$GITHUB_OUTPUT\"\n\n  test-pr:\n    name: Generate HTML files\n    runs-on: ubuntu-24.04-arm\n    needs: checks\n    if: needs.checks.outputs.BRIDGES > 0\n    outputs:\n      COMMENT_LENGTH: ${{ steps.run-tests.outputs.bodylength }}\n    steps:\n      - name: Checkout - PR\n        uses: actions/checkout@v5\n        with:\n          ref: ${{github.event.pull_request.head.ref}}\n          repository: ${{github.event.pull_request.head.repo.full_name}}\n      - name: Build docker image - PR\n        run: docker build -t rss-bridge-pr .\n      - name: Clear workspace\n        run: rm -r * .*\n      - name: Checkout - Current\n        uses: actions/checkout@v5\n      - name: Build docker image - Current\n        run: docker build -t rss-bridge-current .\n      - name: Create configuration files\n        run: |\n          touch DEBUG;\n          PR=${{ github.event.number || 'none' }};\n          wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch;\n          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;\n          echo \"whitelist.txt:\";\n          cat whitelist.txt\n      - name: Run docker container - Current\n        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\n      - name: Run docker container - PR\n        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\n      - name: Setup python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.13'\n          cache: 'pip'\n          cache-dependency-path: '**/*requirements*.txt'\n      - name: Install requirements\n        run: pip install -r ./.github/prtester-requirements.txt\n      - name: Run bridge tests\n        id: run-tests\n        env:\n          PYTHONUNBUFFERED: 1\n        run: |\n          python ./.github/prtester.py --artifacts-base-url \"https://${{ github.repository_owner }}.github.io/${{ vars.ARTIFACTS_REPO || 'rss-bridge-tests' }}/prs/${{ github.event.number || 'none' }}\";\n          body=\"$(cat comment.md)\";\n          body=\"${body//'%'/'%25'}\";\n          body=\"${body//$'\\n'/'%0A'}\";\n          body=\"${body//$'\\r'/'%0D'}\";\n          echo \"bodylength=${#body}\" >> $GITHUB_OUTPUT\n      - name: Upload test results\n        if: steps.run-tests.outputs.bodylength > 130\n        uses: actions/upload-artifact@v5\n        with:\n          name: test-results\n          path: |\n            comment.md\n            *.html\n          if-no-files-found: error\n\n  comment-pr:\n    name: Create or update PR comment\n    runs-on: ubuntu-24.04-arm\n    needs: test-pr\n    if: needs.test-pr.outputs.COMMENT_LENGTH > 130\n    permissions:\n      pull-requests: write\n    steps:\n      - name: Download test results\n        uses: actions/download-artifact@v5\n        with:\n          name: test-results\n      - name: Add comment to job summary\n        run: cat comment.md >> $GITHUB_STEP_SUMMARY\n      - name: Find Comment\n        uses: peter-evans/find-comment@v4\n        id: find-comment\n        with:\n          issue-number: ${{ github.event.pull_request.number }}\n          comment-author: 'github-actions[bot]'\n          body-includes: Pull request artifacts\n      - name: Create or update comment\n        uses: peter-evans/create-or-update-comment@v5\n        with:\n          comment-id: ${{ steps.find-comment.outputs.comment-id }}\n          issue-number: ${{ github.event.pull_request.number }}\n          body-file: comment.md\n          edit-mode: replace\n\n  upload-test-results:\n    name: Upload to GitHub Pages repository\n    runs-on: ubuntu-24.04-arm\n    needs: test-pr\n    if: needs.test-pr.outputs.COMMENT_LENGTH > 130 && needs.checks.outputs.WITH_UPLOAD == 'true'\n    steps:\n      - uses: actions/checkout@v5\n        with:\n          repository: \"${{ github.repository_owner }}/${{ vars.ARTIFACTS_REPO || 'rss-bridge-tests' }}\"\n          ref: 'main'\n          ssh-key:  ${{ secrets.RSSTESTER_SSH_KEY }}\n      - name: Setup git config\n        run: |\n          git config --global user.name \"GitHub Actions\"\n          git config --global user.email \"<>\"\n      - name: Download test results\n        uses: actions/download-artifact@v5\n        with:\n          name: test-results\n      - name: Move test results\n        run: |\n          DIRECTORY=\"$GITHUB_WORKSPACE/prs/${{ github.event.number || 'none' }}\"\n          rm -rf $DIRECTORY\n          mkdir -p $DIRECTORY\n          cd $DIRECTORY\n          mv -f $GITHUB_WORKSPACE/comment.md ./README.md\n          mv -f $GITHUB_WORKSPACE/*.html .\n      - name: Commit and push test results\n        run: |\n          export COMMIT_MESSAGE=\"Test results for PR ${{ github.event.number || 'none' }}\"\n          git add .\n          git commit -m \"$COMMIT_MESSAGE\" || exit 0\n          git push\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\npermissions:\n  contents: read\n\njobs:\n  phpunit8:\n    runs-on: ubuntu-24.04\n    strategy:\n      matrix:\n        php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5']\n    steps:\n      - uses: actions/checkout@v5\n      - uses: shivammathur/setup-php@v2\n        with:\n          php-version: ${{ matrix.php-versions }}\n        env:\n          update: true\n      - run: composer install\n      - run: composer test\n"
  },
  {
    "path": ".gitignore",
    "content": "#################\n## Eclipse\n#################\nvendor/*\ndata/\n*.pydevproject\n.project\n.metadata\ntmp/\n*.tmp\n*.bak\n*.swp\n*~.nib\nlocal.properties\n.classpath\n.settings/\n.loadpath\n\n# External tool builders\n.externalToolBuilders/\n\n# Locally stored \"Eclipse launch configurations\"\n*.launch\n\n# CDT-specific\n.cproject\n\n# PDT-specific\n.buildpath\n\n\n#################\n## Visual Studio\n#################\n\n## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n\n# User-specific files\n*.suo\n*.user\n*.sln.docstates\n\n# Build results\n\n[Dd]ebug/\n[Rr]elease/\nx64/\nbuild/\n[Bb]in/\n[Oo]bj/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n*_i.c\n*_p.c\n*.ilk\n*.meta\n*.obj\n*.pch\n*.pdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.log\n*.scc\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opensdf\n*.sdf\n*.cachefile\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# NCrunch\n*.ncrunch*\n.*crunch*.local.xml\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.Publish.xml\n*.pubxml\n\n# NuGet Packages Directory\n## TODO: If you have NuGet Package Restore enabled, uncomment the next line\n#packages/\n\n# Windows Azure Build Output\ncsx\n*.build.csdef\n\n# Windows Store app package directory\nAppPackages/\n\n# Others\nsql/\n*.Cache\nClientBin/\n[Ss]tyle[Cc]op.*\n~$*\n*~\n*.dbmdl\n*.[Pp]ublish.xml\n*.pfx\n*.publishsettings\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file to a newer\n# Visual Studio version. Backup files are not needed, because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\n\n# SQL Server files\nApp_Data/*.mdf\nApp_Data/*.ldf\n\n#################\n## Other ide stuff\n#################\n.idea/*\n[#]*[#]\n\n#############\n## Windows detritus\n#############\n\n# Windows image file caches\nThumbs.db\nehthumbs.db\n\n# Folder config file\nDesktop.ini\n\n# Recycle Bin used on file shares\n$RECYCLE.BIN/\n\n# Mac crap\n.DS_Store\n\n\n#############\n## Python\n#############\n\n*.py[co]\n\n# Packages\n*.egg\n*.egg-info\ndist/\nbuild/\neggs/\nparts/\nvar/\nsdist/\ndevelop-eggs/\n.installed.cfg\n\n# Installer logs\npip-log.txt\n\n# Unit test / coverage reports\n.coverage\n.phpunit.result.cache\n.tox\n\n#Translations\n*.mo\n\n#Mr Developer\n.mr.developer.cfg\n\n##############\n## RSS-Bridge\n##############\n/cache\n/whitelist.txt\nDEBUG\nconfig.ini.php\nconfig/*\n!config/nginx.conf\n!config/php-fpm.conf\n!config/php.ini\nfavicon.gif\nfavicon.ico\n\n######################\n## VisualStudioCode ##\n######################\n.vscode/*\n\n#Builder\n.buildconfig\n\n#Auth\n.htaccess\n.htpasswd\n\n#Crawler\nrobots.txt\n"
  },
  {
    "path": "CONTRIBUTORS.md",
    "content": "# Contributors\n\n* [16mhz](https://github.com/16mhz)\n* [adamchainz](https://github.com/adamchainz)\n* [Ahiles3005](https://github.com/Ahiles3005)\n* [akirk](https://github.com/akirk)\n* [Albirew](https://github.com/Albirew)\n* [aledeg](https://github.com/aledeg)\n* [alex73](https://github.com/alex73)\n* [alexAubin](https://github.com/alexAubin)\n* [Alkarex](https://github.com/Alkarex)\n* [AmauryCarrade](https://github.com/AmauryCarrade)\n* [arnd-s](https://github.com/arnd-s)\n* [ArthurHoaro](https://github.com/ArthurHoaro)\n* [Astalaseven](https://github.com/Astalaseven)\n* [Astyan-42](https://github.com/Astyan-42)\n* [austinhuang0131](https://github.com/austinhuang0131)\n* [axor-mst](https://github.com/axor-mst)\n* [ayacoo](https://github.com/ayacoo)\n* [az5he6ch](https://github.com/az5he6ch)\n* [b1nj](https://github.com/b1nj)\n* [benasse](https://github.com/benasse)\n* [Binnette](https://github.com/Binnette)\n* [BoboTiG](https://github.com/BoboTiG)\n* [Bockiii](https://github.com/Bockiii)\n* [brtsos](https://github.com/brtsos)\n* [captn3m0](https://github.com/captn3m0)\n* [chemel](https://github.com/chemel)\n* [Chouchen](https://github.com/Chouchen)\n* [ckiw](https://github.com/ckiw)\n* [cn-tools](https://github.com/cn-tools)\n* [cnlpete](https://github.com/cnlpete)\n* [corenting](https://github.com/corenting)\n* [couraudt](https://github.com/couraudt)\n* [csisoap](https://github.com/csisoap)\n* [da2x](https://github.com/da2x)\n* [dabenzel](https://github.com/dabenzel)\n* [Daiyousei](https://github.com/Daiyousei)\n* [dawidsowa](https://github.com/dawidsowa)\n* [DevonHess](https://github.com/DevonHess)\n* [dhuschde](https://github.com/dhuschde)\n* [disk0x](https://github.com/disk0x)\n* [DJCrashdummy](https://github.com/DJCrashdummy)\n* [Djuuu](https://github.com/Djuuu)\n* [DnAp](https://github.com/DnAp)\n* [dominik-th](https://github.com/dominik-th)\n* [Draeli](https://github.com/Draeli)\n* [Dreckiger-Dan](https://github.com/Dreckiger-Dan)\n* [drego85](https://github.com/drego85)\n* [drklee3](https://github.com/drklee3)\n* [DRogueRonin](https://github.com/DRogueRonin)\n* [dvikan](https://github.com/dvikan)\n* [eggwhalefrog](https://github.com/eggwhalefrog)\n* [em92](https://github.com/em92)\n* [eMerzh](https://github.com/eMerzh)\n* [EtienneM](https://github.com/EtienneM)\n* [f0086](https://github.com/f0086)\n* [fanch317](https://github.com/fanch317)\n* [fatuuse](https://github.com/fatuuse)\n* [fivefilters](https://github.com/fivefilters)\n* [floviolleau](https://github.com/floviolleau)\n* [fluffy-critter](https://github.com/fluffy-critter)\n* [fmachen](https://github.com/fmachen)\n* [Frenzie](https://github.com/Frenzie)\n* [fulmeek](https://github.com/fulmeek)\n* [ggiessen](https://github.com/ggiessen)\n* [gileri](https://github.com/gileri)\n* [Ginko-Aloe](https://github.com/Ginko-Aloe)\n* [girlpunk](https://github.com/girlpunk)\n* [Glandos](https://github.com/Glandos)\n* [gloony](https://github.com/gloony)\n* [GregThib](https://github.com/GregThib)\n* [griffaurel](https://github.com/griffaurel)\n* [Grummfy](https://github.com/Grummfy)\n* [gsantner](https://github.com/gsantner)\n* [guigot](https://github.com/guigot)\n* [hollowleviathan](https://github.com/hollowleviathan)\n* [hpacleb](https://github.com/hpacleb)\n* [hunhejj](https://github.com/hunhejj)\n* [husim0](https://github.com/husim0)\n* [IceWreck](https://github.com/IceWreck)\n* [imagoiq](https://github.com/imagoiq)\n* [j0k3r](https://github.com/j0k3r)\n* [JackNUMBER](https://github.com/JackNUMBER)\n* [jacquesh](https://github.com/jacquesh)\n* [jakubvalenta](https://github.com/jakubvalenta)\n* [JasonGhent](https://github.com/JasonGhent)\n* [jcgoette](https://github.com/jcgoette)\n* [jdesgats](https://github.com/jdesgats)\n* [jdigilio](https://github.com/jdigilio)\n* [JeremyRand](https://github.com/JeremyRand)\n* [JimDog546](https://github.com/JimDog546)\n* [jNullj](https://github.com/jNullj)\n* [Jocker666z](https://github.com/Jocker666z)\n* [johnnygroovy](https://github.com/johnnygroovy)\n* [johnpc](https://github.com/johnpc)\n* [joni1993](https://github.com/joni1993)\n* [jtojnar](https://github.com/jtojnar)\n* [KamaleiZestri](https://github.com/KamaleiZestri)\n* [kkoyung](https://github.com/kkoyung)\n* [klimplant](https://github.com/klimplant)\n* [KN4CK3R](https://github.com/KN4CK3R)\n* [kolarcz](https://github.com/kolarcz)\n* [kranack](https://github.com/kranack)\n* [kraoc](https://github.com/kraoc)\n* [krisu5](https://github.com/krisu5)\n* [l1n](https://github.com/l1n)\n* [laBecasse](https://github.com/laBecasse)\n* [lagaisse](https://github.com/lagaisse)\n* [lalannev](https://github.com/lalannev)\n* [langfingaz](https://github.com/langfingaz)\n* [lassana](https://github.com/lassana)\n* [ldidry](https://github.com/ldidry)\n* [Leomaradan](https://github.com/Leomaradan)\n* [leyrer](https://github.com/leyrer)\n* [liamka](https://github.com/liamka)\n* [Limero](https://github.com/Limero)\n* [LogMANOriginal](https://github.com/LogMANOriginal)\n* [lorenzos](https://github.com/lorenzos)\n* [lukasklinger](https://github.com/lukasklinger)\n* [m0zes](https://github.com/m0zes)\n* [Mar-Koeh](https://github.com/Mar-Koeh)\n* [marcus-at-localhost](https://github.com/marcus-at-localhost)\n* [marius8510000-bot](https://github.com/marius8510000-bot)\n* [matthewseal](https://github.com/matthewseal)\n* [mcbyte-it](https://github.com/mcbyte-it)\n* [mdemoss](https://github.com/mdemoss)\n* [melangue](https://github.com/melangue)\n* [metaMMA](https://github.com/metaMMA)\n* [mibe](https://github.com/mibe)\n* [mickaelBert](https://github.com/mickaelBert)\n* [mightymt](https://github.com/mightymt)\n* [mitsukarenai](https://github.com/mitsukarenai)\n* [Monocularity](https://github.com/Monocularity)\n* [MonsieurPoutounours](https://github.com/MonsieurPoutounours)\n* [mr-flibble](https://github.com/mr-flibble)\n* [mro](https://github.com/mro)\n* [mschwld](https://github.com/mschwld)\n* [muekoeff](https://github.com/muekoeff)\n* [mw80](https://github.com/mw80)\n* [mxmehl](https://github.com/mxmehl)\n* [Mynacol](https://github.com/Mynacol)\n* [nel50n](https://github.com/nel50n)\n* [niawag](https://github.com/niawag)\n* [Niehztog](https://github.com/Niehztog)\n* [NikNikYkt](https://github.com/NikNikYkt)\n* [Nono-m0le](https://github.com/Nono-m0le)\n* [NotsoanoNimus](https://github.com/NotsoanoNimus)\n* [obsiwitch](https://github.com/obsiwitch)\n* [Ololbu](https://github.com/Ololbu)\n* [ORelio](https://github.com/ORelio)\n* [otakuf](https://github.com/otakuf)\n* [Park0](https://github.com/Park0)\n* [Paroleen](https://github.com/Paroleen)\n* [Patricol](https://github.com/Patricol)\n* [paulchen](https://github.com/paulchen)\n* [PaulVayssiere](https://github.com/PaulVayssiere)\n* [pellaeon](https://github.com/pellaeon)\n* [PeterDaveHello](https://github.com/PeterDaveHello)\n* [Peterr-K](https://github.com/Peterr-K)\n* [Piranhaplant](https://github.com/Piranhaplant)\n* [pirnz](https://github.com/pirnz)\n* [pit-fgfjiudghdf](https://github.com/pit-fgfjiudghdf)\n* [pitchoule](https://github.com/pitchoule)\n* [pmaziere](https://github.com/pmaziere)\n* [Pofilo](https://github.com/Pofilo)\n* [prysme01](https://github.com/prysme01)\n* [pubak42](https://github.com/pubak42)\n* [Qluxzz](https://github.com/Qluxzz)\n* [quentinus95](https://github.com/quentinus95)\n* [quickwick](https://github.com/quickwick)\n* [rakoo](https://github.com/rakoo)\n* [RawkBob](https://github.com/RawkBob)\n* [regisenguehard](https://github.com/regisenguehard)\n* [Riduidel](https://github.com/Riduidel)\n* [rogerdc](https://github.com/rogerdc)\n* [Roliga](https://github.com/Roliga)\n* [ronansalmon](https://github.com/ronansalmon)\n* [rremizov](https://github.com/rremizov)\n* [s0lesurviv0r](https://github.com/s0lesurviv0r)\n* [sal0max](https://github.com/sal0max)\n* [sebsauvage](https://github.com/sebsauvage)\n* [shutosg](https://github.com/shutosg)\n* [simon816](https://github.com/simon816)\n* [Simounet](https://github.com/Simounet)\n* [somini](https://github.com/somini)\n* [SpangleLabs](https://github.com/SpangleLabs)\n* [SqrtMinusOne](https://github.com/SqrtMinusOne)\n* [squeek502](https://github.com/squeek502)\n* [StelFux](https://github.com/StelFux)\n* [stjohnjohnson](https://github.com/stjohnjohnson)\n* [Stopka](https://github.com/Stopka)\n* [Strubbl](https://github.com/Strubbl)\n* [sublimz](https://github.com/sublimz)\n* [sunchaserinfo](https://github.com/sunchaserinfo)\n* [SuperSandro2000](https://github.com/SuperSandro2000)\n* [sysadminstory](https://github.com/sysadminstory)\n* [t0stiman](https://github.com/t0stiman)\n* [tameroski](https://github.com/tameroski)\n* [teromene](https://github.com/teromene)\n* [tgkenney](https://github.com/tgkenney)\n* [thefranke](https://github.com/thefranke)\n* [TheRadialActive](https://github.com/TheRadialActive)\n* [theScrabi](https://github.com/theScrabi)\n* [thezeroalpha](https://github.com/thezeroalpha)\n* [thibaultcouraud](https://github.com/thibaultcouraud)\n* [timendum](https://github.com/timendum)\n* [TitiTestScalingo](https://github.com/TitiTestScalingo)\n* [tomaszkane](https://github.com/tomaszkane)\n* [tomershvueli](https://github.com/tomershvueli)\n* [TotalCaesar659](https://github.com/TotalCaesar659)\n* [tpikonen](https://github.com/tpikonen)\n* [TReKiE](https://github.com/TReKiE)\n* [triatic](https://github.com/triatic)\n* [User123698745](https://github.com/User123698745)\n* [VerifiedJoseph](https://github.com/VerifiedJoseph)\n* [vitkabele](https://github.com/vitkabele)\n* [WalterBarrett](https://github.com/WalterBarrett)\n* [wtuuju](https://github.com/wtuuju)\n* [xurxof](https://github.com/xurxof)\n* [yamanq](https://github.com/yamanq)\n* [yardenac](https://github.com/yardenac)\n* [ymeister](https://github.com/ymeister)\n* [yue-dongchen](https://github.com/yue-dongchen)\n* [ZeNairolf](https://github.com/ZeNairolf)\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM debian:12-slim AS rssbridge\n\nLABEL description=\"RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one.\"\nLABEL repository=\"https://github.com/RSS-Bridge/rss-bridge\"\nLABEL website=\"https://github.com/RSS-Bridge/rss-bridge\"\n\nARG DEBIAN_FRONTEND=noninteractive\nRUN set -xe && \\\n    apt-get update && \\\n    apt-get install --yes --no-install-recommends \\\n      ca-certificates \\\n      nginx \\\n      nss-plugin-pem \\\n      php-curl \\\n      php-fpm \\\n      php-intl \\\n      # php-json is enabled by default with PHP 8.2 in Debian 12\n      php-mbstring \\\n      php-memcached \\\n      # php-opcache is enabled by default with PHP 8.2 in Debian 12\n      # php-openssl is enabled by default with PHP 8.2 in Debian 12\n      php-sqlite3 \\\n      php-xml \\\n      php-zip \\\n      # php-zlib is enabled by default with PHP 8.2 in Debian 12\n      # for downloading libcurl-impersonate\n      curl \\\n      # for patching libcurl-impersonate\n      patchelf \\\n      && \\\n    # install curl-impersonate library\n    curlimpersonate_version=1.2.5 && \\\n    { \\\n        { \\\n            [ $(arch) = 'aarch64' ] && \\\n            archive=\"libcurl-impersonate-v${curlimpersonate_version}.aarch64-linux-gnu.tar.gz\" && \\\n            sha512sum=\"cd340819d27c03e6833e746c1de181aa828f5986f4152fe0d55d5ea1b0a7c5328db129f9146d6369d2c2e20facd7c0a67e32cc916dddc74d1557106f89636dd0\" \\\n        ; } \\\n        || { \\\n            [ $(arch) = 'armv7l' ] && \\\n            archive=\"libcurl-impersonate-v${curlimpersonate_version}.arm-linux-gnueabihf.tar.gz\" && \\\n            sha512sum=\"143e57779c4872557e8becfd91bf9c92d9085b1c964d103a39b6e85253e3f3257796d205de4b49f6bc25c8ad0a39e5d4ec4f51391037e27d32d6355e52c5d346\" \\\n        ; } \\\n        || { \\\n            [ $(arch) = 'x86_64' ] && \\\n            archive=\"libcurl-impersonate-v${curlimpersonate_version}.x86_64-linux-gnu.tar.gz\" && \\\n            sha512sum=\"816e7d08110f2f5a6e7e2364e7c1d9ec0cc371e9b5024e0239db937379f057bb40ec80e56d0c49cdaf80b7f560888511c1bda5516b850a6d54c46a2eccc94dc6\" \\\n        ; } \\\n    } && \\\n    curl -LO \"https://github.com/lexiforest/curl-impersonate/releases/download/v${curlimpersonate_version}/${archive}\" && \\\n    echo \"$sha512sum  $archive\" | sha512sum -c - && \\\n    mkdir -p /usr/local/lib/curl-impersonate && \\\n    tar xaf \"$archive\" -C /usr/local/lib/curl-impersonate && \\\n    patchelf --set-soname libcurl.so.4 /usr/local/lib/curl-impersonate/libcurl-impersonate.so && \\\n    rm \"$archive\" && \\\n    apt-get purge --assume-yes curl patchelf && \\\n    rm -rf /var/lib/apt/lists/*\n\nENV LD_PRELOAD=/usr/local/lib/curl-impersonate/libcurl-impersonate.so\nENV CURL_IMPERSONATE=chrome142\n\n# logs should go to stdout / stderr\nRUN ln -sfT /dev/stderr /var/log/nginx/error.log; \\\n\tln -sfT /dev/stdout /var/log/nginx/access.log; \\\n\tchown -R --no-dereference www-data:adm /var/log/nginx/\n\nCOPY ./config/nginx.conf /etc/nginx/sites-available/default\nCOPY ./config/php-fpm.conf /etc/php/8.2/fpm/pool.d/rss-bridge.conf\nCOPY ./config/php.ini /etc/php/8.2/fpm/conf.d/90-rss-bridge.ini\n\nCOPY --chown=www-data:www-data ./ /app/\n\nEXPOSE 80\n\nENTRYPOINT [\"/app/docker-entrypoint.sh\"]\n"
  },
  {
    "path": "README.md",
    "content": "# RSS-Bridge\n\n![RSS-Bridge](static/logo_600px.png)\n\nRSS-Bridge is a PHP web application.\n\nIt generates web feeds for websites that don't have one.\n\nOfficially hosted instance: https://rss-bridge.org/bridge01/\n\nIRC channel #rssbridge at https://libera.chat/\n\n[Full documentation](https://rss-bridge.github.io/rss-bridge/index.html)\n\nAlternatively find another\n[public instance](https://rss-bridge.github.io/rss-bridge/General/Public_Hosts.html).\n\nRequires minimum PHP 7.4.\n\n\n[![LICENSE](https://img.shields.io/badge/license-UNLICENSE-blue.svg)](UNLICENSE)\n[![GitHub release](https://img.shields.io/github/release/rss-bridge/rss-bridge.svg?logo=github)](https://github.com/rss-bridge/rss-bridge/releases/latest)\n[![irc.libera.chat](https://img.shields.io/badge/irc.libera.chat-%23rssbridge-blue.svg)](https://web.libera.chat/#rssbridge)\n[![Actions Status](https://img.shields.io/github/actions/workflow/status/RSS-Bridge/rss-bridge/tests.yml?branch=master&label=GitHub%20Actions&logo=github)](https://github.com/RSS-Bridge/rss-bridge/actions)\n\n|||\n|:-:|:-:|\n|![Screenshot #1](/static/screenshot-1.png?raw=true)|![Screenshot #2](/static/screenshot-2.png?raw=true)|\n|![Screenshot #3](/static/screenshot-3.png?raw=true)|![Screenshot #4](/static/screenshot-4.png?raw=true)|\n|![Screenshot #5](/static/screenshot-5.png?raw=true)|![Screenshot #6](/static/screenshot-6.png?raw=true)|\n\n## A subset of bridges (15/447)\n\n* `CssSelectorBridge`: [Scrape out a feed using CSS selectors](https://rss-bridge.org/bridge01/#bridge-CssSelectorBridge)\n* `FeedMergeBridge`: [Combine multiple feeds into one](https://rss-bridge.org/bridge01/#bridge-FeedMergeBridge)\n* `FeedReducerBridge`: [Reduce a noisy feed by some percentage](https://rss-bridge.org/bridge01/#bridge-FeedReducerBridge)\n* `FilterBridge`: [Filter a feed by excluding/including items by keyword](https://rss-bridge.org/bridge01/#bridge-FilterBridge)\n* `GettrBridge`: [Fetches the latest posts from a GETTR user](https://rss-bridge.org/bridge01/#bridge-GettrBridge)\n* `MastodonBridge`: [Fetches statuses from a Mastodon (ActivityPub) instance](https://rss-bridge.org/bridge01/#bridge-MastodonBridge)\n* `RedditBridge`: [Fetches posts from a user/subredit (with filtering options)](https://rss-bridge.org/bridge01/#bridge-RedditBridge)\n* `RumbleBridge`: [Fetches channel/user videos](https://rss-bridge.org/bridge01/#bridge-RumbleBridge)\n* `SoundcloudBridge`: [Fetches music by username](https://rss-bridge.org/bridge01/#bridge-SoundcloudBridge)\n* `TelegramBridge`: [Fetches posts from a public channel](https://rss-bridge.org/bridge01/#bridge-TelegramBridge)\n* `ThePirateBayBridge:` [Fetches torrents by search/user/category](https://rss-bridge.org/bridge01/#bridge-ThePirateBayBridge)\n* `TikTokBridge`: [Fetches posts by username](https://rss-bridge.org/bridge01/#bridge-TikTokBridge)\n* `TwitchBridge`: [Fetches videos from channel](https://rss-bridge.org/bridge01/#bridge-TwitchBridge)\n* `XPathBridge`: [Scrape out a feed using XPath expressions](https://rss-bridge.org/bridge01/#bridge-XPathBridge)\n* `YoutubeBridge`: [Fetches videos by username/channel/playlist/search](https://rss-bridge.org/bridge01/#bridge-YoutubeBridge)\n* `YouTubeCommunityTabBridge`: [Fetches posts from a channel's Posts tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge)\n\n## Tutorial\n\n### How to install on traditional shared web hosting\n\nRSS-Bridge can basically be unzipped into a web folder. Should be working instantly.\n\nLatest zip:\nhttps://github.com/RSS-Bridge/rss-bridge/archive/refs/heads/master.zip (2MB)\n\n### How to install on Debian 12 (nginx + php-fpm)\n\nThese instructions have been tested on a fresh Debian 12 VM from Digital Ocean (1vcpu-512mb-10gb, 5 USD/month).\n\n```shell\ntimedatectl set-timezone Europe/Oslo\n\napt install git nginx php8.2-fpm php-mbstring php-simplexml php-curl php-intl\n\n# Create a user account\nuseradd --shell /bin/bash --create-home rss-bridge\n\ncd /var/www\n\n# Create folder and change its ownership to rss-bridge\nmkdir rss-bridge && chown rss-bridge:rss-bridge rss-bridge/\n\n# Become rss-bridge\nsu rss-bridge\n\n# Clone master branch into existing folder\ngit clone https://github.com/RSS-Bridge/rss-bridge.git rss-bridge/\ncd rss-bridge\n\n# Copy over the default config (OPTIONAL)\ncp -v config.default.ini.php config.ini.php\n\n# Recursively give full permissions to user/owner\nchmod 700 --recursive ./\n\n# Give read and execute to others on folder ./static\nchmod o+rx ./ ./static\n\n# Recursively give give read to others on folder ./static\nchmod o+r --recursive ./static\n```\n\nNginx config:\n\n```nginx\n# /etc/nginx/sites-enabled/rss-bridge.conf\n\nserver {\n    listen 80;\n\n    # TODO: change to your own server name\n    server_name example.com;\n\n    access_log /var/log/nginx/rss-bridge.access.log;\n    error_log /var/log/nginx/rss-bridge.error.log;\n    log_not_found off;\n\n    # Intentionally not setting a root folder\n\n    # Static content only served here\n    location /static/ {\n        alias /var/www/rss-bridge/static/;\n    }\n\n    # Pass off to php-fpm only when location is EXACTLY == /\n    location = / {\n        root /var/www/rss-bridge/;\n        include snippets/fastcgi-php.conf;\n        fastcgi_read_timeout 45s;\n        fastcgi_pass unix:/run/php/rss-bridge.sock;\n    }\n\n    # Reduce log noise\n    location = /favicon.ico {\n        access_log off;\n    }\n\n    # Reduce log noise\n    location = /robots.txt {\n        access_log off;\n    }\n}\n```\n\nPHP FPM pool config:\n```ini\n; /etc/php/8.2/fpm/pool.d/rss-bridge.conf\n\n[rss-bridge]\n\nuser = rss-bridge\ngroup = rss-bridge\n\nlisten = /run/php/rss-bridge.sock\n\nlisten.owner = www-data\nlisten.group = www-data\n\n; Create 10 workers standing by to serve requests\npm = static\npm.max_children = 10\n\n; Respawn worker after 500 requests (workaround for memory leaks etc.)\npm.max_requests = 500\n```\n\nPHP ini config:\n```ini\n; /etc/php/8.2/fpm/conf.d/30-rss-bridge.ini\n\nmax_execution_time = 15\nmemory_limit = 64M\n```\n\nRestart fpm and nginx:\n\n```shell\n# Lint and restart php-fpm\nphp-fpm8.2 -t && systemctl restart php8.2-fpm\n\n# Lint and restart nginx\nnginx -t && systemctl restart nginx\n```\n\n### How to install from Composer\n\nInstall the latest release.\n\n```shell\ncd /var/www\ncomposer create-project -v --no-dev --no-scripts rss-bridge/rss-bridge\n```\n\n### How to install with Caddy\n\nTODO. See https://github.com/RSS-Bridge/rss-bridge/issues/3785\n\n### Install from Docker Hub:\n\nInstall by downloading the docker image from Docker Hub:\n\n```bash\n# Create container\ndocker create --name=rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rssbridge/rss-bridge\n```\n\nYou can put custom `config.ini.php` and bridges into `./config`.\n\n**You must restart container for custom changes to take effect.**\n\nSee `docker-entrypoint.sh` for details.\n\n```bash\n# Start container\ndocker start rss-bridge\n```\n\nBrowse http://localhost:3000/\n\n### Install by locally building from Dockerfile\n\n```bash\n# Build image from Dockerfile\ndocker build -t rss-bridge .\n\n# Create container\ndocker create --name rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rss-bridge\n```\n\nYou can put custom `config.ini.php` and bridges into `./config`.\n\n**You must restart container for custom changes to take effect.**\n\nSee `docker-entrypoint.sh` for details.\n\n```bash\n# Start container\ndocker start rss-bridge\n```\n\nBrowse http://localhost:3000/\n\n### Install with docker-compose (using Docker Hub)\n\nYou can put custom `config.ini.php` and bridges into `./config`.\n\n**You must restart container for custom changes to take effect.**\n\nSee `docker-entrypoint.sh` for details.\n\n```bash\ndocker-compose up\n```\n\nBrowse http://localhost:3000/\n\n### Other installation methods\n\n[![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/sebsauvage/rss-bridge)\n[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)\n[![Deploy to Cloudron](https://cloudron.io/img/button.svg)](https://www.cloudron.io/store/com.rssbridgeapp.cloudronapp.html)\n[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=rssbridge)\n\nThe Heroku quick deploy currently does not work. It might work if you fork this repo and\nmodify the `repository` in `scalingo.json`. See https://github.com/RSS-Bridge/rss-bridge/issues/2688\n\nLearn more in\n[Installation](https://rss-bridge.github.io/rss-bridge/For_Hosts/Installation.html).\n\n## How-to\n\n### How to fix \"Access denied.\"\n\nOutput is from php-fpm. It is unable to read index.php.\n\n    chown rss-bridge:rss-bridge /var/www/rss-bridge/index.php\n\n### How to password-protect the instance (token)\n\nModify `config.ini.php`:\n\n    [authentication]\n\n    token = \"hunter2\"\n\n### How to remove all cache items\n\nAs current user:\n\n    bin/cache-clear\n\nAs user rss-bridge:\n\n    sudo -u rss-bridge bin/cache-clear\n\nAs root:\n\n    sudo bin/cache-clear\n\n### How to remove all expired cache items\n\n    bin/cache-prune\n\n### How to fix \"PHP Fatal error:  Uncaught Exception: The FileCache path is not writable\"\n\n```shell\n# Give rss-bridge ownership\nchown rss-bridge:rss-bridge -R /var/www/rss-bridge/cache\n\n# Or, give www-data ownership\nchown www-data:www-data -R /var/www/rss-bridge/cache\n\n# Or, give everyone write permission\nchmod 777 -R /var/www/rss-bridge/cache\n\n# Or last ditch effort (CAREFUL)\nrm -rf /var/www/rss-bridge/cache/ && mkdir /var/www/rss-bridge/cache/\n```\n\n### How to fix \"attempt to write a readonly database\"\n\nThe sqlite files (db, wal and shm) are not writeable.\n\n    chown -v rss-bridge:rss-bridge cache/*\n\n### How to fix \"Unable to prepare statement: 1, no such table: storage\"\n\n    rm cache/*\n\n### How to create a completely new bridge\n\nNew code files MUST have `declare(strict_types=1);` at the top of file:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n```\n\nCreate the new bridge in e.g. `bridges/BearBlogBridge.php`:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\nclass BearBlogBridge extends BridgeAbstract\n{\n    const NAME = 'BearBlog (bearblog.dev)';\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM('https://herman.bearblog.dev/blog/');\n        foreach ($dom->find('.blog-posts li') as $li) {\n            $a = $li->find('a', 0);\n            $this->items[] = [\n                'title' => $a->plaintext,\n                'uri' => 'https://herman.bearblog.dev' . $a->href,\n            ];\n        }\n    }\n}\n```\n\nLearn more in [bridge api](https://rss-bridge.github.io/rss-bridge/Bridge_API/index.html).\n\n### How to enable all bridges\n\n    enabled_bridges[] = *\n\n### How to enable some bridges\n\n```\nenabled_bridges[] = TwitchBridge\nenabled_bridges[] = GettrBridge\n```\n\n### How to switch to memcached as cache backend\n\n```\n[cache]\n\n; Cache backend: file (default), sqlite, memcached, null\ntype = \"memcached\"\n```\n\n### How to switch to sqlite3 as cache backend\n\n    type = \"sqlite\"\n\n### How to disable bridge errors (as feed items)\n\nWhen a bridge fails, RSS-Bridge will produce a feed with a single item describing the error.\n\nThis way, feed readers pick it up and you are notified.\n\nIf you don't want this behaviour, switch the error output to `http`:\n\n    [error]\n\n    ; Defines how error messages are returned by RSS-Bridge\n    ;\n    ; \"feed\" = As part of the feed (default)\n    ; \"http\" = As HTTP error message\n    ; \"none\" = No errors are reported\n    output = \"http\"\n\n### How to accumulate errors before finally reporting it\n\nModify `report_limit` so that an error must occur 3 times before it is reported.\n\n    ; Defines how often an error must occur before it is reported to the user\n    report_limit = 3\n\nThe report count is reset to 0 each day.\n\n### How to password-protect the instance (HTTP Basic Auth)\n\n    [authentication]\n\n    enable = true\n    username = \"alice\"\n    password = \"cat\"\n\nWill typically require feed readers to be configured with the credentials.\n\nIt may also be possible to manually include the credentials in the URL:\n\nhttps://alice:cat@rss-bridge.org/bridge01/?action=display&bridge=FabriceBellardBridge&format=Html\n\n### How to create a new output format\n\nSee `formats/PlaintextFormat.php` for an example.\n\n### How to run unit tests and linter\n\nThese commands require that you have installed the dev dependencies in `composer.json`.\n\nRun all tests:\n\n    ./vendor/bin/phpunit\n\nRun a single test class:\n\n    ./vendor/bin/phpunit --filter UrlTest\n\nRun linter:\n\n    ./vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0 --extensions=php -p ./\n\nhttps://github.com/PHPCSStandards/PHP_CodeSniffer/wiki\n\n### How to spawn a minimal development environment\n\n    php -S 127.0.0.1:9001\n\nhttp://127.0.0.1:9001/\n\n\n## Explanation\n\nWe are RSS-Bridge community, a group of developers continuing the project initiated by sebsauvage,\nwebmaster of\n[sebsauvage.net](https://sebsauvage.net), author of\n[Shaarli](https://sebsauvage.net/wiki/doku.php?id=php:shaarli) and\n[ZeroBin](https://sebsauvage.net/wiki/doku.php?id=php:zerobin).\n\nSee [CONTRIBUTORS.md](CONTRIBUTORS.md)\n\nRSS-Bridge uses caching to prevent services from banning your server for repeatedly updating feeds.\nThe specific cache duration can be different between bridges.\n\nRSS-Bridge allows you to take full control over which bridges are displayed to the user.\nThat way you can host your own RSS-Bridge service with your favorite collection of bridges!\n\nCurrent maintainers (as of 2024): @dvikan and @Mynacol #2519\n\n## Reference\n\n### Feed item structure\n\nThis is the feed item structure that bridges are expected to produce.\n\n```php\n    $item = [\n        'uri' => 'https://example.com/blog/hello',\n        'title' => 'Hello world',\n        // Publication date in unix timestamp\n        'timestamp' => 1668706254,\n        'author' => 'Alice',\n        'content' => 'Here be item content',\n        'enclosures' => [\n            'https://example.com/foo.png',\n            'https://example.com/bar.png'\n        ],\n        'categories' => [\n            'news',\n            'tech',\n        ],\n        // Globally unique id\n        'uid' => 'e7147580c8747aad',\n    ]\n```\n\n### Output formats\n\n* `Atom`: Atom feed, for use in feed readers\n* `Html`: Simple HTML page\n* `Json`: JSON, for consumption by other applications\n* `Mrss`: MRSS feed, for use in feed readers\n* `Plaintext`: Raw text, for consumption by other applications\n* `Sfeed`: Text, TAB separated\n\n### Cache backends\n\n* `File`\n* `SQLite`\n* `Memcached`\n* `Array`\n* `Null`\n\n### Licenses\n\nThe source code for RSS-Bridge is [Public Domain](UNLICENSE).\n\nRSS-Bridge uses third party libraries with their own license:\n\n  * [`Parsedown`](https://github.com/erusev/parsedown) licensed under the [MIT License](https://opensource.org/licenses/MIT)\n  * [`PHP Simple HTML DOM Parser`](https://simplehtmldom.sourceforge.io/docs/1.9/index.html) licensed under the [MIT License](https://opensource.org/licenses/MIT)\n  * [`php-urljoin`](https://github.com/fluffy-critter/php-urljoin) licensed under the [MIT License](https://opensource.org/licenses/MIT)\n  * [`Laravel framework`](https://github.com/laravel/framework/) licensed under the [MIT License](https://opensource.org/licenses/MIT)\n\n## Rant\n\n*Dear so-called \"social\" websites.*\n\nYour 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.**\n\nYou'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.\n\nWe 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.\n\nWe are rebuilding bridges you have willfully destroyed.\n\nGet your shit together: Put RSS/Atom back in.\n"
  },
  {
    "path": "UNLICENSE",
    "content": "This is free and unencumbered software released into the public domain.\n\nAnyone is free to copy, modify, publish, use, compile, sell, or\ndistribute this software, either in source code form or as a compiled\nbinary, for any purpose, commercial or non-commercial, and by any\nmeans.\n\nIn jurisdictions that recognize copyright laws, the author or authors\nof this software dedicate any and all copyright interest in the\nsoftware to the public domain. We make this dedication for the benefit\nof the public at large and to the detriment of our heirs and\nsuccessors. We intend this dedication to be an overt act of\nrelinquishment in perpetuity of all present and future rights to this\nsoftware under copyright law.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\nIN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR\nOTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,\nARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE.\n\nFor more information, please refer to <http://unlicense.org>\n\n"
  },
  {
    "path": "actions/ConnectivityAction.php",
    "content": "<?php\n\n/**\n * Checks if the website for a given bridge is reachable.\n *\n * **Remarks**\n * - This action is only available in debug mode.\n * - Returns the bridge status as Json-formatted string.\n * - Returns an error if the bridge is not whitelisted.\n * - Returns a responsive web page that automatically checks all whitelisted\n * bridges (using JavaScript) if no bridge is specified.\n */\nclass ConnectivityAction implements ActionInterface\n{\n    private BridgeFactory $bridgeFactory;\n\n    public function __construct(\n        BridgeFactory $bridgeFactory\n    ) {\n        $this->bridgeFactory = $bridgeFactory;\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        if (Configuration::getConfig('system', 'env') !== 'dev') {\n            return new Response('This action is only available in dev environment!', 403);\n        }\n\n        $bridgeName = $request->get('bridge');\n        if (!$bridgeName) {\n            return new Response(render_template('connectivity.html.php'));\n        }\n        $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName);\n        if (!$bridgeClassName) {\n            return new Response('Bridge not found', 404);\n        }\n        return $this->reportBridgeConnectivity($bridgeClassName);\n    }\n\n    private function reportBridgeConnectivity($bridgeClassName)\n    {\n        if (!$this->bridgeFactory->isEnabled($bridgeClassName)) {\n            throw new \\Exception('Bridge is not whitelisted!');\n        }\n\n        $bridge = $this->bridgeFactory->create($bridgeClassName);\n        $curl_opts = [\n            CURLOPT_CONNECTTIMEOUT => 5,\n            CURLOPT_FOLLOWLOCATION => true,\n        ];\n        $result = [\n            'bridge'        => $bridgeClassName,\n            'successful'    => false,\n            'http_code'     => null,\n        ];\n        try {\n            $response = getContents($bridge::URI, [], $curl_opts, true);\n            $result['http_code'] = $response->getCode();\n            if (in_array($result['http_code'], [200])) {\n                $result['successful'] = true;\n            }\n        } catch (\\Exception $e) {\n        }\n\n        return new Response(Json::encode($result), 200, ['content-type' => 'text/json']);\n    }\n}\n"
  },
  {
    "path": "actions/DetectAction.php",
    "content": "<?php\n\nclass DetectAction implements ActionInterface\n{\n    private BridgeFactory $bridgeFactory;\n\n    public function __construct(\n        BridgeFactory $bridgeFactory\n    ) {\n        $this->bridgeFactory = $bridgeFactory;\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        $url = $request->get('url');\n        $format = $request->get('format');\n\n        if (!$url) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a url']));\n        }\n        if (!$format) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format']));\n        }\n\n        foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) {\n            if (!$this->bridgeFactory->isEnabled($bridgeClassName)) {\n                continue;\n            }\n\n            $bridge = $this->bridgeFactory->create($bridgeClassName);\n\n            $bridgeParams = $bridge->detectParameters($url);\n\n            if (!$bridgeParams) {\n                continue;\n            }\n\n            $query = [\n                'action' => 'display',\n                'bridge' => $bridgeClassName,\n                'format' => $format,\n            ];\n            $query = array_merge($query, $bridgeParams);\n            return new Response('', 301, ['location' => '?' . http_build_query($query)]);\n        }\n\n        return new Response(render(__DIR__ . '/../templates/error.html.php', [\n            'message' => 'No bridge found for given URL: ' . $url,\n        ]));\n    }\n}\n"
  },
  {
    "path": "actions/DisplayAction.php",
    "content": "<?php\n\nclass DisplayAction implements ActionInterface\n{\n    private CacheInterface $cache;\n    private Logger $logger;\n    private BridgeFactory $bridgeFactory;\n\n    public function __construct(\n        CacheInterface $cache,\n        Logger $logger,\n        BridgeFactory $bridgeFactory\n    ) {\n        $this->cache = $cache;\n        $this->logger = $logger;\n        $this->bridgeFactory = $bridgeFactory;\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        $bridgeName = $request->get('bridge');\n        $format = $request->get('format');\n        $noproxy = $request->get('_noproxy');\n\n        if (!$bridgeName) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Missing bridge name parameter']), 400);\n        }\n        $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName);\n        if (!$bridgeClassName) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Bridge not found']), 404);\n        }\n\n        if (!$format) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format']), 400);\n        }\n        if (!$this->bridgeFactory->isEnabled($bridgeClassName)) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'This bridge is not whitelisted']), 400);\n        }\n\n        // Disable proxy (if enabled and per user's request)\n        if (\n            Configuration::getConfig('proxy', 'url')\n            && Configuration::getConfig('proxy', 'by_bridge')\n            && $noproxy\n        ) {\n            // This const is only used once in getContents()\n            define('NOPROXY', true);\n        }\n\n        $cacheKey = 'http_' . json_encode($request->toArray());\n\n        $bridge = $this->bridgeFactory->create($bridgeClassName);\n\n        $response = $this->createResponse($request, $bridge, $format);\n\n        if ($response->getCode() === 200) {\n            $ttl = $request->get('_cache_timeout');\n            if (Configuration::getConfig('cache', 'custom_timeout') && isset($ttl)) {\n                $ttl = (int) $ttl;\n            } else {\n                $ttl = $bridge->getCacheTimeout();\n            }\n            $this->cache->set($cacheKey, $response, $ttl);\n        }\n\n        return $response;\n    }\n\n    private function createResponse(Request $request, BridgeAbstract $bridge, string $format)\n    {\n        $items = [];\n\n        try {\n            $bridge->loadConfiguration();\n            // Remove parameters that don't concern bridges\n            $remove = [\n                'token',\n                'action',\n                'bridge',\n                'format',\n                '_noproxy',\n                '_cache_timeout',\n                '_error_time',\n                '_', // Some RSS readers add a cache-busting parameter (_=<timestamp>) to feed URLs, detect and ignore them.\n            ];\n            $requestArray = $request->toArray();\n            $input = array_diff_key($requestArray, array_fill_keys($remove, ''));\n            $bridge->setInput($input);\n            $bridge->collectData();\n            $items = $bridge->getItems();\n        } catch (\\Throwable $e) {\n            if ($e instanceof ClientException) {\n                $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e)));\n            } elseif ($e instanceof RateLimitException) {\n                $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e)));\n                return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 429);\n            } elseif ($e instanceof HttpException) {\n                if (in_array($e->getCode(), [429, 503])) {\n                    // Log with debug, immediately reproduce and return\n                    $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e)));\n                    return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), $e->getCode());\n                }\n                // Some other status code which we let fail normally (but don't log it)\n            } else {\n                $this->logger->error(sprintf('Exception in DisplayAction(%s)', $bridge->getShortName()), ['e' => $e]);\n            }\n            $errorOutput = Configuration::getConfig('error', 'output');\n            $reportLimit = Configuration::getConfig('error', 'report_limit');\n            $errorCount = 1;\n            if ($reportLimit > 1) {\n                $errorCount = $this->logBridgeError($bridge->getName(), $e->getCode());\n            }\n            // Let clients know about the error if we are passed the report limit\n            if ($errorCount >= $reportLimit) {\n                if ($errorOutput === 'feed') {\n                    // Render the exception as a feed item\n                    $items = [$this->createFeedItemFromException($e, $bridge)];\n                } elseif ($errorOutput === 'http') {\n                    return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 500);\n                } elseif ($errorOutput === 'none') {\n                    // Do nothing (produces an empty feed)\n                }\n            }\n        }\n\n        $formatFactory = new FormatFactory();\n        $format = $formatFactory->create($format);\n\n        $format->setItems($items);\n        $format->setFeed($bridge->getFeed());\n        $now = time();\n        $format->setLastModified($now);\n        $headers = [\n            'last-modified' => gmdate('D, d M Y H:i:s ', $now) . 'GMT',\n            'content-type'  => $format->getMimeType() . '; charset=UTF-8',\n        ];\n        $body = $format->render();\n\n        // This is supposed to remove non-utf8 byte sequences, but I'm unsure if it works\n        ini_set('mbstring.substitute_character', 'none');\n        $body = mb_convert_encoding($body, 'UTF-8', 'UTF-8');\n\n        return new Response($body, 200, $headers);\n    }\n\n    private function createFeedItemFromException($e, BridgeAbstract $bridge): array\n    {\n        $item = [];\n\n        // Create a unique identifier every 24 hours\n        $uniqueIdentifier = urlencode((int)(time() / 86400));\n        $title = sprintf('Bridge returned error %s! (%s)', $e->getCode(), $uniqueIdentifier);\n\n        $item['title'] = $title;\n        $item['uri'] = get_current_url();\n        $item['timestamp'] = time();\n\n        // Create an item identifier for feed readers e.g. \"staysafetv twitch videos_19389\"\n        $item['uid'] = $bridge->getName() . '_' . $uniqueIdentifier;\n\n        $content = render_template(__DIR__ . '/../templates/bridge-error.html.php', [\n            'error' => render_template(__DIR__ . '/../templates/exception.html.php', ['e' => $e]),\n            'searchUrl' => self::createGithubSearchUrl($bridge),\n            'issueUrl' => self::createGithubIssueUrl($bridge, $e),\n            'maintainer' => $bridge->getMaintainer(),\n        ]);\n        $item['content'] = $content;\n\n        return $item;\n    }\n\n    private function logBridgeError($bridgeName, $code)\n    {\n        // todo: it's not really necessary to json encode $report\n        $cacheKey = 'error_reporting_' . $bridgeName . '_' . $code;\n        $report = $this->cache->get($cacheKey);\n        if ($report) {\n            $report = Json::decode($report);\n            $report['time'] = time();\n            $report['count']++;\n        } else {\n            $report = [\n                'error' => $code,\n                'time' => time(),\n                'count' => 1,\n            ];\n        }\n        $ttl = 86400 * 5;\n        $this->cache->set($cacheKey, Json::encode($report), $ttl);\n        return $report['count'];\n    }\n\n    private static function createGithubIssueUrl(BridgeAbstract $bridge, \\Throwable $e): string\n    {\n        $maintainer = $bridge->getMaintainer();\n        if (str_contains($maintainer, ',')) {\n            $maintainers = explode(',', $maintainer);\n        } else {\n            $maintainers = [$maintainer];\n        }\n        $maintainers = array_map('trim', $maintainers);\n\n        $queryString = $_SERVER['QUERY_STRING'] ?? '';\n        $query = [\n            'title' => $bridge->getName() . ' failed with: ' . $e->getMessage(),\n            'body' => sprintf(\n                \"```\\n%s\\n\\n%s\\n\\nQuery string: %s\\nVersion: %s\\nOs: %s\\nPHP version: %s\\n```\\nMaintainer: @%s\",\n                create_sane_exception_message($e),\n                implode(\"\\n\", trace_to_call_points(trace_from_exception($e))),\n                $queryString,\n                Configuration::getVersion(),\n                PHP_OS_FAMILY,\n                phpversion() ?: 'Unknown',\n                implode(', @', $maintainers),\n            ),\n            'labels' => 'Bridge-Broken',\n            'assignee' => $maintainer[0],\n        ];\n\n        return 'https://github.com/RSS-Bridge/rss-bridge/issues/new?' . http_build_query($query);\n    }\n\n    private static function createGithubSearchUrl($bridge): string\n    {\n        return sprintf(\n            'https://github.com/RSS-Bridge/rss-bridge/issues?q=%s',\n            urlencode('is:issue is:open ' . $bridge->getName())\n        );\n    }\n}\n"
  },
  {
    "path": "actions/FindfeedAction.php",
    "content": "<?php\n\n/**\n * This action is used by the frontpage form search.\n * It finds a bridge based off of a user input url.\n * It uses bridges' detectParameters implementation.\n */\nclass FindfeedAction implements ActionInterface\n{\n    private BridgeFactory $bridgeFactory;\n\n    public function __construct(\n        BridgeFactory $bridgeFactory\n    ) {\n        $this->bridgeFactory = $bridgeFactory;\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        $url = $request->get('url');\n        $format = $request->get('format');\n\n        if (!$url) {\n            return new Response('You must specify a url', 400);\n        }\n        if (!$format) {\n            return new Response('You must specify a format', 400);\n        }\n\n        $results = [];\n        foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) {\n            if (!$this->bridgeFactory->isEnabled($bridgeClassName)) {\n                continue;\n            }\n\n            $bridge = $this->bridgeFactory->create($bridgeClassName);\n\n            $bridgeParams = $bridge->detectParameters($url);\n\n            if ($bridgeParams === null) {\n                continue;\n            }\n\n            // It's allowed to have no 'context' in a bridge (only a default context without any name)\n            // In this case, the reference to the parameters are found in the first element of the PARAMETERS array\n\n            $context = $bridgeParams['context'] ?? 0;\n\n            $bridgeData = [];\n            // Construct the array of parameters\n            foreach ($bridgeParams as $key => $value) {\n                // 'context' is a special case : it's a bridge parameters, there is no \"name\" for this parameter\n                if ($key == 'context') {\n                    $bridgeData[$key]['name'] = 'Context';\n                    $bridgeData[$key]['value'] = $value;\n                } else {\n                    $bridgeData[$key]['name'] = $this->getParameterName($bridge, $context, $key);\n                    $bridgeData[$key]['value'] = $value;\n                }\n            }\n\n            $bridgeParams['bridge'] = $bridgeClassName;\n            $bridgeParams['format'] = $format;\n            $content = [\n                'url' => './?action=display&' . http_build_query($bridgeParams),\n                'bridgeParams' => $bridgeParams,\n                'bridgeData' => $bridgeData,\n                'bridgeMeta' => [\n                        'name' => $bridge::NAME,\n                        'description' => $bridge::DESCRIPTION,\n                        'parameters' => $bridge::PARAMETERS,\n                        'icon' => $bridge->getIcon(),\n                    ],\n            ];\n            $results[] = $content;\n        }\n        if ($results === []) {\n            return new Response(Json::encode(['message' => 'No bridge found for given url']), 404, ['content-type' => 'application/json']);\n        }\n        return new Response(Json::encode($results), 200, ['content-type' => 'application/json']);\n    }\n\n    // Get parameter name in the actual context, or in the global parameter\n    private function getParameterName($bridge, $context, $key)\n    {\n        if (isset($bridge::PARAMETERS[$context][$key]['name'])) {\n            $name = $bridge::PARAMETERS[$context][$key]['name'];\n        } else if (isset($bridge::PARAMETERS['global'][$key]['name'])) {\n            $name = $bridge::PARAMETERS['global'][$key]['name'];\n        } else {\n            $name = 'Variable \"' . $key . '\" (No name provided)';\n        }\n        return $name;\n    }\n}\n"
  },
  {
    "path": "actions/FrontpageAction.php",
    "content": "<?php\n\nfinal class FrontpageAction implements ActionInterface\n{\n    private BridgeFactory $bridgeFactory;\n\n    public function __construct(\n        BridgeFactory $bridgeFactory\n    ) {\n        $this->bridgeFactory = $bridgeFactory;\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        $token = $request->getAttribute('token');\n\n        $messages = [];\n        $activeBridges = 0;\n\n        $bridgeClassNames = $this->bridgeFactory->getBridgeClassNames();\n\n        foreach ($this->bridgeFactory->getMissingEnabledBridges() as $missingEnabledBridge) {\n            $messages[] = [\n                'body' => sprintf('Warning : Bridge \"%s\" not found', $missingEnabledBridge),\n                'level' => 'warning'\n            ];\n        }\n\n        $body = '';\n        foreach ($bridgeClassNames as $bridgeClassName) {\n            if ($this->bridgeFactory->isEnabled($bridgeClassName)) {\n                $bridge = $this->bridgeFactory->create($bridgeClassName);\n                $body .= self::render($bridge, $bridgeClassName, $token);\n                $activeBridges++;\n            }\n        }\n\n        $response = new Response(render(__DIR__ . '/../templates/frontpage.html.php', [\n            'messages'          => $messages,\n            'admin_email'       => Configuration::getConfig('admin', 'email'),\n            'admin_telegram'    => Configuration::getConfig('admin', 'telegram'),\n            'bridges'           => $body,\n            'active_bridges'    => $activeBridges,\n            'total_bridges'     => count($bridgeClassNames),\n        ]));\n\n        // TODO: The rendered template could be cached, but beware config changes that changes the html\n        return $response;\n    }\n\n    public static function render(\n        BridgeAbstract $bridge,\n        string $bridgeClassName,\n        ?string $token\n    ): string {\n        $uri = $bridge->getURI();\n        $name = $bridge->getName();\n        $icon = $bridge->getIcon();\n        $description = $bridge->getDescription();\n        $parameters = $bridge->getParameters();\n\n        // Checkbox for disabling of proxy (if enabled)\n        if (\n            Configuration::getConfig('proxy', 'url')\n            && Configuration::getConfig('proxy', 'by_bridge')\n        ) {\n            $proxyName = Configuration::getConfig('proxy', 'name') ?: Configuration::getConfig('proxy', 'url');\n            $parameters['global']['_noproxy'] = [\n                'name' => sprintf('Disable proxy (%s)', $proxyName),\n                'type' => 'checkbox',\n            ];\n        }\n\n        if (Configuration::getConfig('cache', 'custom_timeout')) {\n            $parameters['global']['_cache_timeout'] = [\n                'name' => 'Cache timeout in seconds',\n                'type' => 'number',\n                'defaultValue' => $bridge->getCacheTimeout()\n            ];\n        }\n\n        $shortName = $bridge->getShortName();\n        $card = <<<CARD\n            <section\n                class=\"bridge-card\"\n                id=\"bridge-{$bridgeClassName}\"\n                data-ref=\"{$name}\"\n                data-short-name=\"$shortName\"\n            >\n\n            <a style=\"position: absolute; top: 10px; left: 10px\" href=\"#bridge-{$bridgeClassName}\">\n                <h1>#</h1>\n            </a>\n\n            <h2><a href=\"{$uri}\">{$name}</a></h2>\n            <p class=\"description\">{$description}</p>\n\n            <input type=\"checkbox\" class=\"showmore-box\" id=\"showmore-{$bridgeClassName}\" />\n            <label class=\"showmore\" for=\"showmore-{$bridgeClassName}\">Show more</label>\n\n\n        CARD;\n\n        if (count($parameters) === 0) {\n            // The bridge has zero parameters\n            $card .= self::renderForm($bridgeClassName, '', [], $token);\n        } elseif (count($parameters) === 1 && array_key_exists('global', $parameters)) {\n            // The bridge has a single context with key 'global'\n            $card .= self::renderForm($bridgeClassName, '', $parameters['global'], $token);\n        } else {\n            // The bridge has one or more contexts (named or unnamed)\n            foreach ($parameters as $contextName => $contextParameters) {\n                if ($contextName === 'global') {\n                    continue;\n                }\n\n                if (array_key_exists('global', $parameters)) {\n                    // Merge the global parameters into current context\n                    $contextParameters = array_merge($contextParameters, $parameters['global']);\n                }\n\n                if (!is_numeric($contextName)) {\n                    // This is a named context\n                    $card .= '<h5>' . $contextName . \"</h5>\\n\";\n                }\n\n                $card .= self::renderForm($bridgeClassName, $contextName, $contextParameters, $token);\n            }\n        }\n\n        $card .= html_tag('label', 'Show less', [\n                'class' => 'showless',\n                'for'   => \"showmore-$bridgeClassName\",\n            ]) . \"\\n\";\n\n        if (Configuration::getConfig('admin', 'donations') && $bridge->getDonationURI()) {\n            $card .= sprintf(\n                '<p class=\"maintainer\">%s ~ <a href=\"%s\">Donate</a></p>',\n                $bridge->getMaintainer(),\n                $bridge->getDonationURI()\n            );\n        } else {\n            $card .= html_tag('p', $bridge->getMaintainer(), ['class' => 'maintainer']) . \"\\n\";\n        }\n        $card .= \"</section>\\n\\n\";\n\n        return $card;\n    }\n\n    private static function renderForm(\n        string $bridgeClassName,\n        string $contextName,\n        array $parameters,\n        ?string $token\n    ): string {\n        $form = <<<EOD\n        <form method=\"GET\" action=\"?\" class=\"bridge-form\">\n            <input type=\"hidden\" name=\"action\" value=\"display\" />\n            <input type=\"hidden\" name=\"bridge\" value=\"{$bridgeClassName}\" />\n\n        EOD;\n\n        if (Configuration::getConfig('authentication', 'token') && $token) {\n            $form .= html_input([\n                    'type'  => 'hidden',\n                    'name'  => 'token',\n                    'value' => $token,\n                ]) . \"\\n\";\n        }\n\n        if (!empty($contextName)) {\n            $form .= html_input([\n                    'type'  => 'hidden',\n                    'name'  => 'context',\n                    'value' => $contextName,\n                ]) . \"\\n\";\n        }\n\n        $form .= '<div class=\"parameters\">' . \"\\n\";\n\n        foreach ($parameters as $id => $parameter) {\n            if (!isset($parameter['exampleValue'])) {\n                $parameter['exampleValue'] = '';\n            }\n\n            if (!isset($parameter['defaultValue'])) {\n                $parameter['defaultValue'] = '';\n            }\n\n            $idArg = 'arg-' . urlencode($bridgeClassName) . '-' . urlencode($contextName) . '-' . urlencode($id);\n\n            $form .= html_tag('label', $parameter['name'], ['for' => $idArg]) . \"\\n\";\n\n            if (\n                !isset($parameter['type'])\n                || $parameter['type'] === 'text'\n            ) {\n                $form .= self::getTextInput($parameter, $idArg, $id) . \"\\n\";\n            } elseif ($parameter['type'] === 'number') {\n                $form .= self::getNumberInput($parameter, $idArg, $id) . \"\\n\";\n            } elseif ($parameter['type'] === 'list') {\n                $form .= self::getListInput($parameter, $idArg, $id) . \"\\n\";\n            } elseif ($parameter['type'] === 'checkbox') {\n                $form .= self::getCheckboxInput($parameter, $idArg, $id) . \"\\n\";\n            } else {\n                $foo = 2;\n                // oops?\n            }\n\n            $params = [];\n            if (isset($parameter['title'])) {\n                $params = [\n                    'title' => $parameter['title'],\n                    'class' => 'info',\n                ];\n            }\n            if ($parameter['exampleValue'] !== '') {\n                $params = [\n                    'title'         => sprintf(\"Example (right click to use):\\n%s\", $parameter['exampleValue']),\n                    'class'         => 'info',\n                    'oncontextmenu' => 'rssbridge_use_placeholder_value(this);return false',\n                    'data-for'      => $idArg,\n                ];\n            }\n\n            if ($params) {\n                $form .= html_tag('i', 'i', $params) . \"\\n\";\n            } else {\n                $form .= html_tag('i', ' ', ['class' => 'no-info']) . \"\\n\";\n            }\n        }\n\n        $form .= \"</div>\\n\\n\";\n\n        $form .= html_tag('button', 'Generate feed', [\n                'type'          => 'submit',\n                'name'          => 'format',\n                'value'         => 'Html',\n                'formtarget'    => '_blank',\n            ]) . \"\\n\";\n\n        return $form . \"</form>\\n\\n\";\n    }\n\n    public static function getTextInput(array $parameter, string $id, string $name): string\n    {\n        $pattern = $parameter['pattern'] ?? null;\n        $checked = $parameter['defaultValue'] === 'checked';\n        $required = $parameter['required'] ?? false;\n\n        return html_input([\n            'id'            => $id,\n            'type'          => 'text',\n            'value'         => $parameter['defaultValue'],\n            'placeholder'   => $parameter['exampleValue'],\n            'name'          => $name,\n            'pattern'       => $pattern,\n            'checked'       => $checked,\n            'required'      => $required,\n        ]);\n    }\n\n    public static function getNumberInput(array $parameter, string $id, string $name): string\n    {\n        $pattern = $parameter['pattern'] ?? null;\n        $checked = $parameter['defaultValue'] === 'checked';\n        $required = $parameter['required'] ?? false;\n\n        return html_input([\n            'id'            => $id,\n            'type'          => 'number',\n            'value'         => $parameter['defaultValue'],\n            'placeholder'   => $parameter['exampleValue'],\n            'name'          => $name,\n            'pattern'       => $pattern,\n            'checked'       => $checked,\n            'required'      => $required,\n        ]);\n    }\n\n    public static function getCheckboxInput(array $parameter, string $id, string $name): string\n    {\n        return html_input([\n            'id'        => $id,\n            'type'      => 'checkbox',\n            'name'      => $name,\n            'checked'   => $parameter['defaultValue'] === 'checked',\n        ]);\n    }\n\n    public static function getListInput(array $parameter, string $id, string $name): string\n    {\n        $list = sprintf('<select id=\"%s\" name=\"%s\">', $id, $name) . \"\\n\";\n\n        foreach ($parameter['values'] as $name => $value) {\n            if (is_array($value)) {\n                $list .= '<optgroup label=\"' . htmlentities($name) . '\">';\n                foreach ($value as $subname => $subvalue) {\n                    if (\n                        $parameter['defaultValue'] === $subname\n                        || $parameter['defaultValue'] === $subvalue\n                    ) {\n                        $list .= html_option($subname, $subvalue, true) . \"\\n\";\n                    } else {\n                        $list .= html_option($subname, $subvalue) . \"\\n\";\n                    }\n                }\n                $list .= '</optgroup>';\n            } else {\n                if (\n                    $parameter['defaultValue'] === $name\n                    || $parameter['defaultValue'] === $value\n                ) {\n                    $list .= html_option($name, $value, true) . \"\\n\";\n                } else {\n                    $list .= html_option($name, $value) . \"\\n\";\n                }\n            }\n        }\n\n        $list .= \"</select>\\n\";\n\n        return $list;\n    }\n}\n"
  },
  {
    "path": "actions/HealthAction.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass HealthAction implements ActionInterface\n{\n    public function __invoke(Request $request): Response\n    {\n        $response = [\n            'code' => 200,\n            'message' => 'all is good',\n        ];\n        return new Response(Json::encode($response), 200, ['content-type' => 'application/json']);\n    }\n}\n"
  },
  {
    "path": "actions/ListAction.php",
    "content": "<?php\n\nclass ListAction implements ActionInterface\n{\n    private BridgeFactory $bridgeFactory;\n\n    public function __construct(\n        BridgeFactory $bridgeFactory\n    ) {\n        $this->bridgeFactory = $bridgeFactory;\n    }\n\n    public function __invoke(Request $request): Response\n    {\n        $list = new \\stdClass();\n        $list->bridges = [];\n        $list->total = 0;\n\n        foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) {\n            $bridge = $this->bridgeFactory->create($bridgeClassName);\n\n            $list->bridges[$bridgeClassName] = [\n                'status'        => $this->bridgeFactory->isEnabled($bridgeClassName) ? 'active' : 'inactive',\n                'uri'           => $bridge->getURI(),\n                'donationUri'   => $bridge->getDonationURI(),\n                'name'          => $bridge->getName(),\n                'icon'          => $bridge->getIcon(),\n                'parameters'    => $bridge->getParameters(),\n                'maintainer'    => $bridge->getMaintainer(),\n                'description'   => $bridge->getDescription()\n            ];\n        }\n        $list->total = count($list->bridges);\n        return new Response(Json::encode($list), 200, ['content-type' => 'application/json']);\n    }\n}\n"
  },
  {
    "path": "app.json",
    "content": "{\n  \"service\": \"Heroku\",\n  \"name\": \"rss-bridge-heroku\",\n  \"description\": \"RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites which don't have one.\",\n  \"repository\": \"https://github.com/RSS-Bridge/rss-bridge?1651005770\",\n  \"keywords\": [\"php\", \"rss-bridge\", \"rss\"]\n}\n\n"
  },
  {
    "path": "bridges/ABCNewsBridge.php",
    "content": "<?php\n\nclass ABCNewsBridge extends BridgeAbstract\n{\n    const NAME = 'ABC News';\n    const URI = 'https://www.abc.net.au';\n    const DESCRIPTION = 'Topics of the Australian Broadcasting Corporation';\n    const MAINTAINER = 'yue-dongchen';\n\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'type' => 'list',\n                'name' => 'Region',\n                'title' => 'Choose state',\n                'values' => [\n                    'ACT' => 'act',\n                    'NSW' => 'nsw',\n                    'NT' => 'nt',\n                    'QLD' => 'qld',\n                    'SA' => 'sa',\n                    'TAS' => 'tas',\n                    'VIC' => 'vic',\n                    'WA' => 'wa'\n                ],\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = sprintf('https://www.abc.net.au/news/%s', $this->getInput('topic'));\n        $dom = getSimpleHTMLDOM($url);\n        $dom = $dom->find('div[data-component=\"PaginationList\"]', 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('article[data-component=\"DetailCard\"]') as $article) {\n            $a = $article->find('a', 0);\n            $this->items[] = [\n                'title' => $a->plaintext,\n                'uri' => $a->href,\n                'content' => $article->find('p', 0)->plaintext,\n                'timestamp' => strtotime($article->find('time', 0)->datetime),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ABolaBridge.php",
    "content": "<?php\n\nclass ABolaBridge extends BridgeAbstract\n{\n    const NAME = 'A Bola';\n    const URI = 'https://abola.pt/';\n    const DESCRIPTION = 'Returns news from the Portuguese sports newspaper A BOLA.PT';\n    const MAINTAINER = 'rmscoelho';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'News Feed',\n                'type' => 'list',\n                'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT',\n                'values' => [\n                    'Últimas' => 'Nnh/Noticias',\n                    'Seleção Nacional' => 'Selecao/Noticias',\n                    'Futebol Nacional' => [\n                        'Notícias' => 'Nacional/Noticias',\n                        'Primeira Liga' => 'Nacional/Liga/Noticias',\n                        'Liga 2' => 'Nacional/Liga2/Noticias',\n                        'Liga 3' => 'Nacional/Liga3/Noticias',\n                        'Liga Revelação' => 'Nacional/Liga-Revelacao/Noticias',\n                        'Campeonato de Portugal' => 'Nacional/Campeonato-Portugal/Noticias',\n                        'Distritais' => 'Nacional/Distritais/Noticias',\n                        'Taça de Portugal' => 'Nacional/TPortugal/Noticias',\n                        'Futebol Feminino' => 'Nacional/FFeminino/Noticias',\n                        'Futsal' => 'Nacional/Futsal/Noticias',\n                    ],\n                    'Futebol Internacional' => [\n                        'Notícias' => 'Internacional/Noticias/Noticias',\n                        'Liga dos Campeões' => 'Internacional/Liga-dos-campeoes/Noticias',\n                        'Liga Europa' => 'Internacional/Liga-europa/Noticias',\n                        'Liga Conferência' => 'Internacional/Liga-conferencia/Noticias',\n                        'Liga das Nações' => 'Internacional/Liga-das-nacoes/Noticias',\n                        'UEFA Youth League' => 'Internacional/Uefa-Youth-League/Noticias',\n                    ],\n                    'Mercado' => 'Mercado',\n                    'Modalidades' => 'Modalidades/Noticias',\n                    'Motores' => 'Motores/Noticias',\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://abola.pt/img/icons/favicon-96x96.png';\n    }\n\n    public function getName()\n    {\n        return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME;\n    }\n\n    public function getURI()\n    {\n        return self::URI . $this->getInput('feed');\n    }\n\n    public function collectData()\n    {\n        $url = sprintf('https://abola.pt/%s', $this->getInput('feed'));\n        $dom = getSimpleHTMLDOM($url);\n        if ($this->getInput('feed') !== 'Mercado') {\n            $dom = $dom->find('div#body_Todas1_upNoticiasTodas', 0);\n        } else {\n            $dom = $dom->find('div#body_NoticiasMercado_upNoticiasTodas', 0);\n        }\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('div.media') as $key => $article) {\n            //Get thumbnail\n            $image = $article->find('.media-img', 0)->style;\n            $image = preg_replace('/background-image: url\\(/i', '', $image);\n            $image = substr_replace($image, '', -4);\n            $image = preg_replace('/https:\\/\\//i', '', $image);\n            $image = preg_replace('/www\\./i', '', $image);\n            $image = preg_replace('/\\/\\//', '/', $image);\n            $image = preg_replace('/\\/\\/\\//', '//', $image);\n            $image = substr($image, 7);\n            $image = 'https://' . $image;\n            $image = preg_replace('/ptimg/', 'pt/img', $image);\n            $image = preg_replace('/\\/\\/bola/', 'www.abola', $image);\n            //Timestamp\n            $date = date('Y/m/d');\n            if (!is_null($article->find(\"span#body_Todas1_rptNoticiasTodas_lblData_$key\", 0))) {\n                $date = $article->find(\"span#body_Todas1_rptNoticiasTodas_lblData_$key\", 0)->plaintext;\n                $date = preg_replace('/\\./', '/', $date);\n            }\n            $time = $article->find(\"span#body_Todas1_rptNoticiasTodas_lblHora_$key\", 0)->plaintext;\n            $date = explode('/', $date);\n            $time = explode(':', $time);\n            $year = $date[0];\n            $month = $date[1];\n            $day = $date[2];\n            $hour = $time[0];\n            $minute = $time[1];\n            $timestamp = mktime($hour, $minute, 0, $month, $day, $year);\n            //Content\n            $image = '<img src=\"' . $image . '\" alt=\"' . $article->find('h4 span', 0)->plaintext . '\" />';\n            $description = '<p>' . $article->find('.media-texto > span', 0)->plaintext . '</p>';\n            $content = $image . '</br>' . $description;\n            $a = $article->find('.media-body > a', 0);\n            $this->items[] = [\n                'title' => $a->find('h4 span', 0)->plaintext,\n                'uri' => $a->href,\n                'content' => $content,\n                'timestamp' => $timestamp,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AO3Bridge.php",
    "content": "<?php\n\nclass AO3Bridge extends BridgeAbstract\n{\n    const NAME = 'AO3';\n    const URI = 'https://archiveofourown.org/';\n    const CACHE_TIMEOUT = 1800;\n    const DESCRIPTION = 'Returns works or chapters from Archive of Our Own';\n    const MAINTAINER = 'Obsidienne';\n    const PARAMETERS = [\n        'List' => [\n            'url' => [\n                'name' => 'url',\n                'required' => true,\n                // Example: F/F tag\n                'exampleValue' => 'https://archiveofourown.org/tags/F*s*F/works',\n            ],\n            'range' => [\n                'name' => 'Chapter Content',\n                'title' => 'Chapter(s) to include in each work\\'s feed entry',\n                'defaultValue' => null,\n                'type' => 'list',\n                'values' => [\n                    'None' => null,\n                    'First' => 'first',\n                    'Latest' => 'last',\n                    'Entire work' => 'all',\n                ],\n            ],\n            'unique' => [\n                'name' => 'Make separate entries for new fic chapters',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Make separate entries for new fic chapters',\n                'defaultValue' => 'checked',\n            ],\n            'limit' => self::LIMIT,\n        ],\n        'Bookmarks' => [\n            'user' => [\n                'name' => 'user',\n                'required' => true,\n                // Example: Nyaaru's bookmarks\n                'exampleValue' => 'Nyaaru',\n            ],\n        ],\n        'Work' => [\n            'id' => [\n                'name' => 'id',\n                'required' => true,\n                // Example: latest chapters from A Better Past by LysSerris\n                'exampleValue' => '18181853',\n            ],\n        ]\n    ];\n    private $title;\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Bookmarks':\n                $this->collectList($this->getURI());\n                break;\n            case 'List':\n                $this->collectList($this->getURI());\n                break;\n            case 'Work':\n                $this->collectWork($this->getURI());\n                break;\n        }\n    }\n\n    /**\n     * Feed for lists of works (e.g. recent works, search results, filtered tags,\n     * bookmarks, series, collections).\n     */\n    private function collectList($url)\n    {\n        $version = 'v0.0.1';\n        $headers = [\n            \"useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)\"\n        ];\n        $response = getContents($url, $headers);\n\n        $html = \\str_get_html($response);\n        $html = defaultLinkTo($html, self::URI);\n\n        // Get list title. Will include page range + count in some cases\n        $heading = ($html->find('#main h2', 0));\n        if ($heading->find('a.tag')) {\n            $heading = $heading->find('a.tag', 0);\n        }\n        $this->title = $heading->plaintext;\n\n        $limit = $this->getInput('limit') ?? 3;\n        $count = 0;\n        foreach ($html->find('.index.group > li') as $element) {\n            $item = [];\n\n            $title = $element->find('div h4 a', 0);\n            if (!isset($title)) {\n                continue; // discard deleted works\n            }\n            $item['title'] = $title->plaintext;\n            $item['uri'] = $title->href;\n\n            $strdate = $element->find('div p.datetime', 0)->plaintext;\n            $item['timestamp'] = strtotime($strdate);\n\n            // detach from rest of page because remove() is buggy\n            $element = str_get_html($element->outertext());\n            $tags = $element->find('ul.required-tags', 0);\n            foreach ($tags->childNodes() as $tag) {\n                $item['categories'][] = html_entity_decode($tag->plaintext);\n            }\n            $tags->remove();\n            $tags = $element->find('ul.tags', 0);\n            foreach ($tags->childNodes() as $tag) {\n                $item['categories'][] = html_entity_decode($tag->plaintext);\n            }\n            $tags->remove();\n\n            $item['content'] = implode('', $element->childNodes());\n\n            $chapters = $element->find('dl dd.chapters', 0);\n            // bookmarked series and external works do not have a chapters count\n            $chapters = (isset($chapters) ? $chapters->plaintext : 0);\n            if ($this->getInput('unique')) {\n                $item['uid'] = $item['uri'] . \"/$strdate/$chapters\";\n            } else {\n                $item['uid'] = $item['uri'];\n            }\n\n\n            // Fetch workskin of desired chapter(s) in list\n            if ($this->getInput('range') && ($limit == 0 || $count++ < $limit)) {\n                $url = $item['uri'];\n                switch ($this->getInput('range')) {\n                    case ('all'):\n                        $url .= '?view_full_work=true';\n                        break;\n                    case ('first'):\n                        break;\n                    case ('last'):\n                        // only way to get this is using the navigate page unfortunately\n                        $url .= '/navigate';\n                        $response = getContents($url, $headers);\n                        $html = \\str_get_html($response);\n                        $html = defaultLinkTo($html, self::URI);\n                        $url = $html->find('ol.index.group > li > a', -1)->href;\n                        break;\n                }\n                $response = getContents($url, $headers);\n\n                $html = \\str_get_html($response);\n                $html = defaultLinkTo($html, self::URI);\n                // remove duplicate fic summary\n                if ($ficsum = $html->find('#workskin > .preface > .summary', 0)) {\n                    $ficsum->remove();\n                }\n                $item['content'] .= $html->find('#workskin', 0);\n            }\n\n            // Use predictability of download links to generate enclosures\n            $wid = explode('/', $item['uri'])[4];\n            foreach (['azw3', 'epub', 'mobi', 'pdf', 'html'] as $ext) {\n                $item['enclosures'][] = 'https://archiveofourown.org/downloads/' . $wid . '/work.' . $ext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Feed for recent chapters of a specific work.\n     */\n    private function collectWork($url)\n    {\n        $version = 'v0.0.1';\n        $headers = [\n            \"useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)\"\n        ];\n        $response = getContents($url . '/navigate', $headers);\n\n        $html = \\str_get_html($response);\n        $html = defaultLinkTo($html, self::URI);\n\n        $response = getContents($url . '?view_full_work=true', $headers);\n\n        $workhtml = \\str_get_html($response);\n        $workhtml = defaultLinkTo($workhtml, self::URI);\n\n        $this->title = $html->find('h2 a', 0)->plaintext;\n\n        $nav = $html->find('ol.index.group > li');\n        for ($i = 0; $i < count($nav); $i++) {\n            $item = [];\n\n            $element = $nav[$i];\n            $item['title'] = $element->find('a', 0)->plaintext;\n            $item['content'] = $workhtml->find('#chapter-' . ($i + 1), 0);\n            $item['uri'] = $element->find('a', 0)->href;\n\n            $strdate = $element->find('span.datetime', 0)->plaintext;\n            $strdate = str_replace('(', '', $strdate);\n            $strdate = str_replace(')', '', $strdate);\n            $item['timestamp'] = strtotime($strdate);\n\n            $item['uid'] = $item['uri'] . \"/$strdate\";\n\n            $this->items[] = $item;\n        }\n\n        $this->items = array_reverse($this->items);\n    }\n\n    public function getName()\n    {\n        $name = parent::getName() . \" $this->queriedContext\";\n        if (isset($this->title)) {\n            $name .= \" - $this->title\";\n        }\n        return $name;\n    }\n\n    public function getIcon()\n    {\n        return self::URI . '/favicon.ico';\n    }\n\n    public function getURI()\n    {\n        $url = parent::getURI();\n        switch ($this->queriedContext) {\n            case 'Bookmarks':\n                $user = $this->getInput('user');\n                $url = self::URI\n                    . '/users/' . $user\n                    . '/bookmarks?bookmark_search[sort_column]=bookmarkable_date';\n                break;\n            case 'List':\n                $url = $this->getInput('url');\n                break;\n            case 'Work':\n                $url = self::URI . '/works/' . $this->getInput('id');\n                break;\n        }\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/ARDAudiothekBridge.php",
    "content": "<?php\n\nclass ARDAudiothekBridge extends BridgeAbstract\n{\n    const NAME = 'ARD-Audiothek';\n    const URI = 'https://www.ardaudiothek.de';\n    const DESCRIPTION = 'Feed of any show in the ARD-Audiothek, specified by its path';\n    const MAINTAINER = 'Mar-Koeh';\n    /*\n     * The URL Prefix of the API\n     * @const APIENDPOINT https-URL of the used endpoint, ending in `/`\n     */\n    const APIENDPOINT = 'https://api.ardaudiothek.de/';\n    /*\n     * The requested width of the preview image\n     * 448 and 128 have been observed on the wild\n     * @const IMAGEWIDTH width in px of the preview image\n     */\n    const IMAGEWIDTH = 448;\n    /*\n     * Placeholder that will be replace by IMAGEWIDTH in the preview image URL\n     * @const IMAGEWIDTHPLACEHOLDER\n     */\n    const IMAGEWIDTHPLACEHOLDER = '{width}';\n    /*\n     * File extension appended to image link in $this->icon\n     * @const IMAGEEXTENSION\n     */\n    const IMAGEEXTENSION = '.jpg';\n\n    const PARAMETERS = [\n        [\n            'path' => [\n                'name' => 'Show Link or ID',\n                'required' => true,\n                'title' => 'Link to the show page or just its numeric suffix',\n                'defaultValue' => 'https://www.ardaudiothek.de/sendung/kalk-welk/10777871/'\n            ],\n            'limit' => self::LIMIT,\n        ]\n    ];\n\n\n    /**\n     * Holds the title of the current show\n     *\n     * @var string\n     */\n    private $title;\n\n    /**\n     * Holds the URI of the show\n     *\n     * @var string\n     */\n    private $uri;\n\n    /**\n     * Holds the icon of the feed\n     *\n     */\n    private $icon;\n\n    public function collectData()\n    {\n        $path = $this->getInput('path');\n        $limit = $this->getInput('limit');\n\n        $oldTz = date_default_timezone_get();\n        date_default_timezone_set('Europe/Berlin');\n\n        $pathComponents = explode('/', $path);\n        if (empty($pathComponents)) {\n            throwClientException('Path may not be empty');\n        }\n        if (count($pathComponents) < 2) {\n            $showID = $pathComponents[0];\n        } else {\n            $lastKey = count($pathComponents) - 1;\n            $showID = $pathComponents[$lastKey];\n            if (strlen($showID) === 0) {\n                $showID = $pathComponents[$lastKey - 1];\n            }\n        }\n\n        $url = self::APIENDPOINT . 'programsets/' . $showID . '/';\n        $json1 = getContents($url);\n        $data1 = Json::decode($json1, false);\n        $processedJSON = $data1->data->programSet;\n        if (!$processedJSON) {\n            throw new \\Exception('Unable to find show id: ' . $showID);\n        }\n\n        $answerLength = 1;\n        $offset = 0;\n        $numberOfElements = 1;\n\n        while ($answerLength != 0 && $offset < $numberOfElements && (is_null($limit) || $offset < $limit)) {\n            $json2 = getContents($url . '?offset=' . $offset);\n            $data2 = Json::decode($json2, false);\n            $processedJSON = $data2->data->programSet;\n\n            $answerLength = count($processedJSON->items->nodes);\n            $offset = $offset + $answerLength;\n            $numberOfElements = $processedJSON->numberOfElements;\n\n            foreach ($processedJSON->items->nodes as $audio) {\n                $item = [];\n                $item['uri'] = $audio->sharingUrl;\n                $item['title'] = $audio->title;\n                $imageSquare = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $audio->image->url1X1);\n                $image = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $audio->image->url);\n                $item['enclosures'] = [\n                    $audio->audios[0]->url,\n                    $imageSquare\n                ];\n                // synopsis in list is shortened, full synopsis is available using one request per item\n                $item['content'] = '<img src=\"' . $image . '\" /><p>' . $audio->synopsis . '</p>';\n                $item['timestamp'] = $audio->publicationStartDateAndTime;\n                $item['uid'] = $audio->id;\n                $item['author'] = $audio->programSet->publicationService->title;\n\n                $category = $audio->programSet->editorialCategories->title ?? null;\n                if ($category) {\n                    $item['categories'] = [$category];\n                }\n\n                $item['itunes'] = [\n                    'duration' => $audio->duration,\n                ];\n\n                $this->items[] = $item;\n            }\n        }\n        $this->title = $processedJSON->title;\n        $this->uri = $processedJSON->sharingUrl;\n        $this->icon = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $processedJSON->image->url1X1);\n        // add image file extension to URL so icon is shown in generated RSS feeds, see\n        // https://github.com/RSS-Bridge/rss-bridge/blob/4aed05c7b678b5673386d61374bba13637d15487/formats/MrssFormat.php#L76\n        $this->icon = $this->icon . self::IMAGEEXTENSION;\n\n        $this->items = array_slice($this->items, 0, $limit);\n\n        date_default_timezone_set($oldTz);\n    }\n\n    /** {@inheritdoc} */\n    public function getURI()\n    {\n        if (!empty($this->uri)) {\n            return $this->uri;\n        }\n        return parent::getURI();\n    }\n\n    /** {@inheritdoc} */\n    public function getName()\n    {\n        if (!empty($this->title)) {\n            return $this->title;\n        }\n        return parent::getName();\n    }\n\n    /** {@inheritdoc} */\n    public function getIcon()\n    {\n        if (!empty($this->icon)) {\n            return $this->icon;\n        }\n        return parent::getIcon();\n    }\n}\n"
  },
  {
    "path": "bridges/ARDMediathekBridge.php",
    "content": "<?php\n\nclass ARDMediathekBridge extends BridgeAbstract\n{\n    const NAME = 'ARD-Mediathek';\n    const URI = 'https://www.ardmediathek.de';\n    const DESCRIPTION = 'Feed of any series in the ARD-Mediathek, specified by its path';\n    const MAINTAINER = 'yue-dongchen';\n    /*\n     * Number of Items to be requested from ARDmediathek API\n     * 12 has been observed on the wild\n     * 29 is the highest successfully tested value\n     * More Items could be fetched via pagination\n     * The JSON-field pagination holds more information on that\n     * @const PAGESIZE number of requested items\n     */\n    const PAGESIZE = 29;\n    /*\n     * The URL Prefix of the (Webapp-)API\n     * @const APIENDPOINT https-URL of the used endpoint\n     */\n    const APIENDPOINT = 'https://api.ardmediathek.de/page-gateway/widgets/ard/asset/';\n    /*\n     * The URL prefix of the video link\n     * URLs from the webapp include a slug containing titles of show, episode, and tv station.\n     * It seems to work without that.\n     * @const VIDEOLINKPREFIX https-URL prefix of video links\n     */\n    const VIDEOLINKPREFIX = 'https://www.ardmediathek.de/video/';\n    /*\n     * The requested width of the preview image\n     * 432 has been observed on the wild\n     * The webapp seems to also compute and add the height value\n     * It seems to works without that.\n     * @const IMAGEWIDTH width in px of the preview image\n     */\n    const IMAGEWIDTH = 432;\n    /*\n     * Placeholder that will be replace by IMAGEWIDTH in the preview image URL\n     * @const IMAGEWIDTHPLACEHOLDER\n     */\n    const IMAGEWIDTHPLACEHOLDER = '{width}';\n    /**\n     * Title of the current show\n     * @var string\n     */\n    private $title;\n\n    const PARAMETERS = [\n        [\n            'path' => [\n                'name' => 'Show Link or ID',\n                'required' => true,\n                'title' => 'Link to the show page or just its alphanumeric suffix',\n                'defaultValue' => 'https://www.ardmediathek.de/sendung/45-min/Y3JpZDovL25kci5kZS8xMzkx/'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $oldTz = date_default_timezone_get();\n\n        date_default_timezone_set('Europe/Berlin');\n\n        $pathComponents = explode('/', $this->getInput('path'));\n        if (empty($pathComponents)) {\n            throwClientException('Path may not be empty');\n        }\n        if (count($pathComponents) < 2) {\n            $showID = $pathComponents[0];\n        } else {\n            $lastKey = count($pathComponents) - 1;\n            $showID = $pathComponents[$lastKey];\n            if (strlen($showID) === 0) {\n                $showID = $pathComponents[$lastKey - 1];\n            }\n        }\n\n        $url = self::APIENDPOINT . $showID . '?pageSize=' . self::PAGESIZE;\n        $rawJSON = getContents($url);\n        $processedJSON = json_decode($rawJSON);\n\n        foreach ($processedJSON->teasers as $video) {\n            $item = [];\n            // there is also ->links->self->id, ->links->self->urlId, ->links->target->id, ->links->target->urlId\n            $item['uri'] = self::VIDEOLINKPREFIX . $video->id . '/';\n            // there is also ->mediumTitle and ->shortTitle\n            $item['title'] = $video->longTitle;\n            // in the test, aspect16x9 was the only child of images, not sure whether that is always true\n            $item['enclosures'] = [\n                str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $video->images->aspect16x9->src)\n            ];\n            $item['content'] = '<img src=\"' . $item['enclosures'][0] . '\" /><p>';\n            $item['timestamp'] = $video->broadcastedOn;\n            $item['uid'] = $video->id;\n            $item['author'] = $video->publicationService->name;\n            $this->items[] = $item;\n        }\n\n        $this->title = $processedJSON->title;\n\n        date_default_timezone_set($oldTz);\n    }\n\n    /** {@inheritdoc} */\n    public function getName()\n    {\n        if (!empty($this->title)) {\n            return $this->title;\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/ARMCommunityBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass ARMCommunityBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'thefranke';\n    const NAME = 'ARM Community';\n    const URI = 'https://developer.arm.com';\n    const CACHE_TIMEOUT = 86400; // 24h\n    const DESCRIPTION = 'A bridge for the ARM Community blog';\n\n    const PARAMETERS = [\n        'Blog' => [\n            'community' => [\n                'name' => 'Community',\n                'type' => 'list',\n                'values' => [\n                    'AI' => 'ai-blog',\n                    'Announcements' => 'announcements',\n                    'Architectures and Processors' => 'architectures-and-processors-blog',\n                    'Automotive' => 'automotive-blog',\n                    'Embedded and Microcontrollers' => 'embedded-and-microcontrollers-blog',\n                    'Internet of Things (IoT)' => 'internet-of-things-blog',\n                    'Laptops and Desktops' => 'laptops-and-desktops-blog',\n                    'Mobile, Graphics, and Gaming' => 'mobile-graphics-and-gaming-blog',\n                    'Operating Systems' => 'operating-systems-blog',\n                    'Server and Cloud Computing' => 'servers-and-cloud-computing-blog',\n                    'SoC Design and Simulation' => 'soc-design-and-simulation-blog',\n                    'Tools, Software and IDEs' => 'tools-software-ides-blog',\n                ],\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $category = '/community/arm-community-blogs/b/' . $this->getInput('community');\n\n        $header = [\n            'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',\n        ];\n\n        $html = getSimpleHTMLDOM(static::URI . $category, $header);\n        $html = defaultLinkTo($html, static::URI);\n\n        foreach ($html->find('ads-card') as $c) {\n            $articleurl = static::URI . $c->link;\n            $articlehtml = getSimpleHTMLDOMCached($articleurl, static::CACHE_TIMEOUT, $header);\n\n            $date = strtotime($articlehtml->find('#blog-date', 0)->innertext);\n            $title = $articlehtml->find('#blog-title', 0)->innertext;\n            $author = $articlehtml->find('#blog-title', 0)->parent->find('p', 1)->find('a', 0)->innertext;\n            $content = $articlehtml->find('#blog-body', 0)->innertext;\n\n            $this->items[] = [\n                'title'      => $title,\n                'timestamp'  => $date,\n                'author'     => $author,\n                'uri'        => $articleurl,\n                'content'    => $content,\n            ];\n        }\n    }\n\n    public function getName()\n    {\n        $categoryname = $this->getKey('community');\n\n        if (empty($categoryname)) {\n            return static::NAME;\n        }\n\n        return static::NAME . ' - ' . $categoryname;\n    }\n}\n"
  },
  {
    "path": "bridges/ASRockNewsBridge.php",
    "content": "<?php\n\nclass ASRockNewsBridge extends BridgeAbstract\n{\n    const NAME = 'ASRock News';\n    const URI = 'https://www.asrock.com';\n    const DESCRIPTION = 'Returns latest news articles';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI . '/news/index.asp');\n\n        $html = defaultLinkTo($html, self::URI . '/news/');\n\n        foreach ($html->find('div.inner > a') as $index => $a) {\n            $item = [];\n\n            $articlePath = $a->href;\n\n            $articlePageHtml = getSimpleHTMLDOMCached($articlePath, self::CACHE_TIMEOUT);\n\n            $articlePageHtml = defaultLinkTo($articlePageHtml, self::URI);\n\n            $contents = $articlePageHtml->find('div.Contents', 0);\n\n            $item['uri'] = $articlePath;\n            $item['title'] = $contents->find('h3', 0)->innertext;\n\n            $contents->find('h3', 0)->outertext = '';\n\n            $item['content'] = $contents->innertext;\n            $item['timestamp'] = $this->extractDate($a->plaintext);\n\n            $img = $a->find('img', 0);\n            if ($img) {\n                $item['enclosures'][] = $img->src;\n            }\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    private function extractDate($text)\n    {\n        $dateRegex = '/^([0-9]{4}\\/[0-9]{1,2}\\/[0-9]{1,2})/';\n\n        $text = trim($text);\n\n        if (preg_match($dateRegex, $text, $matches)) {\n            return $matches[1];\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "bridges/AcademiaBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass AcademiaBridge extends BridgeAbstract\n{\n    const NAME = 'Academia';\n    const URI = 'https://www.academia.edu';\n    const DESCRIPTION = 'Returns papers from Academia.edu topic pages';\n    const MAINTAINER = 'tillcash';\n    const CACHE_TIMEOUT = 3600; // seconds (1 hour)\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'name' => 'Topic name',\n                'required' => true,\n                'exampleValue' => 'Deadlock_Avoidance',\n            ],\n            'sort' => [\n                'name' => 'Sort by',\n                'type' => 'list',\n                'values' => [\n                    'Newest' => 'Newest',\n                    'Top papers' => 'TopPapers',\n                    'Most cited' => 'MostCited',\n                    'Most downloaded' => 'MostDownloaded',\n                ],\n            ],\n        ],\n    ];\n\n    public function getName()\n    {\n        $topic = $this->getInput('topic');\n        if ($topic) {\n            return self::NAME . ' - ' . str_replace('_', ' ', $topic);\n        }\n\n        return self::NAME;\n    }\n\n    public function collectData()\n    {\n        $topic = $this->getInput('topic');\n        $sort = $this->getInput('sort') ?? 'Newest';\n\n        $url = self::URI . '/Documents/in/' . $topic;\n        if (!filter_var($url, FILTER_VALIDATE_URL)) {\n            throwServerException('Invalid topic name: ' . $topic);\n        }\n\n        if ($sort !== 'Newest') {\n            $url .= '/' . $sort;\n        }\n\n        $dom = getSimpleHTMLDOM($url);\n\n        $json = $dom->find('script[type=\"application/ld+json\"]', 0);\n        if (!$json) {\n            throwServerException('Unable to parse content');\n        }\n\n        $data = Json::decode($json->innertext);\n\n        $articles = $data['subjectOf'] ?? null;\n        if (!is_array($articles) || empty($articles)) {\n            throwServerException('Invalid or empty content');\n        }\n\n        $summaryByUrl = $this->extractSummaries($dom);\n\n        foreach ($articles as $article) {\n            if (($article['@type'] ?? '') !== 'ScholarlyArticle') {\n                continue;\n            }\n\n            $articleUrl = $article['url'] ?? '';\n            if (!filter_var($articleUrl, FILTER_VALIDATE_URL)) {\n                continue;\n            }\n\n            $this->items[] = [\n                'uri' => $articleUrl,\n                'uid' => $articleUrl,\n                'title' => $article['name'] ?? '',\n                'author' => $article['author']['name'] ?? '',\n                'timestamp' => $article['datePublished'] ?? '',\n                'content' => $summaryByUrl[$articleUrl] ?? '',\n            ];\n        }\n    }\n\n    private function extractSummaries($dom): array\n    {\n        $summaryByUrl = [];\n\n        foreach ($dom->find('.work-card-container') as $card) {\n            $a = $card->find('.title a', 0);\n            if (!$a) {\n                continue;\n            }\n\n            $url = $a->href;\n            $complete = $card->find('.complete.hidden', 0);\n            $summary = $complete ? trim($complete->plaintext) : '';\n\n            $summaryByUrl[$url] = $summary;\n        }\n\n        return $summaryByUrl;\n    }\n}\n"
  },
  {
    "path": "bridges/AcrimedBridge.php",
    "content": "<?php\n\nclass AcrimedBridge extends FeedExpander\n{\n    const MAINTAINER = 'qwertygc';\n    const NAME = 'Acrimed';\n    const URI = 'https://www.acrimed.org/';\n    const CACHE_TIMEOUT = 4800; //2hours\n    const DESCRIPTION = 'Returns the newest articles';\n\n    const PARAMETERS = [\n        [\n            'limit' => [\n                'name' => 'limit',\n                'type' => 'number',\n                'defaultValue' => -1,\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = 'https://www.acrimed.org/spip.php?page=backend';\n        $limit = $this->getInput('limit');\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOM($item['uri']);\n        $article = sanitize($articlePage->find('article.article1', 0)->innertext);\n        $article = defaultLinkTo($article, static::URI);\n        $item['content'] = $article;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ActivisionResearchBridge.php",
    "content": "<?php\n\nclass ActivisionResearchBridge extends BridgeAbstract\n{\n    const NAME = 'Activision Research Blog';\n    const URI = 'https://research.activision.com';\n    const DESCRIPTION = 'Posts from the Activision Research blog';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 86400; // 24h\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(static::URI);\n        $dom = $dom->find('div[id=\"home-blog-feed\"]', 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('div[class=\"blog-entry\"]') as $article) {\n            $a = $article->find('a', 0);\n\n            $blogimg = extractFromDelimiters($article->find('div[class=\"blog-img\"]', 0)->style, 'url(', ')');\n\n            $title = htmlspecialchars_decode($article->find('div[class=\"title\"]', 0)->plaintext);\n            $author = htmlspecialchars_decode($article->find('div[class=\"author]', 0)->plaintext);\n            $date = $article->find('div[class=\"pubdate\"]', 0)->plaintext;\n\n            $entry = getSimpleHTMLDOMCached($a->href, static::CACHE_TIMEOUT * 7 * 4);\n            $entry = defaultLinkTo($entry, $this->getURI());\n\n            $content = $entry->find('div[class=\"blog-body\"]', 0);\n            $tagsremove = ['script', 'iframe', 'input', 'form'];\n            $content = sanitize($content, $tagsremove);\n            $content = '<img src=\"' . static::URI . $blogimg . '\" alt=\"\">' . $content;\n\n            $this->items[] = [\n                'title' => $title,\n                'author' => $author,\n                'uri' => $a->href,\n                'content' => $content,\n                'timestamp' => strtotime($date),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AirBreizhBridge.php",
    "content": "<?php\n\nclass AirBreizhBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'fanch317';\n    const NAME = 'Air Breizh';\n    const URI = 'https://www.airbreizh.asso.fr/';\n    const DESCRIPTION = 'Returns newests publications on Air Breizh';\n    const PARAMETERS = [\n        'Publications' => [\n            'theme' => [\n                'name' => 'Thematique',\n                'type' => 'list',\n                'values' => [\n                    'Tout' => '',\n                    'Rapport d\\'activite' => 'rapport-dactivite',\n                    'Etude' => 'etudes',\n                    'Information' => 'information',\n                    'Autres documents' => 'autres-documents',\n                    'Plan Régional de Surveillance de la qualité de l’air' => 'prsqa',\n                    'Transport' => 'transport'\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.airbreizh.asso.fr/voy_content/uploads/2017/11/favicon.png';\n    }\n\n    public function collectData()\n    {\n        $html = '';\n        $html = getSimpleHTMLDOM(static::URI . 'publications/?fwp_publications_thematiques=' . $this->getInput('theme'));\n\n        foreach ($html->find('article') as $article) {\n            $item = [];\n            // Title\n            $item['title'] = $article->find('h2', 0)->plaintext;\n            // Author\n            $item['author'] = 'Air Breizh';\n            // Image\n            $imagelink = $article->find('.card__image', 0)->find('img', 0)->getAttribute('src');\n            // Content preview\n            $item['content'] = '<img src=\"' . $imagelink . '\" />\n\t\t\t<br/>'\n            . $article->find('.card__text', 0)->plaintext;\n            // URL\n            $item['uri'] = $article->find('.publi__buttons', 0)->find('a', 0)->getAttribute('href');\n            // ID\n            $item['id'] = $article->find('.publi__buttons', 0)->find('a', 0)->getAttribute('href');\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AkamaiBridge.php",
    "content": "<?php\n\nclass AkamaiBridge extends FeedExpander\n{\n    const MAINTAINER = 'Mynacol';\n    const NAME = 'Akamai Blog';\n    const URI = 'https://www.akamai.com/blog';\n    const DESCRIPTION = 'Akamai CDN Blog';\n    const PARAMETERS = [[\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 5\n        ]\n    ]];\n\n    const FEED_URI = 'https://feeds.feedburner.com/akamai/blog';\n    const HEADERS = [\n        'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0',\n        'Accept-Language: en',\n    ];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(\n            self::FEED_URI,\n            $this->getInput('limit') ?? static::LIMIT\n        );\n    }\n\n    protected function parseItem(array $item)\n    {\n        $page = getSimpleHTMLDOMCached($item['uri'], self::CACHE_TIMEOUT, self::HEADERS);\n        $page = defaultLinkTo($page, $item['uri']);\n\n        if (!$page) {\n            return $item;\n        }\n\n        $article = $page->find('section.main-content', 0);\n        if (!$article) {\n            return $item;\n        }\n\n        // Extract categories/tags\n        foreach ($article->find('.taglist .cmp-tag-list__list-item') as $tag) {\n            $item['categories'][] = $tag->plaintext;\n        }\n\n        // Remove annoying elements\n        foreach ($article->find('.socialshare, .blogauthor, .taglist, .cmp-prismjs__copy') as $elem) {\n            $elem->remove();\n        }\n        foreach ($article->find('p') as $elem) {\n            if ($elem->plaintext === 'Tags') {\n                $elem->remove();\n            }\n        }\n\n        // Replace content with full text\n        $item['content'] = $article->innertext;\n\n        return $item;\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.akamai.com/site/favicon/android-chrome-192x192.png';\n    }\n}\n"
  },
  {
    "path": "bridges/AlbionOnlineBridge.php",
    "content": "<?php\n\nclass AlbionOnlineBridge extends BridgeAbstract\n{\n    const NAME = 'Albion Online Changelog';\n    const MAINTAINER = 'otakuf';\n    const URI = 'https://albiononline.com';\n    const DESCRIPTION = 'Returns the changes made to the Albion Online';\n    const CACHE_TIMEOUT = 3600; // 60min\n\n    const PARAMETERS = [ [\n        'postcount' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => true,\n            'title' => 'Maximum number of items to return',\n            'defaultValue' => 5,\n        ],\n        'language' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'values' => [\n                'English' => 'en',\n                'Deutsch' => 'de',\n                'Polski' => 'pl',\n                'Français' => 'fr',\n                'Русский' => 'ru',\n                'Português' => 'pt',\n                'Español' => 'es',\n             ],\n            'title' => 'Language of changelog posts',\n            'defaultValue' => 'en',\n        ],\n        'full' => [\n            'name' => 'Full changelog',\n            'type' => 'checkbox',\n            'required' => false,\n            'title' => 'Enable to receive the full changelog post for each item'\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $api = 'https://albiononline.com/';\n        // Example: https://albiononline.com/en/changelog/1/5\n        $url = $api . $this->getInput('language') . '/changelog/1/' . $this->getInput('postcount');\n\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('li') as $data) {\n            $item = [];\n            $item['uri'] = self::URI . $data->find('a', 0)->getAttribute('href');\n            $item['title'] = trim(explode('|', $data->find('span', 0)->plaintext)[0]);\n            // 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\n            //print_r( date_parse_from_format( 'M j, Y' , 'Sep 9, 2020') );\n            //$item['timestamp'] = $this->extractDate($a->plaintext);\n            $item['author'] = 'albiononline.com';\n            if ($this->getInput('full')) {\n                $item['content'] = $this->getFullChangelog($item['uri']);\n            } else {\n                //$item['content'] = trim(preg_replace('/\\s+/', ' ', $data->find('span', 0)->plaintext));\n                // Just use title, no info at all or use title and date, see above\n                $item['content'] = $item['title'];\n            }\n            $item['uid'] = hash('sha256', $item['title']);\n            $this->items[] = $item;\n        }\n    }\n\n    private function getFullChangelog($url)\n    {\n        $html = getSimpleHTMLDOMCached($url);\n        $html = defaultLinkTo($html, self::URI);\n        return $html->find('div.small-12.columns', 1)->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/AlfaBankByBridge.php",
    "content": "<?php\n\nclass AlfaBankByBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'lassana';\n    const NAME = 'AlfaBank.by Новости';\n    const URI = 'https://www.alfabank.by';\n    const DESCRIPTION = 'Уведомления Alfa-Now — новости от Альфа-Банка';\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const PARAMETERS = [\n        'News' => [\n            'business' => [\n                'name' => 'Альфа Бизнес',\n                'type' => 'list',\n                'title' => 'В зависимости от выбора, возращает уведомления для\" .\n\t\t\t\t\t\" клиентов физ. лиц либо для клиентов-юридических лиц и ИП',\n                'values' => [\n                    'Новости' => 'news',\n                    'Новости бизнеса' => 'newsBusiness'\n                ],\n                'defaultValue' => 'news'\n            ],\n            'fullContent' => [\n                'name' => 'Включать содержимое',\n                'type' => 'checkbox',\n                'title' => 'Если выбрано, содержимое уведомлений вставляется в поток (работает медленно)'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $business = $this->getInput('business') == 'newsBusiness';\n        $fullContent = $this->getInput('fullContent') == 'on';\n\n        $mainPageUrl = self::URI . '/about/articles/uvedomleniya/';\n        if ($business) {\n            $mainPageUrl .= '?business=true';\n        }\n        $html = getSimpleHTMLDOM($mainPageUrl);\n        $limit = 0;\n\n        foreach ($html->find('a.notifications__item') as $element) {\n            if ($limit < 10) {\n                $item = [];\n                $item['uid'] = 'urn:sha1:' . hash('sha1', $element->getAttribute('data-notification-id'));\n                $item['title'] = $element->find('div.item-title', 0)->innertext;\n                $item['timestamp'] = DateTime::createFromFormat(\n                    'd M Y',\n                    $this->ruMonthsToEn($element->find('div.item-date', 0)->innertext)\n                )->getTimestamp();\n\n                $itemUrl = self::URI . $element->href;\n                if ($business) {\n                    $itemUrl = str_replace('?business=true', '', $itemUrl);\n                }\n                $item['uri'] = $itemUrl;\n\n                if ($fullContent) {\n                    $itemHtml = getSimpleHTMLDOM($itemUrl);\n                    if ($itemHtml) {\n                        $item['content'] = $itemHtml->find('div.now-p__content-text', 0)->innertext;\n                    }\n                }\n\n                $this->items[] = $item;\n                $limit++;\n            }\n        }\n    }\n\n    public function getIcon()\n    {\n        return static::URI . '/local/images/favicon.ico';\n    }\n\n    private function ruMonthsToEn($date)\n    {\n        $ruMonths = [\n            'Января', 'Февраля', 'Марта', 'Апреля', 'Мая', 'Июня',\n            'Июля', 'Августа', 'Сентября', 'Октября', 'Ноября', 'Декабря' ];\n        $enMonths = [\n            'January', 'February', 'March', 'April', 'May', 'June',\n            'July', 'August', 'September', 'October', 'November', 'December' ];\n        return str_replace($ruMonths, $enMonths, $date);\n    }\n}\n"
  },
  {
    "path": "bridges/AllSidesBridge.php",
    "content": "<?php\n\nclass AllSidesBridge extends BridgeAbstract\n{\n    const NAME = 'AllSides';\n    const URI = 'https://www.allsides.com';\n    const DESCRIPTION = 'Balanced news and media bias ratings.';\n    const MAINTAINER = 'Oliver Nutter';\n    const PARAMETERS = [\n        'global' => [\n            'limit' => [\n                'name' => 'Number of posts to return',\n                'type' => 'number',\n                'defaultValue' => 10,\n                'required' => false,\n                'title' => 'Zero or negative values return all posts (ignored if not fetching full article)',\n            ],\n            'fetch' => [\n                'name' => 'Fetch full article content',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n            ],\n        ],\n        'Headline Roundups' => [],\n    ];\n\n    private const ROUNDUPS_URI = self::URI . '/headline-roundups';\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Headline Roundups':\n                $index = getSimpleHTMLDOM(self::ROUNDUPS_URI);\n                defaultLinkTo($index, self::ROUNDUPS_URI);\n                $entries = $index->find('table.views-table > tbody > tr');\n\n                $limit = (int) $this->getInput('limit');\n                $fetch = (bool) $this->getInput('fetch');\n\n                if ($limit > 0 && $fetch) {\n                    $entries = array_slice($entries, 0, $limit);\n                }\n\n                foreach ($entries as $entry) {\n                    $item = [\n                        'title' => $entry->find('.views-field-name', 0)->text(),\n                        'uri' => $entry->find('a', 0)->href,\n                        'timestamp' => $entry->find('.date-display-single', 0)->content,\n                        'author' => 'AllSides Staff',\n                    ];\n\n                    if ($fetch) {\n                        $article = getSimpleHTMLDOMCached($item['uri']);\n                        defaultLinkTo($article, $item['uri']);\n\n                        $item['content'] = $article->find('.story-id-page-description', 0);\n\n                        foreach ($article->find('.page-tags a') as $tag) {\n                            $item['categories'][] = $tag->text();\n                        }\n                    }\n\n                    $this->items[] = $item;\n                }\n                break;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->queriedContext) {\n            return self::NAME . \" - {$this->queriedContext}\";\n        }\n        return self::NAME;\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Headline Roundups':\n                return self::ROUNDUPS_URI;\n        }\n        return self::URI;\n    }\n}\n"
  },
  {
    "path": "bridges/AllegroBridge.php",
    "content": "<?php\n\nclass AllegroBridge extends BridgeAbstract\n{\n    const NAME = 'Allegro';\n    const URI = 'https://www.allegro.pl';\n    const DESCRIPTION = 'Returns the search results from the Allegro.pl shopping and bidding portal';\n    const MAINTAINER = 'wrobelda';\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Search URL',\n            'title' => 'Copy the URL from your browser\\'s address bar after searching for your items and paste it here',\n            'exampleValue' => 'https://allegro.pl/kategoria/swieze-warzywa-cebula-318660',\n            'required' => true,\n        ],\n        'cookie' => [\n            'name' => 'The complete cookie value',\n            'title' => 'Paste the cookie value from your browser, otherwise 403 gets returned',\n            'required' => true,\n        ],\n        'includeSponsoredOffers' => [\n            'type' => 'checkbox',\n            'name' => 'Include Sponsored Offers',\n            'defaultValue' => 'checked'\n        ],\n        'includePromotedOffers' => [\n            'type' => 'checkbox',\n            'name' => 'Include Promoted Offers',\n            'defaultValue' => 'checked'\n        ]\n    ]];\n\n    public function getName()\n    {\n        $url = $this->getInput('url');\n        if (!$url) {\n            return parent::getName();\n        }\n        $parsedUrl = parse_url($url, PHP_URL_QUERY);\n        if (!$parsedUrl) {\n            return parent::getName();\n        }\n        parse_str($parsedUrl, $fields);\n\n        if (array_key_exists('string', $fields)) {\n            $f = urldecode($fields['string']);\n        } else {\n            $f = false;\n        }\n        if ($f) {\n            return $f;\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        $url = $this->getInput('url');\n        if (!$url) {\n            return parent::getURI();\n        }\n\n        # make sure we order by the most recently listed offers\n        $url = preg_replace('/([?&])order=[^&]+(&|$)/', '$1', $this->getInput('url'));\n        $url .= (parse_url($url, PHP_URL_QUERY) ? '&' : '?') . 'order=n';\n\n        # do not return related listings if no exact matches are found\n        $url .= '&strategy=NO_FALLBACK';\n\n        return $url;\n    }\n\n    public function collectData()\n    {\n        $html = getContents($this->getURI(), [], [CURLOPT_COOKIE => $this->getInput('cookie')]);\n\n        $storeData = null;\n        if (preg_match('/<script[^>]*>\\s*(\\{\\s*?\"__listing_StoreState\".*\\})\\s*<\\/script>/i', $html, $match)) {\n            $data = json_decode($match[1], true);\n            $storeData = $data['__listing_StoreState'] ?? null;\n        }\n\n        foreach ($storeData['items']['elements'] as $elements) {\n            if (!array_key_exists('offerId', $elements)) {\n                continue;\n            }\n            if (!$this->getInput('includeSponsoredOffers') && $elements['isSponsored']) {\n                continue;\n            }\n            if (!$this->getInput('includePromotedOffers') && $elements['promoted']) {\n                continue;\n            }\n\n            $item = [];\n            $item['uid'] = $elements['offerId'];\n            $item['uri'] = $elements['url'];\n            $item['title'] = $elements['alt'];\n\n            $image = $elements['photos'][0]['medium'];\n            if ($image) {\n                $item['enclosures'] = [$image . '#.image'];\n            }\n\n            $price = $elements['price']['mainPrice']['amount'];\n            $currency = $elements['price']['mainPrice']['currency'];\n            $sellerType = $elements['seller']['title'];\n\n            $item['categories'] = [$sellerType];\n\n            $description = '';\n            foreach ($elements['parameters'] as $parameter) {\n                $item['categories'] = array_merge($item['categories'], $parameter['values']);\n                $description .= '<dt>' . $parameter['name'] . ': ' . implode(',', $parameter['values']) . '</dt>';\n            }\n\n            $item['content'] = '<div><strong>'\n                . $price . ' ' . $currency\n                . '</strong></div><dl><dt>'\n                . $sellerType . '</dt>'\n                . $description\n                . '</dl><hr>';\n\n            $this->items[] = $item;\n        }\n    }\n}\n\n"
  },
  {
    "path": "bridges/AllocineFRBridge.php",
    "content": "<?php\n\nclass AllocineFRBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'superbaillot.net';\n    const NAME = 'Allo Cine';\n    const CACHE_TIMEOUT = 25200; // 7h\n    const URI = 'https://www.allocine.fr';\n    const DESCRIPTION = 'Bridge for allocine.fr';\n    const PARAMETERS = [ [\n        'category' => [\n            'name' => 'Emission',\n            'type' => 'list',\n            'title' => 'Sélectionner l\\'emission',\n            'values' => [\n                'Faux Raccord' => 'faux-raccord',\n                'Fanzone' => 'fanzone',\n                'Game In Ciné' => 'game-in-cine',\n                'Pour la faire courte' => 'pour-la-faire-courte',\n                'Home Cinéma' => 'home-cinema',\n                'PILS - Par Ici Les Sorties' => 'pils-par-ici-les-sorties',\n                'AlloCiné : l\\'émission, sur LeStream' => 'allocine-lemission-sur-lestream',\n                'Give Me Five' => 'give-me-five',\n                'Aviez-vous remarqué ?' => 'aviez-vous-remarque',\n                'Et paf, il est mort' => 'et-paf-il-est-mort',\n                'The Big Fan Theory' => 'the-big-fan-theory',\n                'Clichés' => 'cliches',\n                'Complètement...' => 'completement',\n                '#Fun Facts' => 'fun-facts',\n                'Origin Story' => 'origin-story',\n            ]\n        ]\n    ]];\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('category'))) {\n            $categories = [\n                'faux-raccord' => '/video/programme-12284/',\n                'fanzone' => '/video/programme-12298/',\n                'game-in-cine' => '/video/programme-12288/',\n                'pour-la-faire-courte' => '/video/programme-20960/',\n                'home-cinema' => '/video/programme-12287/',\n                'pils-par-ici-les-sorties' => '/video/programme-25789/',\n                'allocine-lemission-sur-lestream' => '/video/programme-25123/',\n                'give-me-five' => '/video/programme-21919/saison-34518/',\n                'aviez-vous-remarque' => '/video/programme-19518/',\n                'et-paf-il-est-mort' => '/video/programme-25113/',\n                'the-big-fan-theory' => '/video/programme-20403/',\n                'cliches' => '/video/programme-24834/',\n                'completement' => '/video/programme-23859/',\n                'fun-facts' => '/video/programme-23040/',\n                'origin-story' => '/video/programme-25667/'\n            ];\n\n            $category = $this->getInput('category');\n            if (array_key_exists($category, $categories)) {\n                return static::URI . $this->getLastSeasonURI($categories[$category]);\n            } else {\n                throwClientException('Emission inconnue');\n            }\n        }\n\n        return parent::getURI();\n    }\n\n    private function getLastSeasonURI($category)\n    {\n        $html = getSimpleHTMLDOMCached(static::URI . $category, 86400);\n        $seasonLink = $html->find('section[class=section-wrap section]', 0)->find('div[class=cf]', 0)->find('a', 0);\n        $URI = $seasonLink->href;\n        return $URI;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('category'))) {\n            return self::NAME . ' : ' . $this->getKey('category');\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('div[class=gd-col-left]', 0)->find('div[class*=video-card]') as $element) {\n            $item = [];\n\n            $title = $element->find('a[class*=meta-title-link]', 0);\n            $content = trim(defaultLinkTo($element->outertext, static::URI));\n\n            // Replace image 'src' with the one in 'data-src'\n            $content = preg_replace('@src=\"data:image/gif;base64,[A-Za-z0-9+\\/]*\"@', '', $content);\n            $content = preg_replace('@data-src=@', 'src=', $content);\n\n            // Remove date in the content to prevent content update while the video is getting older\n            $content = preg_replace('@<div class=\"meta-sub light\">.*<span>[^<]*</span>[^<]*</div>@', '', $content);\n\n            $item['content'] = $content;\n            $item['title'] = trim($title->innertext);\n            $item['uri'] = static::URI . '/' . substr($title->href, 1);\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AllocineFRSortiesBridge.php",
    "content": "<?php\n\nclass AllocineFRSortiesBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Simounet';\n    const NAME = 'AlloCiné Sorties';\n    const CACHE_TIMEOUT = 25200; // 7h\n    const BASE_URI = 'https://www.allocine.fr';\n    const URI = self::BASE_URI . '/film/sorties-semaine/';\n    const DESCRIPTION = 'Bridge for AlloCiné - Sorties cinéma cette semaine';\n\n    public function getName()\n    {\n        return self::NAME;\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('section.section.section-wrap', 0)->find('li.mdl') as $element) {\n            $item = [];\n\n            $thumb = $element->find('figure.thumbnail', 0);\n            $meta = $element->find('div.meta-body', 0);\n            $synopsis = $element->find('div.synopsis', 0);\n            $date = $element->find('span.date', 0);\n\n            $title = $element->find('a[class*=meta-title-link]', 0);\n            $content = trim(defaultLinkTo($thumb->outertext . $meta->outertext . $synopsis->outertext, static::URI));\n\n            // Replace image 'src' with the one in 'data-src'\n            $content = preg_replace('@src=\"data:image/gif;base64,[A-Za-z0-9=+\\/]*\"@', '', $content);\n            $content = preg_replace('@data-src=@', 'src=', $content);\n\n            $item['content'] = $content;\n            $item['title'] = trim($title->innertext);\n            $item['timestamp'] = $this->frenchPubDateToTimestamp($date->plaintext);\n            $item['uri'] = static::BASE_URI . '/' . substr($title->href, 1);\n            $this->items[] = $item;\n        }\n    }\n\n    private function frenchPubDateToTimestamp($date)\n    {\n        return strtotime(\n            strtr(\n                strtolower($date),\n                [\n                    'janvier' => 'jan',\n                    'février' => 'feb',\n                    'mars' => 'march',\n                    'avril' => 'apr',\n                    'mai' => 'may',\n                    'juin' => 'jun',\n                    'juillet' => 'jul',\n                    'août' => 'aug',\n                    'septembre' => 'sep',\n                    'octobre' => 'oct',\n                    'novembre' => 'nov',\n                    'décembre' => 'dec'\n                ]\n            )\n        );\n    }\n}\n"
  },
  {
    "path": "bridges/AlpinePackagesBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass AlpinePackagesBridge extends BridgeAbstract\n{\n    const NAME = 'Alpine Packages';\n    const MAINTAINER = 'rsd76';\n    const URI = 'https://pkgs.alpinelinux.org';\n    const DESCRIPTION = 'Get Alpine package versions';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [\n        [\n            'package' => [\n                'type' => 'text',\n                'name' => 'Package Name',\n                'required' => true,\n                'exampleValue' => 'curl',\n                'title' => 'Name of the package. Use * and ? as wildcards. For example: curl-dev, curl-* or curl-???.'\n            ],\n            'branch' => [\n                'type' => 'text',\n                'name' => 'Package branch',\n                'required' => true,\n                'exampleValue' => 'v3.23',\n                'title' => 'Name of the branch. For example: edge, v3.23, v3.22, etc.'\n            ],\n            'repository' => [\n                'type' => 'list',\n                'name' => 'Repository name',\n                'values' => [\n                    'All' => 'all',\n                    'Community' => 'community',\n                    'Main' => 'main',\n                    'Testing' => 'testing'\n                ],\n                'defaultValue' => 'all'\n            ],\n            'architecture' => [\n                'type' => 'list',\n                'name' => 'Achitecture',\n                'values' => [\n                    'All' => 'all',\n                    'aarch64' => 'aarch64',\n                    'armhf' => 'armhf',\n                    'armv7' => 'armv7',\n                    'loongarch64' => 'loongarch64',\n                    'ppc64le' => 'ppc64le',\n                    'riscv64' => 'riscv64',\n                    's390x' => 's390x',\n                    'x86' => 'x86',\n                    'x86_64' => 'x86_64'\n                ],\n                'defaultValue' => 'all'\n            ]\n        ]\n    ];\n\n    private function getADom($element)\n    {\n        return $element->find('a')[0];\n    }\n\n    private function getElementData($element)\n    {\n        $classes = [\n            'package',\n            'repo',\n            'arch',\n            'maintainer'\n        ];\n        $noAhrefClasses = [\n            'branch',\n            'bdate'\n        ];\n        $data = [];\n        // Get data from element which contains <a href=...>.\n        foreach ($classes as $class) {\n            $td = $this->getTdClassDom($element, $class);\n            $a = $this->getADom($td);\n            $data[$class] = trim($a->plaintext);\n            $data[$class . '-href'] = $a->href;\n        }\n        // Get data from element which only contains text.\n        foreach ($noAhrefClasses as $class) {\n            $td = $this->getTdClassDom($element, $class);\n            $data[$class] = trim($td->plaintext);\n        }\n        // Get version data in a <strong> element.\n        $td = $this->getTdClassDom($element, 'version');\n        $strong = $td->find('strong[class=hint--right hint--rounded text-success]')[0];\n        $data['version'] = trim($strong->plaintext);\n        return $data;\n    }\n\n    private function getTdClassDom($element, $class)\n    {\n        return $element->find('td[class=' . $class . ']')[0];\n    }\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM($this->getUri());\n        $dom = defaultLinkTo($dom, self::URI);\n        $table = $dom->find('table[class=pure-table pure-table-striped]')[0];\n        $tbody = $table->find('tbody')[0];\n        $trs = $tbody->find('tr');\n        foreach ($trs as $tr) {\n            $itemData = $this->getElementData($tr);\n            $this->items[] = [\n                'title' => $itemData['package'] . '-' . $itemData['version'],\n                'uri' => $itemData['package-href'],\n                'timestamp' => strtotime($itemData['bdate']),\n                'uid' => trim($itemData['package']) . $itemData['version'] . $itemData['arch'] . $itemData['branch'] . $itemData['repo'],\n                'author' => $itemData['maintainer'],\n                'categories' => [\n                    'arch: ' . $itemData['arch'],\n                    'branch: ' . $itemData['branch'],\n                    'repo: ' . $itemData['repo']\n                ]\n            ];\n        }\n    }\n\n    public function getName()\n    {\n        $packageName = $this->getInput('package');\n        $branchName = $this->getInput('branch');\n        $repositoryName = $this->getInput('repository');\n        $architecture = $this->getInput('architecture');\n\n        $name = '';\n\n        if ($packageName) {\n            $packageName = strtolower($packageName);\n            $name = $packageName . ' (';\n            if ($branchName) {\n                $branchName = strtolower($branchName);\n                $name .= 'branch ' . $branchName;\n            }\n            if ($repositoryName) {\n                $repositoryName = strtolower($repositoryName);\n                if ($repositoryName !== 'all') {\n                    $name .= ', repo ' . $repositoryName;\n                }\n            }\n            if ($architecture) {\n                $architecture = strtolower($architecture);\n                if ($architecture !== 'all') {\n                    $name .= ', arch ' . $architecture;\n                }\n            }\n            $name .= ') - Alpine packages';\n            return $name;\n        }\n\n        return parent::getName();\n    }\n\n    public function getUri()\n    {\n        $package = $this->getInput('package');\n        $branch = $this->getInput('branch');\n        $repository = $this->getInput('repository');\n        $architecture = $this->getInput('architecture');\n\n        if ($package) {\n            $package = urlencode(strtolower(trim($package)));\n        }\n        if ($branch) {\n            $branch = strtolower(trim($branch));\n        }\n        if ($repository) {\n            $repository = strtolower($repository);\n            if ($repository === 'all') {\n                $repository = '';\n            }\n        }\n        if ($architecture) {\n            $architecture = strtolower(trim($architecture));\n            if ($architecture === 'all') {\n                $architecture = '';\n            }\n        }\n\n        if ($package && $branch) {\n            return self::URI . '/packages?name=' . $package . '&branch=' . $branch . '&repo=' . $repository . '&arch=' . $architecture . '&origin=&flagged=&maintainer=';\n        }\n        return self::URI;\n    }\n}\n"
  },
  {
    "path": "bridges/AmazonBridge.php",
    "content": "<?php\n\nclass AmazonBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Alexis CHEMEL';\n    const NAME = 'Amazon';\n    const URI = 'https://www.amazon.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns products from Amazon search';\n\n    const PARAMETERS = [[\n        'q' => [\n            'name' => 'Keyword',\n            'required' => true,\n            'exampleValue' => 'watch',\n        ],\n        'sort' => [\n            'name' => 'Sort by',\n            'type' => 'list',\n            'values' => [\n                'Relevance' => 'relevanceblender',\n                'Price: Low to High' => 'price-asc-rank',\n                'Price: High to Low' => 'price-desc-rank',\n                'Average Customer Review' => 'review-rank',\n                'Newest Arrivals' => 'date-desc-rank',\n            ],\n            'defaultValue' => 'relevanceblender',\n        ],\n        'tld' => [\n            'name' => 'Country',\n            'type' => 'list',\n            'values' => [\n                'Australia' => 'com.au',\n                'Brazil' => 'com.br',\n                'Canada' => 'ca',\n                'China' => 'cn',\n                'France' => 'fr',\n                'Germany' => 'de',\n                'India' => 'in',\n                'Italy' => 'it',\n                'Japan' => 'co.jp',\n                'Mexico' => 'com.mx',\n                'Netherlands' => 'nl',\n                'Poland' => 'pl',\n                'Spain' => 'es',\n                'Sweden' => 'se',\n                'Turkey' => 'com.tr',\n                'United Kingdom' => 'co.uk',\n                'United States' => 'com',\n            ],\n            'defaultValue' => 'com',\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $baseUrl = sprintf('https://www.amazon.%s', $this->getInput('tld'));\n\n        $url = sprintf(\n            '%s/s/?field-keywords=%s&sort=%s',\n            $baseUrl,\n            urlencode($this->getInput('q')),\n            $this->getInput('sort')\n        );\n\n        $dom = getSimpleHTMLDOM($url);\n\n        $elements = $dom->find('div.s-result-item');\n\n        foreach ($elements as $element) {\n            $item = [];\n\n            $title = $element->find('h2', 0);\n            if (!$title) {\n                continue;\n            }\n\n            $item['title'] = $title->innertext;\n\n            $itemUrl = $element->find('a', 0)->href;\n            $item['uri'] = urljoin($baseUrl, $itemUrl);\n\n            $image = $element->find('img', 0);\n            if ($image) {\n                $item['content'] = '<img src=\"' . $image->getAttribute('src') . '\" /><br />';\n            }\n\n            $price = $element->find('span.a-price > .a-offscreen', 0);\n            if ($price) {\n                $item['content'] .= $price->innertext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('tld')) && !is_null($this->getInput('q'))) {\n            return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('q');\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/AmazonPriceTrackerBridge.php",
    "content": "<?php\n\nclass AmazonPriceTrackerBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'captn3m0, sal0max, bagnacauda';\n    const NAME = 'Amazon Price Tracker';\n    const URI = 'https://www.amazon.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Tracks price for a single product on Amazon';\n\n    const PARAMETERS = [\n        [\n        'asin' => [\n            'name'          => 'ASIN',\n            'required'      => true,\n            'exampleValue'  => 'B0923XT6K7',\n            // https://stackoverflow.com/a/12827734\n            'pattern'       => 'B[\\dA-Z]{9}|\\d{9}(X|\\d)',\n        ],\n        'tld' => [\n            'name' => 'Country',\n            'type' => 'list',\n            'values' => [\n                'Australia'         => 'com.au',\n                'Brazil'        => 'com.br',\n                'Canada'        => 'ca',\n                'China'         => 'cn',\n                'France'        => 'fr',\n                'Germany'       => 'de',\n                'India'         => 'in',\n                'Italy'         => 'it',\n                'Japan'         => 'co.jp',\n                'Mexico'        => 'com.mx',\n                'Netherlands'       => 'nl',\n                'Poland'        => 'pl',\n                'Spain'         => 'es',\n                'Sweden'        => 'se',\n                'Turkey'        => 'com.tr',\n                'United Kingdom'    => 'co.uk',\n                'United States'     => 'com',\n            ],\n            'defaultValue' => 'com',\n        ],\n        ]];\n\n    const PRICE_SELECTORS = [\n        '#priceblock_ourprice',\n        '.priceBlockBuyingPriceString',\n        '#newBuyBoxPrice',\n        '#tp_price_block_total_price_ww',\n        'span.offer-price',\n        '.a-color-price',\n    ];\n\n    const WHITESPACE = \" \\t\\n\\r\\0\\x0B\\xC2\\xA0\";\n\n    protected $title;\n\n    /**\n     * Generates domain name given a amazon TLD\n     */\n    private function getDomainName()\n    {\n        return 'https://www.amazon.' . $this->getInput('tld');\n    }\n\n    /**\n     * Generates URI for a Amazon product page\n     */\n    public function getURI()\n    {\n        if (!is_null($this->getInput('asin'))) {\n            return $this->getDomainName() . '/dp/' . $this->getInput('asin');\n        }\n        return parent::getURI();\n    }\n\n    /**\n     * Scrapes the product title from the html page\n     * returns the default title if scraping fails\n     */\n    private function getTitle($html)\n    {\n        $titleTag = $html->find('#productTitle', 0);\n\n        if (!$titleTag) {\n            return $this->getDefaultTitle();\n        } else {\n            return trim(html_entity_decode($titleTag->innertext, ENT_QUOTES));\n        }\n    }\n\n    /**\n     * Title used by the feed if none could be found\n     */\n    private function getDefaultTitle()\n    {\n        return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('asin');\n    }\n\n    /**\n     * Returns name for the feed\n     * Uses title (already scraped) if it has one\n     */\n    public function getName()\n    {\n        if (isset($this->title)) {\n            return $this->title;\n        } else {\n            return parent::getName();\n        }\n    }\n\n    private function parseDynamicImage($attribute)\n    {\n        $json = json_decode(html_entity_decode($attribute), true);\n\n        if ($json and count($json) > 0) {\n            return array_keys($json)[0];\n        }\n    }\n\n    /**\n     * Returns a generated image tag for the product\n     */\n    private function getImage($html)\n    {\n        $image = 'https://placekitten.com/200/300';\n        $imageSrc = $html->find('#main-image-container img', 0);\n        if ($imageSrc) {\n            $hiresImage = $imageSrc->getAttribute('data-old-hires');\n            $dynamicImageAttribute = $imageSrc->getAttribute('data-a-dynamic-image');\n            $image = $hiresImage ?: $this->parseDynamicImage($dynamicImageAttribute);\n        }\n\n        return <<<EOT\n<img width=\"300\" style=\"max-width:300;max-height:300\" src=\"$image\" alt=\"{$this->title}\" />\nEOT;\n    }\n\n    /**\n     * Return \\simple_html_dom object\n     * for the entire html of the product page\n     */\n    private function getHtml()\n    {\n        $uri = $this->getURI();\n\n        return getSimpleHTMLDOM($uri);\n    }\n\n    private function scrapePriceFromMetrics($html)\n    {\n        $asinData = $html->find('#cerberus-data-metrics', 0);\n\n        // <div id=\"cerberus-data-metrics\" style=\"display: none;\"\n        //  data-asin=\"B00WTHJ5SU\" data-asin-price=\"14.99\" data-asin-shipping=\"0\"\n        //  data-asin-currency-code=\"USD\" data-substitute-count=\"-1\" ... />\n        if ($asinData) {\n            return [\n                'price'     => $asinData->getAttribute('data-asin-price'),\n                'currency'  => $asinData->getAttribute('data-asin-currency-code'),\n                'shipping'  => $asinData->getAttribute('data-asin-shipping')\n            ];\n        }\n\n        return false;\n    }\n\n    private function scrapePriceTwister($html)\n    {\n        $json = $html->find('.twister-plus-buying-options-price-data', 0);\n        if ($json == null) {\n            return null;\n        }\n\n        $data = json_decode($json->innertext, true);\n        foreach ($data as $key => $value) {\n            $value = $value[0];\n            return [\n                'displayPrice' => $value['displayPrice'],\n                'price' => $value['priceAmount'],\n                'currency' => $value['currencySymbol'],\n                'shipping' => null,\n            ];\n        }\n\n        return null;\n    }\n\n    private function scrapePriceGeneric($html)\n    {\n        $default = [\n            'price'         => null,\n            'displayPrice'  => null,\n            'currency'      => null,\n            'shipping'      => null,\n        ];\n        $priceDiv = null;\n\n        foreach (self::PRICE_SELECTORS as $sel) {\n            $priceDiv = $html->find($sel, 0);\n            if ($priceDiv) {\n                break;\n            }\n        }\n\n        if (!$priceDiv) {\n            return $default;\n        }\n\n        $priceString = str_replace(str_split(self::WHITESPACE), '', $priceDiv->plaintext);\n        $price = null;\n        $priceFound = false;\n\n    // find longest repeated string\n        for ($offset = 0; $offset < strlen($priceString); $offset++) {\n            for ($length = 1; substr_count($priceString, substr($priceString, $offset, $length + 1)) >= 2; $length++) {\n                $priceFound = true;\n            }\n\n            if ($priceFound) {\n                $price = substr($priceString, $offset, $length);\n                break;\n            }\n        }\n\n        $currency = str_replace($price, '', $priceString);\n\n        if ($price != null && $currency != null) {\n            return [\n                'price'     => $price,\n                'displayPrice'  => null,\n                'currency'  => $currency,\n                'shipping'  => null\n            ];\n        }\n        return $default;\n    }\n\n    public function collectData()\n    {\n        $html = $this->getHtml();\n        $this->title = $this->getTitle($html);\n        $image = $this->getImage($html);\n        $data = $this->scrapePriceTwister($html) ?? $this->scrapePriceGeneric($html);\n\n        // render\n        $content = '';\n        $price = $data['displayPrice'];\n        if (!$price) {\n            $price = sprintf('%s %s', $data['price'], $data['currency']);\n        }\n        $content .= sprintf('%s<br>Price: %s', $image, $price);\n        if ($data['shipping'] !== null) {\n            $content .= sprintf('<br>Shipping: %s %s</br>', $data['shipping'], $data['currency']);\n        }\n\n        $item = [\n            'title'     => $this->title,\n            'uri'       => $this->getURI(),\n            'content'   => $content,\n            // This is to ensure that feed readers notice the price change\n            'uid'       => md5($data['price'])\n        ];\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/AnfrBridge.php",
    "content": "<?php\n\nclass AnfrBridge extends BridgeAbstract\n{\n    const NAME = 'ANFR';\n    const URI = 'https://data.anfr.fr/';\n    const DESCRIPTION = 'Fetches data from the French administration \"Agence Nationale des Fréquences\".';\n    const CACHE_TIMEOUT = 604800; // 7d\n    const MAINTAINER = 'quent1';\n    const PARAMETERS = [\n        'Données sur les réseaux mobiles' => [\n            'departement' => [\n                'name' => 'Département',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => null,\n                    'Ain' => '001',\n                    'Aisne' => '002',\n                    'Allier' => '003',\n                    'Alpes-de-Haute-Provence' => '004',\n                    'Hautes-Alpes' => '005',\n                    'Alpes-Maritimes' => '006',\n                    'Ardèche' => '007',\n                    'Ardennes' => '008',\n                    'Ariège' => '009',\n                    'Aube' => '010',\n                    'Aude' => '011',\n                    'Aveyron' => '012',\n                    'Bouches-du-Rhône' => '013',\n                    'Calvados' => '014',\n                    'Cantal' => '015',\n                    'Charente' => '016',\n                    'Charente-Maritime' => '017',\n                    'Cher' => '018',\n                    'Corrèze' => '019',\n                    'Corse-du-Sud' => '02A',\n                    'Haute-Corse' => '02B',\n                    'Côte-d\\'Or' => '021',\n                    'Côtes-d\\'Armor' => '022',\n                    'Creuse' => '023',\n                    'Dordogne' => '024',\n                    'Doubs' => '025',\n                    'Drôme' => '026',\n                    'Eure' => '027',\n                    'Eure-et-Loir' => '028',\n                    'Finistère' => '029',\n                    'Gard' => '030',\n                    'Haute-Garonne' => '031',\n                    'Gers' => '032',\n                    'Gironde' => '033',\n                    'Hérault' => '034',\n                    'Ille-et-Vilaine' => '035',\n                    'Indre' => '036',\n                    'Indre-et-Loire' => '037',\n                    'Isère' => '038',\n                    'Jura' => '039',\n                    'Landes' => '040',\n                    'Loir-et-Cher' => '041',\n                    'Loire' => '042',\n                    'Haute-Loire' => '043',\n                    'Loire-Atlantique' => '044',\n                    'Loiret' => '045',\n                    'Lot' => '046',\n                    'Lot-et-Garonne' => '047',\n                    'Lozère' => '048',\n                    'Maine-et-Loire' => '049',\n                    'Manche' => '050',\n                    'Marne' => '051',\n                    'Haute-Marne' => '052',\n                    'Mayenne' => '053',\n                    'Meurthe-et-Moselle' => '054',\n                    'Meuse' => '055',\n                    'Morbihan' => '056',\n                    'Moselle' => '057',\n                    'Nièvre' => '058',\n                    'Nord' => '059',\n                    'Oise' => '060',\n                    'Orne' => '061',\n                    'Pas-de-Calais' => '062',\n                    'Puy-de-Dôme' => '063',\n                    'Pyrénées-Atlantiques' => '064',\n                    'Hautes-Pyrénées' => '065',\n                    'Pyrénées-Orientales' => '066',\n                    'Bas-Rhin' => '067',\n                    'Haut-Rhin' => '068',\n                    'Rhône' => '069',\n                    'Haute-Saône' => '070',\n                    'Saône-et-Loire' => '071',\n                    'Sarthe' => '072',\n                    'Savoie' => '073',\n                    'Haute-Savoie' => '074',\n                    'Paris' => '075',\n                    'Seine-Maritime' => '076',\n                    'Seine-et-Marne' => '077',\n                    'Yvelines' => '078',\n                    'Deux-Sèvres' => '079',\n                    'Somme' => '080',\n                    'Tarn' => '081',\n                    'Tarn-et-Garonne' => '082',\n                    'Var' => '083',\n                    'Vaucluse' => '084',\n                    'Vendée' => '085',\n                    'Vienne' => '086',\n                    'Haute-Vienne' => '087',\n                    'Vosges' => '088',\n                    'Yonne' => '089',\n                    'Territoire de Belfort' => '090',\n                    'Essonne' => '091',\n                    'Hauts-de-Seine' => '092',\n                    'Seine-Saint-Denis' => '093',\n                    'Val-de-Marne' => '094',\n                    'Val-d\\'Oise' => '095',\n                    'Guadeloupe' => '971',\n                    'Martinique' => '972',\n                    'Guyane' => '973',\n                    'La Réunion' => '974',\n                    'Saint-Pierre-et-Miquelon' => '975',\n                    'Mayotte' => '976',\n                    'Saint-Barthélemy' => '977',\n                    'Saint-Martin' => '978',\n                    'Terres australes et antarctiques françaises' => '984',\n                    'Wallis-et-Futuna' => '986',\n                    'Polynésie française' => '987',\n                    'Nouvelle-Calédonie' => '988',\n                    'Île de Clipperton' => '989'\n                ]\n            ],\n            'generation' => [\n                'name' => 'Génération',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => null,\n                    '2G' => '2G',\n                    '3G' => '3G',\n                    '4G' => '4G',\n                    '5G' => '5G',\n                ]\n            ],\n            'operateur' => [\n                'name' => 'Opérateur',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => null,\n                    'Bouygues Télécom' => 'BOUYGUES TELECOM',\n                    'Dauphin Télécom' => 'DAUPHIN TELECOM',\n                    'Digiciel' => 'DIGICEL',\n                    'Free Caraïbes' => 'FREE CARAIBES',\n                    'Free Mobile' => 'FREE MOBILE',\n                    'GLOBALTEL' => 'GLOBALTEL',\n                    'Office des postes et télécommunications de Nouvelle Calédonie' => 'Gouv Nelle Calédonie (OPT)',\n                    'Maore Mobile' => 'MAORE MOBILE',\n                    'ONATi' => 'ONATI',\n                    'Orange' => 'ORANGE',\n                    'Outremer Telecom' => 'OUTREMER TELECOM',\n                    'Vodafone polynésie' => 'PMT/VODAPHONE',\n                    'SFR' => 'SFR',\n                    'SPM Télécom' => 'SPM TELECOM',\n                    'Service des Postes et Télécommunications de Polynésie Française' => 'Gouv Nelle Calédonie (OPT)',\n                    'SRR' => 'SRR',\n                    'Station étrangère' => 'Station étrangère',\n                    'Telco OI' => 'TELCO IO',\n                    'United Telecommunication Services Caraïbes' => 'UTS Caraibes',\n                    'Ora Mobile' => 'VITI SAS',\n                    'Zeop' => 'ZEOP'\n                ]\n            ],\n            'statut' => [\n                'name' => 'Statut',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => null,\n                    'En service' => 'En service',\n                    'Projet approuvé' => 'Projet approuvé',\n                    'Techniquement opérationnel' => 'Techniquement opérationnel',\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $urlParts = [\n            'id' => 'observatoire_2g_3g_4g',\n            'resource_id' => '88ef0887-6b0f-4d3f-8545-6d64c8f597da',\n            'fields' => 'id,adm_lb_nom,sta_nm_dpt,emr_lb_systeme,generation,date_maj,sta_nm_anfr,adr_lb_lieu,adr_lb_add1,adr_lb_add2,adr_lb_add3,adr_nm_cp,statut',\n            'rows' => 10000\n        ];\n\n        if (!empty($this->getInput('departement'))) {\n            $urlParts['refine.sta_nm_dpt'] = urlencode($this->getInput('departement'));\n        }\n\n        if (!empty($this->getInput('generation'))) {\n            $urlParts['refine.generation'] = $this->getInput('generation');\n        }\n\n        if (!empty($this->getInput('operateur'))) {\n            // http_build_query() already does urlencoding so this call is redundant\n            $urlParts['refine.adm_lb_nom'] = urlencode($this->getInput('operateur'));\n        }\n\n        if (!empty($this->getInput('statut'))) {\n            $urlParts['refine.statut'] = urlencode($this->getInput('statut'));\n        }\n\n        // API seems to not play well with urlencoded data\n        $url = urljoin(static::URI, '/d4c/api/records/1.0/download/?' . urldecode(http_build_query($urlParts)));\n\n        $json = getContents($url);\n        $data = Json::decode($json, false);\n        $records = $data->records;\n        $frequenciesByStation = [];\n        foreach ($records as $record) {\n            if (!isset($frequenciesByStation[$record->fields->sta_nm_anfr])) {\n                $street = sprintf(\n                    '%s %s %s',\n                    $record->fields->adr_lb_add1 ?? '',\n                    $record->fields->adr_lb_add2 ?? '',\n                    $record->fields->adr_lb_add3 ?? ''\n                );\n                $frequenciesByStation[$record->fields->sta_nm_anfr] = [\n                    'id' => $record->fields->sta_nm_anfr,\n                    'operator' => $record->fields->adm_lb_nom,\n                    'frequencies' => [],\n                    'lastUpdate' => 0,\n                    'address' => [\n                        'street' => trim($street),\n                        'postCode' => $record->fields->adr_nm_cp,\n                        'city' => $record->fields->adr_lb_lieu\n                    ]\n                ];\n            }\n\n            $frequenciesByStation[$record->fields->sta_nm_anfr]['frequencies'][] = [\n                'generation' => $record->fields->generation,\n                'frequency' => $record->fields->emr_lb_systeme,\n                'status' => $record->fields->statut,\n                'updatedAt' => strtotime($record->fields->date_maj),\n            ];\n\n            $frequenciesByStation[$record->fields->sta_nm_anfr]['lastUpdate'] = max(\n                $frequenciesByStation[$record->fields->sta_nm_anfr]['lastUpdate'],\n                strtotime($record->fields->date_maj)\n            );\n        }\n\n        usort($frequenciesByStation, static fn ($a, $b) => $b['lastUpdate'] <=> $a['lastUpdate']);\n\n        foreach ($frequenciesByStation as $station) {\n            $title = sprintf(\n                '[%s] Mise à jour de la station n°%s à %s (%s)',\n                $station['operator'],\n                $station['id'],\n                $station['address']['city'],\n                $station['address']['postCode']\n            );\n\n            $array_reduce = array_reduce($station['frequencies'], static function ($carry, $frequency) {\n                return sprintf('%s<li>%s : %s</li>', $carry, $frequency['frequency'], $frequency['status']);\n            }, '');\n\n            $content = sprintf(\n                '<h1>Adresse complète</h1><p>%s<br>%s<br>%s</p><h1>Fréquences</h1><p><ul>%s</ul></p>',\n                $station['address']['street'],\n                $station['address']['postCode'],\n                $station['address']['city'],\n                $array_reduce\n            );\n\n            $this->items[] = [\n                'uid'       => $station['id'],\n                'timestamp' => $station['lastUpdate'],\n                'title'     => $title,\n                'content'   => $content,\n            ];\n        }\n    }\n}"
  },
  {
    "path": "bridges/AnidexBridge.php",
    "content": "<?php\n\nclass AnidexBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Anidex';\n    const URI = 'http://anidex.info/'; // anidex.info has ddos-guard so we need to use anidex.moe\n    const ALTERNATE_URI = 'https://anidex.moe/'; // anidex.moe returns 301 unless Host is set to anidex.info\n    const ALTERNATE_HOST = 'anidex.info'; // Correct host for requesting anidex.moe without 301 redirect\n    const DESCRIPTION = 'Returns the newest torrents, with optional search criteria.';\n    const PARAMETERS = [\n        [\n            'id' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'All categories' => '0',\n                    'Anime' => '1,2,3',\n                    'Anime - Sub' => '1',\n                    'Anime - Raw' => '2',\n                    'Anime - Dub' => '3',\n                    'Live Action' => '4,5',\n                    'Live Action - Sub' => '4',\n                    'Live Action - Raw' => '5',\n                    'Light Novel' => '6',\n                    'Manga' => '7,8',\n                    'Manga - Translated' => '7',\n                    'Manga - Raw' => '8',\n                    'Music' => '9,10,11',\n                    'Music - Lossy' => '9',\n                    'Music - Lossless' => '10',\n                    'Music - Video' => '11',\n                    'Games' => '12',\n                    'Applications' => '13',\n                    'Pictures' => '14',\n                    'Adult Video' => '15',\n                    'Other' => '16'\n                ]\n            ],\n            'lang_id' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'All languages' => '0',\n                    'English' => '1',\n                    'Japanese' => '2',\n                    'Polish' => '3',\n                    'Serbo-Croatian' => '4',\n                    'Dutch' => '5',\n                    'Italian' => '6',\n                    'Russian' => '7',\n                    'German' => '8',\n                    'Hungarian' => '9',\n                    'French' => '10',\n                    'Finnish' => '11',\n                    'Vietnamese' => '12',\n                    'Greek' => '13',\n                    'Bulgarian' => '14',\n                    'Spanish (Spain)' => '15',\n                    'Portuguese (Brazil)' => '16',\n                    'Portuguese (Portugal)' => '17',\n                    'Swedish' => '18',\n                    'Arabic' => '19',\n                    'Danish' => '20',\n                    'Chinese (Simplified)' => '21',\n                    'Bengali' => '22',\n                    'Romanian' => '23',\n                    'Czech' => '24',\n                    'Mongolian' => '25',\n                    'Turkish' => '26',\n                    'Indonesian' => '27',\n                    'Korean' => '28',\n                    'Spanish (LATAM)' => '29',\n                    'Persian' => '30',\n                    'Malaysian' => '31'\n                ]\n            ],\n            'group_id' => [\n                'name' => 'Group ID',\n                'type' => 'number'\n            ],\n            'r' => [\n                'name' => 'Hide Remakes',\n                'type' => 'checkbox'\n            ],\n            'b' => [\n                'name' => 'Only Batches',\n                'type' => 'checkbox'\n            ],\n            'a' => [\n                'name' => 'Only Authorized',\n                'type' => 'checkbox'\n            ],\n            'q' => [\n                'name' => 'Keyword',\n                'description' => 'Keyword(s)',\n                'type' => 'text'\n            ],\n            'h' => [\n                'name' => 'Adult content',\n                'type' => 'list',\n                'values' => [\n                    'No filter' => '0',\n                    'Hide +18' => '1',\n                    'Only +18' => '2'\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        // Build Search URL from user-provided parameters\n        $search_url = self::ALTERNATE_URI . '?s=upload_timestamp&o=desc';\n        foreach (['id', 'lang_id', 'group_id'] as $param_name) {\n            $param = $this->getInput($param_name);\n            if (!empty($param) && intval($param) != 0 && ctype_digit(str_replace(',', '', $param))) {\n                $search_url .= '&' . $param_name . '=' . $param;\n            }\n        }\n        foreach (['r', 'b', 'a'] as $param_name) {\n            $param = $this->getInput($param_name);\n            if (!empty($param) && boolval($param)) {\n                $search_url .= '&' . $param_name . '=1';\n            }\n        }\n        $query = $this->getInput('q');\n        if (!empty($query)) {\n            $search_url .= '&q=' . urlencode($query);\n        }\n        $opt = [];\n        $h = $this->getInput('h');\n        if (!empty($h) && intval($h) != 0 && ctype_digit($h)) {\n            $opt[CURLOPT_COOKIE] = 'anidex_h_toggle=' . $h;\n        }\n\n        // We need to use a different Host HTTP header to reach the correct page on ALTERNATE_URI\n        $headers = ['Host: ' . self::ALTERNATE_HOST];\n\n        // The HTTPS certificate presented by anidex.moe is for anidex.info. We need to ignore this.\n        // As a consequence, the bridge is intentionally marked as insecure by setting self::URI to http://\n        $opt[CURLOPT_SSL_VERIFYHOST] = 0;\n        $opt[CURLOPT_SSL_VERIFYPEER] = 0;\n\n        // Retrieve torrent listing from search results, which does not contain torrent description\n        $html = getSimpleHTMLDOM($search_url, $headers, $opt);\n        $links = $html->find('a');\n        $results = [];\n        foreach ($links as $link) {\n            if (strpos($link->href, '/torrent/') === 0 && !in_array($link->href, $results)) {\n                $results[] = $link->href;\n            }\n        }\n        if (empty($results) && empty($this->getInput('q'))) {\n            throwServerException('No results from Anidex: ' . $search_url);\n        }\n\n        //Process each item individually\n        foreach ($results as $element) {\n            //Limit total amount of requests\n            if (count($this->items) >= 20) {\n                break;\n            }\n\n            $torrent_id = str_replace('/torrent/', '', $element);\n\n            //Ignore entries without valid torrent ID\n            if ($torrent_id != 0 && ctype_digit($torrent_id)) {\n                //Retrieve data for this torrent ID\n                $item_browse_uri = self::URI . 'torrent/' . $torrent_id;\n                $item_fetch_uri = self::ALTERNATE_URI . 'torrent/' . $torrent_id;\n\n                //Retrieve full description from torrent page (cached for 24 hours: 86400 seconds)\n                if ($item_html = getSimpleHTMLDOMCached($item_fetch_uri, 86400, $headers, $opt)) {\n                    //Retrieve data from page contents\n                    $item_title = str_replace(' (Torrent) - AniDex ', '', $item_html->find('title', 0)->plaintext);\n                    $item_desc = $item_html->find('div.panel-body', 0);\n                    $item_author = trim($item_html->find('span.fa-user', 0)->parent()->plaintext);\n                    $item_date = strtotime(trim($item_html->find('span.fa-clock', 0)->parent()->plaintext));\n                    $item_image = $this->getURI() . 'images/user_logos/default.png';\n\n                    //Check for description-less torrent andn optionally extract image\n                    $desc_title_found = false;\n                    foreach ($item_html->find('h3.panel-title') as $h3) {\n                        if (strpos($h3, 'Description') !== false) {\n                            $desc_title_found = true;\n                            break;\n                        }\n                    }\n                    if ($desc_title_found) {\n                        //Retrieve image for thumbnail or generic logo fallback\n                        foreach ($item_desc->find('img') as $img) {\n                            if (strpos($img->src, 'prez') === false) {\n                                $item_image = $img->src;\n                                break;\n                            }\n                        }\n                        $item_desc = trim($item_desc->innertext);\n                    } else {\n                        $item_desc = '<em>No description.</em>';\n                    }\n\n                    //Build and add final item\n                    $item = [];\n                    $item['uri'] = $item_browse_uri;\n                    $item['title'] = $item_title;\n                    $item['author'] = $item_author;\n                    $item['timestamp'] = $item_date;\n                    $item['enclosures'] = [$item_image];\n                    $item['content'] = $item_desc;\n                    $this->items[] = $item;\n                }\n            }\n            $element = null;\n        }\n        $results = null;\n    }\n}\n"
  },
  {
    "path": "bridges/AnimeUltimeBridge.php",
    "content": "<?php\n\nclass AnimeUltimeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Anime-Ultime';\n    const URI = 'http://www.anime-ultime.net/';\n    const CACHE_TIMEOUT = 10800; // 3h\n    const DESCRIPTION = 'Returns the newest releases posted on Anime-Ultime.';\n    const PARAMETERS = [ [\n        'type' => [\n        'name' => 'Type',\n        'type' => 'list',\n        'values' => [\n            'Everything' => '',\n            'Anime' => 'A',\n            'Drama' => 'D',\n            'Tokusatsu' => 'T'\n            ]\n        ]\n    ]];\n\n    private $filter = 'Releases';\n\n    public function collectData()\n    {\n        //Add type filter if provided\n        $typeFilter = $this->getKey('type');\n\n        //Build date and filters for making requests\n        $thismonth = date('mY') . $typeFilter;\n        $lastmonth = date('mY', mktime(0, 0, 0, date('n') - 1, 1, date('Y'))) . $typeFilter;\n\n        //Process each HTML page until having 10 releases\n        $processedOK = 0;\n        foreach ([$thismonth, $lastmonth] as $requestFilter) {\n            $url = self::URI . 'history-0-1/' . $requestFilter;\n            $html = getContents($url);\n            // Convert html from iso-8859-1 => utf8\n            $html = utf8_encode($html);\n            $html = str_get_html($html);\n\n            //Relases are sorted by day : process each day individually\n            foreach ($html->find('div.history', 0)->find('h3') as $daySection) {\n                //Retrieve day and build date information\n                $dateString = $daySection->plaintext;\n                $day = intval(substr($dateString, strpos($dateString, ' ') + 1, 2));\n                $item_date = strtotime(str_pad($day, 2, '0', STR_PAD_LEFT)\n                . '-'\n                . substr($requestFilter, 0, 2)\n                . '-'\n                . substr($requestFilter, 2, 4));\n\n                //<h3>day</h3><br /><table><tr> <-- useful data in table rows\n                $release = $daySection->next_sibling()->next_sibling()->first_child();\n\n                //Process each release of that day, ignoring first table row: contains table headers\n                while (!is_null($release = $release->next_sibling())) {\n                    if (count($release->find('td')) > 0) {\n                        //Retrieve metadata from table columns\n                        $item_link_element = $release->find('td', 0)->find('a', 0);\n                        $item_uri = self::URI . $item_link_element->href;\n                        $item_name = html_entity_decode($item_link_element->plaintext);\n\n                        $item_image = self::URI . substr(\n                            $item_link_element->onmouseover,\n                            37,\n                            strpos($item_link_element->onmouseover, ' ', 37) - 37\n                        );\n\n                        $item_episode = html_entity_decode(\n                            str_pad(\n                                $release->find('td', 1)->plaintext,\n                                2,\n                                '0',\n                                STR_PAD_LEFT\n                            )\n                        );\n\n                        $item_fansub = $release->find('td', 2)->plaintext;\n                        $item_type = $release->find('td', 4)->plaintext;\n\n                        if (!empty($item_uri)) {\n                            // Retrieve description from description page\n                            $html_item = getContents($item_uri);\n                            // Convert html from iso-8859-1 => utf8\n                            $html_item = utf8_encode($html_item);\n                            $item_description = substr(\n                                $html_item,\n                                strpos($html_item, 'class=\"principal_contain\" align=\"center\">') + 41\n                            );\n                            $item_description = substr(\n                                $item_description,\n                                0,\n                                strpos($item_description, '<div id=\"table\">')\n                            );\n\n                            // Convert relative image src into absolute image src, remove line breaks\n                            $item_description = defaultLinkTo($item_description, self::URI);\n                            $item_description = str_replace(\"\\r\", '', $item_description);\n                            $item_description = str_replace(\"\\n\", '', $item_description);\n\n                            //Build and add final item\n                            $item = [];\n                            $item['uri'] = $item_uri;\n                            $item['title'] = $item_name . ' ' . $item_type . ' ' . $item_episode;\n                            $item['author'] = $item_fansub;\n                            $item['timestamp'] = $item_date;\n                            $item['enclosures'] = [$item_image];\n                            $item['content'] = $item_description;\n                            $this->items[] = $item;\n                            $processedOK++;\n\n                            //Stop processing once limit is reached\n                            if ($processedOK >= 10) {\n                                return;\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('type'))) {\n            return 'Latest ' . $this->getKey('type') . ' - Anime-Ultime';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/AnisearchBridge.php",
    "content": "<?php\n\nclass AnisearchBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Tone866';\n    const NAME = 'Anisearch';\n    const URI = 'https://www.anisearch.de';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Feed for Anisearch';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Dub',\n            'type' => 'list',\n            'values' => [\n                'DE'\n                => 'https://www.anisearch.de/anime/index/page-1?char=all&synchro=de&sort=date&order=desc&view=4',\n                'EN'\n                => 'https://www.anisearch.de/anime/index/page-1?char=all&synchro=en&sort=date&order=desc&view=4',\n                'JP'\n                => 'https://www.anisearch.de/anime/index/page-1?char=all&synchro=ja&sort=date&order=desc&view=4'\n            ]\n        ],\n        'trailers' => [\n            'name' => 'Trailers',\n            'type' => 'checkbox',\n            'title' => 'Will include trailes',\n            'defaultValue' => false\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $baseurl = 'https://www.anisearch.de/';\n        $trailers = false;\n        $trailers = $this->getInput('trailers');\n        $limit = 10;\n        if ($trailers) {\n            $limit = 5;\n        }\n\n        $dom = getSimpleHTMLDOM($this->getInput('category'));\n\n        foreach ($dom->find('li.btype0') as $key => $li) {\n            if ($key >= $limit) {\n                break;\n            }\n\n            $a = $li->find('a', 0);\n            $title = $a->find('span.title', 0);\n            $url = $baseurl . $a->href;\n\n            //get article\n            $domarticle = getSimpleHTMLDOM($url);\n            $content = $domarticle->find('div.details-text', 0);\n\n            //get header-image and set absolute src\n            $headerimage = $domarticle->find('img#details-cover', 0);\n            $src = $headerimage->src;\n\n            foreach ($content->find('.hidden') as $element) {\n                $element->remove();\n            }\n\n            //get trailer\n            $ytlink = '';\n            if ($trailers) {\n                $trailerlink = $domarticle->find('section#trailers > div > div.swiper > ul.swiper-wrapper > li.swiper-slide > a', 0);\n                if (isset($trailerlink)) {\n                    $trailersite = getSimpleHTMLDOM($baseurl . $trailerlink->href);\n                    $trailer = $trailersite->find('div#video > iframe', 0);\n                    $trailer = $trailer->{'data-xsrc'};\n                    $ytlink = <<<EOT\n                        <br /><iframe width=\"560\" height=\"315\" src=\"$trailer\" title=\"YouTube video player\"\n                        frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\"\n                        referrerpolicy=\"strict-origin\" allowfullscreen></iframe>\n                    EOT;\n                }\n            }\n\n            $this->items[] = [\n                'title' => $title->plaintext,\n                'uri' => $url,\n                'content' => $headerimage . '<br />' . $content . $ytlink\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AnnasArchiveBridge.php",
    "content": "<?php\n\nclass AnnasArchiveBridge extends BridgeAbstract\n{\n    const NAME = 'Anna\\'s Archive';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://annas-archive.org/';\n    const DESCRIPTION = 'Returns books from Anna\\'s Archive';\n    const PARAMETERS = [\n        [\n            'q' => [\n                'name' => 'Query',\n                'exampleValue' => 'apothecary diaries',\n                'required' => true,\n            ],\n            'ext' => [\n                'name' => 'Extension',\n                'type' => 'list',\n                'values' => [\n                    'Any' => null,\n                    'azw3' => 'azw3',\n                    'cbr' => 'cbr',\n                    'cbz' => 'cbz',\n                    'djvu' => 'djvu',\n                    'epub' => 'epub',\n                    'fb2' => 'fb2',\n                    'fb2.zip' => 'fb2.zip',\n                    'mobi' => 'mobi',\n                    'pdf' => 'pdf',\n                ]\n            ],\n            'lang' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'Any' => null,\n                    'Afrikaans [af]' => 'af',\n                    'Arabic [ar]' => 'ar',\n                    'Bangla [bn]' => 'bn',\n                    'Belarusian [be]' => 'be',\n                    'Bulgarian [bg]' => 'bg',\n                    'Catalan [ca]' => 'ca',\n                    'Chinese [zh]' => 'zh',\n                    'Church Slavic [cu]' => 'cu',\n                    'Croatian [hr]' => 'hr',\n                    'Czech [cs]' => 'cs',\n                    'Danish [da]' => 'da',\n                    'Dongxiang [sce]' => 'sce',\n                    'Dutch [nl]' => 'nl',\n                    'English [en]' => 'en',\n                    'French [fr]' => 'fr',\n                    'German [de]' => 'de',\n                    'Greek [el]' => 'el',\n                    'Hebrew [he]' => 'he',\n                    'Hindi [hi]' => 'hi',\n                    'Hungarian [hu]' => 'hu',\n                    'Indonesian [id]' => 'id',\n                    'Irish [ga]' => 'ga',\n                    'Italian [it]' => 'it',\n                    'Japanese [ja]' => 'ja',\n                    'Kazakh [kk]' => 'kk',\n                    'Korean [ko]' => 'ko',\n                    'Latin [la]' => 'la',\n                    'Latvian [lv]' => 'lv',\n                    'Lithuanian [lt]' => 'lt',\n                    'Luxembourgish [lb]' => 'lb',\n                    'Ndolo [ndl]' => 'ndl',\n                    'Norwegian [no]' => 'no',\n                    'Persian [fa]' => 'fa',\n                    'Polish [pl]' => 'pl',\n                    'Portuguese [pt]' => 'pt',\n                    'Romanian [ro]' => 'ro',\n                    'Russian [ru]' => 'ru',\n                    'Serbian [sr]' => 'sr',\n                    'Spanish [es]' => 'es',\n                    'Swedish [sv]' => 'sv',\n                    'Tamil [ta]' => 'ta',\n                    'Traditional Chinese [zh‑Hant]' => 'zh‑Hant',\n                    'Turkish [tr]' => 'tr',\n                    'Ukrainian [uk]' => 'uk',\n                    'Unknown language' => '_empty',\n                    'Unknown language [und]' => 'und',\n                    'Unknown language [urdu]' => 'urdu',\n                    'Urdu [ur]' => 'ur',\n                    'Vietnamese [vi]' => 'vi',\n                    'Welsh [cy]' => 'cy',\n                ]\n            ],\n            'content' => [\n                'name' => 'Type',\n                'type' => 'list',\n                'values' => [\n                    'Any' => null,\n                    'Book (fiction)' => 'book_fiction',\n                    'Book (non‑fiction)' => 'book_nonfiction',\n                    'Book (unknown)' => 'book_unknown',\n                    'Comic book' => 'book_comic',\n                    'Journal article' => 'journal_article',\n                    'Magazine' => 'magazine',\n                    'Standards document' => 'standards_document',\n                ]\n            ],\n            'src' => [\n                'name' => 'Source',\n                'type' => 'list',\n                'values' => [\n                    'Any' => null,\n                    'Internet Archive' => 'ia',\n                    'Libgen.li' => 'lgli',\n                    'Libgen.rs' => 'lgrs',\n                    'Sci‑Hub' => 'scihub',\n                    'Z‑Library' => 'zlib',\n                ]\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $list = getSimpleHTMLDOMCached($url);\n        $list = defaultLinkTo($list, self::URI);\n\n        // Don't attempt to do anything if not found message is given\n        if ($list->find('.js-not-found-additional')) {\n            return;\n        }\n\n        $elements = $list->find('#aarecord-list > div');\n        foreach ($elements as $element) {\n            // stop added entries once partial match list starts\n            if (str_contains($element->innertext, 'partial match')) {\n                break;\n            }\n            if ($element = $element->find('a', 0)) {\n                $item = [];\n                $item['title'] = $element->find('h3', 0)->plaintext;\n                $item['author'] = $element->find('div.italic', 0)->plaintext;\n                $item['uri'] = $element->href;\n                $item['content'] = $element->plaintext;\n                $item['uid'] = $item['uri'];\n\n                $item_html = getSimpleHTMLDOMCached($item['uri'], 86400 * 20);\n                if ($item_html) {\n                    $item_html = defaultLinkTo($item_html, self::URI);\n                    $item['content'] .= $item_html->find('main img', 0);\n                    $item['content'] .= $item_html->find('main .mt-4', 0); // Summary\n                    foreach ($item_html->find('main ul.mb-4 > li > a.js-download-link') as $file) {\n                        if (!str_contains($file->href, 'fast_download')) {\n                            $item['enclosures'][] = $file->href;\n                        }\n                    }\n                    // Remove bulk torrents from enclosures list\n                    $item['enclosures'] = array_diff($item['enclosures'], [self::URI . 'datasets']);\n                }\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if ($this->getInput('q') != null) {\n            $name .= ' - ' . $this->getInput('q');\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $params = array_filter([ // Filter to remove non-provided parameters\n            'q' => $this->getInput('q'),\n            'ext' => $this->getInput('ext'),\n            'lang' => $this->getInput('lang'),\n            'src' => $this->getInput('src'),\n            'content' => $this->getInput('content'),\n        ]);\n        $url = parent::getURI() . 'search?sort=newest&' . http_build_query($params);\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/AppleAppStoreBridge.php",
    "content": "<?php\n\nclass AppleAppStoreBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'NohamR';\n    const NAME = 'Apple App Store';\n    const URI = 'https://apps.apple.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns version updates for a specific application';\n\n    const PARAMETERS = [[\n        'id' => [\n            'name'  => 'Application ID',\n            'required'  => true,\n            'exampleValue'  => '310633997'\n        ],\n        'p' => [\n            'name'  => 'Platform',\n            'type'  => 'list',\n            'values'    => [\n                'iPad'  => 'ipad',\n                'iPhone'    => 'iphone',\n                'Mac'   => 'mac',\n\n                // The following 2 are present in responses\n                // but not yet tested\n                'Web'   => 'web',\n                'Apple TV'  => 'appletv',\n            ],\n            'defaultValue'  => 'mac',\n        ],\n        'country'   => [\n            'name'  => 'Store Country',\n            'type'  => 'list',\n            'values'    => [\n                'US'    => 'US',\n                'India' => 'IN',\n                'Canada' => 'CA',\n                'Germany' => 'DE',\n                'Netherlands' => 'NL',\n                'Belgium (NL)' => 'BENL',\n                'Belgium (FR)' => 'BEFR',\n                'France' => 'FR',\n                'Italy' => 'IT',\n                'United Kingdom' => 'UK',\n                'Spain' => 'ES',\n                'Portugal' => 'PT',\n                'Australia' => 'AU',\n                'New Zealand' => 'NZ',\n                'Indonesia' => 'ID',\n                'Brazil' => 'BR',\n            ],\n            'defaultValue'  => 'US',\n        ],\n        'debug' => [\n            'name' => 'Debug Mode',\n            'type' => 'checkbox',\n            'defaultValue' => false\n        ]\n    ]];\n\n    const PLATFORM_MAPPING = [\n        'iphone' => 'ios',\n        'ipad' => 'ios',\n        'mac' => 'osx'\n    ];\n\n    private $name;\n\n    private function makeHtmlUrl()\n    {\n        $id = $this->getInput('id');\n        $country = $this->getInput('country');\n        return sprintf('https://apps.apple.com/%s/app/id%s', $country, $id);\n    }\n\n    private function makeJsonUrl()\n    {\n        $id = $this->getInput('id');\n        $country = $this->getInput('country');\n        $platform = $this->getInput('p');\n\n        $platform_param = ($platform === 'mac') ? 'mac' : $platform;\n\n        return sprintf(\n            'https://amp-api-edge.apps.apple.com/v1/catalog/%s/apps/%s?platform=%s&extend=versionHistory',\n            $country,\n            $id,\n            $platform_param\n        );\n    }\n\n    public function getName()\n    {\n        if (isset($this->name)) {\n            return sprintf('%s - AppStore Updates', $this->name);\n        }\n\n        return parent::getName();\n    }\n\n    private function debugLog($message)\n    {\n        if ($this->getInput('debug')) {\n            $this->logger->info(sprintf('[AppleAppStoreBridge] %s', $message));\n        }\n    }\n\n    private function getAppData()\n    {\n        // Fetch the HTML page to find the JS bundle URL\n        $url = $this->makeHtmlUrl();\n        $this->debugLog(sprintf('Fetching HTML page for token extraction: %s', $url));\n        $content = getContents($url);\n\n        // Extract the JS bundle path, e.g. /assets/index~BMeKnrDH8T.js\n        $matches = [];\n        if (!preg_match('#<script type=\"module\" crossorigin src=\"(/assets/index~[^\"]+\\.js)\"></script>#', $content, $matches)) {\n            throw new \\Exception('Failed to locate JS bundle tag for token extraction');\n        }\n\n        $jsPath = $matches[1];\n        $jsUrl = 'https://apps.apple.com' . $jsPath;\n        $this->debugLog(sprintf('Fetching JS bundle for token extraction: %s', $jsUrl));\n\n        // Fetch the JS bundle where the JWT is embedded\n        $jsContent = getContents($jsUrl);\n\n        // Find the JWT inside a const assignment, e.g.\n        // const SOME_NAME = \"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6.XXXX.YYYY\";\n        // Match a const assignment that looks like a JWT\n        // eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6 decodes to '{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\"'\n        $tokenMatches = [];\n        // phpcs:disable Generic.Files.LineLength\n        if (!preg_match('~const\\s+\\w+\\s*=\\s*[\\'\\\"](eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+)[\\'\\\"]~', $jsContent, $tokenMatches)) {\n            throw new \\Exception('Failed to extract JWT token from JS bundle');\n        }\n        // phpcs:enable Generic.Files.LineLength\n        $token = $tokenMatches[1];\n        $this->debugLog('Successfully extracted JWT token from JS bundle: ' . $token);\n\n        $url = $this->makeJsonUrl();\n        $this->debugLog(sprintf('Fetching data from API: %s', $url));\n\n        $headers = [\n            'accept: */*',\n            'Authorization: Bearer ' . $token,\n            'cache-control: no-cache',\n            'Origin: https://apps.apple.com',\n            'Referer: https://apps.apple.com/',\n            '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',\n        ];\n\n        $content = getContents($url, $headers);\n\n        try {\n            $json = Json::decode($content);\n        } catch (\\Exception $e) {\n            throw new \\Exception(sprintf('Failed to parse API response: %s', $e->getMessage()));\n        }\n\n        if (!isset($json['data']) || empty($json['data'])) {\n            throw new \\Exception('No app data found in API response');\n        }\n\n        $this->debugLog('Successfully retrieved app data from API');\n        return $json['data'][0];\n    }\n\n    private function extractAppDetails($data)\n    {\n        if (isset($data['attributes'])) {\n            $this->name = $data['attributes']['name'] ?? null;\n            $author = $data['attributes']['artistName'] ?? null;\n            $this->debugLog(sprintf('Found app details in attributes: %s by %s', $this->name, $author));\n            return [$this->name, $author];\n        }\n\n        // Fallback to default values if not found\n        $this->name = sprintf('App %s', $this->getInput('id'));\n        $this->debugLog(sprintf('App details not found, using default: %s', $this->name));\n        return [$this->name, 'Unknown Developer'];\n    }\n\n    private function getVersionHistory($data)\n    {\n        $platform = $this->getInput('p');\n        $this->debugLog(sprintf('Extracting version history for platform: %s', $platform));\n\n        // Get the mapped platform key (ios for iPhone/iPad, osx for Mac)\n        $platform_key = self::PLATFORM_MAPPING[$platform] ?? $platform;\n\n        $version_history = $data['attributes']['platformAttributes'][$platform_key]['versionHistory'] ?? [];\n\n        if (empty($version_history)) {\n            $this->debugLog(sprintf('No version history found for %s', $platform));\n        }\n\n        return $version_history;\n    }\n\n    public function collectData()\n    {\n        $this->debugLog(sprintf('Getting data for %s app', $this->getInput('p')));\n        $data = $this->getAppData();\n\n        // Get app name and author using array destructuring\n        [$name, $author] = $this->extractAppDetails($data);\n\n        // Get version history\n        $version_history = $this->getVersionHistory($data);\n        $this->debugLog(sprintf('Found %d versions for %s', count($version_history), $name));\n\n        foreach ($version_history as $entry) {\n            $version = $entry['versionDisplay'] ?? 'Unknown Version';\n            $release_notes = $entry['releaseNotes'] ?? 'No release notes available';\n            $release_date = $entry['releaseDate'] ?? 'Unknown Date';\n\n            $item = [];\n            $item['title'] = sprintf('%s - %s', $name, $version);\n            $item['content'] = nl2br($release_notes) ?: 'No release notes available';\n            $item['timestamp'] = $release_date;\n            $item['author'] = $author;\n            $item['uri'] = $this->makeHtmlUrl();\n\n            $this->items[] = $item;\n        }\n\n        $this->debugLog(sprintf('Successfully collected %d items', count($this->items)));\n    }\n}"
  },
  {
    "path": "bridges/AppleMusicBridge.php",
    "content": "<?php\n\nclass AppleMusicBridge extends BridgeAbstract\n{\n    const NAME = 'Apple Music';\n    const URI = 'https://www.apple.com';\n    const DESCRIPTION = 'Fetches the latest releases from an artist';\n    const MAINTAINER = 'bockiii';\n    const PARAMETERS = [[\n        'artist' => [\n            'name' => 'Artist ID',\n            'exampleValue' => '909253',\n            'required' => true,\n        ],\n        'limit' => [\n            'name' => 'Latest X Releases (max 50)',\n            'defaultValue' => '10',\n            'required' => true,\n        ],\n    ]];\n    const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours\n\n    private $title;\n\n    public function collectData()\n    {\n        $items = $this->getJson();\n        $artist = $this->getArtist($items);\n\n        $this->title = $artist->artistName;\n\n        foreach ($items as $item) {\n            if ($item->wrapperType === 'collection') {\n                $copyright = $item->copyright ?? '';\n                $artworkUrl500 = str_replace('/100x100', '/500x500', $item->artworkUrl100);\n                $artworkUrl2000 = str_replace('/100x100', '/2000x2000', $item->artworkUrl100);\n                $escapedCollectionName = htmlspecialchars($item->collectionName);\n\n                $this->items[] = [\n                    'title' => $item->collectionName,\n                    'uri' => $item->collectionViewUrl,\n                    'timestamp' => $item->releaseDate,\n                    'enclosures' => $artworkUrl500,\n                    'author' => $item->artistName,\n                    'content' => \"<figure>\n    <img srcset=\\\"$item->artworkUrl60 60w, $item->artworkUrl100 100w, $artworkUrl500 500w, $artworkUrl2000 2000w\\\"\n         sizes=\\\"100%\\\" src=\\\"$artworkUrl2000\\\"\n         alt=\\\"Cover of $escapedCollectionName\\\"\n         style=\\\"display: block; margin: 0 auto;\\\" />\n    <figcaption>\n        from <a href=\\\"$artist->artistLinkUrl\\\">$item->artistName</a><br />$copyright\n    </figcaption>\n</figure>\",\n                ];\n            }\n        }\n    }\n\n    private function getJson()\n    {\n        # Limit the amount of releases to 50\n        if ($this->getInput('limit') > 50) {\n            $limit = 50;\n        } else {\n            $limit = $this->getInput('limit');\n        }\n\n        $url = 'https://itunes.apple.com/lookup?id=' . $this->getInput('artist') . '&entity=album&limit=' . $limit . '&sort=recent';\n        $html = getContents($url);\n        $json = json_decode($html);\n        $result = $json->results;\n\n        if (!is_array($result) || count($result) == 0) {\n            throwServerException('There is no artist with id \"' . $this->getInput('artist') . '\".');\n        }\n\n        return $result;\n    }\n\n    private function getArtist($json)\n    {\n        $nameArray = array_filter($json, function ($obj) {\n            return $obj->wrapperType == 'artist';\n        });\n\n        if (count($nameArray) === 1) {\n            return $nameArray[0];\n        }\n\n        return parent::getName();\n    }\n\n    public function getName()\n    {\n        if (isset($this->title)) {\n            return $this->title;\n        }\n\n        return parent::getName();\n    }\n\n    public function getIcon()\n    {\n        if (empty($this->getInput('artist'))) {\n            return parent::getIcon();\n        }\n\n        // it isn't necessary to set the correct artist name into the url\n        $url = 'https://music.apple.com/us/artist/jon-bellion/' . $this->getInput('artist');\n        $html = getSimpleHTMLDOMCached($url);\n        $image = $html->find('meta[property=\"og:image\"]', 0)->content;\n\n        $imageUpdatedSize = preg_replace('/\\/\\d*x\\d*cw/i', '/144x144-999', $image);\n\n        return $imageUpdatedSize;\n    }\n}\n"
  },
  {
    "path": "bridges/ArsTechnicaBridge.php",
    "content": "<?php\n\nclass ArsTechnicaBridge extends FeedExpander\n{\n    const MAINTAINER = 'phantop';\n    const NAME = 'Ars Technica';\n    const URI = 'https://arstechnica.com/';\n    const DESCRIPTION = 'Returns the latest articles from Ars Technica';\n    const PARAMETERS = [[\n            'section' => [\n                'name' => 'Site section',\n                'type' => 'list',\n                'defaultValue' => 'index',\n                'values' => [\n                    'All' => 'index',\n                    'Apple' => 'apple',\n                    'Board Games' => 'cardboard',\n                    'Cars' => 'cars',\n                    'Features' => 'features',\n                    'Gaming' => 'gaming',\n                    'Information Technology' => 'technology-lab',\n                    'Science' => 'science',\n                    'Staff Blogs' => 'staff-blogs',\n                    'Tech Policy' => 'tech-policy',\n                    'Tech' => 'gadgets',\n                    ]\n            ]\n    ]];\n\n    public function collectData()\n    {\n        $url = 'https://feeds.arstechnica.com/arstechnica/' . $this->getInput('section');\n        $this->collectExpandableDatas($url, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item_html = getSimpleHTMLDOMCached($item['uri']);\n        $item_html = defaultLinkTo($item_html, self::URI);\n\n        $content = '';\n        $header = $item_html->find('article header', 0);\n        $leading = $header->find('p[class*=leading]', 0);\n        if ($leading != null) {\n            $content .= '<p>' . $leading->innertext . '</p>';\n        }\n        $intro_image = $header->find('img.intro-image', 0);\n        if ($intro_image != null) {\n            $content .= '<figure>' . $intro_image;\n\n            $image_caption = $header->find('.caption .caption-content', 0);\n            if ($image_caption != null) {\n                $content .= '<figcaption>' . $image_caption->innertext . '</figcaption>';\n            }\n            $content .= '</figure>';\n        }\n\n        foreach ($item_html->find('.post-content') as $content_tag) {\n            $content .= $content_tag->innertext;\n        }\n\n        $item['content'] = str_get_html($content);\n\n        $parsely = $item_html->find('[name=\"parsely-page\"]', 0);\n        $parsely_json = json_decode(html_entity_decode($parsely->content), true);\n        $item['categories'] = $parsely_json['tags'];\n\n        // Some lightboxes are nested in figures. I'd guess that's a\n        // bug in the website\n        foreach ($item['content']->find('figure div div.ars-lightbox') as $weird_lightbox) {\n            $weird_lightbox->parent->parent->outertext = $weird_lightbox;\n        }\n\n        // It's easier to reconstruct the whole thing than remove\n        // duplicate reactive tags\n        foreach ($item['content']->find('.ars-lightbox') as $lightbox) {\n            $lightbox_content = '';\n            foreach ($lightbox->find('.ars-lightbox-item') as $lightbox_item) {\n                $img = $lightbox_item->find('img', 0);\n                if ($img != null) {\n                    $lightbox_content .= '<figure>' . $img;\n                    $caption = $lightbox_item->find('div.pswp-caption-content', 0);\n                    if ($caption != null) {\n                        $credit = $lightbox_item->find('div.ars-gallery-caption-credit', 0);\n                        if ($credit != null) {\n                            $credit->innertext = 'Credit: ' . $credit->innertext;\n                        }\n                        $lightbox_content .= '<figcaption>' . $caption->innertext . '</figcaption>';\n                    }\n                    $lightbox_content .= '</figure>';\n                }\n            }\n            $lightbox->innertext = $lightbox_content;\n        }\n\n        // remove various ars advertising\n        foreach ($item['content']->find('.ars-interlude-container') as $ad) {\n            $ad->remove();\n        }\n        foreach ($item['content']->find('.toc-container') as $toc) {\n            $toc->remove();\n        }\n\n        // Mostly YouTube videos\n        $iframes = $item['content']->find('iframe');\n        foreach ($iframes as $iframe) {\n            $iframe->outertext = '<a href=\"' . $iframe->src . '\">' . $iframe->src . '</a>';\n        }\n        // This fixed padding around the former iframes and actual inline videos\n        foreach ($item['content']->find('div[style*=aspect-ratio]') as $styled) {\n            $styled->removeAttribute('style');\n        }\n\n        $item['content'] = backgroundToImg($item['content']);\n        $item['uid'] = strval($parsely_json['post_id']);\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ArtStationBridge.php",
    "content": "<?php\n\nclass ArtStationBridge extends BridgeAbstract\n{\n    const NAME = 'ArtStation';\n    const URI = 'https://www.artstation.com';\n    const DESCRIPTION = 'Fetches the latest ten artworks from a search query on ArtStation.';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 3600; // 1h\n\n    const PARAMETERS = [\n        'Search Query' => [\n            'q' => [\n                'name' => 'Search term',\n                'required' => true,\n                'exampleValue'  => 'bird'\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.artstation.com/assets/favicon-58653022bc38c1905ac7aa1b10bffa6b.ico';\n    }\n\n    public function getName()\n    {\n        return self::NAME . ': ' . $this->getInput('q');\n    }\n\n    private function fetchSearch($searchQuery)\n    {\n        $data = '{\"query\":\"' . $searchQuery . '\",\"page\":1,\"per_page\":50,\"sorting\":\"date\",';\n        $data .= '\"pro_first\":\"1\",\"filters\":[],\"additional_fields\":[]}';\n\n        $header = [\n            'Content-Type: application/json',\n            'Accept: application/json'\n        ];\n\n        $opts = [\n            CURLOPT_POST => true,\n            CURLOPT_POSTFIELDS => $data,\n            CURLOPT_RETURNTRANSFER => true\n        ];\n\n        $jsonSearchURL = self::URI . '/api/v2/search/projects.json';\n        $jsonSearchStr = getContents($jsonSearchURL, $header, $opts);\n        return json_decode($jsonSearchStr);\n    }\n\n    private function fetchProject($hashID)\n    {\n        $jsonProjectURL = self::URI . '/projects/' . $hashID . '.json';\n        $jsonProjectStr = getContents($jsonProjectURL);\n        return json_decode($jsonProjectStr);\n    }\n\n    public function collectData()\n    {\n        $searchTerm = $this->getInput('q');\n        $jsonQuery = $this->fetchSearch($searchTerm);\n\n        foreach ($jsonQuery->data as $media) {\n            // get detailed info about media item\n            $jsonProject = $this->fetchProject($media->hash_id);\n\n            // create item\n            $item = [];\n            $item['title'] = $media->title;\n            $item['uri'] = $media->url;\n            $item['timestamp'] = strtotime($jsonProject->published_at);\n            $item['author'] = $media->user->full_name;\n            $item['categories'] = implode(',', $jsonProject->tags);\n\n            $item['content'] = '<a href=\"'\n                . $media->url\n                . '\"><img style=\"max-width: 100%\" src=\"'\n                . $jsonProject->cover_url\n                . '\"></a><p>'\n                . $jsonProject->description\n                . '</p>';\n\n            $numAssets = count($jsonProject->assets);\n\n            if ($numAssets > 1) {\n                $item['content'] .= '<p><a href=\"'\n                    . $media->url\n                    . '\">Project contains '\n                    . ($numAssets - 1)\n                    . ' more item(s).</a></p>';\n            }\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/Arte7Bridge.php",
    "content": "<?php\n\nclass Arte7Bridge extends BridgeAbstract\n{\n    const NAME = 'Arte +7';\n    const URI = 'https://www.arte.tv/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns newest videos from ARTE +7';\n\n    const API_TOKEN = 'Nzc1Yjc1ZjJkYjk1NWFhN2I2MWEwMmRlMzAzNjI5NmU3NWU3ODg4ODJjOWMxNTMxYzEzZGRjYjg2ZGE4MmIwOA';\n\n    const PARAMETERS = [\n        'global' => [\n            'sort_by' => [\n                'type' => 'list',\n                'name' => 'Sort by',\n                'required' => false,\n                'defaultValue' => null,\n                'values' => [\n                    'Default' => null,\n                    'Video rights start date' => 'videoRightsBegin',\n                    'Video rights end date' => 'videoRightsEnd',\n                    'Brodcast date' => 'broadcastBegin',\n                    'Creation date' => 'creationDate',\n                    'Last modified' => 'lastModified',\n                    'Number of views' => 'views',\n                    'Number of views per period' => 'viewsPeriod',\n                    'Available screens' => 'availableScreens',\n                    'Episode' => 'episode'\n                ],\n            ],\n            'sort_direction' => [\n                'type' => 'list',\n                'name' => 'Sort direction',\n                'required' => false,\n                'defaultValue' => 'DESC',\n                'values' => [\n                    'Ascending' => 'ASC',\n                    'Descending' => 'DESC'\n                ],\n            ],\n            'exclude_trailers' => [\n                'name' => 'Exclude trailers',\n                'type' => 'checkbox',\n                'required' => false,\n                'defaultValue' => false\n            ],\n        ],\n        'Category' => [\n            'lang' => [\n                'type' => 'list',\n                'name' => 'Language',\n                'values' => [\n                    'Français' => 'fr',\n                    'Deutsch' => 'de',\n                    'English' => 'en',\n                    'Español' => 'es',\n                    'Polski' => 'pl',\n                    'Italiano' => 'it'\n                ],\n            ],\n            'cat' => [\n                'type' => 'list',\n                'name' => 'Category',\n                'values' => [\n                    'All videos' => null,\n                    'News & society' => 'ACT',\n                    'Series & fiction' => 'SER',\n                    'Cinema' => 'CIN',\n                    'Culture' => 'ARS',\n                    'Culture pop' => 'CPO',\n                    'Discovery' => 'DEC',\n                    'History' => 'HIST',\n                    'Science' => 'SCI',\n                    'Other' => 'AUT'\n                ]\n            ],\n        ],\n        'Collection' => [\n            'lang' => [\n                'type' => 'list',\n                'name' => 'Language',\n                'values' => [\n                    'Français' => 'fr',\n                    'Deutsch' => 'de',\n                    'English' => 'en',\n                    'Español' => 'es',\n                    'Polski' => 'pl',\n                    'Italiano' => 'it'\n                ]\n            ],\n            'col' => [\n                'name' => 'Collection id',\n                'required' => true,\n                'title' => 'ex. RC-014095 pour https://www.arte.tv/de/videos/RC-014095/blow-up/',\n                'exampleValue'  => 'RC-014095'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Category':\n                $category = $this->getInput('cat');\n                $collectionId = null;\n                break;\n            case 'Collection':\n                $collectionId = $this->getInput('col');\n                $category = null;\n                break;\n        }\n\n        $lang = $this->getInput('lang');\n        $sort_by = $this->getInput('sort_by');\n        $sort_direction = $this->getInput('sort_direction') == 'ASC' ? '' : '-';\n\n        $url = 'https://api.arte.tv/api/opa/v3/videos?limit=15&language='\n            . $lang\n            . ($sort_by != null ? '&sort=' . $sort_direction . $sort_by : '')\n            . ($category != null ? '&category.code=' . $category : '')\n            . ($collectionId != null ? '&collections.collectionId=' . $collectionId : '');\n\n        $header = [\n            'Authorization: Bearer ' . self::API_TOKEN\n        ];\n\n        $input = getContents($url, $header);\n        $input_json = json_decode($input, true);\n\n        foreach ($input_json['videos'] as $element) {\n            if ($this->getInput('exclude_trailers') && $element['platform'] == 'EXTRAIT') {\n                continue;\n            }\n\n            $durationSeconds = $element['durationSeconds'];\n\n            $item = [];\n            $item['uri'] = $element['url'];\n            $item['id'] = $element['id'];\n\n            $item['timestamp'] = strtotime($element['videoRightsBegin']);\n            $item['title'] = $element['title'];\n\n            if (!empty($element['subtitle'])) {\n                $item['title'] = $element['title'] . ' | ' . $element['subtitle'];\n            }\n\n            $durationMinutes = round((int)$durationSeconds / 60);\n            $item['content'] = $element['teaserText']\n            . '<br><br>'\n            . $durationMinutes\n            . 'min<br><a href=\"'\n            . $item['uri']\n            . '\"><img src=\"'\n            . $element['mainImage']['url']\n            . '\" /></a>';\n\n            $item['itunes'] = [\n                'duration' => $durationSeconds,\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AsahiShimbunAJWBridge.php",
    "content": "<?php\n\nclass AsahiShimbunAJWBridge extends BridgeAbstract\n{\n    const NAME = 'Asahi Shimbun AJW';\n    const BASE_URI = 'http://www.asahi.com';\n    const URI = self::BASE_URI . '/ajw/';\n    const DESCRIPTION = 'Asahi Shimbun - Asia & Japan Watch';\n    const MAINTAINER = 'somini';\n    const PARAMETERS = [\n        [\n            'section' => [\n                'type' => 'list',\n                'name' => 'Section',\n                'values' => [\n                    'Japan » Social Affairs' => 'japan/social',\n                    'Japan » People' => 'japan/people',\n                    'Japan » 3/11 Disaster' => 'japan/0311disaster',\n                    'Japan » Sci & Tech' => 'japan/sci_tech',\n                    'Politics' => 'politics',\n                    'Business' => 'business',\n                    'Culture » Style' => 'culture/style',\n                    'Culture » Movies' => 'culture/movies',\n                    'Culture » Manga & Anime' => 'culture/manga_anime',\n                    'Asia » China' => 'asia_world/china',\n                    'Asia » Korean Peninsula' => 'asia_world/korean_peninsula',\n                    'Asia » Around Asia' => 'asia_world/around_asia',\n                    'Asia » World' => 'asia_world/world',\n                    'Opinion » Editorial' => 'opinion/editorial',\n                    'Opinion » Vox Populi' => 'opinion/vox',\n                ],\n                'defaultValue' => 'politics',\n            ]\n        ]\n    ];\n\n    private function getSectionURI($section)\n    {\n        return $this->getURI() . $section . '/';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getSectionURI($this->getInput('section')));\n\n        foreach ($html->find('#MainInner li a') as $element) {\n            if ($element->parent()->class == 'HeadlineTopImage-S') {\n                continue;\n            }\n            $item = [];\n\n            $item['uri'] = self::BASE_URI . $element->href;\n            $e_lead = $element->find('span.Lead', 0);\n            if ($e_lead) {\n                $item['content'] = $e_lead->innertext;\n                $e_lead->outertext = '';\n            } else {\n                $item['content'] = $element->innertext;\n            }\n            $e_date = $element->find('span.EnDate', 0);\n            if ($e_date) {\n                $item['timestamp'] = strtotime($e_date->innertext);\n                $e_date->outertext = '';\n            }\n            $e_video = $element->find('span.EnVideo', 0);\n            if ($e_video) {\n                $e_video->outertext = '';\n                $element->innertext = \"VIDEO: $element->innertext\";\n            }\n            $e_title = $element->find('.title', 0);\n            if ($e_title) {\n                $item['title'] = $e_title->innertext;\n            } else {\n                $item['title'] = $element->innertext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AssociatedPressNewsBridge.php",
    "content": "<?php\n\nclass AssociatedPressNewsBridge extends BridgeAbstract\n{\n    const NAME = 'Associated Press News';\n    const URI = 'https://apnews.com/';\n    const DESCRIPTION = 'Returns newest articles by topic';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'Standard Topics' => [\n            'topic' => [\n                'name' => 'Topic',\n                'type' => 'list',\n                'values' => [\n                    'AP Top News' => 'apf-topnews',\n                    'Sports' => 'apf-sports',\n                    'Entertainment' => 'apf-entertainment',\n                    'Oddities' => 'apf-oddities',\n                    'Travel' => 'apf-Travel',\n                    'Technology' => 'apf-technology',\n                    'Lifestyle' => 'apf-lifestyle',\n                    'Business' => 'apf-business',\n                    'U.S. News' => 'apf-usnews',\n                    'Health' => 'apf-Health',\n                    'Science' => 'apf-science',\n                    'World News' => 'apf-WorldNews',\n                    'Politics' => 'apf-politics',\n                    'Religion' => 'apf-religion',\n                    'Photo Galleries' => 'PhotoGalleries',\n                    'Fact Checks' => 'APFactCheck',\n                    'Videos' => 'apf-videos',\n                ],\n                'defaultValue' => 'apf-topnews',\n            ],\n        ],\n        'Custom Topic' => [\n            'topic' => [\n                'name' => 'Topic',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'europe'\n            ],\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 900; // 15 mins\n\n    private $detectParamRegex = '/^https?:\\/\\/(?:www\\.)?apnews\\.com\\/(?:[tag|hub]+\\/)?([\\w-]+)$/';\n    private $tagEndpoint = 'https://afs-prod.appspot.com/api/v2/feed/tag?tags=';\n    private $feedName = '';\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        if (preg_match($this->detectParamRegex, $url, $matches) > 0) {\n            $params['topic'] = $matches[1];\n            $params['context'] = 'Custom Topic';\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function collectData()\n    {\n        switch ($this->getInput('topic')) {\n            case 'Podcasts':\n                throwClientException('Podcasts topic feed is not supported');\n                break;\n            case 'PressReleases':\n                throwClientException('PressReleases topic feed is not supported');\n                break;\n            default:\n                $this->collectCardData();\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('topic'))) {\n            return self::URI . $this->getInput('topic');\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName . ' - Associated Press';\n        }\n\n        return parent::getName();\n    }\n\n    private function getTagURI()\n    {\n        if (!is_null($this->getInput('topic'))) {\n            return $this->tagEndpoint . $this->getInput('topic');\n        }\n\n        return parent::getURI();\n    }\n\n    private function collectCardData()\n    {\n        $json = getContents($this->getTagURI());\n\n        $tagContents = json_decode($json, true);\n\n        if (empty($tagContents['tagObjs'])) {\n            throwClientException('Topic not found: ' . $this->getInput('topic'));\n        }\n\n        $this->feedName = $tagContents['tagObjs'][0]['name'];\n\n        foreach ($tagContents['cards'] as $card) {\n            $item = [];\n\n            // skip hub peeks & Notifications\n            if ($card['cardType'] == 'Hub Peek' || $card['cardType'] == 'Notification') {\n                continue;\n            }\n\n            $storyContent = $card['contents'][0];\n\n            switch ($storyContent['contentType']) {\n                case 'web': // Skip link only content\n                    continue 2;\n\n                case 'video':\n                    $html = $this->processVideo($storyContent);\n\n                    $item['enclosures'][] = 'https://storage.googleapis.com/afs-prod/media/'\n                        . $storyContent['media'][0]['id'] . '/800.jpeg';\n                    break;\n                default:\n                    if (empty($storyContent['storyHTML'])) { // Skip if no storyHTML\n                        continue 2;\n                    }\n\n                    $html = defaultLinkTo($storyContent['storyHTML'], self::URI);\n                    $html = str_get_html($html);\n\n                    $this->processMediaPlaceholders($html, $storyContent['id']);\n                    $this->processHubLinks($html, $storyContent);\n                    $this->processIframes($html);\n\n                    if (!is_null($storyContent['leadPhotoId'])) {\n                        $leadPhotoUrl = sprintf('https://storage.googleapis.com/afs-prod/media/%s/800.jpeg', $storyContent['leadPhotoId']);\n                        $leadPhotoImageTag = sprintf('<img src=\"%s\">', $leadPhotoUrl);\n                        // Move the image to the beginning of the content\n                        $html = $leadPhotoImageTag . $html;\n                        // Explicitly not adding it to the item's enclosures!\n                    }\n            }\n\n            $item['title'] = $card['contents'][0]['headline'];\n            $item['uri'] = self::URI . $card['shortId'];\n\n            if ($card['contents'][0]['localLinkUrl']) {\n                $item['uri'] = $card['contents'][0]['localLinkUrl'];\n            }\n\n            $item['timestamp'] = $storyContent['published'];\n\n            if (is_null($storyContent['bylines']) === false) {\n                // Remove 'By' from the bylines\n                if (substr($storyContent['bylines'], 0, 2) == 'By') {\n                    $item['author'] = ltrim($storyContent['bylines'], 'By ');\n                } else {\n                    $item['author'] = $storyContent['bylines'];\n                }\n            }\n\n            $item['content'] = $html;\n\n            foreach ($storyContent['tagObjs'] as $tag) {\n                $item['categories'][] = $tag['name'];\n            }\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 15) {\n                break;\n            }\n        }\n    }\n\n    private function processMediaPlaceholders($html, $id)\n    {\n        if ($html->find('div.media-placeholder', 0)) {\n            // Fetch page content\n            $json = getContents('https://afs-prod.appspot.com/api/v2/content/' . $id);\n            $storyContent = json_decode($json, true);\n\n            foreach ($html->find('div.media-placeholder') as $div) {\n                $key = array_search($div->id, $storyContent['mediumIds']);\n\n                if (!isset($storyContent['media'][$key])) {\n                    continue;\n                }\n\n                $media = $storyContent['media'][$key];\n\n                if ($media['type'] === 'Photo') {\n                    $mediaUrl = $media['gcsBaseUrl'] . $media['imageRenderedSizes'][0] . $media['imageFileExtension'];\n                    $mediaCaption = $media['caption'];\n\n                    $div->outertext = <<<EOD\n\t<figure><img loading=\"lazy\" src=\"{$mediaUrl}\"/><figcaption>{$mediaCaption}</figcaption></figure>\nEOD;\n                }\n\n                if ($media['type'] === 'YouTube') {\n                    $div->outertext = handleYoutube($media['externalId']);\n                }\n            }\n        }\n    }\n\n    /*\n        Create full coverage links (HubLinks)\n    */\n    private function processHubLinks($html, $storyContent)\n    {\n        if (!empty($storyContent['richEmbeds'])) {\n            foreach ($storyContent['richEmbeds'] as $embed) {\n                if ($embed['type'] === 'Hub Link') {\n                    $url = self::URI . $embed['tag']['id'];\n                    $div = $html->find('div[id=' . $embed['id'] . ']', 0);\n\n                    if ($div) {\n                        $div->outertext = <<<EOD\n<p><a href=\"{$url}\">{$embed['calloutText']} {$embed['displayName']}</a></p>\nEOD;\n                    }\n                }\n            }\n        }\n    }\n\n    private function processVideo($storyContent)\n    {\n        $video = $storyContent['media'][0];\n\n        if ($video['type'] === 'YouTube') {\n            $html = handleYoutube($video['externalId']);\n        } else {\n            $html = <<<EOD\n<video controls poster=\"https://storage.googleapis.com/afs-prod/media/{$video['id']}/800.jpeg\" preload=\"none\">\n\t<source src=\"{$video['gcsBaseUrl']} {$video['videoRenderedSizes'][0]} {$video['videoFileExtension']}\" type=\"video/mp4\">\n</video>\nEOD;\n        }\n\n        return $html;\n    }\n\n    // Remove datawrapper.dwcdn.net iframes and related javaScript\n    private function processIframes($html)\n    {\n        foreach ($html->find('iframe') as $index => $iframe) {\n            if (preg_match('/datawrapper\\.dwcdn\\.net/', $iframe->src)) {\n                $iframe->outertext = '';\n\n                if ($html->find('script', $index)) {\n                    $html->find('script', $index)->outertext = '';\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AstrophysicsDataSystemBridge.php",
    "content": "<?php\n\nclass AstrophysicsDataSystemBridge extends BridgeAbstract\n{\n    const NAME = 'SAO/NASA Astrophysics Data System';\n    const DESCRIPTION = 'Returns the latest publications from a query';\n    const URI = 'https://ui.adsabs.harvard.edu';\n    const PARAMETERS = [\n        'Publications' => [\n            'query' => [\n                'name' => 'query',\n                'title' => 'Same format as the search bar on the website',\n                'exampleValue' => 'author:\"huchra, john\"',\n                'required' => true\n            ]\n        ]];\n\n    private $feedTitle;\n\n    public function getName()\n    {\n        if ($this->queriedContext === 'Publications') {\n            return $this->feedTitle;\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if ($this->queriedContext === 'Publications') {\n            return self::URI . '/search/?q=' . urlencode($this->getInput('query'));\n        }\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $headers = [\n            'Cookie: core=always;'\n        ];\n        $html = str_get_html(defaultLinkTo(getContents($this->getURI(), $headers), self::URI));\n        $this->feedTitle = html_entity_decode($html->find('title', 0)->plaintext);\n        foreach ($html->find('div.row > ul > li') as $pub) {\n            $item = [];\n            $item['title'] = $pub->find('h3.s-results-title', 0)->plaintext;\n            $item['content'] = $pub->find('div.s-results-links', 0);\n            $item['uri'] = $pub->find('a.abs-redirect-link', 0)->href;\n            $item['author'] = rtrim($pub->find('li.article-author', 0)->plaintext, ' ;');\n            $item['timestamp'] = $pub->find('div[aria-label=\"date published\"]', 0)->plaintext;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AtmoNouvelleAquitaineBridge.php",
    "content": "<?php\n\nclass AtmoNouvelleAquitaineBridge extends BridgeAbstract\n{\n    const NAME = 'Atmo Nouvelle Aquitaine';\n    const URI = 'https://www.atmo-nouvelleaquitaine.org';\n    const DESCRIPTION = 'Fetches the latest air polution of cities in Nouvelle Aquitaine from Atmo';\n    const MAINTAINER = 'floviolleau';\n    const PARAMETERS = [[\n        'cities' => [\n            'name' => 'Choisir une ville',\n            'type' => 'list',\n            'values' => self::CITIES\n        ]\n    ]];\n    const CACHE_TIMEOUT = 7200;\n\n    private $dom;\n\n    private function getClosest($search, $arr)\n    {\n        $closest = null;\n        foreach ($arr as $key => $value) {\n            if ($closest === null || abs((int)$search - $closest) > abs((int)$key - (int)$search)) {\n                $closest = (int)$key;\n            }\n        }\n        return $arr[$closest];\n    }\n\n    public function collectData()\n    {\n        // this bridge is broken and unmaintained\n        return;\n\n        $uri = self::URI . '/monair/commune/' . $this->getInput('cities');\n\n        $html = getSimpleHTMLDOM($uri);\n\n        $this->dom = $html->find('#block-system-main .city-prevision-map', 0);\n\n        $message = $this->getIndexMessage() . ' ' . $this->getQualityMessage();\n        $message .= ' ' . $this->getTomorrowTrendIndexMessage() . ' ' . $this->getTomorrowTrendQualityMessage();\n\n        $item['uri'] = $uri;\n        $today = date('d/m/Y');\n        $item['title'] = \"Bulletin de l'air du $today pour la région Nouvelle Aquitaine.\";\n        $item['title'] .= ' Retrouvez plus d\\'informations en allant sur atmo-nouvelleaquitaine.org #QualiteAir.';\n        $item['author'] = 'floviolleau';\n        $item['content'] = $message;\n        $item['uid'] = hash('sha256', $item['title']);\n\n        $this->items[] = $item;\n    }\n\n    private function getIndex()\n    {\n        $index = $this->dom->find('.indice', 0)->innertext;\n\n        if ($index == 'XX') {\n            return -1;\n        }\n\n        return $index;\n    }\n\n    private function getMaxIndexText()\n    {\n        // will return '/100'\n        return $this->dom->find('.pourcent', 0)->innertext;\n    }\n\n    private function getQualityText($index, $indexes)\n    {\n        if ($index == -1) {\n            if (array_key_exists('no-available', $indexes)) {\n                return $indexes['no-available'];\n            }\n\n            return 'Aucune donnée';\n        }\n\n        return $this->getClosest($index, $indexes);\n    }\n\n    private function getLegendIndexes()\n    {\n        $rawIndexes = $this->dom->find('.prevision-legend .prevision-legend-label');\n        $indexes = [];\n        for ($i = 0; $i < count($rawIndexes); $i++) {\n            if ($rawIndexes[$i]->hasAttribute('data-color')) {\n                $indexes[$rawIndexes[$i]->getAttribute('data-color')] = $rawIndexes[$i]->innertext;\n            }\n        }\n\n        return $indexes;\n    }\n\n    private function getTomorrowTrendIndex()\n    {\n        $tomorrowTrendDomNode = $this->dom\n            ->find('.day-controls.raster-controls .list-raster-controls .raster-control', 2);\n        $tomorrowTrendIndexNode = null;\n\n        if ($tomorrowTrendDomNode) {\n            $tomorrowTrendIndexNode = $tomorrowTrendDomNode->find('.raster-control-link', 0);\n        }\n\n        if ($tomorrowTrendIndexNode && $tomorrowTrendIndexNode->hasAttribute('data-index')) {\n            $tomorrowTrendIndex = $tomorrowTrendIndexNode->getAttribute('data-index');\n        } else {\n            return -1;\n        }\n\n        return $tomorrowTrendIndex;\n    }\n\n    private function getTomorrowTrendQualityText($trendIndex, $indexes)\n    {\n        if ($trendIndex == -1) {\n            if (array_key_exists('no-available', $indexes)) {\n                return $indexes['no-available'];\n            }\n\n            return 'Aucune donnée';\n        }\n\n        return $this->getClosest($trendIndex, $indexes);\n    }\n\n    private function getIndexMessage()\n    {\n        $index = $this->getIndex();\n        $maxIndexText = $this->getMaxIndexText();\n\n        if ($index == -1) {\n            return 'Aucune donnée pour l\\'indice.';\n        }\n\n        return \"L'indice d'aujourd'hui est $index$maxIndexText.\";\n    }\n\n    private function getQualityMessage()\n    {\n        $index = $index = $this->getIndex();\n        $indexes = $this->getLegendIndexes();\n        $quality = $this->getQualityText($index, $indexes);\n\n        if ($index == -1) {\n            return 'Aucune donnée pour la qualité de l\\'air.';\n        }\n\n        return \"La qualité de l'air est $quality.\";\n    }\n\n    private function getTomorrowTrendIndexMessage()\n    {\n        $trendIndex = $this->getTomorrowTrendIndex();\n        $maxIndexText = $this->getMaxIndexText();\n\n        if ($trendIndex == -1) {\n            return 'Aucune donnée pour l\\'indice prévu demain.';\n        }\n\n        return \"L'indice prévu pour demain est $trendIndex$maxIndexText.\";\n    }\n\n    private function getTomorrowTrendQualityMessage()\n    {\n        $trendIndex = $this->getTomorrowTrendIndex();\n        $indexes = $this->getLegendIndexes();\n        $trendQuality = $this->getTomorrowTrendQualityText($trendIndex, $indexes);\n\n        if ($trendIndex == -1) {\n            return 'Aucune donnée pour la qualité de l\\'air de demain.';\n        }\n        return \"La qualite de l'air pour demain sera $trendQuality.\";\n    }\n\n    const CITIES = [\n        'Aast (64460)' => '64001',\n        'Abère (64160)' => '64002',\n        'Abidos (64150)' => '64003',\n        'Abitain (64390)' => '64004',\n        'Abjat-sur-Bandiat (24300)' => '24001',\n        'Abos (64360)' => '64005',\n        'Abzac (16500)' => '16001',\n        'Abzac (33230)' => '33001',\n        'Accous (64490)' => '64006',\n        'Adilly (79200)' => '79002',\n        'Adriers (86430)' => '86001',\n        'Affieux (19260)' => '19001',\n        'Agen (47000)' => '47001',\n        'Agmé (47350)' => '47002',\n        'Agnac (47800)' => '47003',\n        'Agnos (64400)' => '64007',\n        'Agonac (24460)' => '24002',\n        'Agris (16110)' => '16003',\n        'Agudelle (17500)' => '17002',\n        'Ahaxe-Alciette-Bascassan (64220)' => '64008',\n        'Ahetze (64210)' => '64009',\n        'Ahun (23150)' => '23001',\n        'Aïcirits-Camou-Suhast (64120)' => '64010',\n        'Aiffres (79230)' => '79003',\n        'Aignes-et-Puypéroux (16190)' => '16004',\n        'Aigonnay (79370)' => '79004',\n        'Aigre (16140)' => '16005',\n        'Aigrefeuille-d\\'Aunis (17290)' => '17003',\n        'Aiguillon (47190)' => '47004',\n        'Aillas (33124)' => '33002',\n        'Aincille (64220)' => '64011',\n        'Ainharp (64130)' => '64012',\n        'Ainhice-Mongelos (64220)' => '64013',\n        'Ainhoa (64250)' => '64014',\n        'Aire-sur-l\\'Adour (40800)' => '40001',\n        'Airvault (79600)' => '79005',\n        'Aix (19200)' => '19002',\n        'Aixe-sur-Vienne (87700)' => '87001',\n        'Ajain (23380)' => '23002',\n        'Ajat (24210)' => '24004',\n        'Albignac (19190)' => '19003',\n        'Albussac (19380)' => '19004',\n        'Alçay-Alçabéhéty-Sunharette (64470)' => '64015',\n        'Aldudes (64430)' => '64016',\n        'Allas-Bocage (17150)' => '17005',\n        'Allas-Champagne (17500)' => '17006',\n        'Allas-les-Mines (24220)' => '24006',\n        'Allassac (19240)' => '19005',\n        'Allemans (24600)' => '24007',\n        'Allemans-du-Dropt (47800)' => '47005',\n        'Alles-sur-Dordogne (24480)' => '24005',\n        'Alleyrat (19200)' => '19006',\n        'Alleyrat (23200)' => '23003',\n        'Allez-et-Cazeneuve (47110)' => '47006',\n        'Allonne (79130)' => '79007',\n        'Allons (47420)' => '47007',\n        'Alloue (16490)' => '16007',\n        'Alos-Sibas-Abense (64470)' => '64017',\n        'Altillac (19120)' => '19007',\n        'Amailloux (79350)' => '79008',\n        'Ambarès-et-Lagrave (33440)' => '33003',\n        'Ambazac (87240)' => '87002',\n        'Ambérac (16140)' => '16008',\n        'Ambernac (16490)' => '16009',\n        'Amberre (86110)' => '86002',\n        'Ambès (33810)' => '33004',\n        'Ambleville (16300)' => '16010',\n        'Ambrugeat (19250)' => '19008',\n        'Ambrus (47160)' => '47008',\n        'Amendeuix-Oneix (64120)' => '64018',\n        'Amorots-Succos (64120)' => '64019',\n        'Amou (40330)' => '40002',\n        'Amuré (79210)' => '79009',\n        'Anais (16560)' => '16011',\n        'Anais (17540)' => '17007',\n        'Ance (64570)' => '64020',\n        'Anché (86700)' => '86003',\n        'Andernos-les-Bains (33510)' => '33005',\n        'Andilly (17230)' => '17008',\n        'Andiran (47170)' => '47009',\n        'Andoins (64420)' => '64021',\n        'Andrein (64390)' => '64022',\n        'Angaïs (64510)' => '64023',\n        'Angeac-Champagne (16130)' => '16012',\n        'Angeac-Charente (16120)' => '16013',\n        'Angeduc (16300)' => '16014',\n        'Anglade (33390)' => '33006',\n        'Angles-sur-l\\'Anglin (86260)' => '86004',\n        'Anglet (64600)' => '64024',\n        'Angliers (17540)' => '17009',\n        'Angliers (86330)' => '86005',\n        'Angoisse (24270)' => '24008',\n        'Angoulême (16000)' => '16015',\n        'Angoulins (17690)' => '17010',\n        'Angoumé (40990)' => '40003',\n        'Angous (64190)' => '64025',\n        'Angresse (40150)' => '40004',\n        'Anhaux (64220)' => '64026',\n        'Anlhiac (24160)' => '24009',\n        'Annepont (17350)' => '17011',\n        'Annesse-et-Beaulieu (24430)' => '24010',\n        'Annezay (17380)' => '17012',\n        'Anos (64160)' => '64027',\n        'Anoye (64350)' => '64028',\n        'Ansac-sur-Vienne (16500)' => '16016',\n        'Antagnac (47700)' => '47010',\n        'Antezant-la-Chapelle (17400)' => '17013',\n        'Anthé (47370)' => '47011',\n        'Antigny (86310)' => '86006',\n        'Antonne-et-Trigonant (24420)' => '24011',\n        'Antran (86100)' => '86007',\n        'Anville (16170)' => '16017',\n        'Anzême (23000)' => '23004',\n        'Anzex (47700)' => '47012',\n        'Aramits (64570)' => '64029',\n        'Arancou (64270)' => '64031',\n        'Araujuzon (64190)' => '64032',\n        'Araux (64190)' => '64033',\n        'Arbanats (33640)' => '33007',\n        'Arbérats-Sillègue (64120)' => '64034',\n        'Arbis (33760)' => '33008',\n        'Arbonne (64210)' => '64035',\n        'Arboucave (40320)' => '40005',\n        'Arbouet-Sussaute (64120)' => '64036',\n        'Arbus (64230)' => '64037',\n        'Arcachon (33120)' => '33009',\n        'Arçais (79210)' => '79010',\n        'Arcangues (64200)' => '64038',\n        'Arçay (86200)' => '86008',\n        'Arces (17120)' => '17015',\n        'Archiac (17520)' => '17016',\n        'Archignac (24590)' => '24012',\n        'Archigny (86210)' => '86009',\n        'Archingeay (17380)' => '17017',\n        'Arcins (33460)' => '33010',\n        'Ardilleux (79110)' => '79011',\n        'Ardillières (17290)' => '17018',\n        'Ardin (79160)' => '79012',\n        'Aren (64400)' => '64039',\n        'Arengosse (40110)' => '40006',\n        'Arès (33740)' => '33011',\n        'Aressy (64320)' => '64041',\n        'Arette (64570)' => '64040',\n        'Arfeuille-Châtain (23700)' => '23005',\n        'Argagnon (64300)' => '64042',\n        'Argelos (40700)' => '40007',\n        'Argelos (64450)' => '64043',\n        'Argelouse (40430)' => '40008',\n        'Argentat (19400)' => '19010',\n        'Argenton (47250)' => '47013',\n        'Argenton-l\\'Église (79290)' => '79014',\n        'Argentonnay (79150)' => '79013',\n        'Arget (64410)' => '64044',\n        'Arhansus (64120)' => '64045',\n        'Arjuzanx (40110)' => '40009',\n        'Armendarits (64640)' => '64046',\n        'Armillac (47800)' => '47014',\n        'Arnac-la-Poste (87160)' => '87003',\n        'Arnac-Pompadour (19230)' => '19011',\n        'Arnéguy (64220)' => '64047',\n        'Arnos (64370)' => '64048',\n        'Aroue-Ithorots-Olhaïby (64120)' => '64049',\n        'Arrast-Larrebieu (64130)' => '64050',\n        'Arraute-Charritte (64120)' => '64051',\n        'Arrènes (23210)' => '23006',\n        'Arricau-Bordes (64350)' => '64052',\n        'Arrien (64420)' => '64053',\n        'Arros-de-Nay (64800)' => '64054',\n        'Arrosès (64350)' => '64056',\n        'Ars (16130)' => '16018',\n        'Ars (23480)' => '23007',\n        'Ars-en-Ré (17590)' => '17019',\n        'Arsac (33460)' => '33012',\n        'Arsague (40330)' => '40011',\n        'Artassenx (40090)' => '40012',\n        'Arthenac (17520)' => '17020',\n        'Arthez-d\\'Armagnac (40190)' => '40013',\n        'Arthez-d\\'Asson (64800)' => '64058',\n        'Arthez-de-Béarn (64370)' => '64057',\n        'Artigueloutan (64420)' => '64059',\n        'Artiguelouve (64230)' => '64060',\n        'Artigues-près-Bordeaux (33370)' => '33013',\n        'Artix (64170)' => '64061',\n        'Arudy (64260)' => '64062',\n        'Arue (40120)' => '40014',\n        'Arvert (17530)' => '17021',\n        'Arveyres (33500)' => '33015',\n        'Arx (40310)' => '40015',\n        'Arzacq-Arraziguet (64410)' => '64063',\n        'Asasp-Arros (64660)' => '64064',\n        'Ascain (64310)' => '64065',\n        'Ascarat (64220)' => '64066',\n        'Aslonnes (86340)' => '86010',\n        'Asnières-en-Poitou (79170)' => '79015',\n        'Asnières-la-Giraud (17400)' => '17022',\n        'Asnières-sur-Blour (86430)' => '86011',\n        'Asnières-sur-Nouère (16290)' => '16019',\n        'Asnois (86250)' => '86012',\n        'Asques (33240)' => '33016',\n        'Assais-les-Jumeaux (79600)' => '79016',\n        'Assat (64510)' => '64067',\n        'Asson (64800)' => '64068',\n        'Astaffort (47220)' => '47015',\n        'Astaillac (19120)' => '19012',\n        'Aste-Béon (64260)' => '64069',\n        'Astis (64450)' => '64070',\n        'Athos-Aspis (64390)' => '64071',\n        'Aubagnan (40700)' => '40016',\n        'Aubas (24290)' => '24014',\n        'Aubazines (19190)' => '19013',\n        'Aubertin (64290)' => '64072',\n        'Aubeterre-sur-Dronne (16390)' => '16020',\n        'Aubiac (33430)' => '33017',\n        'Aubiac (47310)' => '47016',\n        'Aubigné (79110)' => '79018',\n        'Aubigny (79390)' => '79019',\n        'Aubin (64230)' => '64073',\n        'Aubous (64330)' => '64074',\n        'Aubusson (23200)' => '23008',\n        'Audaux (64190)' => '64075',\n        'Audenge (33980)' => '33019',\n        'Audignon (40500)' => '40017',\n        'Audon (40400)' => '40018',\n        'Audrix (24260)' => '24015',\n        'Auga (64450)' => '64077',\n        'Auge (23170)' => '23009',\n        'Augé (79400)' => '79020',\n        'Auge-Saint-Médard (16170)' => '16339',\n        'Augères (23210)' => '23010',\n        'Augignac (24300)' => '24016',\n        'Augne (87120)' => '87004',\n        'Aujac (17770)' => '17023',\n        'Aulnay (17470)' => '17024',\n        'Aulnay (86330)' => '86013',\n        'Aulon (23210)' => '23011',\n        'Aumagne (17770)' => '17025',\n        'Aunac (16460)' => '16023',\n        'Auradou (47140)' => '47017',\n        'Aureil (87220)' => '87005',\n        'Aureilhan (40200)' => '40019',\n        'Auriac (19220)' => '19014',\n        'Auriac (64450)' => '64078',\n        'Auriac-du-Périgord (24290)' => '24018',\n        'Auriac-sur-Dropt (47120)' => '47018',\n        'Auriat (23400)' => '23012',\n        'Aurice (40500)' => '40020',\n        'Auriolles (33790)' => '33020',\n        'Aurions-Idernes (64350)' => '64079',\n        'Auros (33124)' => '33021',\n        'Aussac-Vadalle (16560)' => '16024',\n        'Aussevielle (64230)' => '64080',\n        'Aussurucq (64130)' => '64081',\n        'Auterrive (64270)' => '64082',\n        'Autevielle-Saint-Martin-Bideren (64390)' => '64083',\n        'Authon-Ébéon (17770)' => '17026',\n        'Auzances (23700)' => '23013',\n        'Availles-en-Châtellerault (86530)' => '86014',\n        'Availles-Limouzine (86460)' => '86015',\n        'Availles-Thouarsais (79600)' => '79022',\n        'Avanton (86170)' => '86016',\n        'Avensan (33480)' => '33022',\n        'Avon (79800)' => '79023',\n        'Avy (17800)' => '17027',\n        'Aydie (64330)' => '64084',\n        'Aydius (64490)' => '64085',\n        'Ayen (19310)' => '19015',\n        'Ayguemorte-les-Graves (33640)' => '33023',\n        'Ayherre (64240)' => '64086',\n        'Ayron (86190)' => '86017',\n        'Aytré (17440)' => '17028',\n        'Azat-Châtenet (23210)' => '23014',\n        'Azat-le-Ris (87360)' => '87006',\n        'Azay-le-Brûlé (79400)' => '79024',\n        'Azay-sur-Thouet (79130)' => '79025',\n        'Azerables (23160)' => '23015',\n        'Azerat (24210)' => '24019',\n        'Azur (40140)' => '40021',\n        'Badefols-d\\'Ans (24390)' => '24021',\n        'Badefols-sur-Dordogne (24150)' => '24022',\n        'Bagas (33190)' => '33024',\n        'Bagnizeau (17160)' => '17029',\n        'Bahus-Soubiran (40320)' => '40022',\n        'Baigneaux (33760)' => '33025',\n        'Baignes-Sainte-Radegonde (16360)' => '16025',\n        'Baigts (40380)' => '40023',\n        'Baigts-de-Béarn (64300)' => '64087',\n        'Bajamont (47480)' => '47019',\n        'Balansun (64300)' => '64088',\n        'Balanzac (17600)' => '17030',\n        'Baleix (64460)' => '64089',\n        'Baleyssagues (47120)' => '47020',\n        'Baliracq-Maumusson (64330)' => '64090',\n        'Baliros (64510)' => '64091',\n        'Balizac (33730)' => '33026',\n        'Ballans (17160)' => '17031',\n        'Balledent (87290)' => '87007',\n        'Ballon (17290)' => '17032',\n        'Balzac (16430)' => '16026',\n        'Banca (64430)' => '64092',\n        'Baneuil (24150)' => '24023',\n        'Banize (23120)' => '23016',\n        'Banos (40500)' => '40024',\n        'Bar (19800)' => '19016',\n        'Barbaste (47230)' => '47021',\n        'Barbezières (16140)' => '16027',\n        'Barbezieux-Saint-Hilaire (16300)' => '16028',\n        'Barcus (64130)' => '64093',\n        'Bardenac (16210)' => '16029',\n        'Bardos (64520)' => '64094',\n        'Bardou (24560)' => '24024',\n        'Barie (33190)' => '33027',\n        'Barinque (64160)' => '64095',\n        'Baron (33750)' => '33028',\n        'Barraute-Camu (64390)' => '64096',\n        'Barret (16300)' => '16030',\n        'Barro (16700)' => '16031',\n        'Bars (24210)' => '24025',\n        'Barsac (33720)' => '33030',\n        'Barzan (17120)' => '17034',\n        'Barzun (64530)' => '64097',\n        'Bas-Mauco (40500)' => '40026',\n        'Bascons (40090)' => '40025',\n        'Bassac (16120)' => '16032',\n        'Bassanne (33190)' => '33031',\n        'Bassens (33530)' => '33032',\n        'Bassercles (40700)' => '40027',\n        'Basses (86200)' => '86018',\n        'Bassignac-le-Bas (19430)' => '19017',\n        'Bassignac-le-Haut (19220)' => '19018',\n        'Bassillac (24330)' => '24026',\n        'Bassillon-Vauzé (64350)' => '64098',\n        'Bassussarry (64200)' => '64100',\n        'Bastanès (64190)' => '64099',\n        'Bastennes (40360)' => '40028',\n        'Basville (23260)' => '23017',\n        'Bats (40320)' => '40029',\n        'Baudignan (40310)' => '40030',\n        'Baudreix (64800)' => '64101',\n        'Baurech (33880)' => '33033',\n        'Bayac (24150)' => '24027',\n        'Bayas (33230)' => '33034',\n        'Bayers (16460)' => '16033',\n        'Bayon-sur-Gironde (33710)' => '33035',\n        'Bayonne (64100)' => '64102',\n        'Bazac (16210)' => '16034',\n        'Bazas (33430)' => '33036',\n        'Bazauges (17490)' => '17035',\n        'Bazelat (23160)' => '23018',\n        'Bazens (47130)' => '47022',\n        'Beaugas (47290)' => '47023',\n        'Beaugeay (17620)' => '17036',\n        'Beaulieu-sous-Parthenay (79420)' => '79029',\n        'Beaulieu-sur-Dordogne (19120)' => '19019',\n        'Beaulieu-sur-Sonnette (16450)' => '16035',\n        'Beaumont (19390)' => '19020',\n        'Beaumont (86490)' => '86019',\n        'Beaumont-du-Lac (87120)' => '87009',\n        'Beaumontois en Périgord (24440)' => '24028',\n        'Beaupouyet (24400)' => '24029',\n        'Beaupuy (47200)' => '47024',\n        'Beauregard-de-Terrasson (24120)' => '24030',\n        'Beauregard-et-Bassac (24140)' => '24031',\n        'Beauronne (24400)' => '24032',\n        'Beaussac (24340)' => '24033',\n        'Beaussais-Vitré (79370)' => '79030',\n        'Beautiran (33640)' => '33037',\n        'Beauvais-sur-Matha (17490)' => '17037',\n        'Beauville (47470)' => '47025',\n        'Beauvoir-sur-Niort (79360)' => '79031',\n        'Beauziac (47700)' => '47026',\n        'Béceleuf (79160)' => '79032',\n        'Bécheresse (16250)' => '16036',\n        'Bédeille (64460)' => '64103',\n        'Bedenac (17210)' => '17038',\n        'Bedous (64490)' => '64104',\n        'Bégaar (40400)' => '40031',\n        'Bégadan (33340)' => '33038',\n        'Bègles (33130)' => '33039',\n        'Béguey (33410)' => '33040',\n        'Béguios (64120)' => '64105',\n        'Béhasque-Lapiste (64120)' => '64106',\n        'Béhorléguy (64220)' => '64107',\n        'Beissat (23260)' => '23019',\n        'Beleymas (24140)' => '24034',\n        'Belhade (40410)' => '40032',\n        'Belin-Béliet (33830)' => '33042',\n        'Bélis (40120)' => '40033',\n        'Bellac (87300)' => '87011',\n        'Bellebat (33760)' => '33043',\n        'Bellechassagne (19290)' => '19021',\n        'Bellefond (33760)' => '33044',\n        'Bellefonds (86210)' => '86020',\n        'Bellegarde-en-Marche (23190)' => '23020',\n        'Belleville (79360)' => '79033',\n        'Bellocq (64270)' => '64108',\n        'Bellon (16210)' => '16037',\n        'Belluire (17800)' => '17039',\n        'Bélus (40300)' => '40034',\n        'Belvès-de-Castillon (33350)' => '33045',\n        'Benassay (86470)' => '86021',\n        'Benayes (19510)' => '19022',\n        'Bénéjacq (64800)' => '64109',\n        'Bénesse-lès-Dax (40180)' => '40035',\n        'Bénesse-Maremne (40230)' => '40036',\n        'Benest (16350)' => '16038',\n        'Bénévent-l\\'Abbaye (23210)' => '23021',\n        'Benon (17170)' => '17041',\n        'Benquet (40280)' => '40037',\n        'Bentayou-Sérée (64460)' => '64111',\n        'Béost (64440)' => '64110',\n        'Berbiguières (24220)' => '24036',\n        'Bercloux (17770)' => '17042',\n        'Bérenx (64300)' => '64112',\n        'Bergerac (24100)' => '24037',\n        'Bergouey (40250)' => '40038',\n        'Bergouey-Viellenave (64270)' => '64113',\n        'Bernac (16700)' => '16039',\n        'Bernadets (64160)' => '64114',\n        'Bernay-Saint-Martin (17330)' => '17043',\n        'Berneuil (16480)' => '16040',\n        'Berneuil (17460)' => '17044',\n        'Berneuil (87300)' => '87012',\n        'Bernos-Beaulac (33430)' => '33046',\n        'Berrie (86120)' => '86022',\n        'Berrogain-Laruns (64130)' => '64115',\n        'Bersac-sur-Rivalier (87370)' => '87013',\n        'Berson (33390)' => '33047',\n        'Berthegon (86420)' => '86023',\n        'Berthez (33124)' => '33048',\n        'Bertric-Burée (24320)' => '24038',\n        'Béruges (86190)' => '86024',\n        'Bescat (64260)' => '64116',\n        'Bésingrand (64150)' => '64117',\n        'Bessac (16250)' => '16041',\n        'Bessé (16140)' => '16042',\n        'Besse (24550)' => '24039',\n        'Bessines (79000)' => '79034',\n        'Bessines-sur-Gartempe (87250)' => '87014',\n        'Betbezer-d\\'Armagnac (40240)' => '40039',\n        'Bétête (23270)' => '23022',\n        'Béthines (86310)' => '86025',\n        'Bétracq (64350)' => '64118',\n        'Beurlay (17250)' => '17045',\n        'Beuste (64800)' => '64119',\n        'Beuxes (86120)' => '86026',\n        'Beychac-et-Caillau (33750)' => '33049',\n        'Beylongue (40370)' => '40040',\n        'Beynac (87700)' => '87015',\n        'Beynac-et-Cazenac (24220)' => '24040',\n        'Beynat (19190)' => '19023',\n        'Beyrie-en-Béarn (64230)' => '64121',\n        'Beyrie-sur-Joyeuse (64120)' => '64120',\n        'Beyries (40700)' => '40041',\n        'Beyssac (19230)' => '19024',\n        'Beyssenac (19230)' => '19025',\n        'Bézenac (24220)' => '24041',\n        'Biard (86580)' => '86027',\n        'Biarritz (64200)' => '64122',\n        'Biarrotte (40390)' => '40042',\n        'Bias (40170)' => '40043',\n        'Bias (47300)' => '47027',\n        'Biaudos (40390)' => '40044',\n        'Bidache (64520)' => '64123',\n        'Bidarray (64780)' => '64124',\n        'Bidart (64210)' => '64125',\n        'Bidos (64400)' => '64126',\n        'Bielle (64260)' => '64127',\n        'Bieujac (33210)' => '33050',\n        'Biganos (33380)' => '33051',\n        'Bignay (17400)' => '17046',\n        'Bignoux (86800)' => '86028',\n        'Bilhac (19120)' => '19026',\n        'Bilhères (64260)' => '64128',\n        'Billère (64140)' => '64129',\n        'Bioussac (16700)' => '16044',\n        'Birac (16120)' => '16045',\n        'Birac (33430)' => '33053',\n        'Birac-sur-Trec (47200)' => '47028',\n        'Biras (24310)' => '24042',\n        'Biriatou (64700)' => '64130',\n        'Biron (17800)' => '17047',\n        'Biron (24540)' => '24043',\n        'Biron (64300)' => '64131',\n        'Biscarrosse (40600)' => '40046',\n        'Bizanos (64320)' => '64132',\n        'Blaignac (33190)' => '33054',\n        'Blaignan (33340)' => '33055',\n        'Blanquefort (33290)' => '33056',\n        'Blanquefort-sur-Briolance (47500)' => '47029',\n        'Blanzac (87300)' => '87017',\n        'Blanzac-lès-Matha (17160)' => '17048',\n        'Blanzac-Porcheresse (16250)' => '16046',\n        'Blanzaguet-Saint-Cybard (16320)' => '16047',\n        'Blanzay (86400)' => '86029',\n        'Blanzay-sur-Boutonne (17470)' => '17049',\n        'Blasimon (33540)' => '33057',\n        'Blaslay (86170)' => '86030',\n        'Blaudeix (23140)' => '23023',\n        'Blaye (33390)' => '33058',\n        'Blaymont (47470)' => '47030',\n        'Blésignac (33670)' => '33059',\n        'Blessac (23200)' => '23024',\n        'Blis-et-Born (24330)' => '24044',\n        'Blond (87300)' => '87018',\n        'Boé (47550)' => '47031',\n        'Boeil-Bezing (64510)' => '64133',\n        'Bois (17240)' => '17050',\n        'Boisbreteau (16480)' => '16048',\n        'Boismé (79300)' => '79038',\n        'Boisné-La Tude (16320)' => '16082',\n        'Boisredon (17150)' => '17052',\n        'Boisse (24560)' => '24045',\n        'Boisserolles (79360)' => '79039',\n        'Boisseuil (87220)' => '87019',\n        'Boisseuilh (24390)' => '24046',\n        'Bommes (33210)' => '33060',\n        'Bon-Encontre (47240)' => '47032',\n        'Bonloc (64240)' => '64134',\n        'Bonnac-la-Côte (87270)' => '87020',\n        'Bonnat (23220)' => '23025',\n        'Bonnefond (19170)' => '19027',\n        'Bonnegarde (40330)' => '40047',\n        'Bonnes (16390)' => '16049',\n        'Bonnes (86300)' => '86031',\n        'Bonnetan (33370)' => '33061',\n        'Bonneuil (16120)' => '16050',\n        'Bonneuil-Matours (86210)' => '86032',\n        'Bonneville (16170)' => '16051',\n        'Bonneville-et-Saint-Avit-de-Fumadières (24230)' => '24048',\n        'Bonnut (64300)' => '64135',\n        'Bonzac (33910)' => '33062',\n        'Boos (40370)' => '40048',\n        'Borce (64490)' => '64136',\n        'Bord-Saint-Georges (23230)' => '23026',\n        'Bordeaux (33000)' => '33063',\n        'Bordères (64800)' => '64137',\n        'Bordères-et-Lamensans (40270)' => '40049',\n        'Bordes (64510)' => '64138',\n        'Bords (17430)' => '17053',\n        'Boresse-et-Martron (17270)' => '17054',\n        'Borrèze (24590)' => '24050',\n        'Bors (Canton de Baignes-Sainte-Radegonde) (16360)' => '16053',\n        'Bors (Canton de Montmoreau-Saint-Cybard) (16190)' => '16052',\n        'Bort-les-Orgues (19110)' => '19028',\n        'Boscamnant (17360)' => '17055',\n        'Bosdarros (64290)' => '64139',\n        'Bosmie-l\\'Aiguille (87110)' => '87021',\n        'Bosmoreau-les-Mines (23400)' => '23027',\n        'Bosroger (23200)' => '23028',\n        'Bosset (24130)' => '24051',\n        'Bossugan (33350)' => '33064',\n        'Bostens (40090)' => '40050',\n        'Boucau (64340)' => '64140',\n        'Boudy-de-Beauregard (47290)' => '47033',\n        'Boueilh-Boueilho-Lasque (64330)' => '64141',\n        'Bouëx (16410)' => '16055',\n        'Bougarber (64230)' => '64142',\n        'Bouglon (47250)' => '47034',\n        'Bougneau (17800)' => '17056',\n        'Bougon (79800)' => '79042',\n        'Bougue (40090)' => '40051',\n        'Bouhet (17540)' => '17057',\n        'Bouillac (24480)' => '24052',\n        'Bouillé-Loretz (79290)' => '79043',\n        'Bouillé-Saint-Paul (79290)' => '79044',\n        'Bouillon (64410)' => '64143',\n        'Bouin (79110)' => '79045',\n        'Boulazac Isle Manoire (24750)' => '24053',\n        'Bouliac (33270)' => '33065',\n        'Boumourt (64370)' => '64144',\n        'Bouniagues (24560)' => '24054',\n        'Bourcefranc-le-Chapus (17560)' => '17058',\n        'Bourdalat (40190)' => '40052',\n        'Bourdeilles (24310)' => '24055',\n        'Bourdelles (33190)' => '33066',\n        'Bourdettes (64800)' => '64145',\n        'Bouresse (86410)' => '86034',\n        'Bourg (33710)' => '33067',\n        'Bourg-Archambault (86390)' => '86035',\n        'Bourg-Charente (16200)' => '16056',\n        'Bourg-des-Maisons (24320)' => '24057',\n        'Bourg-du-Bost (24600)' => '24058',\n        'Bourganeuf (23400)' => '23030',\n        'Bourgnac (24400)' => '24059',\n        'Bourgneuf (17220)' => '17059',\n        'Bourgougnague (47410)' => '47035',\n        'Bourideys (33113)' => '33068',\n        'Bourlens (47370)' => '47036',\n        'Bournand (86120)' => '86036',\n        'Bournel (47210)' => '47037',\n        'Bourniquel (24150)' => '24060',\n        'Bournos (64450)' => '64146',\n        'Bourran (47320)' => '47038',\n        'Bourriot-Bergonce (40120)' => '40053',\n        'Bourrou (24110)' => '24061',\n        'Boussac (23600)' => '23031',\n        'Boussac-Bourg (23600)' => '23032',\n        'Boussais (79600)' => '79047',\n        'Boussès (47420)' => '47039',\n        'Bouteilles-Saint-Sébastien (24320)' => '24062',\n        'Boutenac-Touvent (17120)' => '17060',\n        'Bouteville (16120)' => '16057',\n        'Boutiers-Saint-Trojan (16100)' => '16058',\n        'Bouzic (24250)' => '24063',\n        'Brach (33480)' => '33070',\n        'Bran (17210)' => '17061',\n        'Branceilles (19500)' => '19029',\n        'Branne (33420)' => '33071',\n        'Brannens (33124)' => '33072',\n        'Brantôme en Périgord (24310)' => '24064',\n        'Brassempouy (40330)' => '40054',\n        'Braud-et-Saint-Louis (33820)' => '33073',\n        'Brax (47310)' => '47040',\n        'Bresdon (17490)' => '17062',\n        'Bressuire (79300)' => '79049',\n        'Bretagne-de-Marsan (40280)' => '40055',\n        'Bretignolles (79140)' => '79050',\n        'Brettes (16240)' => '16059',\n        'Breuil-la-Réorte (17700)' => '17063',\n        'Breuil-Magné (17870)' => '17065',\n        'Breuilaufa (87300)' => '87022',\n        'Breuilh (24380)' => '24065',\n        'Breuillet (17920)' => '17064',\n        'Bréville (16370)' => '16060',\n        'Brie (16590)' => '16061',\n        'Brie (79100)' => '79054',\n        'Brie-sous-Archiac (17520)' => '17066',\n        'Brie-sous-Barbezieux (16300)' => '16062',\n        'Brie-sous-Chalais (16210)' => '16063',\n        'Brie-sous-Matha (17160)' => '17067',\n        'Brie-sous-Mortagne (17120)' => '17068',\n        'Brieuil-sur-Chizé (79170)' => '79055',\n        'Brignac-la-Plaine (19310)' => '19030',\n        'Brigueil-le-Chantre (86290)' => '86037',\n        'Brigueuil (16420)' => '16064',\n        'Brillac (16500)' => '16065',\n        'Brion (86160)' => '86038',\n        'Brion-près-Thouet (79290)' => '79056',\n        'Brioux-sur-Boutonne (79170)' => '79057',\n        'Briscous (64240)' => '64147',\n        'Brive-la-Gaillarde (19100)' => '19031',\n        'Brives-sur-Charente (17800)' => '17069',\n        'Brivezac (19120)' => '19032',\n        'Brizambourg (17770)' => '17070',\n        'Brocas (40420)' => '40056',\n        'Brossac (16480)' => '16066',\n        'Brouchaud (24210)' => '24066',\n        'Brouqueyran (33124)' => '33074',\n        'Brousse (23700)' => '23034',\n        'Bruch (47130)' => '47041',\n        'Bruges (33520)' => '33075',\n        'Bruges-Capbis-Mifaget (64800)' => '64148',\n        'Brugnac (47260)' => '47042',\n        'Brûlain (79230)' => '79058',\n        'Brux (86510)' => '86039',\n        'Buanes (40320)' => '40057',\n        'Budelière (23170)' => '23035',\n        'Budos (33720)' => '33076',\n        'Bugeat (19170)' => '19033',\n        'Bugnein (64190)' => '64149',\n        'Bujaleuf (87460)' => '87024',\n        'Bunus (64120)' => '64150',\n        'Bunzac (16110)' => '16067',\n        'Burgaronne (64390)' => '64151',\n        'Burgnac (87800)' => '87025',\n        'Burie (17770)' => '17072',\n        'Buros (64160)' => '64152',\n        'Burosse-Mendousse (64330)' => '64153',\n        'Bussac (24350)' => '24069',\n        'Bussac-Forêt (17210)' => '17074',\n        'Bussac-sur-Charente (17100)' => '17073',\n        'Busserolles (24360)' => '24070',\n        'Bussière-Badil (24360)' => '24071',\n        'Bussière-Dunoise (23320)' => '23036',\n        'Bussière-Galant (87230)' => '87027',\n        'Bussière-Nouvelle (23700)' => '23037',\n        'Bussière-Poitevine (87320)' => '87028',\n        'Bussière-Saint-Georges (23600)' => '23038',\n        'Bussunarits-Sarrasquette (64220)' => '64154',\n        'Bustince-Iriberry (64220)' => '64155',\n        'Buxerolles (86180)' => '86041',\n        'Buxeuil (37160)' => '86042',\n        'Buzet-sur-Baïse (47160)' => '47043',\n        'Buziet (64680)' => '64156',\n        'Buzy (64260)' => '64157',\n        'Cabanac-et-Villagrains (33650)' => '33077',\n        'Cabara (33420)' => '33078',\n        'Cabariot (17430)' => '17075',\n        'Cabidos (64410)' => '64158',\n        'Cachen (40120)' => '40058',\n        'Cadarsac (33750)' => '33079',\n        'Cadaujac (33140)' => '33080',\n        'Cadillac (33410)' => '33081',\n        'Cadillac-en-Fronsadais (33240)' => '33082',\n        'Cadillon (64330)' => '64159',\n        'Cagnotte (40300)' => '40059',\n        'Cahuzac (47330)' => '47044',\n        'Calès (24150)' => '24073',\n        'Calignac (47600)' => '47045',\n        'Callen (40430)' => '40060',\n        'Calonges (47430)' => '47046',\n        'Calviac-en-Périgord (24370)' => '24074',\n        'Camarsac (33750)' => '33083',\n        'Cambes (33880)' => '33084',\n        'Cambes (47350)' => '47047',\n        'Camblanes-et-Meynac (33360)' => '33085',\n        'Cambo-les-Bains (64250)' => '64160',\n        'Came (64520)' => '64161',\n        'Camiac-et-Saint-Denis (33420)' => '33086',\n        'Camiran (33190)' => '33087',\n        'Camou-Cihigue (64470)' => '64162',\n        'Campagnac-lès-Quercy (24550)' => '24075',\n        'Campagne (24260)' => '24076',\n        'Campagne (40090)' => '40061',\n        'Campet-et-Lamolère (40090)' => '40062',\n        'Camps-Saint-Mathurin-Léobazel (19430)' => '19034',\n        'Camps-sur-l\\'Isle (33660)' => '33088',\n        'Campsegret (24140)' => '24077',\n        'Campugnan (33390)' => '33089',\n        'Cancon (47290)' => '47048',\n        'Candresse (40180)' => '40063',\n        'Canéjan (33610)' => '33090',\n        'Canenx-et-Réaut (40090)' => '40064',\n        'Cantenac (33460)' => '33091',\n        'Cantillac (24530)' => '24079',\n        'Cantois (33760)' => '33092',\n        'Capbreton (40130)' => '40065',\n        'Capdrot (24540)' => '24080',\n        'Capian (33550)' => '33093',\n        'Caplong (33220)' => '33094',\n        'Captieux (33840)' => '33095',\n        'Carbon-Blanc (33560)' => '33096',\n        'Carcans (33121)' => '33097',\n        'Carcarès-Sainte-Croix (40400)' => '40066',\n        'Carcen-Ponson (40400)' => '40067',\n        'Cardan (33410)' => '33098',\n        'Cardesse (64360)' => '64165',\n        'Carignan-de-Bordeaux (33360)' => '33099',\n        'Carlux (24370)' => '24081',\n        'Caro (64220)' => '64166',\n        'Carrère (64160)' => '64167',\n        'Carresse-Cassaber (64270)' => '64168',\n        'Cars (33390)' => '33100',\n        'Carsac-Aillac (24200)' => '24082',\n        'Carsac-de-Gurson (24610)' => '24083',\n        'Cartelègue (33390)' => '33101',\n        'Carves (24170)' => '24084',\n        'Cassen (40380)' => '40068',\n        'Casseneuil (47440)' => '47049',\n        'Casseuil (33190)' => '33102',\n        'Cassignas (47340)' => '47050',\n        'Castagnède (64270)' => '64170',\n        'Castaignos-Souslens (40700)' => '40069',\n        'Castandet (40270)' => '40070',\n        'Casteide-Cami (64170)' => '64171',\n        'Casteide-Candau (64370)' => '64172',\n        'Casteide-Doat (64460)' => '64173',\n        'Castel-Sarrazin (40330)' => '40074',\n        'Castelculier (47240)' => '47051',\n        'Casteljaloux (47700)' => '47052',\n        'Castella (47340)' => '47053',\n        'Castelmoron-d\\'Albret (33540)' => '33103',\n        'Castelmoron-sur-Lot (47260)' => '47054',\n        'Castelnau-Chalosse (40360)' => '40071',\n        'Castelnau-de-Médoc (33480)' => '33104',\n        'Castelnau-sur-Gupie (47180)' => '47056',\n        'Castelnau-Tursan (40320)' => '40072',\n        'Castelnaud-de-Gratecambe (47290)' => '47055',\n        'Castelnaud-la-Chapelle (24250)' => '24086',\n        'Castelner (40700)' => '40073',\n        'Castels (24220)' => '24087',\n        'Castelviel (33540)' => '33105',\n        'Castéra-Loubix (64460)' => '64174',\n        'Castet (64260)' => '64175',\n        'Castetbon (64190)' => '64176',\n        'Castétis (64300)' => '64177',\n        'Castetnau-Camblong (64190)' => '64178',\n        'Castetner (64300)' => '64179',\n        'Castetpugon (64330)' => '64180',\n        'Castets (40260)' => '40075',\n        'Castets-en-Dorthe (33210)' => '33106',\n        'Castillon (Canton d\\'Arthez-de-Béarn) (64370)' => '64181',\n        'Castillon (Canton de Lembeye) (64350)' => '64182',\n        'Castillon-de-Castets (33210)' => '33107',\n        'Castillon-la-Bataille (33350)' => '33108',\n        'Castillonnès (47330)' => '47057',\n        'Castres-Gironde (33640)' => '33109',\n        'Caubeyres (47160)' => '47058',\n        'Caubios-Loos (64230)' => '64183',\n        'Caubon-Saint-Sauveur (47120)' => '47059',\n        'Caudecoste (47220)' => '47060',\n        'Caudrot (33490)' => '33111',\n        'Caumont (33540)' => '33112',\n        'Caumont-sur-Garonne (47430)' => '47061',\n        'Cauna (40500)' => '40076',\n        'Caunay (79190)' => '79060',\n        'Cauneille (40300)' => '40077',\n        'Caupenne (40250)' => '40078',\n        'Cause-de-Clérans (24150)' => '24088',\n        'Cauvignac (33690)' => '33113',\n        'Cauzac (47470)' => '47062',\n        'Cavarc (47330)' => '47063',\n        'Cavignac (33620)' => '33114',\n        'Cazalis (33113)' => '33115',\n        'Cazalis (40700)' => '40079',\n        'Cazats (33430)' => '33116',\n        'Cazaugitat (33790)' => '33117',\n        'Cazères-sur-l\\'Adour (40270)' => '40080',\n        'Cazideroque (47370)' => '47064',\n        'Cazoulès (24370)' => '24089',\n        'Ceaux-en-Couhé (86700)' => '86043',\n        'Ceaux-en-Loudun (86200)' => '86044',\n        'Celle-Lévescault (86600)' => '86045',\n        'Cellefrouin (16260)' => '16068',\n        'Celles (17520)' => '17076',\n        'Celles (24600)' => '24090',\n        'Celles-sur-Belle (79370)' => '79061',\n        'Cellettes (16230)' => '16069',\n        'Cénac (33360)' => '33118',\n        'Cénac-et-Saint-Julien (24250)' => '24091',\n        'Cendrieux (24380)' => '24092',\n        'Cenon (33150)' => '33119',\n        'Cenon-sur-Vienne (86530)' => '86046',\n        'Cercles (24320)' => '24093',\n        'Cercoux (17270)' => '17077',\n        'Cère (40090)' => '40081',\n        'Cerizay (79140)' => '79062',\n        'Cernay (86140)' => '86047',\n        'Cérons (33720)' => '33120',\n        'Cersay (79290)' => '79063',\n        'Cescau (64170)' => '64184',\n        'Cessac (33760)' => '33121',\n        'Cestas (33610)' => '33122',\n        'Cette-Eygun (64490)' => '64185',\n        'Ceyroux (23210)' => '23042',\n        'Cézac (33620)' => '33123',\n        'Chabanais (16150)' => '16070',\n        'Chabournay (86380)' => '86048',\n        'Chabrac (16150)' => '16071',\n        'Chabrignac (19350)' => '19035',\n        'Chadenac (17800)' => '17078',\n        'Chadurie (16250)' => '16072',\n        'Chail (79500)' => '79064',\n        'Chaillac-sur-Vienne (87200)' => '87030',\n        'Chaillevette (17890)' => '17079',\n        'Chalagnac (24380)' => '24094',\n        'Chalais (16210)' => '16073',\n        'Chalais (24800)' => '24095',\n        'Chalais (86200)' => '86049',\n        'Chalandray (86190)' => '86050',\n        'Challignac (16300)' => '16074',\n        'Châlus (87230)' => '87032',\n        'Chamadelle (33230)' => '33124',\n        'Chamberaud (23480)' => '23043',\n        'Chamberet (19370)' => '19036',\n        'Chambon (17290)' => '17080',\n        'Chambon-Sainte-Croix (23220)' => '23044',\n        'Chambon-sur-Voueize (23170)' => '23045',\n        'Chambonchard (23110)' => '23046',\n        'Chamborand (23240)' => '23047',\n        'Chamboret (87140)' => '87033',\n        'Chamboulive (19450)' => '19037',\n        'Chameyrat (19330)' => '19038',\n        'Chamouillac (17130)' => '17081',\n        'Champagnac (17500)' => '17082',\n        'Champagnac-de-Belair (24530)' => '24096',\n        'Champagnac-la-Noaille (19320)' => '19039',\n        'Champagnac-la-Prune (19320)' => '19040',\n        'Champagnac-la-Rivière (87150)' => '87034',\n        'Champagnat (23190)' => '23048',\n        'Champagne (17620)' => '17083',\n        'Champagne-et-Fontaine (24320)' => '24097',\n        'Champagné-le-Sec (86510)' => '86051',\n        'Champagne-Mouton (16350)' => '16076',\n        'Champagné-Saint-Hilaire (86160)' => '86052',\n        'Champagne-Vigny (16250)' => '16075',\n        'Champagnolles (17240)' => '17084',\n        'Champcevinel (24750)' => '24098',\n        'Champdeniers-Saint-Denis (79220)' => '79066',\n        'Champdolent (17430)' => '17085',\n        'Champeaux-et-la-Chapelle-Pommier (24340)' => '24099',\n        'Champigny-le-Sec (86170)' => '86053',\n        'Champmillon (16290)' => '16077',\n        'Champnétery (87400)' => '87035',\n        'Champniers (16430)' => '16078',\n        'Champniers (86400)' => '86054',\n        'Champniers-et-Reilhac (24360)' => '24100',\n        'Champs-Romain (24470)' => '24101',\n        'Champsac (87230)' => '87036',\n        'Champsanglard (23220)' => '23049',\n        'Chanac-les-Mines (19150)' => '19041',\n        'Chancelade (24650)' => '24102',\n        'Chaniers (17610)' => '17086',\n        'Chantecorps (79340)' => '79068',\n        'Chanteix (19330)' => '19042',\n        'Chanteloup (79320)' => '79069',\n        'Chantemerle-sur-la-Soie (17380)' => '17087',\n        'Chantérac (24190)' => '24104',\n        'Chantillac (16360)' => '16079',\n        'Chapdeuil (24320)' => '24105',\n        'Chapelle-Spinasse (19300)' => '19046',\n        'Chapelle-Viviers (86300)' => '86059',\n        'Chaptelat (87270)' => '87038',\n        'Chard (23700)' => '23053',\n        'Charmé (16140)' => '16083',\n        'Charrais (86170)' => '86060',\n        'Charras (16380)' => '16084',\n        'Charre (64190)' => '64186',\n        'Charritte-de-Bas (64130)' => '64187',\n        'Charron (17230)' => '17091',\n        'Charron (23700)' => '23054',\n        'Charroux (86250)' => '86061',\n        'Chartrier-Ferrière (19600)' => '19047',\n        'Chartuzac (17130)' => '17092',\n        'Chassaignes (24600)' => '24114',\n        'Chasseneuil-du-Poitou (86360)' => '86062',\n        'Chasseneuil-sur-Bonnieure (16260)' => '16085',\n        'Chassenon (16150)' => '16086',\n        'Chassiecq (16350)' => '16087',\n        'Chassors (16200)' => '16088',\n        'Chasteaux (19600)' => '19049',\n        'Chatain (86250)' => '86063',\n        'Château-Chervix (87380)' => '87039',\n        'Château-Garnier (86350)' => '86064',\n        'Château-l\\'Évêque (24460)' => '24115',\n        'Château-Larcher (86370)' => '86065',\n        'Châteaubernard (16100)' => '16089',\n        'Châteauneuf-la-Forêt (87130)' => '87040',\n        'Châteauneuf-sur-Charente (16120)' => '16090',\n        'Châteauponsac (87290)' => '87041',\n        'Châtelaillon-Plage (17340)' => '17094',\n        'Châtelard (23700)' => '23055',\n        'Châtellerault (86100)' => '86066',\n        'Châtelus-le-Marcheix (23430)' => '23056',\n        'Châtelus-Malvaleix (23270)' => '23057',\n        'Chatenet (17210)' => '17095',\n        'Châtignac (16480)' => '16091',\n        'Châtillon (86700)' => '86067',\n        'Châtillon-sur-Thouet (79200)' => '79080',\n        'Châtres (24120)' => '24116',\n        'Chauffour-sur-Vell (19500)' => '19050',\n        'Chaumeil (19390)' => '19051',\n        'Chaunac (17130)' => '17096',\n        'Chaunay (86510)' => '86068',\n        'Chauray (79180)' => '79081',\n        'Chauvigny (86300)' => '86070',\n        'Chavagnac (24120)' => '24117',\n        'Chavanac (19290)' => '19052',\n        'Chavanat (23250)' => '23060',\n        'Chaveroche (19200)' => '19053',\n        'Chazelles (16380)' => '16093',\n        'Chef-Boutonne (79110)' => '79083',\n        'Cheissoux (87460)' => '87043',\n        'Chenac-Saint-Seurin-d\\'Uzet (17120)' => '17098',\n        'Chenailler-Mascheix (19120)' => '19054',\n        'Chenay (79120)' => '79084',\n        'Cheneché (86380)' => '86071',\n        'Chénérailles (23130)' => '23061',\n        'Chenevelles (86450)' => '86072',\n        'Chéniers (23220)' => '23062',\n        'Chenommet (16460)' => '16094',\n        'Chenon (16460)' => '16095',\n        'Chepniers (17210)' => '17099',\n        'Chérac (17610)' => '17100',\n        'Chéraute (64130)' => '64188',\n        'Cherbonnières (17470)' => '17101',\n        'Chérigné (79170)' => '79085',\n        'Chermignac (17460)' => '17102',\n        'Chéronnac (87600)' => '87044',\n        'Cherval (24320)' => '24119',\n        'Cherveix-Cubas (24390)' => '24120',\n        'Cherves (86170)' => '86073',\n        'Cherves-Châtelars (16310)' => '16096',\n        'Cherves-Richemont (16370)' => '16097',\n        'Chervettes (17380)' => '17103',\n        'Cherveux (79410)' => '79086',\n        'Chevanceaux (17210)' => '17104',\n        'Chey (79120)' => '79087',\n        'Chiché (79350)' => '79088',\n        'Chillac (16480)' => '16099',\n        'Chirac (16150)' => '16100',\n        'Chirac-Bellevue (19160)' => '19055',\n        'Chiré-en-Montreuil (86190)' => '86074',\n        'Chives (17510)' => '17105',\n        'Chizé (79170)' => '79090',\n        'Chouppes (86110)' => '86075',\n        'Chourgnac (24640)' => '24121',\n        'Ciboure (64500)' => '64189',\n        'Cierzac (17520)' => '17106',\n        'Cieux (87520)' => '87045',\n        'Ciré-d\\'Aunis (17290)' => '17107',\n        'Cirières (79140)' => '79091',\n        'Cissac-Médoc (33250)' => '33125',\n        'Cissé (86170)' => '86076',\n        'Civaux (86320)' => '86077',\n        'Civrac-de-Blaye (33920)' => '33126',\n        'Civrac-en-Médoc (33340)' => '33128',\n        'Civrac-sur-Dordogne (33350)' => '33127',\n        'Civray (86400)' => '86078',\n        'Cladech (24170)' => '24122',\n        'Clairac (47320)' => '47065',\n        'Clairavaux (23500)' => '23063',\n        'Claix (16440)' => '16101',\n        'Clam (17500)' => '17108',\n        'Claracq (64330)' => '64190',\n        'Classun (40320)' => '40082',\n        'Clavé (79420)' => '79092',\n        'Clavette (17220)' => '17109',\n        'Clèdes (40320)' => '40083',\n        'Clérac (17270)' => '17110',\n        'Clergoux (19320)' => '19056',\n        'Clermont (40180)' => '40084',\n        'Clermont-d\\'Excideuil (24160)' => '24124',\n        'Clermont-de-Beauregard (24140)' => '24123',\n        'Clermont-Dessous (47130)' => '47066',\n        'Clermont-Soubiran (47270)' => '47067',\n        'Clessé (79350)' => '79094',\n        'Cleyrac (33540)' => '33129',\n        'Clion (17240)' => '17111',\n        'Cloué (86600)' => '86080',\n        'Clugnat (23270)' => '23064',\n        'Clussais-la-Pommeraie (79190)' => '79095',\n        'Coarraze (64800)' => '64191',\n        'Cocumont (47250)' => '47068',\n        'Cognac (16100)' => '16102',\n        'Cognac-la-Forêt (87310)' => '87046',\n        'Coimères (33210)' => '33130',\n        'Coirac (33540)' => '33131',\n        'Coivert (17330)' => '17114',\n        'Colayrac-Saint-Cirq (47450)' => '47069',\n        'Collonges-la-Rouge (19500)' => '19057',\n        'Colombier (24560)' => '24126',\n        'Colombiers (17460)' => '17115',\n        'Colombiers (86490)' => '86081',\n        'Colondannes (23800)' => '23065',\n        'Coly (24120)' => '24127',\n        'Comberanche-et-Épeluche (24600)' => '24128',\n        'Combiers (16320)' => '16103',\n        'Combrand (79140)' => '79096',\n        'Combressol (19250)' => '19058',\n        'Commensacq (40210)' => '40085',\n        'Compreignac (87140)' => '87047',\n        'Comps (33710)' => '33132',\n        'Concèze (19350)' => '19059',\n        'Conchez-de-Béarn (64330)' => '64192',\n        'Condac (16700)' => '16104',\n        'Condat-sur-Ganaveix (19140)' => '19060',\n        'Condat-sur-Trincou (24530)' => '24129',\n        'Condat-sur-Vézère (24570)' => '24130',\n        'Condat-sur-Vienne (87920)' => '87048',\n        'Condéon (16360)' => '16105',\n        'Condezaygues (47500)' => '47070',\n        'Confolens (16500)' => '16106',\n        'Confolent-Port-Dieu (19200)' => '19167',\n        'Conne-de-Labarde (24560)' => '24132',\n        'Connezac (24300)' => '24131',\n        'Consac (17150)' => '17116',\n        'Contré (17470)' => '17117',\n        'Corbère-Abères (64350)' => '64193',\n        'Corgnac-sur-l\\'Isle (24800)' => '24134',\n        'Corignac (17130)' => '17118',\n        'Corme-Écluse (17600)' => '17119',\n        'Corme-Royal (17600)' => '17120',\n        'Cornil (19150)' => '19061',\n        'Cornille (24750)' => '24135',\n        'Corrèze (19800)' => '19062',\n        'Coslédaà-Lube-Boast (64160)' => '64194',\n        'Cosnac (19360)' => '19063',\n        'Coubeyrac (33890)' => '33133',\n        'Coubjours (24390)' => '24136',\n        'Coublucq (64410)' => '64195',\n        'Coudures (40500)' => '40086',\n        'Couffy-sur-Sarsonne (19340)' => '19064',\n        'Couhé (86700)' => '86082',\n        'Coulaures (24420)' => '24137',\n        'Coulgens (16560)' => '16107',\n        'Coulombiers (86600)' => '86083',\n        'Coulon (79510)' => '79100',\n        'Coulonges (16330)' => '16108',\n        'Coulonges (17800)' => '17122',\n        'Coulonges (86290)' => '86084',\n        'Coulonges-sur-l\\'Autize (79160)' => '79101',\n        'Coulonges-Thouarsais (79330)' => '79102',\n        'Coulounieix-Chamiers (24660)' => '24138',\n        'Coulx (47260)' => '47071',\n        'Couquèques (33340)' => '33134',\n        'Courant (17330)' => '17124',\n        'Courbiac (47370)' => '47072',\n        'Courbillac (16200)' => '16109',\n        'Courcelles (17400)' => '17125',\n        'Courcerac (17160)' => '17126',\n        'Courcôme (16240)' => '16110',\n        'Courçon (17170)' => '17127',\n        'Courcoury (17100)' => '17128',\n        'Courgeac (16190)' => '16111',\n        'Courlac (16210)' => '16112',\n        'Courlay (79440)' => '79103',\n        'Courpiac (33760)' => '33135',\n        'Courpignac (17130)' => '17129',\n        'Cours (47360)' => '47073',\n        'Cours (79220)' => '79104',\n        'Cours-de-Monségur (33580)' => '33136',\n        'Cours-de-Pile (24520)' => '24140',\n        'Cours-les-Bains (33690)' => '33137',\n        'Coursac (24430)' => '24139',\n        'Courteix (19340)' => '19065',\n        'Coussac-Bonneval (87500)' => '87049',\n        'Coussay (86110)' => '86085',\n        'Coussay-les-Bois (86270)' => '86086',\n        'Couthures-sur-Garonne (47180)' => '47074',\n        'Coutières (79340)' => '79105',\n        'Coutras (33230)' => '33138',\n        'Couture (16460)' => '16114',\n        'Couture-d\\'Argenson (79110)' => '79106',\n        'Coutures (24320)' => '24141',\n        'Coutures (33580)' => '33139',\n        'Coux (17130)' => '17130',\n        'Coux et Bigaroque-Mouzens (24220)' => '24142',\n        'Couze-et-Saint-Front (24150)' => '24143',\n        'Couzeix (87270)' => '87050',\n        'Cozes (17120)' => '17131',\n        'Cramchaban (17170)' => '17132',\n        'Craon (86110)' => '86087',\n        'Cravans (17260)' => '17133',\n        'Crazannes (17350)' => '17134',\n        'Créon (33670)' => '33140',\n        'Créon-d\\'Armagnac (40240)' => '40087',\n        'Cressac-Saint-Genis (16250)' => '16115',\n        'Cressat (23140)' => '23068',\n        'Cressé (17160)' => '17135',\n        'Creyssac (24350)' => '24144',\n        'Creysse (24100)' => '24145',\n        'Creyssensac-et-Pissot (24380)' => '24146',\n        'Crézières (79110)' => '79107',\n        'Criteuil-la-Magdeleine (16300)' => '16116',\n        'Crocq (23260)' => '23069',\n        'Croignon (33750)' => '33141',\n        'Croix-Chapeau (17220)' => '17136',\n        'Cromac (87160)' => '87053',\n        'Crouseilles (64350)' => '64196',\n        'Croutelle (86240)' => '86088',\n        'Crozant (23160)' => '23070',\n        'Croze (23500)' => '23071',\n        'Cubjac (24640)' => '24147',\n        'Cublac (19520)' => '19066',\n        'Cubnezais (33620)' => '33142',\n        'Cubzac-les-Ponts (33240)' => '33143',\n        'Cudos (33430)' => '33144',\n        'Cuhon (86110)' => '86089',\n        'Cunèges (24240)' => '24148',\n        'Cuq (47220)' => '47076',\n        'Cuqueron (64360)' => '64197',\n        'Curac (16210)' => '16117',\n        'Curçay-sur-Dive (86120)' => '86090',\n        'Curemonte (19500)' => '19067',\n        'Cursan (33670)' => '33145',\n        'Curzay-sur-Vonne (86600)' => '86091',\n        'Cussac (87150)' => '87054',\n        'Cussac-Fort-Médoc (33460)' => '33146',\n        'Cuzorn (47500)' => '47077',\n        'Daglan (24250)' => '24150',\n        'Daignac (33420)' => '33147',\n        'Damazan (47160)' => '47078',\n        'Dampierre-sur-Boutonne (17470)' => '17138',\n        'Dampniat (19360)' => '19068',\n        'Dangé-Saint-Romain (86220)' => '86092',\n        'Darazac (19220)' => '19069',\n        'Dardenac (33420)' => '33148',\n        'Darnac (87320)' => '87055',\n        'Darnets (19300)' => '19070',\n        'Daubèze (33540)' => '33149',\n        'Dausse (47140)' => '47079',\n        'Davignac (19250)' => '19071',\n        'Dax (40100)' => '40088',\n        'Denguin (64230)' => '64198',\n        'Dercé (86420)' => '86093',\n        'Deviat (16190)' => '16118',\n        'Dévillac (47210)' => '47080',\n        'Dienné (86410)' => '86094',\n        'Dieulivol (33580)' => '33150',\n        'Dignac (16410)' => '16119',\n        'Dinsac (87210)' => '87056',\n        'Dirac (16410)' => '16120',\n        'Dissay (86130)' => '86095',\n        'Diusse (64330)' => '64199',\n        'Doazit (40700)' => '40089',\n        'Doazon (64370)' => '64200',\n        'Doeuil-sur-le-Mignon (17330)' => '17139',\n        'Dognen (64190)' => '64201',\n        'Doissat (24170)' => '24151',\n        'Dolmayrac (47110)' => '47081',\n        'Dolus-d\\'Oléron (17550)' => '17140',\n        'Domeyrot (23140)' => '23072',\n        'Domezain-Berraute (64120)' => '64202',\n        'Domme (24250)' => '24152',\n        'Dompierre-les-Églises (87190)' => '87057',\n        'Dompierre-sur-Charente (17610)' => '17141',\n        'Dompierre-sur-Mer (17139)' => '17142',\n        'Domps (87120)' => '87058',\n        'Dondas (47470)' => '47082',\n        'Donnezac (33860)' => '33151',\n        'Dontreix (23700)' => '23073',\n        'Donzac (33410)' => '33152',\n        'Donzacq (40360)' => '40090',\n        'Donzenac (19270)' => '19072',\n        'Douchapt (24350)' => '24154',\n        'Doudrac (47210)' => '47083',\n        'Doulezon (33350)' => '33153',\n        'Doumy (64450)' => '64203',\n        'Dournazac (87230)' => '87060',\n        'Doussay (86140)' => '86096',\n        'Douville (24140)' => '24155',\n        'Doux (79390)' => '79108',\n        'Douzains (47330)' => '47084',\n        'Douzat (16290)' => '16121',\n        'Douzillac (24190)' => '24157',\n        'Droux (87190)' => '87061',\n        'Duhort-Bachen (40800)' => '40091',\n        'Dumes (40500)' => '40092',\n        'Dun-le-Palestel (23800)' => '23075',\n        'Durance (47420)' => '47085',\n        'Duras (47120)' => '47086',\n        'Dussac (24270)' => '24158',\n        'Eaux-Bonnes (64440)' => '64204',\n        'Ébréon (16140)' => '16122',\n        'Échallat (16170)' => '16123',\n        'Échebrune (17800)' => '17145',\n        'Échillais (17620)' => '17146',\n        'Échiré (79410)' => '79109',\n        'Échourgnac (24410)' => '24159',\n        'Écoyeux (17770)' => '17147',\n        'Écuras (16220)' => '16124',\n        'Écurat (17810)' => '17148',\n        'Édon (16320)' => '16125',\n        'Égletons (19300)' => '19073',\n        'Église-Neuve-d\\'Issac (24400)' => '24161',\n        'Église-Neuve-de-Vergt (24380)' => '24160',\n        'Empuré (16240)' => '16127',\n        'Engayrac (47470)' => '47087',\n        'Ensigné (79170)' => '79111',\n        'Épannes (79270)' => '79112',\n        'Épargnes (17120)' => '17152',\n        'Épenède (16490)' => '16128',\n        'Éraville (16120)' => '16129',\n        'Escalans (40310)' => '40093',\n        'Escassefort (47350)' => '47088',\n        'Escaudes (33840)' => '33155',\n        'Escaunets (65500)' => '65160',\n        'Esclottes (47120)' => '47089',\n        'Escoire (24420)' => '24162',\n        'Escos (64270)' => '64205',\n        'Escot (64490)' => '64206',\n        'Escou (64870)' => '64207',\n        'Escoubès (64160)' => '64208',\n        'Escource (40210)' => '40094',\n        'Escoussans (33760)' => '33156',\n        'Escout (64870)' => '64209',\n        'Escurès (64350)' => '64210',\n        'Eslourenties-Daban (64420)' => '64211',\n        'Esnandes (17137)' => '17153',\n        'Espagnac (19150)' => '19075',\n        'Espartignac (19140)' => '19076',\n        'Espéchède (64160)' => '64212',\n        'Espelette (64250)' => '64213',\n        'Espès-Undurein (64130)' => '64214',\n        'Espiens (47600)' => '47090',\n        'Espiet (33420)' => '33157',\n        'Espiute (64390)' => '64215',\n        'Espoey (64420)' => '64216',\n        'Esquiule (64400)' => '64217',\n        'Esse (16500)' => '16131',\n        'Essouvert (17400)' => '17277',\n        'Estérençuby (64220)' => '64218',\n        'Estialescq (64290)' => '64219',\n        'Estibeaux (40290)' => '40095',\n        'Estigarde (40240)' => '40096',\n        'Estillac (47310)' => '47091',\n        'Estivals (19600)' => '19077',\n        'Estivaux (19410)' => '19078',\n        'Estos (64400)' => '64220',\n        'Étagnac (16150)' => '16132',\n        'Étaules (17750)' => '17155',\n        'Étauliers (33820)' => '33159',\n        'Etcharry (64120)' => '64221',\n        'Etchebar (64470)' => '64222',\n        'Étouars (24360)' => '24163',\n        'Étriac (16250)' => '16133',\n        'Etsaut (64490)' => '64223',\n        'Eugénie-les-Bains (40320)' => '40097',\n        'Évaux-les-Bains (23110)' => '23076',\n        'Excideuil (24160)' => '24164',\n        'Exideuil (16150)' => '16134',\n        'Exireuil (79400)' => '79114',\n        'Exoudun (79800)' => '79115',\n        'Expiremont (17130)' => '17156',\n        'Eybouleuf (87400)' => '87062',\n        'Eyburie (19140)' => '19079',\n        'Eygurande (19340)' => '19080',\n        'Eygurande-et-Gardedeuil (24700)' => '24165',\n        'Eyjeaux (87220)' => '87063',\n        'Eyliac (24330)' => '24166',\n        'Eymet (24500)' => '24167',\n        'Eymouthiers (16220)' => '16135',\n        'Eymoutiers (87120)' => '87064',\n        'Eynesse (33220)' => '33160',\n        'Eyrans (33390)' => '33161',\n        'Eyrein (19800)' => '19081',\n        'Eyres-Moncube (40500)' => '40098',\n        'Eysines (33320)' => '33162',\n        'Eysus (64400)' => '64224',\n        'Eyvirat (24460)' => '24170',\n        'Eyzerac (24800)' => '24171',\n        'Faleyras (33760)' => '33163',\n        'Fals (47220)' => '47092',\n        'Fanlac (24290)' => '24174',\n        'Fargues (33210)' => '33164',\n        'Fargues (40500)' => '40099',\n        'Fargues-Saint-Hilaire (33370)' => '33165',\n        'Fargues-sur-Ourbise (47700)' => '47093',\n        'Fauguerolles (47400)' => '47094',\n        'Fauillet (47400)' => '47095',\n        'Faurilles (24560)' => '24176',\n        'Faux (24560)' => '24177',\n        'Faux-la-Montagne (23340)' => '23077',\n        'Faux-Mazuras (23400)' => '23078',\n        'Favars (19330)' => '19082',\n        'Faye-l\\'Abbesse (79350)' => '79116',\n        'Faye-sur-Ardin (79160)' => '79117',\n        'Féas (64570)' => '64225',\n        'Felletin (23500)' => '23079',\n        'Fénery (79450)' => '79118',\n        'Féniers (23100)' => '23080',\n        'Fenioux (17350)' => '17157',\n        'Fenioux (79160)' => '79119',\n        'Ferrensac (47330)' => '47096',\n        'Ferrières (17170)' => '17158',\n        'Festalemps (24410)' => '24178',\n        'Feugarolles (47230)' => '47097',\n        'Feuillade (16380)' => '16137',\n        'Feyt (19340)' => '19083',\n        'Feytiat (87220)' => '87065',\n        'Fichous-Riumayou (64410)' => '64226',\n        'Fieux (47600)' => '47098',\n        'Firbeix (24450)' => '24180',\n        'Flaugeac (24240)' => '24181',\n        'Flaujagues (33350)' => '33168',\n        'Flavignac (87230)' => '87066',\n        'Flayat (23260)' => '23081',\n        'Fléac (16730)' => '16138',\n        'Fléac-sur-Seugne (17800)' => '17159',\n        'Fleix (86300)' => '86098',\n        'Fleurac (16200)' => '16139',\n        'Fleurac (24580)' => '24183',\n        'Fleurat (23320)' => '23082',\n        'Fleuré (86340)' => '86099',\n        'Floirac (17120)' => '17160',\n        'Floirac (33270)' => '33167',\n        'Florimont-Gaumier (24250)' => '24184',\n        'Floudès (33190)' => '33169',\n        'Folles (87250)' => '87067',\n        'Fomperron (79340)' => '79121',\n        'Fongrave (47260)' => '47099',\n        'Fonroque (24500)' => '24186',\n        'Fontaine-Chalendray (17510)' => '17162',\n        'Fontaine-le-Comte (86240)' => '86100',\n        'Fontaines-d\\'Ozillac (17500)' => '17163',\n        'Fontanières (23110)' => '23083',\n        'Fontclaireau (16230)' => '16140',\n        'Fontcouverte (17100)' => '17164',\n        'Fontenet (17400)' => '17165',\n        'Fontenille (16230)' => '16141',\n        'Fontenille-Saint-Martin-d\\'Entraigues (79110)' => '79122',\n        'Fontet (33190)' => '33170',\n        'Forges (17290)' => '17166',\n        'Forgès (19380)' => '19084',\n        'Fors (79230)' => '79125',\n        'Fossemagne (24210)' => '24188',\n        'Fossès-et-Baleyssac (33190)' => '33171',\n        'Fougueyrolles (33220)' => '24189',\n        'Foulayronnes (47510)' => '47100',\n        'Fouleix (24380)' => '24190',\n        'Fouquebrune (16410)' => '16143',\n        'Fouqueure (16140)' => '16144',\n        'Fouras (17450)' => '17168',\n        'Fourques-sur-Garonne (47200)' => '47101',\n        'Fours (33390)' => '33172',\n        'Foussignac (16200)' => '16145',\n        'Fraisse (24130)' => '24191',\n        'Francescas (47600)' => '47102',\n        'François (79260)' => '79128',\n        'Francs (33570)' => '33173',\n        'Fransèches (23480)' => '23086',\n        'Fréchou (47600)' => '47103',\n        'Frégimont (47360)' => '47104',\n        'Frespech (47140)' => '47105',\n        'Fresselines (23450)' => '23087',\n        'Fressines (79370)' => '79129',\n        'Fromental (87250)' => '87068',\n        'Fronsac (33126)' => '33174',\n        'Frontenac (33760)' => '33175',\n        'Frontenay-Rohan-Rohan (79270)' => '79130',\n        'Frozes (86190)' => '86102',\n        'Fumel (47500)' => '47106',\n        'Gaas (40350)' => '40101',\n        'Gabarnac (33410)' => '33176',\n        'Gabarret (40310)' => '40102',\n        'Gabaston (64160)' => '64227',\n        'Gabat (64120)' => '64228',\n        'Gabillou (24210)' => '24192',\n        'Gageac-et-Rouillac (24240)' => '24193',\n        'Gaillan-en-Médoc (33340)' => '33177',\n        'Gaillères (40090)' => '40103',\n        'Gajac (33430)' => '33178',\n        'Gajoubert (87330)' => '87069',\n        'Galapian (47190)' => '47107',\n        'Galgon (33133)' => '33179',\n        'Gamarde-les-Bains (40380)' => '40104',\n        'Gamarthe (64220)' => '64229',\n        'Gan (64290)' => '64230',\n        'Gans (33430)' => '33180',\n        'Garat (16410)' => '16146',\n        'Gardegan-et-Tourtirac (33350)' => '33181',\n        'Gardères (65320)' => '65185',\n        'Gardes-le-Pontaroux (16320)' => '16147',\n        'Gardonne (24680)' => '24194',\n        'Garein (40420)' => '40105',\n        'Garindein (64130)' => '64231',\n        'Garlède-Mondebat (64450)' => '64232',\n        'Garlin (64330)' => '64233',\n        'Garos (64410)' => '64234',\n        'Garrey (40180)' => '40106',\n        'Garris (64120)' => '64235',\n        'Garrosse (40110)' => '40107',\n        'Gartempe (23320)' => '23088',\n        'Gastes (40160)' => '40108',\n        'Gaugeac (24540)' => '24195',\n        'Gaujac (47200)' => '47108',\n        'Gaujacq (40330)' => '40109',\n        'Gauriac (33710)' => '33182',\n        'Gauriaguet (33240)' => '33183',\n        'Gavaudun (47150)' => '47109',\n        'Gayon (64350)' => '64236',\n        'Geaune (40320)' => '40110',\n        'Geay (17250)' => '17171',\n        'Geay (79330)' => '79131',\n        'Gelos (64110)' => '64237',\n        'Geloux (40090)' => '40111',\n        'Gémozac (17260)' => '17172',\n        'Genac-Bignac (16170)' => '16148',\n        'Gençay (86160)' => '86103',\n        'Générac (33920)' => '33184',\n        'Génis (24160)' => '24196',\n        'Génissac (33420)' => '33185',\n        'Genneton (79150)' => '79132',\n        'Genouillac (16270)' => '16149',\n        'Genouillac (23350)' => '23089',\n        'Genouillé (17430)' => '17174',\n        'Genouillé (86250)' => '86104',\n        'Gensac (33890)' => '33186',\n        'Gensac-la-Pallue (16130)' => '16150',\n        'Genté (16130)' => '16151',\n        'Gentioux-Pigerolles (23340)' => '23090',\n        'Ger (64530)' => '64238',\n        'Gerderest (64160)' => '64239',\n        'Gère-Bélesten (64260)' => '64240',\n        'Germignac (17520)' => '17175',\n        'Germond-Rouvre (79220)' => '79133',\n        'Géronce (64400)' => '64241',\n        'Gestas (64190)' => '64242',\n        'Géus-d\\'Arzacq (64370)' => '64243',\n        'Geüs-d\\'Oloron (64400)' => '64244',\n        'Gibourne (17160)' => '17176',\n        'Gibret (40380)' => '40112',\n        'Gimel-les-Cascades (19800)' => '19085',\n        'Gimeux (16130)' => '16152',\n        'Ginestet (24130)' => '24197',\n        'Gioux (23500)' => '23091',\n        'Gironde-sur-Dropt (33190)' => '33187',\n        'Giscos (33840)' => '33188',\n        'Givrezac (17260)' => '17178',\n        'Gizay (86340)' => '86105',\n        'Glandon (87500)' => '87071',\n        'Glanges (87380)' => '87072',\n        'Glénay (79330)' => '79134',\n        'Glénic (23380)' => '23092',\n        'Glénouze (86200)' => '86106',\n        'Goès (64400)' => '64245',\n        'Gomer (64420)' => '64246',\n        'Gond-Pontouvre (16160)' => '16154',\n        'Gondeville (16200)' => '16153',\n        'Gontaud-de-Nogaret (47400)' => '47110',\n        'Goos (40180)' => '40113',\n        'Gornac (33540)' => '33189',\n        'Gorre (87310)' => '87073',\n        'Gotein-Libarrenx (64130)' => '64247',\n        'Goualade (33840)' => '33190',\n        'Gouex (86320)' => '86107',\n        'Goulles (19430)' => '19086',\n        'Gourbera (40990)' => '40114',\n        'Gourdon-Murat (19170)' => '19087',\n        'Gourgé (79200)' => '79135',\n        'Gournay-Loizé (79110)' => '79136',\n        'Gours (33660)' => '33191',\n        'Gourville (16170)' => '16156',\n        'Gourvillette (17490)' => '17180',\n        'Gousse (40465)' => '40115',\n        'Gout-Rossignol (24320)' => '24199',\n        'Gouts (40400)' => '40116',\n        'Gouzon (23230)' => '23093',\n        'Gradignan (33170)' => '33192',\n        'Grand-Brassac (24350)' => '24200',\n        'Grandjean (17350)' => '17181',\n        'Grandsaigne (19300)' => '19088',\n        'Granges-d\\'Ans (24390)' => '24202',\n        'Granges-sur-Lot (47260)' => '47111',\n        'Granzay-Gript (79360)' => '79137',\n        'Grassac (16380)' => '16158',\n        'Grateloup-Saint-Gayrand (47400)' => '47112',\n        'Graves-Saint-Amant (16120)' => '16297',\n        'Grayan-et-l\\'Hôpital (33590)' => '33193',\n        'Grayssas (47270)' => '47113',\n        'Grenade-sur-l\\'Adour (40270)' => '40117',\n        'Grézac (17120)' => '17183',\n        'Grèzes (24120)' => '24204',\n        'Grézet-Cavagnan (47250)' => '47114',\n        'Grézillac (33420)' => '33194',\n        'Grignols (24110)' => '24205',\n        'Grignols (33690)' => '33195',\n        'Grives (24170)' => '24206',\n        'Groléjac (24250)' => '24207',\n        'Gros-Chastang (19320)' => '19089',\n        'Grun-Bordas (24380)' => '24208',\n        'Guéret (23000)' => '23096',\n        'Guérin (47250)' => '47115',\n        'Guesnes (86420)' => '86109',\n        'Guéthary (64210)' => '64249',\n        'Guiche (64520)' => '64250',\n        'Guillac (33420)' => '33196',\n        'Guillos (33720)' => '33197',\n        'Guimps (16300)' => '16160',\n        'Guinarthe-Parenties (64390)' => '64251',\n        'Guitinières (17500)' => '17187',\n        'Guîtres (33230)' => '33198',\n        'Guizengeard (16480)' => '16161',\n        'Gujan-Mestras (33470)' => '33199',\n        'Gumond (19320)' => '19090',\n        'Gurat (16320)' => '16162',\n        'Gurmençon (64400)' => '64252',\n        'Gurs (64190)' => '64253',\n        'Habas (40290)' => '40118',\n        'Hagetaubin (64370)' => '64254',\n        'Hagetmau (40700)' => '40119',\n        'Haimps (17160)' => '17188',\n        'Haims (86310)' => '86110',\n        'Halsou (64480)' => '64255',\n        'Hanc (79110)' => '79140',\n        'Hasparren (64240)' => '64256',\n        'Hastingues (40300)' => '40120',\n        'Hauriet (40250)' => '40121',\n        'Haut-de-Bosdarros (64800)' => '64257',\n        'Haut-Mauco (40280)' => '40122',\n        'Hautefage (19400)' => '19091',\n        'Hautefage-la-Tour (47340)' => '47117',\n        'Hautefaye (24300)' => '24209',\n        'Hautefort (24390)' => '24210',\n        'Hautesvignes (47400)' => '47118',\n        'Haux (33550)' => '33201',\n        'Haux (64470)' => '64258',\n        'Hélette (64640)' => '64259',\n        'Hendaye (64700)' => '64260',\n        'Herm (40990)' => '40123',\n        'Herré (40310)' => '40124',\n        'Herrère (64680)' => '64261',\n        'Heugas (40180)' => '40125',\n        'Hiers-Brouage (17320)' => '17189',\n        'Hiersac (16290)' => '16163',\n        'Hiesse (16490)' => '16164',\n        'Higuères-Souye (64160)' => '64262',\n        'Hinx (40180)' => '40126',\n        'Hontanx (40190)' => '40127',\n        'Horsarrieu (40700)' => '40128',\n        'Hosta (64120)' => '64265',\n        'Hostens (33125)' => '33202',\n        'Houeillès (47420)' => '47119',\n        'Houlette (16200)' => '16165',\n        'Hours (64420)' => '64266',\n        'Hourtin (33990)' => '33203',\n        'Hure (33190)' => '33204',\n        'Ibarrolle (64120)' => '64267',\n        'Idaux-Mendy (64130)' => '64268',\n        'Idron (64320)' => '64269',\n        'Igon (64800)' => '64270',\n        'Iholdy (64640)' => '64271',\n        'Île-d\\'Aix (17123)' => '17004',\n        'Ilharre (64120)' => '64272',\n        'Illats (33720)' => '33205',\n        'Ingrandes (86220)' => '86111',\n        'Irais (79600)' => '79141',\n        'Irissarry (64780)' => '64273',\n        'Irouléguy (64220)' => '64274',\n        'Isle (87170)' => '87075',\n        'Isle-Saint-Georges (33640)' => '33206',\n        'Ispoure (64220)' => '64275',\n        'Issac (24400)' => '24211',\n        'Issigeac (24560)' => '24212',\n        'Issor (64570)' => '64276',\n        'Issoudun-Létrieix (23130)' => '23097',\n        'Isturits (64240)' => '64277',\n        'Iteuil (86240)' => '86113',\n        'Itxassou (64250)' => '64279',\n        'Izeste (64260)' => '64280',\n        'Izon (33450)' => '33207',\n        'Jabreilles-les-Bordes (87370)' => '87076',\n        'Jalesches (23270)' => '23098',\n        'Janailhac (87800)' => '87077',\n        'Janaillat (23250)' => '23099',\n        'Jardres (86800)' => '86114',\n        'Jarnac (16200)' => '16167',\n        'Jarnac-Champagne (17520)' => '17192',\n        'Jarnages (23140)' => '23100',\n        'Jasses (64190)' => '64281',\n        'Jatxou (64480)' => '64282',\n        'Jau-Dignac-et-Loirac (33590)' => '33208',\n        'Jauldes (16560)' => '16168',\n        'Jaunay-Clan (86130)' => '86115',\n        'Jaure (24140)' => '24213',\n        'Javerdat (87520)' => '87078',\n        'Javerlhac-et-la-Chapelle-Saint-Robert (24300)' => '24214',\n        'Javrezac (16100)' => '16169',\n        'Jaxu (64220)' => '64283',\n        'Jayac (24590)' => '24215',\n        'Jazeneuil (86600)' => '86116',\n        'Jazennes (17260)' => '17196',\n        'Jonzac (17500)' => '17197',\n        'Josse (40230)' => '40129',\n        'Jouac (87890)' => '87080',\n        'Jouhet (86500)' => '86117',\n        'Jouillat (23220)' => '23101',\n        'Jourgnac (87800)' => '87081',\n        'Journet (86290)' => '86118',\n        'Journiac (24260)' => '24217',\n        'Joussé (86350)' => '86119',\n        'Jugazan (33420)' => '33209',\n        'Jugeals-Nazareth (19500)' => '19093',\n        'Juicq (17770)' => '17198',\n        'Juignac (16190)' => '16170',\n        'Juillac (19350)' => '19094',\n        'Juillac (33890)' => '33210',\n        'Juillac-le-Coq (16130)' => '16171',\n        'Juillé (16230)' => '16173',\n        'Juillé (79170)' => '79142',\n        'Julienne (16200)' => '16174',\n        'Jumilhac-le-Grand (24630)' => '24218',\n        'Jurançon (64110)' => '64284',\n        'Juscorps (79230)' => '79144',\n        'Jusix (47180)' => '47120',\n        'Jussas (17130)' => '17199',\n        'Juxue (64120)' => '64285',\n        'L\\'Absie (79240)' => '79001',\n        'L\\'Église-aux-Bois (19170)' => '19074',\n        'L\\'Éguille (17600)' => '17151',\n        'L\\'Hôpital-d\\'Orion (64270)' => '64263',\n        'L\\'Hôpital-Saint-Blaise (64130)' => '64264',\n        'L\\'Houmeau (17137)' => '17190',\n        'L\\'Isle-d\\'Espagnac (16340)' => '16166',\n        'L\\'Isle-Jourdain (86150)' => '86112',\n        'La Bachellerie (24210)' => '24020',\n        'La Barde (17360)' => '17033',\n        'La Bastide-Clairence (64240)' => '64289',\n        'La Bataille (79110)' => '79027',\n        'La Bazeuge (87210)' => '87008',\n        'La Boissière-d\\'Ans (24640)' => '24047',\n        'La Boissière-en-Gâtine (79310)' => '79040',\n        'La Brède (33650)' => '33213',\n        'La Brée-les-Bains (17840)' => '17486',\n        'La Brionne (23000)' => '23033',\n        'La Brousse (17160)' => '17071',\n        'La Bussière (86310)' => '86040',\n        'La Cassagne (24120)' => '24085',\n        'La Celle-Dunoise (23800)' => '23039',\n        'La Celle-sous-Gouzon (23230)' => '23040',\n        'La Cellette (23350)' => '23041',\n        'La Chapelle (16140)' => '16081',\n        'La Chapelle-Aubareil (24290)' => '24106',\n        'La Chapelle-aux-Brocs (19360)' => '19043',\n        'La Chapelle-aux-Saints (19120)' => '19044',\n        'La Chapelle-Baloue (23160)' => '23050',\n        'La Chapelle-Bâton (79220)' => '79070',\n        'La Chapelle-Bâton (86250)' => '86055',\n        'La Chapelle-Bertrand (79200)' => '79071',\n        'La Chapelle-des-Pots (17100)' => '17089',\n        'La Chapelle-Faucher (24530)' => '24107',\n        'La Chapelle-Gonaguet (24350)' => '24108',\n        'La Chapelle-Grésignac (24320)' => '24109',\n        'La Chapelle-Montabourlet (24320)' => '24110',\n        'La Chapelle-Montbrandeix (87440)' => '87037',\n        'La Chapelle-Montmoreau (24300)' => '24111',\n        'La Chapelle-Montreuil (86470)' => '86056',\n        'La Chapelle-Moulière (86210)' => '86058',\n        'La Chapelle-Pouilloux (79190)' => '79074',\n        'La Chapelle-Saint-Étienne (79240)' => '79075',\n        'La Chapelle-Saint-Géraud (19430)' => '19045',\n        'La Chapelle-Saint-Jean (24390)' => '24113',\n        'La Chapelle-Saint-Laurent (79430)' => '79076',\n        'La Chapelle-Saint-Martial (23250)' => '23051',\n        'La Chapelle-Taillefert (23000)' => '23052',\n        'La Chapelle-Thireuil (79160)' => '79077',\n        'La Chaussade (23200)' => '23059',\n        'La Chaussée (86330)' => '86069',\n        'La Chèvrerie (16240)' => '16098',\n        'La Clisse (17600)' => '17112',\n        'La Clotte (17360)' => '17113',\n        'La Coquille (24450)' => '24133',\n        'La Couarde (79800)' => '79098',\n        'La Couarde-sur-Mer (17670)' => '17121',\n        'La Couronne (16400)' => '16113',\n        'La Courtine (23100)' => '23067',\n        'La Crèche (79260)' => '79048',\n        'La Croisille-sur-Briance (87130)' => '87051',\n        'La Croix-Blanche (47340)' => '47075',\n        'La Croix-Comtesse (17330)' => '17137',\n        'La Croix-sur-Gartempe (87210)' => '87052',\n        'La Dornac (24120)' => '24153',\n        'La Douze (24330)' => '24156',\n        'La Faye (16700)' => '16136',\n        'La Ferrière-Airoux (86160)' => '86097',\n        'La Ferrière-en-Parthenay (79390)' => '79120',\n        'La Feuillade (24120)' => '24179',\n        'La Flotte (17630)' => '17161',\n        'La Force (24130)' => '24222',\n        'La Forêt-de-Tessé (16240)' => '16142',\n        'La Forêt-du-Temple (23360)' => '23084',\n        'La Forêt-sur-Sèvre (79380)' => '79123',\n        'La Foye-Monjault (79360)' => '79127',\n        'La Frédière (17770)' => '17169',\n        'La Genétouze (17360)' => '17173',\n        'La Geneytouse (87400)' => '87070',\n        'La Gonterie-Boulouneix (24310)' => '24198',\n        'La Grève-sur-Mignon (17170)' => '17182',\n        'La Grimaudière (86330)' => '86108',\n        'La Gripperie-Saint-Symphorien (17620)' => '17184',\n        'La Jard (17460)' => '17191',\n        'La Jarne (17220)' => '17193',\n        'La Jarrie (17220)' => '17194',\n        'La Jarrie-Audouin (17330)' => '17195',\n        'La Jemaye (24410)' => '24216',\n        'La Jonchère-Saint-Maurice (87340)' => '87079',\n        'La Laigne (17170)' => '17201',\n        'La Lande-de-Fronsac (33240)' => '33219',\n        'La Magdeleine (16240)' => '16197',\n        'La Mazière-aux-Bons-Hommes (23260)' => '23129',\n        'La Meyze (87800)' => '87096',\n        'La Mothe-Saint-Héray (79800)' => '79184',\n        'La Nouaille (23500)' => '23144',\n        'La Péruse (16270)' => '16259',\n        'La Petite-Boissière (79700)' => '79207',\n        'La Peyratte (79200)' => '79208',\n        'La Porcherie (87380)' => '87120',\n        'La Pouge (23250)' => '23157',\n        'La Puye (86260)' => '86202',\n        'La Réole (33190)' => '33352',\n        'La Réunion (47700)' => '47222',\n        'La Rivière (33126)' => '33356',\n        'La Roche-Canillac (19320)' => '19174',\n        'La Roche-Chalais (24490)' => '24354',\n        'La Roche-l\\'Abeille (87800)' => '87127',\n        'La Roche-Posay (86270)' => '86207',\n        'La Roche-Rigault (86200)' => '86079',\n        'La Rochebeaucourt-et-Argentine (24340)' => '24353',\n        'La Rochefoucauld (16110)' => '16281',\n        'La Rochelle (17000)' => '17300',\n        'La Rochénard (79270)' => '79229',\n        'La Rochette (16110)' => '16282',\n        'La Ronde (17170)' => '17303',\n        'La Roque-Gageac (24250)' => '24355',\n        'La Roquille (33220)' => '33360',\n        'La Saunière (23000)' => '23169',\n        'La Sauve (33670)' => '33505',\n        'La Sauvetat-de-Savères (47270)' => '47289',\n        'La Sauvetat-du-Dropt (47800)' => '47290',\n        'La Sauvetat-sur-Lède (47150)' => '47291',\n        'La Serre-Bussière-Vieille (23190)' => '23172',\n        'La Souterraine (23300)' => '23176',\n        'La Tâche (16260)' => '16377',\n        'La Teste-de-Buch (33260)' => '33529',\n        'La Tour-Blanche (24320)' => '24554',\n        'La Tremblade (17390)' => '17452',\n        'La Trimouille (86290)' => '86273',\n        'La Vallée (17250)' => '17455',\n        'La Vergne (17400)' => '17465',\n        'La Villedieu (17470)' => '17471',\n        'La Villedieu (23340)' => '23264',\n        'La Villedieu-du-Clain (86340)' => '86290',\n        'La Villeneuve (23260)' => '23265',\n        'La Villetelle (23260)' => '23266',\n        'Laà-Mondrans (64300)' => '64286',\n        'Laàs (64390)' => '64287',\n        'Labarde (33460)' => '33211',\n        'Labastide-Castel-Amouroux (47250)' => '47121',\n        'Labastide-Cézéracq (64170)' => '64288',\n        'Labastide-Chalosse (40700)' => '40130',\n        'Labastide-d\\'Armagnac (40240)' => '40131',\n        'Labastide-Monréjeau (64170)' => '64290',\n        'Labastide-Villefranche (64270)' => '64291',\n        'Labatmale (64530)' => '64292',\n        'Labatut (40300)' => '40132',\n        'Labatut (64460)' => '64293',\n        'Labenne (40530)' => '40133',\n        'Labescau (33690)' => '33212',\n        'Labets-Biscay (64120)' => '64294',\n        'Labeyrie (64300)' => '64295',\n        'Labouheyre (40210)' => '40134',\n        'Labretonie (47350)' => '47122',\n        'Labrit (40420)' => '40135',\n        'Lacadée (64300)' => '64296',\n        'Lacajunte (40320)' => '40136',\n        'Lacanau (33680)' => '33214',\n        'Lacapelle-Biron (47150)' => '47123',\n        'Lacarre (64220)' => '64297',\n        'Lacarry-Arhan-Charritte-de-Haut (64470)' => '64298',\n        'Lacaussade (47150)' => '47124',\n        'Lacelle (19170)' => '19095',\n        'Lacépède (47360)' => '47125',\n        'Lachaise (16300)' => '16176',\n        'Lachapelle (47350)' => '47126',\n        'Lacommande (64360)' => '64299',\n        'Lacq (64170)' => '64300',\n        'Lacquy (40120)' => '40137',\n        'Lacrabe (40700)' => '40138',\n        'Lacropte (24380)' => '24220',\n        'Ladapeyre (23270)' => '23102',\n        'Ladaux (33760)' => '33215',\n        'Ladignac-le-Long (87500)' => '87082',\n        'Ladignac-sur-Rondelles (19150)' => '19096',\n        'Ladiville (16120)' => '16177',\n        'Lados (33124)' => '33216',\n        'Lafage-sur-Sombre (19320)' => '19097',\n        'Lafat (23800)' => '23103',\n        'Lafitte-sur-Lot (47320)' => '47127',\n        'Lafox (47240)' => '47128',\n        'Lagarde-Enval (19150)' => '19098',\n        'Lagarde-sur-le-Né (16300)' => '16178',\n        'Lagarrigue (47190)' => '47129',\n        'Lageon (79200)' => '79145',\n        'Lagleygeolle (19500)' => '19099',\n        'Laglorieuse (40090)' => '40139',\n        'Lagor (64150)' => '64301',\n        'Lagorce (33230)' => '33218',\n        'Lagord (17140)' => '17200',\n        'Lagos (64800)' => '64302',\n        'Lagrange (40240)' => '40140',\n        'Lagraulière (19700)' => '19100',\n        'Lagruère (47400)' => '47130',\n        'Laguenne (19150)' => '19101',\n        'Laguinge-Restoue (64470)' => '64303',\n        'Lagupie (47180)' => '47131',\n        'Lahonce (64990)' => '64304',\n        'Lahontan (64270)' => '64305',\n        'Lahosse (40250)' => '40141',\n        'Lahourcade (64150)' => '64306',\n        'Lalande-de-Pomerol (33500)' => '33222',\n        'Lalandusse (47330)' => '47132',\n        'Lalinde (24150)' => '24223',\n        'Lalongue (64350)' => '64307',\n        'Lalonquette (64450)' => '64308',\n        'Laluque (40465)' => '40142',\n        'Lamarque (33460)' => '33220',\n        'Lamayou (64460)' => '64309',\n        'Lamazière-Basse (19160)' => '19102',\n        'Lamazière-Haute (19340)' => '19103',\n        'Lamongerie (19510)' => '19104',\n        'Lamontjoie (47310)' => '47133',\n        'Lamonzie-Montastruc (24520)' => '24224',\n        'Lamonzie-Saint-Martin (24680)' => '24225',\n        'Lamothe (40250)' => '40143',\n        'Lamothe-Landerron (33190)' => '33221',\n        'Lamothe-Montravel (24230)' => '24226',\n        'Landerrouat (33790)' => '33223',\n        'Landerrouet-sur-Ségur (33540)' => '33224',\n        'Landes (17380)' => '17202',\n        'Landiras (33720)' => '33225',\n        'Landrais (17290)' => '17203',\n        'Langoiran (33550)' => '33226',\n        'Langon (33210)' => '33227',\n        'Lanne-en-Barétous (64570)' => '64310',\n        'Lannecaube (64350)' => '64311',\n        'Lanneplaà (64300)' => '64312',\n        'Lannes (47170)' => '47134',\n        'Lanouaille (24270)' => '24227',\n        'Lanquais (24150)' => '24228',\n        'Lansac (33710)' => '33228',\n        'Lantabat (64640)' => '64313',\n        'Lanteuil (19190)' => '19105',\n        'Lanton (33138)' => '33229',\n        'Laparade (47260)' => '47135',\n        'Laperche (47800)' => '47136',\n        'Lapleau (19550)' => '19106',\n        'Laplume (47310)' => '47137',\n        'Lapouyade (33620)' => '33230',\n        'Laprade (16390)' => '16180',\n        'Larbey (40250)' => '40144',\n        'Larceveau-Arros-Cibits (64120)' => '64314',\n        'Larche (19600)' => '19107',\n        'Largeasse (79240)' => '79147',\n        'Laroche-près-Feyt (19340)' => '19108',\n        'Laroin (64110)' => '64315',\n        'Laroque (33410)' => '33231',\n        'Laroque-Timbaut (47340)' => '47138',\n        'Larrau (64560)' => '64316',\n        'Larressore (64480)' => '64317',\n        'Larreule (64410)' => '64318',\n        'Larribar-Sorhapuru (64120)' => '64319',\n        'Larrivière-Saint-Savin (40270)' => '40145',\n        'Lartigue (33840)' => '33232',\n        'Laruns (64440)' => '64320',\n        'Laruscade (33620)' => '33233',\n        'Larzac (24170)' => '24230',\n        'Lascaux (19130)' => '19109',\n        'Lasclaveries (64450)' => '64321',\n        'Lasse (64220)' => '64322',\n        'Lasserre (47600)' => '47139',\n        'Lasserre (64350)' => '64323',\n        'Lasseube (64290)' => '64324',\n        'Lasseubetat (64290)' => '64325',\n        'Lathus-Saint-Rémy (86390)' => '86120',\n        'Latillé (86190)' => '86121',\n        'Latresne (33360)' => '33234',\n        'Latrille (40800)' => '40146',\n        'Latronche (19160)' => '19110',\n        'Laugnac (47360)' => '47140',\n        'Laurède (40250)' => '40147',\n        'Lauret (40320)' => '40148',\n        'Laurière (87370)' => '87083',\n        'Laussou (47150)' => '47141',\n        'Lauthiers (86300)' => '86122',\n        'Lauzun (47410)' => '47142',\n        'Laval-sur-Luzège (19550)' => '19111',\n        'Lavalade (24540)' => '24231',\n        'Lavardac (47230)' => '47143',\n        'Lavaufranche (23600)' => '23104',\n        'Lavaur (24550)' => '24232',\n        'Lavausseau (86470)' => '86123',\n        'Lavaveix-les-Mines (23150)' => '23105',\n        'Lavazan (33690)' => '33235',\n        'Lavergne (47800)' => '47144',\n        'Laveyssière (24130)' => '24233',\n        'Lavignac (87230)' => '87084',\n        'Lavoux (86800)' => '86124',\n        'Lay-Lamidou (64190)' => '64326',\n        'Layrac (47390)' => '47145',\n        'Le Barp (33114)' => '33029',\n        'Le Beugnon (79130)' => '79035',\n        'Le Bois-Plage-en-Ré (17580)' => '17051',\n        'Le Bouchage (16350)' => '16054',\n        'Le Bourdeix (24300)' => '24056',\n        'Le Bourdet (79210)' => '79046',\n        'Le Bourg-d\\'Hem (23220)' => '23029',\n        'Le Bouscat (33110)' => '33069',\n        'Le Breuil-Bernard (79320)' => '79051',\n        'Le Bugue (24260)' => '24067',\n        'Le Buis (87140)' => '87023',\n        'Le Buisson-de-Cadouin (24480)' => '24068',\n        'Le Busseau (79240)' => '79059',\n        'Le Chalard (87500)' => '87031',\n        'Le Change (24640)' => '24103',\n        'Le Chastang (19190)' => '19048',\n        'Le Château-d\\'Oléron (17480)' => '17093',\n        'Le Châtenet-en-Dognon (87400)' => '87042',\n        'Le Chauchet (23130)' => '23058',\n        'Le Chay (17600)' => '17097',\n        'Le Chillou (79600)' => '79089',\n        'Le Compas (23700)' => '23066',\n        'Le Donzeil (23480)' => '23074',\n        'Le Dorat (87210)' => '87059',\n        'Le Douhet (17100)' => '17143',\n        'Le Fieu (33230)' => '33166',\n        'Le Fleix (24130)' => '24182',\n        'Le Fouilloux (17270)' => '17167',\n        'Le Frêche (40190)' => '40100',\n        'Le Gicq (17160)' => '17177',\n        'Le Grand-Bourg (23240)' => '23095',\n        'Le Grand-Madieu (16450)' => '16157',\n        'Le Grand-Village-Plage (17370)' => '17485',\n        'Le Gua (17600)' => '17185',\n        'Le Gué-d\\'Alleré (17540)' => '17186',\n        'Le Haillan (33185)' => '33200',\n        'Le Jardin (19300)' => '19092',\n        'Le Lardin-Saint-Lazare (24570)' => '24229',\n        'Le Leuy (40250)' => '40153',\n        'Le Lindois (16310)' => '16188',\n        'Le Lonzac (19470)' => '19118',\n        'Le Mas-d\\'Agenais (47430)' => '47159',\n        'Le Mas-d\\'Artige (23100)' => '23125',\n        'Le Monteil-au-Vicomte (23460)' => '23134',\n        'Le Mung (17350)' => '17252',\n        'Le Nizan (33430)' => '33305',\n        'Le Palais-sur-Vienne (87410)' => '87113',\n        'Le Passage (47520)' => '47201',\n        'Le Pescher (19190)' => '19163',\n        'Le Pian-Médoc (33290)' => '33322',\n        'Le Pian-sur-Garonne (33490)' => '33323',\n        'Le Pin (17210)' => '17276',\n        'Le Pin (79140)' => '79210',\n        'Le Pizou (24700)' => '24329',\n        'Le Porge (33680)' => '33333',\n        'Le Pout (33670)' => '33335',\n        'Le Puy (33580)' => '33345',\n        'Le Retail (79130)' => '79226',\n        'Le Rochereau (86170)' => '86208',\n        'Le Sen (40420)' => '40297',\n        'Le Seure (17770)' => '17426',\n        'Le Taillan-Médoc (33320)' => '33519',\n        'Le Tallud (79200)' => '79322',\n        'Le Tâtre (16360)' => '16380',\n        'Le Teich (33470)' => '33527',\n        'Le Temple (33680)' => '33528',\n        'Le Temple-sur-Lot (47110)' => '47306',\n        'Le Thou (17290)' => '17447',\n        'Le Tourne (33550)' => '33534',\n        'Le Tuzan (33125)' => '33536',\n        'Le Vanneau-Irleau (79270)' => '79337',\n        'Le Verdon-sur-Mer (33123)' => '33544',\n        'Le Vert (79170)' => '79346',\n        'Le Vieux-Cérier (16350)' => '16403',\n        'Le Vigeant (86150)' => '86289',\n        'Le Vigen (87110)' => '87205',\n        'Le Vignau (40270)' => '40329',\n        'Lecumberry (64220)' => '64327',\n        'Lédat (47300)' => '47146',\n        'Ledeuix (64400)' => '64328',\n        'Lée (64320)' => '64329',\n        'Lées-Athas (64490)' => '64330',\n        'Lège-Cap-Ferret (33950)' => '33236',\n        'Léguillac-de-Cercles (24340)' => '24235',\n        'Léguillac-de-l\\'Auche (24110)' => '24236',\n        'Leigné-les-Bois (86450)' => '86125',\n        'Leigné-sur-Usseau (86230)' => '86127',\n        'Leignes-sur-Fontaine (86300)' => '86126',\n        'Lembeye (64350)' => '64331',\n        'Lembras (24100)' => '24237',\n        'Lème (64450)' => '64332',\n        'Lempzours (24800)' => '24238',\n        'Lencloître (86140)' => '86128',\n        'Lencouacq (40120)' => '40149',\n        'Léogeats (33210)' => '33237',\n        'Léognan (33850)' => '33238',\n        'Léon (40550)' => '40150',\n        'Léoville (17500)' => '17204',\n        'Lépaud (23170)' => '23106',\n        'Lépinas (23150)' => '23107',\n        'Léren (64270)' => '64334',\n        'Lerm-et-Musset (33840)' => '33239',\n        'Les Adjots (16700)' => '16002',\n        'Les Alleuds (79190)' => '79006',\n        'Les Angles-sur-Corrèze (19000)' => '19009',\n        'Les Artigues-de-Lussac (33570)' => '33014',\n        'Les Billanges (87340)' => '87016',\n        'Les Billaux (33500)' => '33052',\n        'Les Cars (87230)' => '87029',\n        'Les Éduts (17510)' => '17149',\n        'Les Églises-d\\'Argenteuil (17400)' => '17150',\n        'Les Églisottes-et-Chalaures (33230)' => '33154',\n        'Les Essards (16210)' => '16130',\n        'Les Essards (17250)' => '17154',\n        'Les Esseintes (33190)' => '33158',\n        'Les Eyzies-de-Tayac-Sireuil (24620)' => '24172',\n        'Les Farges (24290)' => '24175',\n        'Les Forges (79340)' => '79124',\n        'Les Fosses (79360)' => '79126',\n        'Les Gonds (17100)' => '17179',\n        'Les Gours (16140)' => '16155',\n        'Les Grands-Chézeaux (87160)' => '87074',\n        'Les Graulges (24340)' => '24203',\n        'Les Groseillers (79220)' => '79139',\n        'Les Lèches (24400)' => '24234',\n        'Les Lèves-et-Thoumeyragues (33220)' => '33242',\n        'Les Mars (23700)' => '23123',\n        'Les Mathes (17570)' => '17225',\n        'Les Métairies (16200)' => '16220',\n        'Les Nouillers (17380)' => '17266',\n        'Les Ormes (86220)' => '86183',\n        'Les Peintures (33230)' => '33315',\n        'Les Pins (16260)' => '16261',\n        'Les Portes-en-Ré (17880)' => '17286',\n        'Les Salles-de-Castillon (33350)' => '33499',\n        'Les Salles-Lavauguyon (87440)' => '87189',\n        'Les Touches-de-Périgny (17160)' => '17451',\n        'Les Trois-Moutiers (86120)' => '86274',\n        'Lescar (64230)' => '64335',\n        'Lescun (64490)' => '64336',\n        'Lesgor (40400)' => '40151',\n        'Lésignac-Durand (16310)' => '16183',\n        'Lésigny (86270)' => '86129',\n        'Lesparre-Médoc (33340)' => '33240',\n        'Lesperon (40260)' => '40152',\n        'Lespielle (64350)' => '64337',\n        'Lespourcy (64160)' => '64338',\n        'Lessac (16500)' => '16181',\n        'Lestards (19170)' => '19112',\n        'Lestelle-Bétharram (64800)' => '64339',\n        'Lesterps (16420)' => '16182',\n        'Lestiac-sur-Garonne (33550)' => '33241',\n        'Leugny (86220)' => '86130',\n        'Lévignac-de-Guyenne (47120)' => '47147',\n        'Lévignacq (40170)' => '40154',\n        'Leyrat (23600)' => '23108',\n        'Leyritz-Moncassin (47700)' => '47148',\n        'Lezay (79120)' => '79148',\n        'Lhommaizé (86410)' => '86131',\n        'Lhoumois (79390)' => '79149',\n        'Libourne (33500)' => '33243',\n        'Lichans-Sunhar (64470)' => '64340',\n        'Lichères (16460)' => '16184',\n        'Lichos (64130)' => '64341',\n        'Licq-Athérey (64560)' => '64342',\n        'Liginiac (19160)' => '19113',\n        'Liglet (86290)' => '86132',\n        'Lignan-de-Bazas (33430)' => '33244',\n        'Lignan-de-Bordeaux (33360)' => '33245',\n        'Lignareix (19200)' => '19114',\n        'Ligné (16140)' => '16185',\n        'Ligneyrac (19500)' => '19115',\n        'Lignières-Sonneville (16130)' => '16186',\n        'Ligueux (33220)' => '33246',\n        'Ligugé (86240)' => '86133',\n        'Limalonges (79190)' => '79150',\n        'Limendous (64420)' => '64343',\n        'Limeuil (24510)' => '24240',\n        'Limeyrat (24210)' => '24241',\n        'Limoges (87000)' => '87085',\n        'Linard (23220)' => '23109',\n        'Linards (87130)' => '87086',\n        'Linars (16730)' => '16187',\n        'Linazay (86400)' => '86134',\n        'Liniers (86800)' => '86135',\n        'Linxe (40260)' => '40155',\n        'Liorac-sur-Louyre (24520)' => '24242',\n        'Liourdres (19120)' => '19116',\n        'Lioux-les-Monges (23700)' => '23110',\n        'Liposthey (40410)' => '40156',\n        'Lisle (24350)' => '24243',\n        'Lissac-sur-Couze (19600)' => '19117',\n        'Listrac-de-Durèze (33790)' => '33247',\n        'Listrac-Médoc (33480)' => '33248',\n        'Lit-et-Mixe (40170)' => '40157',\n        'Livron (64530)' => '64344',\n        'Lizant (86400)' => '86136',\n        'Lizières (23240)' => '23111',\n        'Lohitzun-Oyhercq (64120)' => '64345',\n        'Loire-les-Marais (17870)' => '17205',\n        'Loiré-sur-Nie (17470)' => '17206',\n        'Loix (17111)' => '17207',\n        'Lolme (24540)' => '24244',\n        'Lombia (64160)' => '64346',\n        'Lonçon (64410)' => '64347',\n        'Londigny (16700)' => '16189',\n        'Longèves (17230)' => '17208',\n        'Longré (16240)' => '16190',\n        'Longueville (47200)' => '47150',\n        'Lonnes (16230)' => '16191',\n        'Lons (64140)' => '64348',\n        'Lonzac (17520)' => '17209',\n        'Lorignac (17240)' => '17210',\n        'Lorigné (79190)' => '79152',\n        'Lormont (33310)' => '33249',\n        'Losse (40240)' => '40158',\n        'Lostanges (19500)' => '19119',\n        'Loubejac (24550)' => '24245',\n        'Loubens (33190)' => '33250',\n        'Loubès-Bernac (47120)' => '47151',\n        'Loubieng (64300)' => '64349',\n        'Loubigné (79110)' => '79153',\n        'Loubillé (79110)' => '79154',\n        'Louchats (33125)' => '33251',\n        'Loudun (86200)' => '86137',\n        'Louer (40380)' => '40159',\n        'Lougratte (47290)' => '47152',\n        'Louhossoa (64250)' => '64350',\n        'Louignac (19310)' => '19120',\n        'Louin (79600)' => '79156',\n        'Loulay (17330)' => '17211',\n        'Loupes (33370)' => '33252',\n        'Loupiac (33410)' => '33253',\n        'Loupiac-de-la-Réole (33190)' => '33254',\n        'Lourdios-Ichère (64570)' => '64351',\n        'Lourdoueix-Saint-Pierre (23360)' => '23112',\n        'Lourenties (64420)' => '64352',\n        'Lourquen (40250)' => '40160',\n        'Louvie-Juzon (64260)' => '64353',\n        'Louvie-Soubiron (64440)' => '64354',\n        'Louvigny (64410)' => '64355',\n        'Louzac-Saint-André (16100)' => '16193',\n        'Louzignac (17160)' => '17212',\n        'Louzy (79100)' => '79157',\n        'Lozay (17330)' => '17213',\n        'Lubbon (40240)' => '40161',\n        'Lubersac (19210)' => '19121',\n        'Luc-Armau (64350)' => '64356',\n        'Lucarré (64350)' => '64357',\n        'Lucbardez-et-Bargues (40090)' => '40162',\n        'Lucgarier (64420)' => '64358',\n        'Luchapt (86430)' => '86138',\n        'Luchat (17600)' => '17214',\n        'Luché-sur-Brioux (79170)' => '79158',\n        'Luché-Thouarsais (79330)' => '79159',\n        'Lucmau (33840)' => '33255',\n        'Lucq-de-Béarn (64360)' => '64359',\n        'Ludon-Médoc (33290)' => '33256',\n        'Lüe (40210)' => '40163',\n        'Lugaignac (33420)' => '33257',\n        'Lugasson (33760)' => '33258',\n        'Luglon (40630)' => '40165',\n        'Lugon-et-l\\'Île-du-Carnay (33240)' => '33259',\n        'Lugos (33830)' => '33260',\n        'Lunas (24130)' => '24246',\n        'Lupersat (23190)' => '23113',\n        'Lupsault (16140)' => '16194',\n        'Luquet (65320)' => '65292',\n        'Lurbe-Saint-Christau (64660)' => '64360',\n        'Lusignac (24320)' => '24247',\n        'Lusignan (86600)' => '86139',\n        'Lusignan-Petit (47360)' => '47154',\n        'Lussac (16450)' => '16195',\n        'Lussac (17500)' => '17215',\n        'Lussac (33570)' => '33261',\n        'Lussac-les-Châteaux (86320)' => '86140',\n        'Lussac-les-Églises (87360)' => '87087',\n        'Lussagnet (40270)' => '40166',\n        'Lussagnet-Lusson (64160)' => '64361',\n        'Lussant (17430)' => '17216',\n        'Lussas-et-Nontronneau (24300)' => '24248',\n        'Lussat (23170)' => '23114',\n        'Lusseray (79170)' => '79160',\n        'Luxé (16230)' => '16196',\n        'Luxe-Sumberraute (64120)' => '64362',\n        'Luxey (40430)' => '40167',\n        'Luzay (79100)' => '79161',\n        'Lys (64260)' => '64363',\n        'Macau (33460)' => '33262',\n        'Macaye (64240)' => '64364',\n        'Macqueville (17490)' => '17217',\n        'Madaillan (47360)' => '47155',\n        'Madirac (33670)' => '33263',\n        'Madranges (19470)' => '19122',\n        'Magescq (40140)' => '40168',\n        'Magnac-Bourg (87380)' => '87088',\n        'Magnac-Laval (87190)' => '87089',\n        'Magnac-Lavalette-Villars (16320)' => '16198',\n        'Magnac-sur-Touvre (16600)' => '16199',\n        'Magnat-l\\'Étrange (23260)' => '23115',\n        'Magné (79460)' => '79162',\n        'Magné (86160)' => '86141',\n        'Mailhac-sur-Benaize (87160)' => '87090',\n        'Maillas (40120)' => '40169',\n        'Maillé (86190)' => '86142',\n        'Maillères (40120)' => '40170',\n        'Maine-de-Boixe (16230)' => '16200',\n        'Mainsat (23700)' => '23116',\n        'Mainxe (16200)' => '16202',\n        'Mainzac (16380)' => '16203',\n        'Mairé (86270)' => '86143',\n        'Mairé-Levescault (79190)' => '79163',\n        'Maison-Feyne (23800)' => '23117',\n        'Maisonnais-sur-Tardoire (87440)' => '87091',\n        'Maisonnay (79500)' => '79164',\n        'Maisonneuve (86170)' => '86144',\n        'Maisonnisses (23150)' => '23118',\n        'Maisontiers (79600)' => '79165',\n        'Malaussanne (64410)' => '64365',\n        'Malaville (16120)' => '16204',\n        'Malemort (19360)' => '19123',\n        'Malleret (23260)' => '23119',\n        'Malleret-Boussac (23600)' => '23120',\n        'Malval (23220)' => '23121',\n        'Manaurie (24620)' => '24249',\n        'Mano (40410)' => '40171',\n        'Manot (16500)' => '16205',\n        'Mansac (19520)' => '19124',\n        'Mansat-la-Courrière (23400)' => '23122',\n        'Mansle (16230)' => '16206',\n        'Mant (40700)' => '40172',\n        'Manzac-sur-Vern (24110)' => '24251',\n        'Marans (17230)' => '17218',\n        'Maransin (33230)' => '33264',\n        'Marc-la-Tour (19150)' => '19127',\n        'Marçay (86370)' => '86145',\n        'Marcellus (47200)' => '47156',\n        'Marcenais (33620)' => '33266',\n        'Marcheprime (33380)' => '33555',\n        'Marcillac (33860)' => '33267',\n        'Marcillac-la-Croisille (19320)' => '19125',\n        'Marcillac-la-Croze (19500)' => '19126',\n        'Marcillac-Lanville (16140)' => '16207',\n        'Marcillac-Saint-Quentin (24200)' => '24252',\n        'Marennes (17320)' => '17219',\n        'Mareuil (16170)' => '16208',\n        'Mareuil (24340)' => '24253',\n        'Margaux (33460)' => '33268',\n        'Margerides (19200)' => '19128',\n        'Margueron (33220)' => '33269',\n        'Marignac (17800)' => '17220',\n        'Marigny (79360)' => '79166',\n        'Marigny-Brizay (86380)' => '86146',\n        'Marigny-Chemereau (86370)' => '86147',\n        'Marillac-le-Franc (16110)' => '16209',\n        'Marimbault (33430)' => '33270',\n        'Marions (33690)' => '33271',\n        'Marmande (47200)' => '47157',\n        'Marmont-Pachas (47220)' => '47158',\n        'Marnac (24220)' => '24254',\n        'Marnay (86160)' => '86148',\n        'Marnes (79600)' => '79167',\n        'Marpaps (40330)' => '40173',\n        'Marquay (24620)' => '24255',\n        'Marsac (16570)' => '16210',\n        'Marsac (23210)' => '23124',\n        'Marsac-sur-l\\'Isle (24430)' => '24256',\n        'Marsais (17700)' => '17221',\n        'Marsalès (24540)' => '24257',\n        'Marsaneix (24750)' => '24258',\n        'Marsas (33620)' => '33272',\n        'Marsilly (17137)' => '17222',\n        'Martaizé (86330)' => '86149',\n        'Marthon (16380)' => '16211',\n        'Martignas-sur-Jalle (33127)' => '33273',\n        'Martillac (33650)' => '33274',\n        'Martres (33760)' => '33275',\n        'Marval (87440)' => '87092',\n        'Masbaraud-Mérignat (23400)' => '23126',\n        'Mascaraàs-Haron (64330)' => '64366',\n        'Maslacq (64300)' => '64367',\n        'Masléon (87130)' => '87093',\n        'Masparraute (64120)' => '64368',\n        'Maspie-Lalonquère-Juillacq (64350)' => '64369',\n        'Masquières (47370)' => '47160',\n        'Massac (17490)' => '17223',\n        'Massais (79150)' => '79168',\n        'Masseilles (33690)' => '33276',\n        'Massels (47140)' => '47161',\n        'Masseret (19510)' => '19129',\n        'Massignac (16310)' => '16212',\n        'Massognes (86170)' => '86150',\n        'Massoulès (47140)' => '47162',\n        'Massugas (33790)' => '33277',\n        'Matha (17160)' => '17224',\n        'Maucor (64160)' => '64370',\n        'Maulay (86200)' => '86151',\n        'Mauléon (79700)' => '79079',\n        'Mauléon-Licharre (64130)' => '64371',\n        'Mauprévoir (86460)' => '86152',\n        'Maure (64460)' => '64372',\n        'Maurens (24140)' => '24259',\n        'Mauriac (33540)' => '33278',\n        'Mauries (40320)' => '40174',\n        'Maurrin (40270)' => '40175',\n        'Maussac (19250)' => '19130',\n        'Mautes (23190)' => '23127',\n        'Mauvezin-d\\'Armagnac (40240)' => '40176',\n        'Mauvezin-sur-Gupie (47200)' => '47163',\n        'Mauzac-et-Grand-Castang (24150)' => '24260',\n        'Mauzé-sur-le-Mignon (79210)' => '79170',\n        'Mauzé-Thouarsais (79100)' => '79171',\n        'Mauzens-et-Miremont (24260)' => '24261',\n        'Mayac (24420)' => '24262',\n        'Maylis (40250)' => '40177',\n        'Mazeirat (23150)' => '23128',\n        'Mazeray (17400)' => '17226',\n        'Mazères (33210)' => '33279',\n        'Mazères-Lezons (64110)' => '64373',\n        'Mazerolles (16310)' => '16213',\n        'Mazerolles (17800)' => '17227',\n        'Mazerolles (40090)' => '40178',\n        'Mazerolles (64230)' => '64374',\n        'Mazerolles (86320)' => '86153',\n        'Mazeuil (86110)' => '86154',\n        'Mazeyrolles (24550)' => '24263',\n        'Mazières (16270)' => '16214',\n        'Mazières-en-Gâtine (79310)' => '79172',\n        'Mazières-Naresse (47210)' => '47164',\n        'Mazières-sur-Béronne (79500)' => '79173',\n        'Mazion (33390)' => '33280',\n        'Méasnes (23360)' => '23130',\n        'Médillac (16210)' => '16215',\n        'Médis (17600)' => '17228',\n        'Mées (40990)' => '40179',\n        'Méharin (64120)' => '64375',\n        'Meilhac (87800)' => '87094',\n        'Meilhan (40400)' => '40180',\n        'Meilhan-sur-Garonne (47180)' => '47165',\n        'Meilhards (19510)' => '19131',\n        'Meillon (64510)' => '64376',\n        'Melle (79500)' => '79174',\n        'Melleran (79190)' => '79175',\n        'Mendionde (64240)' => '64377',\n        'Menditte (64130)' => '64378',\n        'Mendive (64220)' => '64379',\n        'Ménesplet (24700)' => '24264',\n        'Ménigoute (79340)' => '79176',\n        'Ménoire (19190)' => '19132',\n        'Mensignac (24350)' => '24266',\n        'Méracq (64410)' => '64380',\n        'Mercoeur (19430)' => '19133',\n        'Mérignac (16200)' => '16216',\n        'Mérignac (17210)' => '17229',\n        'Mérignac (33700)' => '33281',\n        'Mérignas (33350)' => '33282',\n        'Mérinchal (23420)' => '23131',\n        'Méritein (64190)' => '64381',\n        'Merlines (19340)' => '19134',\n        'Merpins (16100)' => '16217',\n        'Meschers-sur-Gironde (17132)' => '17230',\n        'Mescoules (24240)' => '24267',\n        'Mesnac (16370)' => '16218',\n        'Mesplède (64370)' => '64382',\n        'Messac (17130)' => '17231',\n        'Messanges (40660)' => '40181',\n        'Messé (79120)' => '79177',\n        'Messemé (86200)' => '86156',\n        'Mesterrieux (33540)' => '33283',\n        'Mestes (19200)' => '19135',\n        'Meursac (17120)' => '17232',\n        'Meux (17500)' => '17233',\n        'Meuzac (87380)' => '87095',\n        'Meymac (19250)' => '19136',\n        'Meyrals (24220)' => '24268',\n        'Meyrignac-l\\'Église (19800)' => '19137',\n        'Meyssac (19500)' => '19138',\n        'Mézin (47170)' => '47167',\n        'Mézos (40170)' => '40182',\n        'Mialet (24450)' => '24269',\n        'Mialos (64410)' => '64383',\n        'Mignaloux-Beauvoir (86550)' => '86157',\n        'Migné-Auxances (86440)' => '86158',\n        'Migré (17330)' => '17234',\n        'Migron (17770)' => '17235',\n        'Milhac-d\\'Auberoche (24330)' => '24270',\n        'Milhac-de-Nontron (24470)' => '24271',\n        'Millac (86150)' => '86159',\n        'Millevaches (19290)' => '19139',\n        'Mimbaste (40350)' => '40183',\n        'Mimizan (40200)' => '40184',\n        'Minzac (24610)' => '24272',\n        'Mios (33380)' => '33284',\n        'Miossens-Lanusse (64450)' => '64385',\n        'Mirambeau (17150)' => '17236',\n        'Miramont-de-Guyenne (47800)' => '47168',\n        'Miramont-Sensacq (40320)' => '40185',\n        'Mirebeau (86110)' => '86160',\n        'Mirepeix (64800)' => '64386',\n        'Missé (79100)' => '79178',\n        'Misson (40290)' => '40186',\n        'Moëze (17780)' => '17237',\n        'Moirax (47310)' => '47169',\n        'Moissannes (87400)' => '87099',\n        'Molières (24480)' => '24273',\n        'Moliets-et-Maa (40660)' => '40187',\n        'Momas (64230)' => '64387',\n        'Mombrier (33710)' => '33285',\n        'Momuy (40700)' => '40188',\n        'Momy (64350)' => '64388',\n        'Monassut-Audiracq (64160)' => '64389',\n        'Monbahus (47290)' => '47170',\n        'Monbalen (47340)' => '47171',\n        'Monbazillac (24240)' => '24274',\n        'Moncaup (64350)' => '64390',\n        'Moncaut (47310)' => '47172',\n        'Moncayolle-Larrory-Mendibieu (64130)' => '64391',\n        'Monceaux-sur-Dordogne (19400)' => '19140',\n        'Moncla (64330)' => '64392',\n        'Monclar (47380)' => '47173',\n        'Moncontour (86330)' => '86161',\n        'Moncoutant (79320)' => '79179',\n        'Moncrabeau (47600)' => '47174',\n        'Mondion (86230)' => '86162',\n        'Monein (64360)' => '64393',\n        'Monestier (24240)' => '24276',\n        'Monestier-Merlines (19340)' => '19141',\n        'Monestier-Port-Dieu (19110)' => '19142',\n        'Monfaucon (24130)' => '24277',\n        'Monflanquin (47150)' => '47175',\n        'Mongaillard (47230)' => '47176',\n        'Mongauzy (33190)' => '33287',\n        'Monget (40700)' => '40189',\n        'Monheurt (47160)' => '47177',\n        'Monmadalès (24560)' => '24278',\n        'Monmarvès (24560)' => '24279',\n        'Monpazier (24540)' => '24280',\n        'Monpezat (64350)' => '64394',\n        'Monplaisant (24170)' => '24293',\n        'Monprimblanc (33410)' => '33288',\n        'Mons (16140)' => '16221',\n        'Mons (17160)' => '17239',\n        'Monsac (24440)' => '24281',\n        'Monsaguel (24560)' => '24282',\n        'Monsec (24340)' => '24283',\n        'Monségur (33580)' => '33289',\n        'Monségur (40700)' => '40190',\n        'Monségur (47150)' => '47178',\n        'Monségur (64460)' => '64395',\n        'Monsempron-Libos (47500)' => '47179',\n        'Mont (64300)' => '64396',\n        'Mont-de-Marsan (40000)' => '40192',\n        'Mont-Disse (64330)' => '64401',\n        'Montagnac-d\\'Auberoche (24210)' => '24284',\n        'Montagnac-la-Crempse (24140)' => '24285',\n        'Montagnac-sur-Auvignon (47600)' => '47180',\n        'Montagnac-sur-Lède (47150)' => '47181',\n        'Montagne (33570)' => '33290',\n        'Montagoudin (33190)' => '33291',\n        'Montagrier (24350)' => '24286',\n        'Montagut (64410)' => '64397',\n        'Montaignac-Saint-Hippolyte (19300)' => '19143',\n        'Montaigut-le-Blanc (23320)' => '23132',\n        'Montalembert (79190)' => '79180',\n        'Montamisé (86360)' => '86163',\n        'Montaner (64460)' => '64398',\n        'Montardon (64121)' => '64399',\n        'Montastruc (47380)' => '47182',\n        'Montauriol (47330)' => '47183',\n        'Montaut (24560)' => '24287',\n        'Montaut (40500)' => '40191',\n        'Montaut (47210)' => '47184',\n        'Montaut (64800)' => '64400',\n        'Montayral (47500)' => '47185',\n        'Montazeau (24230)' => '24288',\n        'Montboucher (23400)' => '23133',\n        'Montboyer (16620)' => '16222',\n        'Montbron (16220)' => '16223',\n        'Montcaret (24230)' => '24289',\n        'Montégut (40190)' => '40193',\n        'Montemboeuf (16310)' => '16225',\n        'Montendre (17130)' => '17240',\n        'Montesquieu (47130)' => '47186',\n        'Monteton (47120)' => '47187',\n        'Montferrand-du-Périgord (24440)' => '24290',\n        'Montfort (64190)' => '64403',\n        'Montfort-en-Chalosse (40380)' => '40194',\n        'Montgaillard (40500)' => '40195',\n        'Montgibaud (19210)' => '19144',\n        'Montguyon (17270)' => '17241',\n        'Monthoiron (86210)' => '86164',\n        'Montignac (24290)' => '24291',\n        'Montignac (33760)' => '33292',\n        'Montignac-Charente (16330)' => '16226',\n        'Montignac-de-Lauzun (47800)' => '47188',\n        'Montignac-le-Coq (16390)' => '16227',\n        'Montignac-Toupinerie (47350)' => '47189',\n        'Montigné (16170)' => '16228',\n        'Montils (17800)' => '17242',\n        'Montjean (16240)' => '16229',\n        'Montlieu-la-Garde (17210)' => '17243',\n        'Montmérac (16300)' => '16224',\n        'Montmoreau-Saint-Cybard (16190)' => '16230',\n        'Montmorillon (86500)' => '86165',\n        'Montory (64470)' => '64404',\n        'Montpellier-de-Médillan (17260)' => '17244',\n        'Montpeyroux (24610)' => '24292',\n        'Montpezat (47360)' => '47190',\n        'Montpon-Ménestérol (24700)' => '24294',\n        'Montpouillan (47200)' => '47191',\n        'Montravers (79140)' => '79183',\n        'Montrem (24110)' => '24295',\n        'Montreuil-Bonnin (86470)' => '86166',\n        'Montrol-Sénard (87330)' => '87100',\n        'Montrollet (16420)' => '16231',\n        'Montroy (17220)' => '17245',\n        'Monts-sur-Guesnes (86420)' => '86167',\n        'Montsoué (40500)' => '40196',\n        'Montussan (33450)' => '33293',\n        'Monviel (47290)' => '47192',\n        'Moragne (17430)' => '17246',\n        'Morcenx (40110)' => '40197',\n        'Morganx (40700)' => '40198',\n        'Morizès (33190)' => '33294',\n        'Morlaàs (64160)' => '64405',\n        'Morlanne (64370)' => '64406',\n        'Mornac (16600)' => '16232',\n        'Mornac-sur-Seudre (17113)' => '17247',\n        'Mortagne-sur-Gironde (17120)' => '17248',\n        'Mortemart (87330)' => '87101',\n        'Mortiers (17500)' => '17249',\n        'Morton (86120)' => '86169',\n        'Mortroux (23220)' => '23136',\n        'Mosnac (16120)' => '16233',\n        'Mosnac (17240)' => '17250',\n        'Mougon (79370)' => '79185',\n        'Mouguerre (64990)' => '64407',\n        'Mouhous (64330)' => '64408',\n        'Mouillac (33240)' => '33295',\n        'Mouleydier (24520)' => '24296',\n        'Moulidars (16290)' => '16234',\n        'Mouliets-et-Villemartin (33350)' => '33296',\n        'Moulin-Neuf (24700)' => '24297',\n        'Moulinet (47290)' => '47193',\n        'Moulis-en-Médoc (33480)' => '33297',\n        'Moulismes (86500)' => '86170',\n        'Moulon (33420)' => '33298',\n        'Moumour (64400)' => '64409',\n        'Mourens (33410)' => '33299',\n        'Mourenx (64150)' => '64410',\n        'Mourioux-Vieilleville (23210)' => '23137',\n        'Mouscardès (40290)' => '40199',\n        'Moussac (86150)' => '86171',\n        'Moustey (40410)' => '40200',\n        'Moustier (47800)' => '47194',\n        'Moustier-Ventadour (19300)' => '19145',\n        'Mouterre-Silly (86200)' => '86173',\n        'Mouterre-sur-Blourde (86430)' => '86172',\n        'Mouthiers-sur-Boëme (16440)' => '16236',\n        'Moutier-d\\'Ahun (23150)' => '23138',\n        'Moutier-Malcard (23220)' => '23139',\n        'Moutier-Rozeille (23200)' => '23140',\n        'Moutiers-sous-Chantemerle (79320)' => '79188',\n        'Mouton (16460)' => '16237',\n        'Moutonneau (16460)' => '16238',\n        'Mouzon (16310)' => '16239',\n        'Mugron (40250)' => '40201',\n        'Muron (17430)' => '17253',\n        'Musculdy (64130)' => '64411',\n        'Mussidan (24400)' => '24299',\n        'Nabas (64190)' => '64412',\n        'Nabinaud (16390)' => '16240',\n        'Nabirat (24250)' => '24300',\n        'Nachamps (17380)' => '17254',\n        'Nadaillac (24590)' => '24301',\n        'Nailhac (24390)' => '24302',\n        'Naillat (23800)' => '23141',\n        'Naintré (86530)' => '86174',\n        'Nalliers (86310)' => '86175',\n        'Nanclars (16230)' => '16241',\n        'Nancras (17600)' => '17255',\n        'Nanteuil (79400)' => '79189',\n        'Nanteuil-Auriac-de-Bourzac (24320)' => '24303',\n        'Nanteuil-en-Vallée (16700)' => '16242',\n        'Nantheuil (24800)' => '24304',\n        'Nanthiat (24800)' => '24305',\n        'Nantiat (87140)' => '87103',\n        'Nantillé (17770)' => '17256',\n        'Narcastet (64510)' => '64413',\n        'Narp (64190)' => '64414',\n        'Narrosse (40180)' => '40202',\n        'Nassiet (40330)' => '40203',\n        'Nastringues (24230)' => '24306',\n        'Naujac-sur-Mer (33990)' => '33300',\n        'Naujan-et-Postiac (33420)' => '33301',\n        'Naussannes (24440)' => '24307',\n        'Navailles-Angos (64450)' => '64415',\n        'Navarrenx (64190)' => '64416',\n        'Naves (19460)' => '19146',\n        'Nay (64800)' => '64417',\n        'Néac (33500)' => '33302',\n        'Nedde (87120)' => '87104',\n        'Négrondes (24460)' => '24308',\n        'Néoux (23200)' => '23142',\n        'Nérac (47600)' => '47195',\n        'Nerbis (40250)' => '40204',\n        'Nercillac (16200)' => '16243',\n        'Néré (17510)' => '17257',\n        'Nérigean (33750)' => '33303',\n        'Nérignac (86150)' => '86176',\n        'Nersac (16440)' => '16244',\n        'Nespouls (19600)' => '19147',\n        'Neuffons (33580)' => '33304',\n        'Neuillac (17520)' => '17258',\n        'Neulles (17500)' => '17259',\n        'Neuvic (19160)' => '19148',\n        'Neuvic (24190)' => '24309',\n        'Neuvic-Entier (87130)' => '87105',\n        'Neuvicq (17270)' => '17260',\n        'Neuvicq-le-Château (17490)' => '17261',\n        'Neuville (19380)' => '19149',\n        'Neuville-de-Poitou (86170)' => '86177',\n        'Neuvy-Bouin (79130)' => '79190',\n        'Nexon (87800)' => '87106',\n        'Nicole (47190)' => '47196',\n        'Nieuil (16270)' => '16245',\n        'Nieuil-l\\'Espoir (86340)' => '86178',\n        'Nieul (87510)' => '87107',\n        'Nieul-le-Virouil (17150)' => '17263',\n        'Nieul-lès-Saintes (17810)' => '17262',\n        'Nieul-sur-Mer (17137)' => '17264',\n        'Nieulle-sur-Seudre (17600)' => '17265',\n        'Niort (79000)' => '79191',\n        'Noailhac (19500)' => '19150',\n        'Noaillac (33190)' => '33306',\n        'Noaillan (33730)' => '33307',\n        'Noailles (19600)' => '19151',\n        'Noguères (64150)' => '64418',\n        'Nomdieu (47600)' => '47197',\n        'Nonac (16190)' => '16246',\n        'Nonards (19120)' => '19152',\n        'Nonaville (16120)' => '16247',\n        'Nontron (24300)' => '24311',\n        'Noth (23300)' => '23143',\n        'Notre-Dame-de-Sanilhac (24660)' => '24312',\n        'Nouaillé-Maupertuis (86340)' => '86180',\n        'Nouhant (23170)' => '23145',\n        'Nouic (87330)' => '87108',\n        'Nousse (40380)' => '40205',\n        'Nousty (64420)' => '64419',\n        'Nouzerines (23600)' => '23146',\n        'Nouzerolles (23360)' => '23147',\n        'Nouziers (23350)' => '23148',\n        'Nuaillé-d\\'Aunis (17540)' => '17267',\n        'Nuaillé-sur-Boutonne (17470)' => '17268',\n        'Nueil-les-Aubiers (79250)' => '79195',\n        'Nueil-sous-Faye (86200)' => '86181',\n        'Objat (19130)' => '19153',\n        'Oeyregave (40300)' => '40206',\n        'Oeyreluy (40180)' => '40207',\n        'Ogenne-Camptort (64190)' => '64420',\n        'Ogeu-les-Bains (64680)' => '64421',\n        'Oiron (79100)' => '79196',\n        'Oloron-Sainte-Marie (64400)' => '64422',\n        'Omet (33410)' => '33308',\n        'Onard (40380)' => '40208',\n        'Ondres (40440)' => '40209',\n        'Onesse-Laharie (40110)' => '40210',\n        'Oraàs (64390)' => '64423',\n        'Oradour (16140)' => '16248',\n        'Oradour-Fanais (16500)' => '16249',\n        'Oradour-Saint-Genest (87210)' => '87109',\n        'Oradour-sur-Glane (87520)' => '87110',\n        'Oradour-sur-Vayres (87150)' => '87111',\n        'Orches (86230)' => '86182',\n        'Ordiarp (64130)' => '64424',\n        'Ordonnac (33340)' => '33309',\n        'Orègue (64120)' => '64425',\n        'Orgedeuil (16220)' => '16250',\n        'Orgnac-sur-Vézère (19410)' => '19154',\n        'Origne (33113)' => '33310',\n        'Orignolles (17210)' => '17269',\n        'Orin (64400)' => '64426',\n        'Oriolles (16480)' => '16251',\n        'Orion (64390)' => '64427',\n        'Orist (40300)' => '40211',\n        'Orival (16210)' => '16252',\n        'Orliac (24170)' => '24313',\n        'Orliac-de-Bar (19390)' => '19155',\n        'Orliaguet (24370)' => '24314',\n        'Oroux (79390)' => '79197',\n        'Orriule (64390)' => '64428',\n        'Orsanco (64120)' => '64429',\n        'Orthevielle (40300)' => '40212',\n        'Orthez (64300)' => '64430',\n        'Orx (40230)' => '40213',\n        'Os-Marsillon (64150)' => '64431',\n        'Ossages (40290)' => '40214',\n        'Ossas-Suhare (64470)' => '64432',\n        'Osse-en-Aspe (64490)' => '64433',\n        'Ossenx (64190)' => '64434',\n        'Osserain-Rivareyte (64390)' => '64435',\n        'Ossès (64780)' => '64436',\n        'Ostabat-Asme (64120)' => '64437',\n        'Ouillon (64160)' => '64438',\n        'Ousse (64320)' => '64439',\n        'Ousse-Suzan (40110)' => '40215',\n        'Ouzilly (86380)' => '86184',\n        'Oyré (86220)' => '86186',\n        'Ozenx-Montestrucq (64300)' => '64440',\n        'Ozillac (17500)' => '17270',\n        'Ozourt (40380)' => '40216',\n        'Pageas (87230)' => '87112',\n        'Pagolle (64120)' => '64441',\n        'Paillé (17470)' => '17271',\n        'Paillet (33550)' => '33311',\n        'Pailloles (47440)' => '47198',\n        'Paizay-le-Chapt (79170)' => '79198',\n        'Paizay-le-Sec (86300)' => '86187',\n        'Paizay-le-Tort (79500)' => '79199',\n        'Paizay-Naudouin-Embourie (16240)' => '16253',\n        'Palazinges (19190)' => '19156',\n        'Palisse (19160)' => '19157',\n        'Palluaud (16390)' => '16254',\n        'Pamplie (79220)' => '79200',\n        'Pamproux (79800)' => '79201',\n        'Panazol (87350)' => '87114',\n        'Pandrignes (19150)' => '19158',\n        'Parbayse (64360)' => '64442',\n        'Parcoul-Chenaud (24410)' => '24316',\n        'Pardaillan (47120)' => '47199',\n        'Pardies (64150)' => '64443',\n        'Pardies-Piétat (64800)' => '64444',\n        'Parempuyre (33290)' => '33312',\n        'Parentis-en-Born (40160)' => '40217',\n        'Parleboscq (40310)' => '40218',\n        'Parranquet (47210)' => '47200',\n        'Parsac-Rimondeix (23140)' => '23149',\n        'Parthenay (79200)' => '79202',\n        'Parzac (16450)' => '16255',\n        'Pas-de-Jeu (79100)' => '79203',\n        'Passirac (16480)' => '16256',\n        'Pau (64000)' => '64445',\n        'Pauillac (33250)' => '33314',\n        'Paulhiac (47150)' => '47202',\n        'Paulin (24590)' => '24317',\n        'Paunat (24510)' => '24318',\n        'Paussac-et-Saint-Vivien (24310)' => '24319',\n        'Payré (86700)' => '86188',\n        'Payros-Cazautets (40320)' => '40219',\n        'Payroux (86350)' => '86189',\n        'Pays de Belvès (24170)' => '24035',\n        'Payzac (24270)' => '24320',\n        'Pazayac (24120)' => '24321',\n        'Pécorade (40320)' => '40220',\n        'Pellegrue (33790)' => '33316',\n        'Penne-d\\'Agenais (47140)' => '47203',\n        'Pensol (87440)' => '87115',\n        'Péré (17700)' => '17272',\n        'Péret-Bel-Air (19300)' => '19159',\n        'Pérignac (16250)' => '16258',\n        'Pérignac (17800)' => '17273',\n        'Périgné (79170)' => '79204',\n        'Périgny (17180)' => '17274',\n        'Périgueux (24000)' => '24322',\n        'Périssac (33240)' => '33317',\n        'Pérols-sur-Vézère (19170)' => '19160',\n        'Perpezac-le-Blanc (19310)' => '19161',\n        'Perpezac-le-Noir (19410)' => '19162',\n        'Perquie (40190)' => '40221',\n        'Pers (79190)' => '79205',\n        'Persac (86320)' => '86190',\n        'Pessac (33600)' => '33318',\n        'Pessac-sur-Dordogne (33890)' => '33319',\n        'Pessines (17810)' => '17275',\n        'Petit-Bersac (24600)' => '24323',\n        'Petit-Palais-et-Cornemps (33570)' => '33320',\n        'Peujard (33240)' => '33321',\n        'Pey (40300)' => '40222',\n        'Peyrabout (23000)' => '23150',\n        'Peyrat-de-Bellac (87300)' => '87116',\n        'Peyrat-la-Nonière (23130)' => '23151',\n        'Peyrat-le-Château (87470)' => '87117',\n        'Peyre (40700)' => '40223',\n        'Peyrehorade (40300)' => '40224',\n        'Peyrelevade (19290)' => '19164',\n        'Peyrelongue-Abos (64350)' => '64446',\n        'Peyrière (47350)' => '47204',\n        'Peyrignac (24210)' => '24324',\n        'Peyrilhac (87510)' => '87118',\n        'Peyrillac-et-Millac (24370)' => '24325',\n        'Peyrissac (19260)' => '19165',\n        'Peyzac-le-Moustier (24620)' => '24326',\n        'Pezuls (24510)' => '24327',\n        'Philondenx (40320)' => '40225',\n        'Piégut-Pluviers (24360)' => '24328',\n        'Pierre-Buffière (87260)' => '87119',\n        'Pierrefitte (19450)' => '19166',\n        'Pierrefitte (23130)' => '23152',\n        'Pierrefitte (79330)' => '79209',\n        'Piets-Plasence-Moustrou (64410)' => '64447',\n        'Pillac (16390)' => '16260',\n        'Pimbo (40320)' => '40226',\n        'Pindères (47700)' => '47205',\n        'Pindray (86500)' => '86191',\n        'Pinel-Hauterive (47380)' => '47206',\n        'Pineuilh (33220)' => '33324',\n        'Pionnat (23140)' => '23154',\n        'Pioussay (79110)' => '79211',\n        'Pisany (17600)' => '17278',\n        'Pissos (40410)' => '40227',\n        'Plaisance (24560)' => '24168',\n        'Plaisance (86500)' => '86192',\n        'Plassac (17240)' => '17279',\n        'Plassac (33390)' => '33325',\n        'Plassac-Rouffiac (16250)' => '16263',\n        'Plassay (17250)' => '17280',\n        'Plazac (24580)' => '24330',\n        'Pleine-Selve (33820)' => '33326',\n        'Pleumartin (86450)' => '86193',\n        'Pleuville (16490)' => '16264',\n        'Pliboux (79190)' => '79212',\n        'Podensac (33720)' => '33327',\n        'Poey-d\\'Oloron (64400)' => '64449',\n        'Poey-de-Lescar (64230)' => '64448',\n        'Poitiers (86000)' => '86194',\n        'Polignac (17210)' => '17281',\n        'Pomarez (40360)' => '40228',\n        'Pomerol (33500)' => '33328',\n        'Pommiers-Moulons (17130)' => '17282',\n        'Pompaire (79200)' => '79213',\n        'Pompéjac (33730)' => '33329',\n        'Pompiey (47230)' => '47207',\n        'Pompignac (33370)' => '33330',\n        'Pompogne (47420)' => '47208',\n        'Pomport (24240)' => '24331',\n        'Pomps (64370)' => '64450',\n        'Pondaurat (33190)' => '33331',\n        'Pons (17800)' => '17283',\n        'Ponson-Debat-Pouts (64460)' => '64451',\n        'Ponson-Dessus (64460)' => '64452',\n        'Pont-du-Casse (47480)' => '47209',\n        'Pont-l\\'Abbé-d\\'Arnoult (17250)' => '17284',\n        'Pontacq (64530)' => '64453',\n        'Pontarion (23250)' => '23155',\n        'Pontcharraud (23260)' => '23156',\n        'Pontenx-les-Forges (40200)' => '40229',\n        'Ponteyraud (24410)' => '24333',\n        'Pontiacq-Viellepinte (64460)' => '64454',\n        'Pontonx-sur-l\\'Adour (40465)' => '40230',\n        'Pontours (24150)' => '24334',\n        'Porchères (33660)' => '33332',\n        'Port-d\\'Envaux (17350)' => '17285',\n        'Port-de-Lanne (40300)' => '40231',\n        'Port-de-Piles (86220)' => '86195',\n        'Port-des-Barques (17730)' => '17484',\n        'Port-Sainte-Foy-et-Ponchapt (33220)' => '24335',\n        'Port-Sainte-Marie (47130)' => '47210',\n        'Portet (64330)' => '64455',\n        'Portets (33640)' => '33334',\n        'Pouançay (86120)' => '86196',\n        'Pouant (86200)' => '86197',\n        'Poudenas (47170)' => '47211',\n        'Poudenx (40700)' => '40232',\n        'Pouffonds (79500)' => '79214',\n        'Pougne-Hérisson (79130)' => '79215',\n        'Pouillac (17210)' => '17287',\n        'Pouillé (86800)' => '86198',\n        'Pouillon (40350)' => '40233',\n        'Pouliacq (64410)' => '64456',\n        'Poullignac (16190)' => '16267',\n        'Poursac (16700)' => '16268',\n        'Poursay-Garnaud (17400)' => '17288',\n        'Poursiugues-Boucoue (64410)' => '64457',\n        'Poussanges (23500)' => '23158',\n        'Poussignac (47700)' => '47212',\n        'Pouydesseaux (40120)' => '40234',\n        'Poyanne (40380)' => '40235',\n        'Poyartin (40380)' => '40236',\n        'Pradines (19170)' => '19168',\n        'Prahecq (79230)' => '79216',\n        'Prailles (79370)' => '79217',\n        'Pranzac (16110)' => '16269',\n        'Prats-de-Carlux (24370)' => '24336',\n        'Prats-du-Périgord (24550)' => '24337',\n        'Prayssas (47360)' => '47213',\n        'Préchac (33730)' => '33336',\n        'Préchacq-Josbaig (64190)' => '64458',\n        'Préchacq-les-Bains (40465)' => '40237',\n        'Préchacq-Navarrenx (64190)' => '64459',\n        'Précilhon (64400)' => '64460',\n        'Préguillac (17460)' => '17289',\n        'Preignac (33210)' => '33337',\n        'Pressac (86460)' => '86200',\n        'Pressignac (16150)' => '16270',\n        'Pressignac-Vicq (24150)' => '24338',\n        'Pressigny (79390)' => '79218',\n        'Preyssac-d\\'Excideuil (24160)' => '24339',\n        'Priaires (79210)' => '79219',\n        'Prignac (17160)' => '17290',\n        'Prignac-en-Médoc (33340)' => '33338',\n        'Prignac-et-Marcamps (33710)' => '33339',\n        'Prigonrieux (24130)' => '24340',\n        'Prin-Deyrançon (79210)' => '79220',\n        'Prinçay (86420)' => '86201',\n        'Prissé-la-Charrière (79360)' => '79078',\n        'Proissans (24200)' => '24341',\n        'Puch-d\\'Agenais (47160)' => '47214',\n        'Pugnac (33710)' => '33341',\n        'Pugny (79320)' => '79222',\n        'Puihardy (79160)' => '79223',\n        'Puilboreau (17138)' => '17291',\n        'Puisseguin (33570)' => '33342',\n        'Pujo-le-Plan (40190)' => '40238',\n        'Pujols (33350)' => '33344',\n        'Pujols (47300)' => '47215',\n        'Pujols-sur-Ciron (33210)' => '33343',\n        'Puy-d\\'Arnac (19120)' => '19169',\n        'Puy-du-Lac (17380)' => '17292',\n        'Puy-Malsignat (23130)' => '23159',\n        'Puybarban (33190)' => '33346',\n        'Puymiclan (47350)' => '47216',\n        'Puymirol (47270)' => '47217',\n        'Puymoyen (16400)' => '16271',\n        'Puynormand (33660)' => '33347',\n        'Puyol-Cazalet (40320)' => '40239',\n        'Puyoô (64270)' => '64461',\n        'Puyravault (17700)' => '17293',\n        'Puyréaux (16230)' => '16272',\n        'Puyrenier (24340)' => '24344',\n        'Puyrolland (17380)' => '17294',\n        'Puysserampion (47800)' => '47218',\n        'Queaux (86150)' => '86203',\n        'Queyrac (33340)' => '33348',\n        'Queyssac (24140)' => '24345',\n        'Queyssac-les-Vignes (19120)' => '19170',\n        'Quinçay (86190)' => '86204',\n        'Quinsac (24530)' => '24346',\n        'Quinsac (33360)' => '33349',\n        'Raix (16240)' => '16273',\n        'Ramous (64270)' => '64462',\n        'Rampieux (24440)' => '24347',\n        'Rancogne (16110)' => '16274',\n        'Rancon (87290)' => '87121',\n        'Ranton (86200)' => '86205',\n        'Ranville-Breuillaud (16140)' => '16275',\n        'Raslay (86120)' => '86206',\n        'Rauzan (33420)' => '33350',\n        'Rayet (47210)' => '47219',\n        'Razac-d\\'Eymet (24500)' => '24348',\n        'Razac-de-Saussignac (24240)' => '24349',\n        'Razac-sur-l\\'Isle (24430)' => '24350',\n        'Razès (87640)' => '87122',\n        'Razimet (47160)' => '47220',\n        'Réaup-Lisse (47170)' => '47221',\n        'Réaux sur Trèfle (17500)' => '17295',\n        'Rébénacq (64260)' => '64463',\n        'Reffannes (79420)' => '79225',\n        'Reignac (16360)' => '16276',\n        'Reignac (33860)' => '33351',\n        'Rempnat (87120)' => '87123',\n        'Renung (40270)' => '40240',\n        'Réparsac (16200)' => '16277',\n        'Rétaud (17460)' => '17296',\n        'Reterre (23110)' => '23160',\n        'Retjons (40120)' => '40164',\n        'Reygade (19430)' => '19171',\n        'Ribagnac (24240)' => '24351',\n        'Ribarrouy (64330)' => '64464',\n        'Ribérac (24600)' => '24352',\n        'Rilhac-Lastours (87800)' => '87124',\n        'Rilhac-Rancon (87570)' => '87125',\n        'Rilhac-Treignac (19260)' => '19172',\n        'Rilhac-Xaintrie (19220)' => '19173',\n        'Rimbez-et-Baudiets (40310)' => '40242',\n        'Rimons (33580)' => '33353',\n        'Riocaud (33220)' => '33354',\n        'Rion-des-Landes (40370)' => '40243',\n        'Rions (33410)' => '33355',\n        'Rioux (17460)' => '17298',\n        'Rioux-Martin (16210)' => '16279',\n        'Riupeyrous (64160)' => '64465',\n        'Rivedoux-Plage (17940)' => '17297',\n        'Rivehaute (64190)' => '64466',\n        'Rives (47210)' => '47223',\n        'Rivière-Saas-et-Gourby (40180)' => '40244',\n        'Rivières (16110)' => '16280',\n        'Roaillan (33210)' => '33357',\n        'Roche-le-Peyroux (19160)' => '19175',\n        'Rochechouart (87600)' => '87126',\n        'Rochefort (17300)' => '17299',\n        'Roches (23270)' => '23162',\n        'Roches-Prémarie-Andillé (86340)' => '86209',\n        'Roiffé (86120)' => '86210',\n        'Rom (79120)' => '79230',\n        'Romagne (33760)' => '33358',\n        'Romagne (86700)' => '86211',\n        'Romans (79260)' => '79231',\n        'Romazières (17510)' => '17301',\n        'Romegoux (17250)' => '17302',\n        'Romestaing (47250)' => '47224',\n        'Ronsenac (16320)' => '16283',\n        'Rontignon (64110)' => '64467',\n        'Roquebrune (33580)' => '33359',\n        'Roquefort (40120)' => '40245',\n        'Roquefort (47310)' => '47225',\n        'Roquiague (64130)' => '64468',\n        'Rosiers-d\\'Égletons (19300)' => '19176',\n        'Rosiers-de-Juillac (19350)' => '19177',\n        'Rouffiac (16210)' => '16284',\n        'Rouffiac (17800)' => '17304',\n        'Rouffignac (17130)' => '17305',\n        'Rouffignac-de-Sigoulès (24240)' => '24357',\n        'Rouffignac-Saint-Cernin-de-Reilhac (24580)' => '24356',\n        'Rougnac (16320)' => '16285',\n        'Rougnat (23700)' => '23164',\n        'Rouillac (16170)' => '16286',\n        'Rouillé (86480)' => '86213',\n        'Roullet-Saint-Estèphe (16440)' => '16287',\n        'Roumagne (47800)' => '47226',\n        'Roumazières-Loubert (16270)' => '16192',\n        'Roussac (87140)' => '87128',\n        'Roussines (16310)' => '16289',\n        'Rouzède (16220)' => '16290',\n        'Royan (17200)' => '17306',\n        'Royère-de-Vassivière (23460)' => '23165',\n        'Royères (87400)' => '87129',\n        'Roziers-Saint-Georges (87130)' => '87130',\n        'Ruch (33350)' => '33361',\n        'Rudeau-Ladosse (24340)' => '24221',\n        'Ruelle-sur-Touvre (16600)' => '16291',\n        'Ruffec (16700)' => '16292',\n        'Ruffiac (47700)' => '47227',\n        'Sablonceaux (17600)' => '17307',\n        'Sablons (33910)' => '33362',\n        'Sabres (40630)' => '40246',\n        'Sadillac (24500)' => '24359',\n        'Sadirac (33670)' => '33363',\n        'Sadroc (19270)' => '19178',\n        'Sagelat (24170)' => '24360',\n        'Sagnat (23800)' => '23166',\n        'Saillac (19500)' => '19179',\n        'Saillans (33141)' => '33364',\n        'Saillat-sur-Vienne (87720)' => '87131',\n        'Saint Aulaye-Puymangou (24410)' => '24376',\n        'Saint Maurice Étusson (79150)' => '79280',\n        'Saint-Abit (64800)' => '64469',\n        'Saint-Adjutory (16310)' => '16293',\n        'Saint-Agnant (17620)' => '17308',\n        'Saint-Agnant-de-Versillat (23300)' => '23177',\n        'Saint-Agnant-près-Crocq (23260)' => '23178',\n        'Saint-Agne (24520)' => '24361',\n        'Saint-Agnet (40800)' => '40247',\n        'Saint-Aignan (33126)' => '33365',\n        'Saint-Aigulin (17360)' => '17309',\n        'Saint-Alpinien (23200)' => '23179',\n        'Saint-Amand (23200)' => '23180',\n        'Saint-Amand-de-Coly (24290)' => '24364',\n        'Saint-Amand-de-Vergt (24380)' => '24365',\n        'Saint-Amand-Jartoudeix (23400)' => '23181',\n        'Saint-Amand-le-Petit (87120)' => '87132',\n        'Saint-Amand-Magnazeix (87290)' => '87133',\n        'Saint-Amand-sur-Sèvre (79700)' => '79235',\n        'Saint-Amant-de-Boixe (16330)' => '16295',\n        'Saint-Amant-de-Bonnieure (16230)' => '16296',\n        'Saint-Amant-de-Montmoreau (16190)' => '16294',\n        'Saint-Amant-de-Nouère (16170)' => '16298',\n        'Saint-André-d\\'Allas (24200)' => '24366',\n        'Saint-André-de-Cubzac (33240)' => '33366',\n        'Saint-André-de-Double (24190)' => '24367',\n        'Saint-André-de-Lidon (17260)' => '17310',\n        'Saint-André-de-Seignanx (40390)' => '40248',\n        'Saint-André-du-Bois (33490)' => '33367',\n        'Saint-André-et-Appelles (33220)' => '33369',\n        'Saint-André-sur-Sèvre (79380)' => '79236',\n        'Saint-Androny (33390)' => '33370',\n        'Saint-Angeau (16230)' => '16300',\n        'Saint-Angel (19200)' => '19180',\n        'Saint-Antoine-Cumond (24410)' => '24368',\n        'Saint-Antoine-d\\'Auberoche (24330)' => '24369',\n        'Saint-Antoine-de-Breuilh (24230)' => '24370',\n        'Saint-Antoine-de-Ficalba (47340)' => '47228',\n        'Saint-Antoine-du-Queyret (33790)' => '33372',\n        'Saint-Antoine-sur-l\\'Isle (33660)' => '33373',\n        'Saint-Aquilin (24110)' => '24371',\n        'Saint-Armou (64160)' => '64470',\n        'Saint-Astier (24110)' => '24372',\n        'Saint-Astier (47120)' => '47229',\n        'Saint-Aubin (40250)' => '40249',\n        'Saint-Aubin (47150)' => '47230',\n        'Saint-Aubin-de-Blaye (33820)' => '33374',\n        'Saint-Aubin-de-Branne (33420)' => '33375',\n        'Saint-Aubin-de-Cadelech (24500)' => '24373',\n        'Saint-Aubin-de-Lanquais (24560)' => '24374',\n        'Saint-Aubin-de-Médoc (33160)' => '33376',\n        'Saint-Aubin-de-Nabirat (24250)' => '24375',\n        'Saint-Aubin-du-Plain (79300)' => '79238',\n        'Saint-Aubin-le-Cloud (79450)' => '79239',\n        'Saint-Augustin (17570)' => '17311',\n        'Saint-Augustin (19390)' => '19181',\n        'Saint-Aulaire (19130)' => '19182',\n        'Saint-Aulais-la-Chapelle (16300)' => '16301',\n        'Saint-Auvent (87310)' => '87135',\n        'Saint-Avit (16210)' => '16302',\n        'Saint-Avit (40090)' => '40250',\n        'Saint-Avit (47350)' => '47231',\n        'Saint-Avit-de-Soulège (33220)' => '33377',\n        'Saint-Avit-de-Tardes (23200)' => '23182',\n        'Saint-Avit-de-Vialard (24260)' => '24377',\n        'Saint-Avit-le-Pauvre (23480)' => '23183',\n        'Saint-Avit-Rivière (24540)' => '24378',\n        'Saint-Avit-Saint-Nazaire (33220)' => '33378',\n        'Saint-Avit-Sénieur (24440)' => '24379',\n        'Saint-Barbant (87330)' => '87136',\n        'Saint-Bard (23260)' => '23184',\n        'Saint-Barthélemy (40390)' => '40251',\n        'Saint-Barthélemy-d\\'Agenais (47350)' => '47232',\n        'Saint-Barthélemy-de-Bellegarde (24700)' => '24380',\n        'Saint-Barthélemy-de-Bussière (24360)' => '24381',\n        'Saint-Bazile (87150)' => '87137',\n        'Saint-Bazile-de-la-Roche (19320)' => '19183',\n        'Saint-Bazile-de-Meyssac (19500)' => '19184',\n        'Saint-Benoît (86280)' => '86214',\n        'Saint-Boès (64300)' => '64471',\n        'Saint-Bonnet (16300)' => '16303',\n        'Saint-Bonnet-Avalouze (19150)' => '19185',\n        'Saint-Bonnet-Briance (87260)' => '87138',\n        'Saint-Bonnet-de-Bellac (87300)' => '87139',\n        'Saint-Bonnet-Elvert (19380)' => '19186',\n        'Saint-Bonnet-l\\'Enfantier (19410)' => '19188',\n        'Saint-Bonnet-la-Rivière (19130)' => '19187',\n        'Saint-Bonnet-les-Tours-de-Merle (19430)' => '19189',\n        'Saint-Bonnet-près-Bort (19200)' => '19190',\n        'Saint-Bonnet-sur-Gironde (17150)' => '17312',\n        'Saint-Brice (16100)' => '16304',\n        'Saint-Brice (33540)' => '33379',\n        'Saint-Brice-sur-Vienne (87200)' => '87140',\n        'Saint-Bris-des-Bois (17770)' => '17313',\n        'Saint-Caprais-de-Blaye (33820)' => '33380',\n        'Saint-Caprais-de-Bordeaux (33880)' => '33381',\n        'Saint-Caprais-de-Lerm (47270)' => '47234',\n        'Saint-Capraise-d\\'Eymet (24500)' => '24383',\n        'Saint-Capraise-de-Lalinde (24150)' => '24382',\n        'Saint-Cassien (24540)' => '24384',\n        'Saint-Castin (64160)' => '64472',\n        'Saint-Cernin-de-l\\'Herm (24550)' => '24386',\n        'Saint-Cernin-de-Labarde (24560)' => '24385',\n        'Saint-Cernin-de-Larche (19600)' => '19191',\n        'Saint-Césaire (17770)' => '17314',\n        'Saint-Chabrais (23130)' => '23185',\n        'Saint-Chamant (19380)' => '19192',\n        'Saint-Chamassy (24260)' => '24388',\n        'Saint-Christoly-de-Blaye (33920)' => '33382',\n        'Saint-Christoly-Médoc (33340)' => '33383',\n        'Saint-Christophe (16420)' => '16306',\n        'Saint-Christophe (17220)' => '17315',\n        'Saint-Christophe (23000)' => '23186',\n        'Saint-Christophe (86230)' => '86217',\n        'Saint-Christophe-de-Double (33230)' => '33385',\n        'Saint-Christophe-des-Bardes (33330)' => '33384',\n        'Saint-Christophe-sur-Roc (79220)' => '79241',\n        'Saint-Cibard (33570)' => '33386',\n        'Saint-Ciers-Champagne (17520)' => '17316',\n        'Saint-Ciers-d\\'Abzac (33910)' => '33387',\n        'Saint-Ciers-de-Canesse (33710)' => '33388',\n        'Saint-Ciers-du-Taillon (17240)' => '17317',\n        'Saint-Ciers-sur-Bonnieure (16230)' => '16307',\n        'Saint-Ciers-sur-Gironde (33820)' => '33389',\n        'Saint-Cirgues-la-Loutre (19220)' => '19193',\n        'Saint-Cirq (24260)' => '24389',\n        'Saint-Clair (86330)' => '86218',\n        'Saint-Claud (16450)' => '16308',\n        'Saint-Clément (19700)' => '19194',\n        'Saint-Clément-des-Baleines (17590)' => '17318',\n        'Saint-Colomb-de-Lauzun (47410)' => '47235',\n        'Saint-Côme (33430)' => '33391',\n        'Saint-Coutant (16350)' => '16310',\n        'Saint-Coutant (79120)' => '79243',\n        'Saint-Coutant-le-Grand (17430)' => '17320',\n        'Saint-Crépin (17380)' => '17321',\n        'Saint-Crépin-d\\'Auberoche (24330)' => '24390',\n        'Saint-Crépin-de-Richemont (24310)' => '24391',\n        'Saint-Crépin-et-Carlucet (24590)' => '24392',\n        'Saint-Cricq-Chalosse (40700)' => '40253',\n        'Saint-Cricq-du-Gave (40300)' => '40254',\n        'Saint-Cricq-Villeneuve (40190)' => '40255',\n        'Saint-Cybardeaux (16170)' => '16312',\n        'Saint-Cybranet (24250)' => '24395',\n        'Saint-Cyprien (19130)' => '19195',\n        'Saint-Cyprien (24220)' => '24396',\n        'Saint-Cyr (86130)' => '86219',\n        'Saint-Cyr (87310)' => '87141',\n        'Saint-Cyr-du-Doret (17170)' => '17322',\n        'Saint-Cyr-la-Lande (79100)' => '79244',\n        'Saint-Cyr-la-Roche (19130)' => '19196',\n        'Saint-Cyr-les-Champagnes (24270)' => '24397',\n        'Saint-Denis-d\\'Oléron (17650)' => '17323',\n        'Saint-Denis-de-Pile (33910)' => '33393',\n        'Saint-Denis-des-Murs (87400)' => '87142',\n        'Saint-Dizant-du-Bois (17150)' => '17324',\n        'Saint-Dizant-du-Gua (17240)' => '17325',\n        'Saint-Dizier-la-Tour (23130)' => '23187',\n        'Saint-Dizier-les-Domaines (23270)' => '23188',\n        'Saint-Dizier-Leyrenne (23400)' => '23189',\n        'Saint-Domet (23190)' => '23190',\n        'Saint-Dos (64270)' => '64474',\n        'Saint-Éloi (23000)' => '23191',\n        'Saint-Éloy-les-Tuileries (19210)' => '19198',\n        'Saint-Émilion (33330)' => '33394',\n        'Saint-Esteben (64640)' => '64476',\n        'Saint-Estèphe (24360)' => '24398',\n        'Saint-Estèphe (33180)' => '33395',\n        'Saint-Étienne-aux-Clos (19200)' => '19199',\n        'Saint-Étienne-d\\'Orthe (40300)' => '40256',\n        'Saint-Étienne-de-Baïgorry (64430)' => '64477',\n        'Saint-Étienne-de-Fougères (47380)' => '47239',\n        'Saint-Étienne-de-Fursac (23290)' => '23192',\n        'Saint-Étienne-de-Lisse (33330)' => '33396',\n        'Saint-Étienne-de-Puycorbier (24400)' => '24399',\n        'Saint-Étienne-de-Villeréal (47210)' => '47240',\n        'Saint-Étienne-la-Cigogne (79360)' => '79247',\n        'Saint-Étienne-la-Geneste (19160)' => '19200',\n        'Saint-Eugène (17520)' => '17326',\n        'Saint-Eutrope (16190)' => '16314',\n        'Saint-Eutrope-de-Born (47210)' => '47241',\n        'Saint-Exupéry (33190)' => '33398',\n        'Saint-Exupéry-les-Roches (19200)' => '19201',\n        'Saint-Faust (64110)' => '64478',\n        'Saint-Félix (16480)' => '16315',\n        'Saint-Félix (17330)' => '17327',\n        'Saint-Félix-de-Bourdeilles (24340)' => '24403',\n        'Saint-Félix-de-Foncaude (33540)' => '33399',\n        'Saint-Félix-de-Reillac-et-Mortemart (24260)' => '24404',\n        'Saint-Félix-de-Villadeix (24510)' => '24405',\n        'Saint-Ferme (33580)' => '33400',\n        'Saint-Fiel (23000)' => '23195',\n        'Saint-Fort-sur-Gironde (17240)' => '17328',\n        'Saint-Fort-sur-le-Né (16130)' => '16316',\n        'Saint-Fraigne (16140)' => '16317',\n        'Saint-Fréjoux (19200)' => '19204',\n        'Saint-Frion (23500)' => '23196',\n        'Saint-Front (16460)' => '16318',\n        'Saint-Front-d\\'Alemps (24460)' => '24408',\n        'Saint-Front-de-Pradoux (24400)' => '24409',\n        'Saint-Front-la-Rivière (24300)' => '24410',\n        'Saint-Front-sur-Lémance (47500)' => '47242',\n        'Saint-Front-sur-Nizonne (24300)' => '24411',\n        'Saint-Froult (17780)' => '17329',\n        'Saint-Gaudent (86400)' => '86220',\n        'Saint-Gein (40190)' => '40259',\n        'Saint-Gelais (79410)' => '79249',\n        'Saint-Génard (79500)' => '79251',\n        'Saint-Gence (87510)' => '87143',\n        'Saint-Généroux (79600)' => '79252',\n        'Saint-Genès-de-Blaye (33390)' => '33405',\n        'Saint-Genès-de-Castillon (33350)' => '33406',\n        'Saint-Genès-de-Fronsac (33240)' => '33407',\n        'Saint-Genès-de-Lombaud (33670)' => '33408',\n        'Saint-Genest-d\\'Ambière (86140)' => '86221',\n        'Saint-Genest-sur-Roselle (87260)' => '87144',\n        'Saint-Geniès (24590)' => '24412',\n        'Saint-Geniez-ô-Merle (19220)' => '19205',\n        'Saint-Genis-d\\'Hiersac (16570)' => '16320',\n        'Saint-Genis-de-Saintonge (17240)' => '17331',\n        'Saint-Genis-du-Bois (33760)' => '33409',\n        'Saint-Georges (16700)' => '16321',\n        'Saint-Georges (47370)' => '47328',\n        'Saint-Georges-Antignac (17240)' => '17332',\n        'Saint-Georges-Blancaneix (24130)' => '24413',\n        'Saint-Georges-d\\'Oléron (17190)' => '17337',\n        'Saint-Georges-de-Didonne (17110)' => '17333',\n        'Saint-Georges-de-Longuepierre (17470)' => '17334',\n        'Saint-Georges-de-Montclard (24140)' => '24414',\n        'Saint-Georges-de-Noisné (79400)' => '79253',\n        'Saint-Georges-de-Rex (79210)' => '79254',\n        'Saint-Georges-des-Agoûts (17150)' => '17335',\n        'Saint-Georges-des-Coteaux (17810)' => '17336',\n        'Saint-Georges-du-Bois (17700)' => '17338',\n        'Saint-Georges-la-Pouge (23250)' => '23197',\n        'Saint-Georges-lès-Baillargeaux (86130)' => '86222',\n        'Saint-Georges-les-Landes (87160)' => '87145',\n        'Saint-Georges-Nigremont (23500)' => '23198',\n        'Saint-Geours-d\\'Auribat (40380)' => '40260',\n        'Saint-Geours-de-Maremne (40230)' => '40261',\n        'Saint-Géraud (47120)' => '47245',\n        'Saint-Géraud-de-Corps (24700)' => '24415',\n        'Saint-Germain (86310)' => '86223',\n        'Saint-Germain-Beaupré (23160)' => '23199',\n        'Saint-Germain-d\\'Esteuil (33340)' => '33412',\n        'Saint-Germain-de-Belvès (24170)' => '24416',\n        'Saint-Germain-de-Grave (33490)' => '33411',\n        'Saint-Germain-de-la-Rivière (33240)' => '33414',\n        'Saint-Germain-de-Longue-Chaume (79200)' => '79255',\n        'Saint-Germain-de-Lusignan (17500)' => '17339',\n        'Saint-Germain-de-Marencennes (17700)' => '17340',\n        'Saint-Germain-de-Montbron (16380)' => '16323',\n        'Saint-Germain-de-Vibrac (17500)' => '17341',\n        'Saint-Germain-des-Prés (24160)' => '24417',\n        'Saint-Germain-du-Puch (33750)' => '33413',\n        'Saint-Germain-du-Salembre (24190)' => '24418',\n        'Saint-Germain-du-Seudre (17240)' => '17342',\n        'Saint-Germain-et-Mons (24520)' => '24419',\n        'Saint-Germain-Lavolps (19290)' => '19206',\n        'Saint-Germain-les-Belles (87380)' => '87146',\n        'Saint-Germain-les-Vergnes (19330)' => '19207',\n        'Saint-Germier (79340)' => '79256',\n        'Saint-Gervais (33240)' => '33415',\n        'Saint-Gervais-les-Trois-Clochers (86230)' => '86224',\n        'Saint-Géry (24400)' => '24420',\n        'Saint-Geyrac (24330)' => '24421',\n        'Saint-Gilles-les-Forêts (87130)' => '87147',\n        'Saint-Girons-d\\'Aiguevives (33920)' => '33416',\n        'Saint-Girons-en-Béarn (64300)' => '64479',\n        'Saint-Gladie-Arrive-Munein (64390)' => '64480',\n        'Saint-Goin (64400)' => '64481',\n        'Saint-Gor (40120)' => '40262',\n        'Saint-Gourson (16700)' => '16325',\n        'Saint-Goussaud (23430)' => '23200',\n        'Saint-Grégoire-d\\'Ardennes (17240)' => '17343',\n        'Saint-Groux (16230)' => '16326',\n        'Saint-Hilaire-Bonneval (87260)' => '87148',\n        'Saint-Hilaire-d\\'Estissac (24140)' => '24422',\n        'Saint-Hilaire-de-la-Noaille (33190)' => '33418',\n        'Saint-Hilaire-de-Lusignan (47450)' => '47246',\n        'Saint-Hilaire-de-Villefranche (17770)' => '17344',\n        'Saint-Hilaire-du-Bois (17500)' => '17345',\n        'Saint-Hilaire-du-Bois (33540)' => '33419',\n        'Saint-Hilaire-Foissac (19550)' => '19208',\n        'Saint-Hilaire-la-Palud (79210)' => '79257',\n        'Saint-Hilaire-la-Plaine (23150)' => '23201',\n        'Saint-Hilaire-la-Treille (87190)' => '87149',\n        'Saint-Hilaire-le-Château (23250)' => '23202',\n        'Saint-Hilaire-les-Courbes (19170)' => '19209',\n        'Saint-Hilaire-les-Places (87800)' => '87150',\n        'Saint-Hilaire-Luc (19160)' => '19210',\n        'Saint-Hilaire-Peyroux (19560)' => '19211',\n        'Saint-Hilaire-Taurieux (19400)' => '19212',\n        'Saint-Hippolyte (17430)' => '17346',\n        'Saint-Hippolyte (33330)' => '33420',\n        'Saint-Jacques-de-Thouars (79100)' => '79258',\n        'Saint-Jal (19700)' => '19213',\n        'Saint-Jammes (64160)' => '64482',\n        'Saint-Jean-d\\'Angély (17400)' => '17347',\n        'Saint-Jean-d\\'Angle (17620)' => '17348',\n        'Saint-Jean-d\\'Ataux (24190)' => '24424',\n        'Saint-Jean-d\\'Estissac (24140)' => '24426',\n        'Saint-Jean-d\\'Eyraud (24140)' => '24427',\n        'Saint-Jean-d\\'Illac (33127)' => '33422',\n        'Saint-Jean-de-Blaignac (33420)' => '33421',\n        'Saint-Jean-de-Côle (24800)' => '24425',\n        'Saint-Jean-de-Duras (47120)' => '47247',\n        'Saint-Jean-de-Lier (40380)' => '40263',\n        'Saint-Jean-de-Liversay (17170)' => '17349',\n        'Saint-Jean-de-Luz (64500)' => '64483',\n        'Saint-Jean-de-Marsacq (40230)' => '40264',\n        'Saint-Jean-de-Sauves (86330)' => '86225',\n        'Saint-Jean-de-Thouars (79100)' => '79259',\n        'Saint-Jean-de-Thurac (47270)' => '47248',\n        'Saint-Jean-le-Vieux (64220)' => '64484',\n        'Saint-Jean-Ligoure (87260)' => '87151',\n        'Saint-Jean-Pied-de-Port (64220)' => '64485',\n        'Saint-Jean-Poudge (64330)' => '64486',\n        'Saint-Jory-de-Chalais (24800)' => '24428',\n        'Saint-Jory-las-Bloux (24160)' => '24429',\n        'Saint-Jouin-de-Marnes (79600)' => '79260',\n        'Saint-Jouin-de-Milly (79380)' => '79261',\n        'Saint-Jouvent (87510)' => '87152',\n        'Saint-Julien-aux-Bois (19220)' => '19214',\n        'Saint-Julien-Beychevelle (33250)' => '33423',\n        'Saint-Julien-d\\'Armagnac (40240)' => '40265',\n        'Saint-Julien-d\\'Eymet (24500)' => '24433',\n        'Saint-Julien-de-Crempse (24140)' => '24431',\n        'Saint-Julien-de-l\\'Escap (17400)' => '17350',\n        'Saint-Julien-de-Lampon (24370)' => '24432',\n        'Saint-Julien-en-Born (40170)' => '40266',\n        'Saint-Julien-l\\'Ars (86800)' => '86226',\n        'Saint-Julien-la-Genête (23110)' => '23203',\n        'Saint-Julien-le-Châtel (23130)' => '23204',\n        'Saint-Julien-le-Pèlerin (19430)' => '19215',\n        'Saint-Julien-le-Petit (87460)' => '87153',\n        'Saint-Julien-le-Vendômois (19210)' => '19216',\n        'Saint-Julien-Maumont (19500)' => '19217',\n        'Saint-Julien-près-Bort (19110)' => '19218',\n        'Saint-Junien (87200)' => '87154',\n        'Saint-Junien-la-Bregère (23400)' => '23205',\n        'Saint-Junien-les-Combes (87300)' => '87155',\n        'Saint-Just (24320)' => '24434',\n        'Saint-Just-Ibarre (64120)' => '64487',\n        'Saint-Just-le-Martel (87590)' => '87156',\n        'Saint-Just-Luzac (17320)' => '17351',\n        'Saint-Justin (40240)' => '40267',\n        'Saint-Laon (86200)' => '86227',\n        'Saint-Laurent (23000)' => '23206',\n        'Saint-Laurent (47130)' => '47249',\n        'Saint-Laurent-Bretagne (64160)' => '64488',\n        'Saint-Laurent-d\\'Arce (33240)' => '33425',\n        'Saint-Laurent-de-Belzagot (16190)' => '16328',\n        'Saint-Laurent-de-Céris (16450)' => '16329',\n        'Saint-Laurent-de-Cognac (16100)' => '16330',\n        'Saint-Laurent-de-Gosse (40390)' => '40268',\n        'Saint-Laurent-de-Jourdes (86410)' => '86228',\n        'Saint-Laurent-de-la-Barrière (17380)' => '17352',\n        'Saint-Laurent-de-la-Prée (17450)' => '17353',\n        'Saint-Laurent-des-Combes (16480)' => '16331',\n        'Saint-Laurent-des-Combes (33330)' => '33426',\n        'Saint-Laurent-des-Hommes (24400)' => '24436',\n        'Saint-Laurent-des-Vignes (24100)' => '24437',\n        'Saint-Laurent-du-Bois (33540)' => '33427',\n        'Saint-Laurent-du-Plan (33190)' => '33428',\n        'Saint-Laurent-la-Vallée (24170)' => '24438',\n        'Saint-Laurent-les-Églises (87240)' => '87157',\n        'Saint-Laurent-Médoc (33112)' => '33424',\n        'Saint-Laurent-sur-Gorre (87310)' => '87158',\n        'Saint-Laurs (79160)' => '79263',\n        'Saint-Léger (16250)' => '16332',\n        'Saint-Léger (17800)' => '17354',\n        'Saint-Léger (47160)' => '47250',\n        'Saint-Léger-Bridereix (23300)' => '23207',\n        'Saint-Léger-de-Balson (33113)' => '33429',\n        'Saint-Léger-de-la-Martinière (79500)' => '79264',\n        'Saint-Léger-de-Montbrillais (86120)' => '86229',\n        'Saint-Léger-de-Montbrun (79100)' => '79265',\n        'Saint-Léger-la-Montagne (87340)' => '87159',\n        'Saint-Léger-le-Guérétois (23000)' => '23208',\n        'Saint-Léger-Magnazeix (87190)' => '87160',\n        'Saint-Léomer (86290)' => '86230',\n        'Saint-Léon (33670)' => '33431',\n        'Saint-Léon (47160)' => '47251',\n        'Saint-Léon-d\\'Issigeac (24560)' => '24441',\n        'Saint-Léon-sur-l\\'Isle (24110)' => '24442',\n        'Saint-Léon-sur-Vézère (24290)' => '24443',\n        'Saint-Léonard-de-Noblat (87400)' => '87161',\n        'Saint-Lin (79420)' => '79267',\n        'Saint-Lon-les-Mines (40300)' => '40269',\n        'Saint-Loubert (33210)' => '33432',\n        'Saint-Loubès (33450)' => '33433',\n        'Saint-Loubouer (40320)' => '40270',\n        'Saint-Louis-de-Montferrand (33440)' => '33434',\n        'Saint-Louis-en-l\\'Isle (24400)' => '24444',\n        'Saint-Loup (17380)' => '17356',\n        'Saint-Loup (23130)' => '23209',\n        'Saint-Loup-Lamairé (79600)' => '79268',\n        'Saint-Macaire (33490)' => '33435',\n        'Saint-Macoux (86400)' => '86231',\n        'Saint-Magne (33125)' => '33436',\n        'Saint-Magne-de-Castillon (33350)' => '33437',\n        'Saint-Maigrin (17520)' => '17357',\n        'Saint-Maime-de-Péreyrol (24380)' => '24459',\n        'Saint-Maixant (23200)' => '23210',\n        'Saint-Maixant (33490)' => '33438',\n        'Saint-Maixent-de-Beugné (79160)' => '79269',\n        'Saint-Maixent-l\\'École (79400)' => '79270',\n        'Saint-Mandé-sur-Brédoire (17470)' => '17358',\n        'Saint-Marc-à-Frongier (23200)' => '23211',\n        'Saint-Marc-à-Loubaud (23460)' => '23212',\n        'Saint-Marc-la-Lande (79310)' => '79271',\n        'Saint-Marcel-du-Périgord (24510)' => '24445',\n        'Saint-Marcory (24540)' => '24446',\n        'Saint-Mard (17700)' => '17359',\n        'Saint-Marien (23600)' => '23213',\n        'Saint-Mariens (33620)' => '33439',\n        'Saint-Martial (16190)' => '16334',\n        'Saint-Martial (17330)' => '17361',\n        'Saint-Martial (33490)' => '33440',\n        'Saint-Martial-d\\'Albarède (24160)' => '24448',\n        'Saint-Martial-d\\'Artenset (24700)' => '24449',\n        'Saint-Martial-de-Gimel (19150)' => '19220',\n        'Saint-Martial-de-Mirambeau (17150)' => '17362',\n        'Saint-Martial-de-Nabirat (24250)' => '24450',\n        'Saint-Martial-de-Valette (24300)' => '24451',\n        'Saint-Martial-de-Vitaterne (17500)' => '17363',\n        'Saint-Martial-Entraygues (19400)' => '19221',\n        'Saint-Martial-le-Mont (23150)' => '23214',\n        'Saint-Martial-le-Vieux (23100)' => '23215',\n        'Saint-Martial-sur-Isop (87330)' => '87163',\n        'Saint-Martial-sur-Né (17520)' => '17364',\n        'Saint-Martial-Viveyrol (24320)' => '24452',\n        'Saint-Martin-Château (23460)' => '23216',\n        'Saint-Martin-Curton (47700)' => '47254',\n        'Saint-Martin-d\\'Arberoue (64640)' => '64489',\n        'Saint-Martin-d\\'Arrossa (64780)' => '64490',\n        'Saint-Martin-d\\'Ary (17270)' => '17365',\n        'Saint-Martin-d\\'Oney (40090)' => '40274',\n        'Saint-Martin-de-Beauville (47270)' => '47255',\n        'Saint-Martin-de-Bernegoue (79230)' => '79273',\n        'Saint-Martin-de-Coux (17360)' => '17366',\n        'Saint-Martin-de-Fressengeas (24800)' => '24453',\n        'Saint-Martin-de-Gurson (24610)' => '24454',\n        'Saint-Martin-de-Hinx (40390)' => '40272',\n        'Saint-Martin-de-Juillers (17400)' => '17367',\n        'Saint-Martin-de-Jussac (87200)' => '87164',\n        'Saint-Martin-de-Laye (33910)' => '33442',\n        'Saint-Martin-de-Lerm (33540)' => '33443',\n        'Saint-Martin-de-Mâcon (79100)' => '79274',\n        'Saint-Martin-de-Ré (17410)' => '17369',\n        'Saint-Martin-de-Ribérac (24600)' => '24455',\n        'Saint-Martin-de-Saint-Maixent (79400)' => '79276',\n        'Saint-Martin-de-Sanzay (79290)' => '79277',\n        'Saint-Martin-de-Seignanx (40390)' => '40273',\n        'Saint-Martin-de-Sescas (33490)' => '33444',\n        'Saint-Martin-de-Villeréal (47210)' => '47256',\n        'Saint-Martin-des-Combes (24140)' => '24456',\n        'Saint-Martin-du-Bois (33910)' => '33445',\n        'Saint-Martin-du-Clocher (16700)' => '16335',\n        'Saint-Martin-du-Fouilloux (79420)' => '79278',\n        'Saint-Martin-du-Puy (33540)' => '33446',\n        'Saint-Martin-l\\'Ars (86350)' => '86234',\n        'Saint-Martin-l\\'Astier (24400)' => '24457',\n        'Saint-Martin-la-Méanne (19320)' => '19222',\n        'Saint-Martin-Lacaussade (33390)' => '33441',\n        'Saint-Martin-le-Mault (87360)' => '87165',\n        'Saint-Martin-le-Pin (24300)' => '24458',\n        'Saint-Martin-le-Vieux (87700)' => '87166',\n        'Saint-Martin-lès-Melle (79500)' => '79279',\n        'Saint-Martin-Petit (47180)' => '47257',\n        'Saint-Martin-Sainte-Catherine (23430)' => '23217',\n        'Saint-Martin-Sepert (19210)' => '19223',\n        'Saint-Martin-Terressus (87400)' => '87167',\n        'Saint-Mary (16260)' => '16336',\n        'Saint-Mathieu (87440)' => '87168',\n        'Saint-Maurice-de-Lestapel (47290)' => '47259',\n        'Saint-Maurice-des-Lions (16500)' => '16337',\n        'Saint-Maurice-la-Clouère (86160)' => '86235',\n        'Saint-Maurice-la-Souterraine (23300)' => '23219',\n        'Saint-Maurice-les-Brousses (87800)' => '87169',\n        'Saint-Maurice-près-Crocq (23260)' => '23218',\n        'Saint-Maurice-sur-Adour (40270)' => '40275',\n        'Saint-Maurin (47270)' => '47260',\n        'Saint-Maxire (79410)' => '79281',\n        'Saint-Méard (87130)' => '87170',\n        'Saint-Méard-de-Drône (24600)' => '24460',\n        'Saint-Méard-de-Gurçon (24610)' => '24461',\n        'Saint-Médard (16300)' => '16338',\n        'Saint-Médard (17500)' => '17372',\n        'Saint-Médard (64370)' => '64491',\n        'Saint-Médard (79370)' => '79282',\n        'Saint-Médard-d\\'Aunis (17220)' => '17373',\n        'Saint-Médard-d\\'Excideuil (24160)' => '24463',\n        'Saint-Médard-d\\'Eyrans (33650)' => '33448',\n        'Saint-Médard-de-Guizières (33230)' => '33447',\n        'Saint-Médard-de-Mussidan (24400)' => '24462',\n        'Saint-Médard-en-Jalles (33160)' => '33449',\n        'Saint-Médard-la-Rochette (23200)' => '23220',\n        'Saint-Même-les-Carrières (16720)' => '16340',\n        'Saint-Merd-de-Lapleau (19320)' => '19225',\n        'Saint-Merd-la-Breuille (23100)' => '23221',\n        'Saint-Merd-les-Oussines (19170)' => '19226',\n        'Saint-Mesmin (24270)' => '24464',\n        'Saint-Mexant (19330)' => '19227',\n        'Saint-Michel (16470)' => '16341',\n        'Saint-Michel (64220)' => '64492',\n        'Saint-Michel-de-Castelnau (33840)' => '33450',\n        'Saint-Michel-de-Double (24400)' => '24465',\n        'Saint-Michel-de-Fronsac (33126)' => '33451',\n        'Saint-Michel-de-Lapujade (33190)' => '33453',\n        'Saint-Michel-de-Montaigne (24230)' => '24466',\n        'Saint-Michel-de-Rieufret (33720)' => '33452',\n        'Saint-Michel-de-Veisse (23480)' => '23222',\n        'Saint-Michel-de-Villadeix (24380)' => '24468',\n        'Saint-Michel-Escalus (40550)' => '40276',\n        'Saint-Moreil (23400)' => '23223',\n        'Saint-Morillon (33650)' => '33454',\n        'Saint-Nazaire-sur-Charente (17780)' => '17375',\n        'Saint-Nexans (24520)' => '24472',\n        'Saint-Nicolas-de-la-Balerme (47220)' => '47262',\n        'Saint-Oradoux-de-Chirouze (23100)' => '23224',\n        'Saint-Oradoux-près-Crocq (23260)' => '23225',\n        'Saint-Ouen-d\\'Aunis (17230)' => '17376',\n        'Saint-Ouen-la-Thène (17490)' => '17377',\n        'Saint-Ouen-sur-Gartempe (87300)' => '87172',\n        'Saint-Palais (33820)' => '33456',\n        'Saint-Palais (64120)' => '64493',\n        'Saint-Palais-de-Négrignac (17210)' => '17378',\n        'Saint-Palais-de-Phiolin (17800)' => '17379',\n        'Saint-Palais-du-Né (16300)' => '16342',\n        'Saint-Palais-sur-Mer (17420)' => '17380',\n        'Saint-Pancrace (24530)' => '24474',\n        'Saint-Pandelon (40180)' => '40277',\n        'Saint-Pantaléon-de-Lapleau (19160)' => '19228',\n        'Saint-Pantaléon-de-Larche (19600)' => '19229',\n        'Saint-Pantaly-d\\'Ans (24640)' => '24475',\n        'Saint-Pantaly-d\\'Excideuil (24160)' => '24476',\n        'Saint-Pardon-de-Conques (33210)' => '33457',\n        'Saint-Pardoult (17400)' => '17381',\n        'Saint-Pardoux (79310)' => '79285',\n        'Saint-Pardoux (87250)' => '87173',\n        'Saint-Pardoux-Corbier (19210)' => '19230',\n        'Saint-Pardoux-d\\'Arnet (23260)' => '23226',\n        'Saint-Pardoux-de-Drône (24600)' => '24477',\n        'Saint-Pardoux-du-Breuil (47200)' => '47263',\n        'Saint-Pardoux-et-Vielvic (24170)' => '24478',\n        'Saint-Pardoux-Isaac (47800)' => '47264',\n        'Saint-Pardoux-l\\'Ortigier (19270)' => '19234',\n        'Saint-Pardoux-la-Croisille (19320)' => '19231',\n        'Saint-Pardoux-la-Rivière (24470)' => '24479',\n        'Saint-Pardoux-le-Neuf (19200)' => '19232',\n        'Saint-Pardoux-le-Neuf (23200)' => '23228',\n        'Saint-Pardoux-le-Vieux (19200)' => '19233',\n        'Saint-Pardoux-les-Cards (23150)' => '23229',\n        'Saint-Pardoux-Morterolles (23400)' => '23227',\n        'Saint-Pastour (47290)' => '47265',\n        'Saint-Paul (19150)' => '19235',\n        'Saint-Paul (33390)' => '33458',\n        'Saint-Paul (87260)' => '87174',\n        'Saint-Paul-de-Serre (24380)' => '24480',\n        'Saint-Paul-en-Born (40200)' => '40278',\n        'Saint-Paul-en-Gâtine (79240)' => '79286',\n        'Saint-Paul-la-Roche (24800)' => '24481',\n        'Saint-Paul-lès-Dax (40990)' => '40279',\n        'Saint-Paul-Lizonne (24320)' => '24482',\n        'Saint-Pé-de-Léren (64270)' => '64494',\n        'Saint-Pé-Saint-Simon (47170)' => '47266',\n        'Saint-Pée-sur-Nivelle (64310)' => '64495',\n        'Saint-Perdon (40090)' => '40280',\n        'Saint-Perdoux (24560)' => '24483',\n        'Saint-Pey-d\\'Armens (33330)' => '33459',\n        'Saint-Pey-de-Castets (33350)' => '33460',\n        'Saint-Philippe-d\\'Aiguille (33350)' => '33461',\n        'Saint-Philippe-du-Seignal (33220)' => '33462',\n        'Saint-Pierre-Bellevue (23460)' => '23232',\n        'Saint-Pierre-Chérignat (23430)' => '23230',\n        'Saint-Pierre-d\\'Amilly (17700)' => '17382',\n        'Saint-Pierre-d\\'Aurillac (33490)' => '33463',\n        'Saint-Pierre-d\\'Exideuil (86400)' => '86237',\n        'Saint-Pierre-d\\'Eyraud (24130)' => '24487',\n        'Saint-Pierre-d\\'Irube (64990)' => '64496',\n        'Saint-Pierre-d\\'Oléron (17310)' => '17385',\n        'Saint-Pierre-de-Bat (33760)' => '33464',\n        'Saint-Pierre-de-Buzet (47160)' => '47267',\n        'Saint-Pierre-de-Chignac (24330)' => '24484',\n        'Saint-Pierre-de-Clairac (47270)' => '47269',\n        'Saint-Pierre-de-Côle (24800)' => '24485',\n        'Saint-Pierre-de-Frugie (24450)' => '24486',\n        'Saint-Pierre-de-Fursac (23290)' => '23231',\n        'Saint-Pierre-de-Juillers (17400)' => '17383',\n        'Saint-Pierre-de-l\\'Isle (17330)' => '17384',\n        'Saint-Pierre-de-Maillé (86260)' => '86236',\n        'Saint-Pierre-de-Mons (33210)' => '33465',\n        'Saint-Pierre-des-Échaubrognes (79700)' => '79289',\n        'Saint-Pierre-du-Mont (40280)' => '40281',\n        'Saint-Pierre-du-Palais (17270)' => '17386',\n        'Saint-Pierre-le-Bost (23600)' => '23233',\n        'Saint-Pierre-sur-Dropt (47120)' => '47271',\n        'Saint-Pompain (79160)' => '79290',\n        'Saint-Pompont (24170)' => '24488',\n        'Saint-Porchaire (17250)' => '17387',\n        'Saint-Preuil (16130)' => '16343',\n        'Saint-Priest (23110)' => '23234',\n        'Saint-Priest-de-Gimel (19800)' => '19236',\n        'Saint-Priest-la-Feuille (23300)' => '23235',\n        'Saint-Priest-la-Plaine (23240)' => '23236',\n        'Saint-Priest-les-Fougères (24450)' => '24489',\n        'Saint-Priest-Ligoure (87800)' => '87176',\n        'Saint-Priest-Palus (23400)' => '23237',\n        'Saint-Priest-sous-Aixe (87700)' => '87177',\n        'Saint-Priest-Taurion (87480)' => '87178',\n        'Saint-Privat (19220)' => '19237',\n        'Saint-Privat-des-Prés (24410)' => '24490',\n        'Saint-Projet-Saint-Constant (16110)' => '16344',\n        'Saint-Quantin-de-Rançanne (17800)' => '17388',\n        'Saint-Quentin-de-Baron (33750)' => '33466',\n        'Saint-Quentin-de-Caplong (33220)' => '33467',\n        'Saint-Quentin-de-Chalais (16210)' => '16346',\n        'Saint-Quentin-du-Dropt (47330)' => '47272',\n        'Saint-Quentin-la-Chabanne (23500)' => '23238',\n        'Saint-Quentin-sur-Charente (16150)' => '16345',\n        'Saint-Rabier (24210)' => '24491',\n        'Saint-Raphaël (24160)' => '24493',\n        'Saint-Rémy (19290)' => '19238',\n        'Saint-Rémy (24700)' => '24494',\n        'Saint-Rémy (79410)' => '79293',\n        'Saint-Rémy-sur-Creuse (86220)' => '86241',\n        'Saint-Robert (19310)' => '19239',\n        'Saint-Robert (47340)' => '47273',\n        'Saint-Rogatien (17220)' => '17391',\n        'Saint-Romain (16210)' => '16347',\n        'Saint-Romain (86250)' => '86242',\n        'Saint-Romain-de-Benet (17600)' => '17393',\n        'Saint-Romain-de-Monpazier (24540)' => '24495',\n        'Saint-Romain-et-Saint-Clément (24800)' => '24496',\n        'Saint-Romain-la-Virvée (33240)' => '33470',\n        'Saint-Romain-le-Noble (47270)' => '47274',\n        'Saint-Romain-sur-Gironde (17240)' => '17392',\n        'Saint-Romans-des-Champs (79230)' => '79294',\n        'Saint-Romans-lès-Melle (79500)' => '79295',\n        'Saint-Salvadour (19700)' => '19240',\n        'Saint-Salvy (47360)' => '47275',\n        'Saint-Sardos (47360)' => '47276',\n        'Saint-Saturnin (16290)' => '16348',\n        'Saint-Saturnin-du-Bois (17700)' => '17394',\n        'Saint-Saud-Lacoussière (24470)' => '24498',\n        'Saint-Sauvant (17610)' => '17395',\n        'Saint-Sauvant (86600)' => '86244',\n        'Saint-Sauveur (24520)' => '24499',\n        'Saint-Sauveur (33250)' => '33471',\n        'Saint-Sauveur-d\\'Aunis (17540)' => '17396',\n        'Saint-Sauveur-de-Meilhan (47180)' => '47277',\n        'Saint-Sauveur-de-Puynormand (33660)' => '33472',\n        'Saint-Sauveur-Lalande (24700)' => '24500',\n        'Saint-Savin (33920)' => '33473',\n        'Saint-Savin (86310)' => '86246',\n        'Saint-Savinien (17350)' => '17397',\n        'Saint-Saviol (86400)' => '86247',\n        'Saint-Sébastien (23160)' => '23239',\n        'Saint-Secondin (86350)' => '86248',\n        'Saint-Selve (33650)' => '33474',\n        'Saint-Sernin (47120)' => '47278',\n        'Saint-Setiers (19290)' => '19241',\n        'Saint-Seurin-de-Bourg (33710)' => '33475',\n        'Saint-Seurin-de-Cadourne (33180)' => '33476',\n        'Saint-Seurin-de-Cursac (33390)' => '33477',\n        'Saint-Seurin-de-Palenne (17800)' => '17398',\n        'Saint-Seurin-de-Prats (24230)' => '24501',\n        'Saint-Seurin-sur-l\\'Isle (33660)' => '33478',\n        'Saint-Sève (33190)' => '33479',\n        'Saint-Sever (40500)' => '40282',\n        'Saint-Sever-de-Saintonge (17800)' => '17400',\n        'Saint-Séverin (16390)' => '16350',\n        'Saint-Séverin-d\\'Estissac (24190)' => '24502',\n        'Saint-Séverin-sur-Boutonne (17330)' => '17401',\n        'Saint-Sigismond-de-Clermont (17240)' => '17402',\n        'Saint-Silvain-Bas-le-Roc (23600)' => '23240',\n        'Saint-Silvain-Bellegarde (23190)' => '23241',\n        'Saint-Silvain-Montaigut (23320)' => '23242',\n        'Saint-Silvain-sous-Toulx (23140)' => '23243',\n        'Saint-Simeux (16120)' => '16351',\n        'Saint-Simon (16120)' => '16352',\n        'Saint-Simon-de-Bordes (17500)' => '17403',\n        'Saint-Simon-de-Pellouaille (17260)' => '17404',\n        'Saint-Sixte (47220)' => '47279',\n        'Saint-Solve (19130)' => '19242',\n        'Saint-Sorlin-de-Conac (17150)' => '17405',\n        'Saint-Sornin (16220)' => '16353',\n        'Saint-Sornin (17600)' => '17406',\n        'Saint-Sornin-la-Marche (87210)' => '87179',\n        'Saint-Sornin-Lavolps (19230)' => '19243',\n        'Saint-Sornin-Leulac (87290)' => '87180',\n        'Saint-Sulpice-d\\'Arnoult (17250)' => '17408',\n        'Saint-Sulpice-d\\'Excideuil (24800)' => '24505',\n        'Saint-Sulpice-de-Cognac (16370)' => '16355',\n        'Saint-Sulpice-de-Faleyrens (33330)' => '33480',\n        'Saint-Sulpice-de-Guilleragues (33580)' => '33481',\n        'Saint-Sulpice-de-Mareuil (24340)' => '24503',\n        'Saint-Sulpice-de-Pommiers (33540)' => '33482',\n        'Saint-Sulpice-de-Roumagnac (24600)' => '24504',\n        'Saint-Sulpice-de-Royan (17200)' => '17409',\n        'Saint-Sulpice-de-Ruffec (16460)' => '16356',\n        'Saint-Sulpice-et-Cameyrac (33450)' => '33483',\n        'Saint-Sulpice-Laurière (87370)' => '87181',\n        'Saint-Sulpice-le-Dunois (23800)' => '23244',\n        'Saint-Sulpice-le-Guérétois (23000)' => '23245',\n        'Saint-Sulpice-les-Bois (19250)' => '19244',\n        'Saint-Sulpice-les-Champs (23480)' => '23246',\n        'Saint-Sulpice-les-Feuilles (87160)' => '87182',\n        'Saint-Sylvain (19380)' => '19245',\n        'Saint-Sylvestre (87240)' => '87183',\n        'Saint-Sylvestre-sur-Lot (47140)' => '47280',\n        'Saint-Symphorien (33113)' => '33484',\n        'Saint-Symphorien (79270)' => '79298',\n        'Saint-Symphorien-sur-Couze (87140)' => '87184',\n        'Saint-Thomas-de-Conac (17150)' => '17410',\n        'Saint-Trojan (33710)' => '33486',\n        'Saint-Trojan-les-Bains (17370)' => '17411',\n        'Saint-Urcisse (47270)' => '47281',\n        'Saint-Vaize (17100)' => '17412',\n        'Saint-Vallier (16480)' => '16357',\n        'Saint-Varent (79330)' => '79299',\n        'Saint-Vaury (23320)' => '23247',\n        'Saint-Viance (19240)' => '19246',\n        'Saint-Victor (24350)' => '24508',\n        'Saint-Victor-en-Marche (23000)' => '23248',\n        'Saint-Victour (19200)' => '19247',\n        'Saint-Victurnien (87420)' => '87185',\n        'Saint-Vincent (64800)' => '64498',\n        'Saint-Vincent-de-Connezac (24190)' => '24509',\n        'Saint-Vincent-de-Cosse (24220)' => '24510',\n        'Saint-Vincent-de-Lamontjoie (47310)' => '47282',\n        'Saint-Vincent-de-Paul (33440)' => '33487',\n        'Saint-Vincent-de-Paul (40990)' => '40283',\n        'Saint-Vincent-de-Pertignas (33420)' => '33488',\n        'Saint-Vincent-de-Tyrosse (40230)' => '40284',\n        'Saint-Vincent-Jalmoutiers (24410)' => '24511',\n        'Saint-Vincent-la-Châtre (79500)' => '79301',\n        'Saint-Vincent-le-Paluel (24200)' => '24512',\n        'Saint-Vincent-sur-l\\'Isle (24420)' => '24513',\n        'Saint-Vite (47500)' => '47283',\n        'Saint-Vitte-sur-Briance (87380)' => '87186',\n        'Saint-Vivien (17220)' => '17413',\n        'Saint-Vivien (24230)' => '24514',\n        'Saint-Vivien-de-Blaye (33920)' => '33489',\n        'Saint-Vivien-de-Médoc (33590)' => '33490',\n        'Saint-Vivien-de-Monségur (33580)' => '33491',\n        'Saint-Xandre (17138)' => '17414',\n        'Saint-Yaguen (40400)' => '40285',\n        'Saint-Ybard (19140)' => '19248',\n        'Saint-Yrieix-la-Montagne (23460)' => '23249',\n        'Saint-Yrieix-la-Perche (87500)' => '87187',\n        'Saint-Yrieix-le-Déjalat (19300)' => '19249',\n        'Saint-Yrieix-les-Bois (23150)' => '23250',\n        'Saint-Yrieix-sous-Aixe (87700)' => '87188',\n        'Saint-Yrieix-sur-Charente (16710)' => '16358',\n        'Saint-Yzan-de-Soudiac (33920)' => '33492',\n        'Saint-Yzans-de-Médoc (33340)' => '33493',\n        'Sainte-Alvère-Saint-Laurent Les Bâtons (24510)' => '24362',\n        'Sainte-Anne-Saint-Priest (87120)' => '87134',\n        'Sainte-Bazeille (47180)' => '47233',\n        'Sainte-Blandine (79370)' => '79240',\n        'Sainte-Colombe (16230)' => '16309',\n        'Sainte-Colombe (17210)' => '17319',\n        'Sainte-Colombe (33350)' => '33390',\n        'Sainte-Colombe (40700)' => '40252',\n        'Sainte-Colombe-de-Duras (47120)' => '47236',\n        'Sainte-Colombe-de-Villeneuve (47300)' => '47237',\n        'Sainte-Colombe-en-Bruilhois (47310)' => '47238',\n        'Sainte-Colome (64260)' => '64473',\n        'Sainte-Croix (24440)' => '24393',\n        'Sainte-Croix-de-Mareuil (24340)' => '24394',\n        'Sainte-Croix-du-Mont (33410)' => '33392',\n        'Sainte-Eanne (79800)' => '79246',\n        'Sainte-Engrâce (64560)' => '64475',\n        'Sainte-Eulalie (33560)' => '33397',\n        'Sainte-Eulalie-d\\'Ans (24640)' => '24401',\n        'Sainte-Eulalie-d\\'Eymet (24500)' => '24402',\n        'Sainte-Eulalie-en-Born (40200)' => '40257',\n        'Sainte-Féréole (19270)' => '19202',\n        'Sainte-Feyre (23000)' => '23193',\n        'Sainte-Feyre-la-Montagne (23500)' => '23194',\n        'Sainte-Florence (33350)' => '33401',\n        'Sainte-Fortunade (19490)' => '19203',\n        'Sainte-Foy (40190)' => '40258',\n        'Sainte-Foy-de-Belvès (24170)' => '24406',\n        'Sainte-Foy-de-Longas (24510)' => '24407',\n        'Sainte-Foy-la-Grande (33220)' => '33402',\n        'Sainte-Foy-la-Longue (33490)' => '33403',\n        'Sainte-Gemme (17250)' => '17330',\n        'Sainte-Gemme (33580)' => '33404',\n        'Sainte-Gemme (79330)' => '79250',\n        'Sainte-Gemme-Martaillac (47250)' => '47244',\n        'Sainte-Hélène (33480)' => '33417',\n        'Sainte-Innocence (24500)' => '24423',\n        'Sainte-Lheurine (17520)' => '17355',\n        'Sainte-Livrade-sur-Lot (47110)' => '47252',\n        'Sainte-Marie-de-Chignac (24330)' => '24447',\n        'Sainte-Marie-de-Gosse (40390)' => '40271',\n        'Sainte-Marie-de-Ré (17740)' => '17360',\n        'Sainte-Marie-de-Vaux (87420)' => '87162',\n        'Sainte-Marie-Lapanouze (19160)' => '19219',\n        'Sainte-Marthe (47430)' => '47253',\n        'Sainte-Maure-de-Peyriac (47170)' => '47258',\n        'Sainte-Même (17770)' => '17374',\n        'Sainte-Mondane (24370)' => '24470',\n        'Sainte-Nathalène (24200)' => '24471',\n        'Sainte-Néomaye (79260)' => '79283',\n        'Sainte-Orse (24210)' => '24473',\n        'Sainte-Ouenne (79220)' => '79284',\n        'Sainte-Radegonde (17250)' => '17389',\n        'Sainte-Radegonde (24560)' => '24492',\n        'Sainte-Radegonde (33350)' => '33468',\n        'Sainte-Radegonde (79100)' => '79292',\n        'Sainte-Radégonde (86300)' => '86239',\n        'Sainte-Ramée (17240)' => '17390',\n        'Sainte-Sévère (16200)' => '16349',\n        'Sainte-Soline (79120)' => '79297',\n        'Sainte-Souline (16480)' => '16354',\n        'Sainte-Soulle (17220)' => '17407',\n        'Sainte-Terre (33350)' => '33485',\n        'Sainte-Trie (24160)' => '24507',\n        'Sainte-Verge (79100)' => '79300',\n        'Saintes (17100)' => '17415',\n        'Saires (86420)' => '86249',\n        'Saivres (79400)' => '79302',\n        'Saix (86120)' => '86250',\n        'Salagnac (24160)' => '24515',\n        'Salaunes (33160)' => '33494',\n        'Saleignes (17510)' => '17416',\n        'Salies-de-Béarn (64270)' => '64499',\n        'Salignac-de-Mirambeau (17130)' => '17417',\n        'Salignac-Eyvigues (24590)' => '24516',\n        'Salignac-sur-Charente (17800)' => '17418',\n        'Salleboeuf (33370)' => '33496',\n        'Salles (33770)' => '33498',\n        'Salles (47150)' => '47284',\n        'Salles (79800)' => '79303',\n        'Salles-d\\'Angles (16130)' => '16359',\n        'Salles-de-Barbezieux (16300)' => '16360',\n        'Salles-de-Belvès (24170)' => '24517',\n        'Salles-de-Villefagnan (16700)' => '16361',\n        'Salles-Lavalette (16190)' => '16362',\n        'Salles-Mongiscard (64300)' => '64500',\n        'Salles-sur-Mer (17220)' => '17420',\n        'Sallespisse (64300)' => '64501',\n        'Salon (24380)' => '24518',\n        'Salon-la-Tour (19510)' => '19250',\n        'Samadet (40320)' => '40286',\n        'Samazan (47250)' => '47285',\n        'Sames (64520)' => '64502',\n        'Sammarçolles (86200)' => '86252',\n        'Samonac (33710)' => '33500',\n        'Samsons-Lion (64350)' => '64503',\n        'Sanguinet (40460)' => '40287',\n        'Sannat (23110)' => '23167',\n        'Sansais (79270)' => '79304',\n        'Sanxay (86600)' => '86253',\n        'Sarbazan (40120)' => '40288',\n        'Sardent (23250)' => '23168',\n        'Sare (64310)' => '64504',\n        'Sarlande (24270)' => '24519',\n        'Sarlat-la-Canéda (24200)' => '24520',\n        'Sarliac-sur-l\\'Isle (24420)' => '24521',\n        'Sarpourenx (64300)' => '64505',\n        'Sarran (19800)' => '19251',\n        'Sarrance (64490)' => '64506',\n        'Sarrazac (24800)' => '24522',\n        'Sarraziet (40500)' => '40289',\n        'Sarron (40800)' => '40290',\n        'Sarroux (19110)' => '19252',\n        'Saubion (40230)' => '40291',\n        'Saubole (64420)' => '64507',\n        'Saubrigues (40230)' => '40292',\n        'Saubusse (40180)' => '40293',\n        'Saucats (33650)' => '33501',\n        'Saucède (64400)' => '64508',\n        'Saugnac-et-Cambran (40180)' => '40294',\n        'Saugnacq-et-Muret (40410)' => '40295',\n        'Saugon (33920)' => '33502',\n        'Sauguis-Saint-Étienne (64470)' => '64509',\n        'Saujon (17600)' => '17421',\n        'Saulgé (86500)' => '86254',\n        'Saulgond (16420)' => '16363',\n        'Sault-de-Navailles (64300)' => '64510',\n        'Sauméjan (47420)' => '47286',\n        'Saumont (47600)' => '47287',\n        'Saumos (33680)' => '33503',\n        'Saurais (79200)' => '79306',\n        'Saussignac (24240)' => '24523',\n        'Sauternes (33210)' => '33504',\n        'Sauvagnac (16310)' => '16364',\n        'Sauvagnas (47340)' => '47288',\n        'Sauvagnon (64230)' => '64511',\n        'Sauvelade (64150)' => '64512',\n        'Sauveterre-de-Béarn (64390)' => '64513',\n        'Sauveterre-de-Guyenne (33540)' => '33506',\n        'Sauveterre-la-Lémance (47500)' => '47292',\n        'Sauveterre-Saint-Denis (47220)' => '47293',\n        'Sauviac (33430)' => '33507',\n        'Sauviat-sur-Vige (87400)' => '87190',\n        'Sauvignac (16480)' => '16365',\n        'Sauzé-Vaussais (79190)' => '79307',\n        'Savennes (23000)' => '23170',\n        'Savignac (33124)' => '33508',\n        'Savignac-de-Duras (47120)' => '47294',\n        'Savignac-de-l\\'Isle (33910)' => '33509',\n        'Savignac-de-Miremont (24260)' => '24524',\n        'Savignac-de-Nontron (24300)' => '24525',\n        'Savignac-Lédrier (24270)' => '24526',\n        'Savignac-les-Églises (24420)' => '24527',\n        'Savignac-sur-Leyze (47150)' => '47295',\n        'Savigné (86400)' => '86255',\n        'Savigny-Lévescault (86800)' => '86256',\n        'Savigny-sous-Faye (86140)' => '86257',\n        'Sceau-Saint-Angel (24300)' => '24528',\n        'Sciecq (79000)' => '79308',\n        'Scillé (79240)' => '79309',\n        'Scorbé-Clairvaux (86140)' => '86258',\n        'Séby (64410)' => '64514',\n        'Secondigné-sur-Belle (79170)' => '79310',\n        'Secondigny (79130)' => '79311',\n        'Sedze-Maubecq (64160)' => '64515',\n        'Sedzère (64160)' => '64516',\n        'Ségalas (47410)' => '47296',\n        'Segonzac (16130)' => '16366',\n        'Segonzac (19310)' => '19253',\n        'Segonzac (24600)' => '24529',\n        'Ségur-le-Château (19230)' => '19254',\n        'Seigné (17510)' => '17422',\n        'Seignosse (40510)' => '40296',\n        'Seilhac (19700)' => '19255',\n        'Séligné (79170)' => '79312',\n        'Sembas (47360)' => '47297',\n        'Séméacq-Blachon (64350)' => '64517',\n        'Semens (33490)' => '33510',\n        'Semillac (17150)' => '17423',\n        'Semoussac (17150)' => '17424',\n        'Semussac (17120)' => '17425',\n        'Sencenac-Puy-de-Fourches (24310)' => '24530',\n        'Sendets (33690)' => '33511',\n        'Sendets (64320)' => '64518',\n        'Sénestis (47430)' => '47298',\n        'Senillé-Saint-Sauveur (86100)' => '86245',\n        'Sepvret (79120)' => '79313',\n        'Sérandon (19160)' => '19256',\n        'Séreilhac (87620)' => '87191',\n        'Sergeac (24290)' => '24531',\n        'Sérignac-Péboudou (47410)' => '47299',\n        'Sérignac-sur-Garonne (47310)' => '47300',\n        'Sérigny (86230)' => '86260',\n        'Sérilhac (19190)' => '19257',\n        'Sermur (23700)' => '23171',\n        'Séron (65320)' => '65422',\n        'Serres-Castet (64121)' => '64519',\n        'Serres-et-Montguyard (24500)' => '24532',\n        'Serres-Gaston (40700)' => '40298',\n        'Serres-Morlaàs (64160)' => '64520',\n        'Serres-Sainte-Marie (64170)' => '64521',\n        'Serreslous-et-Arribans (40700)' => '40299',\n        'Sers (16410)' => '16368',\n        'Servanches (24410)' => '24533',\n        'Servières-le-Château (19220)' => '19258',\n        'Sévignacq (64160)' => '64523',\n        'Sévignacq-Meyracq (64260)' => '64522',\n        'Sèvres-Anxaumont (86800)' => '86261',\n        'Sexcles (19430)' => '19259',\n        'Seyches (47350)' => '47301',\n        'Seyresse (40180)' => '40300',\n        'Siecq (17490)' => '17427',\n        'Siest (40180)' => '40301',\n        'Sigalens (33690)' => '33512',\n        'Sigogne (16200)' => '16369',\n        'Sigoulès (24240)' => '24534',\n        'Sillars (86320)' => '86262',\n        'Sillas (33690)' => '33513',\n        'Simacourbe (64350)' => '64524',\n        'Simeyrols (24370)' => '24535',\n        'Sindères (40110)' => '40302',\n        'Singleyrac (24500)' => '24536',\n        'Sioniac (19120)' => '19260',\n        'Siorac-de-Ribérac (24600)' => '24537',\n        'Siorac-en-Périgord (24170)' => '24538',\n        'Sireuil (16440)' => '16370',\n        'Siros (64230)' => '64525',\n        'Smarves (86240)' => '86263',\n        'Solférino (40210)' => '40303',\n        'Solignac (87110)' => '87192',\n        'Sommières-du-Clain (86160)' => '86264',\n        'Sompt (79110)' => '79314',\n        'Sonnac (17160)' => '17428',\n        'Soorts-Hossegor (40150)' => '40304',\n        'Sorbets (40320)' => '40305',\n        'Sorde-l\\'Abbaye (40300)' => '40306',\n        'Sore (40430)' => '40307',\n        'Sorges et Ligueux en Périgord (24420)' => '24540',\n        'Sornac (19290)' => '19261',\n        'Sort-en-Chalosse (40180)' => '40308',\n        'Sos (47170)' => '47302',\n        'Sossais (86230)' => '86265',\n        'Soubise (17780)' => '17429',\n        'Soubran (17150)' => '17430',\n        'Soubrebost (23250)' => '23173',\n        'Soudaine-Lavinadière (19370)' => '19262',\n        'Soudan (79800)' => '79316',\n        'Soudat (24360)' => '24541',\n        'Soudeilles (19300)' => '19263',\n        'Souffrignac (16380)' => '16372',\n        'Soulac-sur-Mer (33780)' => '33514',\n        'Soulaures (24540)' => '24542',\n        'Soulignac (33760)' => '33515',\n        'Soulignonne (17250)' => '17431',\n        'Soumans (23600)' => '23174',\n        'Soumensac (47120)' => '47303',\n        'Souméras (17130)' => '17432',\n        'Soumoulou (64420)' => '64526',\n        'Souprosse (40250)' => '40309',\n        'Souraïde (64250)' => '64527',\n        'Soursac (19550)' => '19264',\n        'Sourzac (24400)' => '24543',\n        'Sous-Parsat (23150)' => '23175',\n        'Sousmoulins (17130)' => '17433',\n        'Soussac (33790)' => '33516',\n        'Soussans (33460)' => '33517',\n        'Soustons (40140)' => '40310',\n        'Soutiers (79310)' => '79318',\n        'Souvigné (16240)' => '16373',\n        'Souvigné (79800)' => '79319',\n        'Soyaux (16800)' => '16374',\n        'Suaux (16260)' => '16375',\n        'Suhescun (64780)' => '64528',\n        'Surdoux (87130)' => '87193',\n        'Surgères (17700)' => '17434',\n        'Surin (79220)' => '79320',\n        'Surin (86250)' => '86266',\n        'Suris (16270)' => '16376',\n        'Sus (64190)' => '64529',\n        'Susmiou (64190)' => '64530',\n        'Sussac (87130)' => '87194',\n        'Tabaille-Usquain (64190)' => '64531',\n        'Tabanac (33550)' => '33518',\n        'Tadousse-Ussau (64330)' => '64532',\n        'Taillant (17350)' => '17435',\n        'Taillebourg (17350)' => '17436',\n        'Taillebourg (47200)' => '47304',\n        'Taillecavat (33580)' => '33520',\n        'Taizé (79100)' => '79321',\n        'Taizé-Aizie (16700)' => '16378',\n        'Talais (33590)' => '33521',\n        'Talence (33400)' => '33522',\n        'Taller (40260)' => '40311',\n        'Talmont-sur-Gironde (17120)' => '17437',\n        'Tamniès (24620)' => '24544',\n        'Tanzac (17260)' => '17438',\n        'Taponnat-Fleurignac (16110)' => '16379',\n        'Tardes (23170)' => '23251',\n        'Tardets-Sorholus (64470)' => '64533',\n        'Targon (33760)' => '33523',\n        'Tarnac (19170)' => '19265',\n        'Tarnès (33240)' => '33524',\n        'Tarnos (40220)' => '40312',\n        'Taron-Sadirac-Viellenave (64330)' => '64534',\n        'Tarsacq (64360)' => '64535',\n        'Tartas (40400)' => '40313',\n        'Taugon (17170)' => '17439',\n        'Tauriac (33710)' => '33525',\n        'Tayac (33570)' => '33526',\n        'Tayrac (47270)' => '47305',\n        'Teillots (24390)' => '24545',\n        'Temple-Laguyon (24390)' => '24546',\n        'Tercé (86800)' => '86268',\n        'Tercillat (23350)' => '23252',\n        'Tercis-les-Bains (40180)' => '40314',\n        'Ternant (17400)' => '17440',\n        'Ternay (86120)' => '86269',\n        'Terrasson-Lavilledieu (24120)' => '24547',\n        'Tersannes (87360)' => '87195',\n        'Tesson (17460)' => '17441',\n        'Tessonnière (79600)' => '79325',\n        'Téthieu (40990)' => '40315',\n        'Teuillac (33710)' => '33530',\n        'Teyjat (24300)' => '24548',\n        'Thaims (17120)' => '17442',\n        'Thairé (17290)' => '17443',\n        'Thalamy (19200)' => '19266',\n        'Thauron (23250)' => '23253',\n        'Theil-Rabier (16240)' => '16381',\n        'Thénac (17460)' => '17444',\n        'Thénac (24240)' => '24549',\n        'Thénezay (79390)' => '79326',\n        'Thenon (24210)' => '24550',\n        'Thézac (17600)' => '17445',\n        'Thézac (47370)' => '47307',\n        'Thèze (64450)' => '64536',\n        'Thiat (87320)' => '87196',\n        'Thiviers (24800)' => '24551',\n        'Thollet (86290)' => '86270',\n        'Thonac (24290)' => '24552',\n        'Thorigné (79370)' => '79327',\n        'Thorigny-sur-le-Mignon (79360)' => '79328',\n        'Thors (17160)' => '17446',\n        'Thouars (79100)' => '79329',\n        'Thouars-sur-Garonne (47230)' => '47308',\n        'Thouron (87140)' => '87197',\n        'Thurageau (86110)' => '86271',\n        'Thuré (86540)' => '86272',\n        'Tilh (40360)' => '40316',\n        'Tillou (79110)' => '79330',\n        'Tizac-de-Curton (33420)' => '33531',\n        'Tizac-de-Lapouyade (33620)' => '33532',\n        'Tocane-Saint-Apre (24350)' => '24553',\n        'Tombeboeuf (47380)' => '47309',\n        'Tonnay-Boutonne (17380)' => '17448',\n        'Tonnay-Charente (17430)' => '17449',\n        'Tonneins (47400)' => '47310',\n        'Torsac (16410)' => '16382',\n        'Torxé (17380)' => '17450',\n        'Tosse (40230)' => '40317',\n        'Toulenne (33210)' => '33533',\n        'Toulouzette (40250)' => '40318',\n        'Toulx-Sainte-Croix (23600)' => '23254',\n        'Tourliac (47210)' => '47311',\n        'Tournon-d\\'Agenais (47370)' => '47312',\n        'Tourriers (16560)' => '16383',\n        'Tourtenay (79100)' => '79331',\n        'Tourtoirac (24390)' => '24555',\n        'Tourtrès (47380)' => '47313',\n        'Touvérac (16360)' => '16384',\n        'Touvre (16600)' => '16385',\n        'Touzac (16120)' => '16386',\n        'Toy-Viam (19170)' => '19268',\n        'Trayes (79240)' => '79332',\n        'Treignac (19260)' => '19269',\n        'Trélissac (24750)' => '24557',\n        'Trémolat (24510)' => '24558',\n        'Trémons (47140)' => '47314',\n        'Trensacq (40630)' => '40319',\n        'Trentels (47140)' => '47315',\n        'Tresses (33370)' => '33535',\n        'Triac-Lautrait (16200)' => '16387',\n        'Trizay (17250)' => '17453',\n        'Troche (19230)' => '19270',\n        'Trois-Fonds (23230)' => '23255',\n        'Trois-Palis (16730)' => '16388',\n        'Trois-Villes (64470)' => '64537',\n        'Tudeils (19120)' => '19271',\n        'Tugéras-Saint-Maurice (17130)' => '17454',\n        'Tulle (19000)' => '19272',\n        'Turenne (19500)' => '19273',\n        'Turgon (16350)' => '16389',\n        'Tursac (24620)' => '24559',\n        'Tusson (16140)' => '16390',\n        'Tuzie (16700)' => '16391',\n        'Uchacq-et-Parentis (40090)' => '40320',\n        'Uhart-Cize (64220)' => '64538',\n        'Uhart-Mixe (64120)' => '64539',\n        'Urcuit (64990)' => '64540',\n        'Urdès (64370)' => '64541',\n        'Urdos (64490)' => '64542',\n        'Urepel (64430)' => '64543',\n        'Urgons (40320)' => '40321',\n        'Urost (64160)' => '64544',\n        'Urrugne (64122)' => '64545',\n        'Urt (64240)' => '64546',\n        'Urval (24480)' => '24560',\n        'Ussac (19270)' => '19274',\n        'Usseau (79210)' => '79334',\n        'Usseau (86230)' => '86275',\n        'Ussel (19200)' => '19275',\n        'Usson-du-Poitou (86350)' => '86276',\n        'Ustaritz (64480)' => '64547',\n        'Uza (40170)' => '40322',\n        'Uzan (64370)' => '64548',\n        'Uzein (64230)' => '64549',\n        'Uzerche (19140)' => '19276',\n        'Uzeste (33730)' => '33537',\n        'Uzos (64110)' => '64550',\n        'Val d\\'Issoire (87330)' => '87097',\n        'Val de Virvée (33240)' => '33018',\n        'Val des Vignes (16250)' => '16175',\n        'Valdivienne (86300)' => '86233',\n        'Valence (16460)' => '16392',\n        'Valeuil (24310)' => '24561',\n        'Valeyrac (33340)' => '33538',\n        'Valiergues (19200)' => '19277',\n        'Vallans (79270)' => '79335',\n        'Vallereuil (24190)' => '24562',\n        'Vallière (23120)' => '23257',\n        'Valojoulx (24290)' => '24563',\n        'Vançais (79120)' => '79336',\n        'Vandré (17700)' => '17457',\n        'Vanxains (24600)' => '24564',\n        'Vanzac (17500)' => '17458',\n        'Vanzay (79120)' => '79338',\n        'Varaignes (24360)' => '24565',\n        'Varaize (17400)' => '17459',\n        'Vareilles (23300)' => '23258',\n        'Varennes (24150)' => '24566',\n        'Varennes (86110)' => '86277',\n        'Varès (47400)' => '47316',\n        'Varetz (19240)' => '19278',\n        'Vars (16330)' => '16393',\n        'Vars-sur-Roseix (19130)' => '19279',\n        'Varzay (17460)' => '17460',\n        'Vasles (79340)' => '79339',\n        'Vaulry (87140)' => '87198',\n        'Vaunac (24800)' => '24567',\n        'Vausseroux (79420)' => '79340',\n        'Vautebis (79420)' => '79341',\n        'Vaux (86700)' => '86278',\n        'Vaux-Lavalette (16320)' => '16394',\n        'Vaux-Rouillac (16170)' => '16395',\n        'Vaux-sur-Mer (17640)' => '17461',\n        'Vaux-sur-Vienne (86220)' => '86279',\n        'Vayres (33870)' => '33539',\n        'Vayres (87600)' => '87199',\n        'Végennes (19120)' => '19280',\n        'Veix (19260)' => '19281',\n        'Vélines (24230)' => '24568',\n        'Vellèches (86230)' => '86280',\n        'Vendays-Montalivet (33930)' => '33540',\n        'Vendeuvre-du-Poitou (86380)' => '86281',\n        'Vendoire (24320)' => '24569',\n        'Vénérand (17100)' => '17462',\n        'Vensac (33590)' => '33541',\n        'Ventouse (16460)' => '16396',\n        'Vérac (33240)' => '33542',\n        'Verdelais (33490)' => '33543',\n        'Verdets (64400)' => '64551',\n        'Verdille (16140)' => '16397',\n        'Verdon (24520)' => '24570',\n        'Vergeroux (17300)' => '17463',\n        'Vergné (17330)' => '17464',\n        'Vergt (24380)' => '24571',\n        'Vergt-de-Biron (24540)' => '24572',\n        'Vérines (17540)' => '17466',\n        'Verneiges (23170)' => '23259',\n        'Verneuil (16310)' => '16398',\n        'Verneuil-Moustiers (87360)' => '87200',\n        'Verneuil-sur-Vienne (87430)' => '87201',\n        'Vernon (86340)' => '86284',\n        'Vernoux-en-Gâtine (79240)' => '79342',\n        'Vernoux-sur-Boutonne (79170)' => '79343',\n        'Verrières (16130)' => '16399',\n        'Verrières (86410)' => '86285',\n        'Verrue (86420)' => '86286',\n        'Verruyes (79310)' => '79345',\n        'Vert (40420)' => '40323',\n        'Verteillac (24320)' => '24573',\n        'Verteuil-d\\'Agenais (47260)' => '47317',\n        'Verteuil-sur-Charente (16510)' => '16400',\n        'Vertheuil (33180)' => '33545',\n        'Vervant (16330)' => '16401',\n        'Vervant (17400)' => '17467',\n        'Veyrac (87520)' => '87202',\n        'Veyrières (19200)' => '19283',\n        'Veyrignac (24370)' => '24574',\n        'Veyrines-de-Domme (24250)' => '24575',\n        'Veyrines-de-Vergt (24380)' => '24576',\n        'Vézac (24220)' => '24577',\n        'Vézières (86120)' => '86287',\n        'Vialer (64330)' => '64552',\n        'Viam (19170)' => '19284',\n        'Vianne (47230)' => '47318',\n        'Vibrac (16120)' => '16402',\n        'Vibrac (17130)' => '17468',\n        'Vicq-d\\'Auribat (40380)' => '40324',\n        'Vicq-sur-Breuilh (87260)' => '87203',\n        'Vicq-sur-Gartempe (86260)' => '86288',\n        'Vidaillat (23250)' => '23260',\n        'Videix (87600)' => '87204',\n        'Vielle-Saint-Girons (40560)' => '40326',\n        'Vielle-Soubiran (40240)' => '40327',\n        'Vielle-Tursan (40320)' => '40325',\n        'Viellenave-d\\'Arthez (64170)' => '64554',\n        'Viellenave-de-Navarrenx (64190)' => '64555',\n        'Vielleségure (64150)' => '64556',\n        'Viennay (79200)' => '79347',\n        'Viersat (23170)' => '23261',\n        'Vieux-Boucau-les-Bains (40480)' => '40328',\n        'Vieux-Mareuil (24340)' => '24579',\n        'Vieux-Ruffec (16350)' => '16404',\n        'Vigeois (19410)' => '19285',\n        'Vigeville (23140)' => '23262',\n        'Vignes (64410)' => '64557',\n        'Vignolles (16300)' => '16405',\n        'Vignols (19130)' => '19286',\n        'Vignonet (33330)' => '33546',\n        'Vilhonneur (16220)' => '16406',\n        'Villac (24120)' => '24580',\n        'Villamblard (24140)' => '24581',\n        'Villandraut (33730)' => '33547',\n        'Villard (23800)' => '23263',\n        'Villars (24530)' => '24582',\n        'Villars-en-Pons (17260)' => '17469',\n        'Villars-les-Bois (17770)' => '17470',\n        'Villebois-Lavalette (16320)' => '16408',\n        'Villebramar (47380)' => '47319',\n        'Villedoux (17230)' => '17472',\n        'Villefagnan (16240)' => '16409',\n        'Villefavard (87190)' => '87206',\n        'Villefollet (79170)' => '79348',\n        'Villefranche-de-Lonchat (24610)' => '24584',\n        'Villefranche-du-Périgord (24550)' => '24585',\n        'Villefranche-du-Queyran (47160)' => '47320',\n        'Villefranque (64990)' => '64558',\n        'Villegats (16700)' => '16410',\n        'Villegouge (33141)' => '33548',\n        'Villejésus (16140)' => '16411',\n        'Villejoubert (16560)' => '16412',\n        'Villemain (79110)' => '79349',\n        'Villemorin (17470)' => '17473',\n        'Villemort (86310)' => '86291',\n        'Villenave (40110)' => '40330',\n        'Villenave-d\\'Ornon (33140)' => '33550',\n        'Villenave-de-Rions (33550)' => '33549',\n        'Villenave-près-Béarn (65500)' => '65476',\n        'Villeneuve (33710)' => '33551',\n        'Villeneuve-de-Duras (47120)' => '47321',\n        'Villeneuve-de-Marsan (40190)' => '40331',\n        'Villeneuve-la-Comtesse (17330)' => '17474',\n        'Villeneuve-sur-Lot (47300)' => '47323',\n        'Villeréal (47210)' => '47324',\n        'Villeton (47400)' => '47325',\n        'Villetoureix (24600)' => '24586',\n        'Villexavier (17500)' => '17476',\n        'Villiers (86190)' => '86292',\n        'Villiers-Couture (17510)' => '17477',\n        'Villiers-en-Bois (79360)' => '79350',\n        'Villiers-en-Plaine (79160)' => '79351',\n        'Villiers-le-Roux (16240)' => '16413',\n        'Villiers-sur-Chizé (79170)' => '79352',\n        'Villognon (16230)' => '16414',\n        'Vinax (17510)' => '17478',\n        'Vindelle (16430)' => '16415',\n        'Viodos-Abense-de-Bas (64130)' => '64559',\n        'Virazeil (47200)' => '47326',\n        'Virelade (33720)' => '33552',\n        'Virollet (17260)' => '17479',\n        'Virsac (33240)' => '33553',\n        'Virson (17290)' => '17480',\n        'Vitrac (24200)' => '24587',\n        'Vitrac-Saint-Vincent (16310)' => '16416',\n        'Vitrac-sur-Montane (19800)' => '19287',\n        'Viven (64450)' => '64560',\n        'Viville (16120)' => '16417',\n        'Vivonne (86370)' => '86293',\n        'Voeuil-et-Giget (16400)' => '16418',\n        'Voissay (17400)' => '17481',\n        'Vouharte (16330)' => '16419',\n        'Vouhé (17700)' => '17482',\n        'Vouhé (79310)' => '79354',\n        'Vouillé (79230)' => '79355',\n        'Vouillé (86190)' => '86294',\n        'Voulême (86400)' => '86295',\n        'Voulgézac (16250)' => '16420',\n        'Voulmentin (79150)' => '79242',\n        'Voulon (86700)' => '86296',\n        'Vouneuil-sous-Biard (86580)' => '86297',\n        'Vouneuil-sur-Vienne (86210)' => '86298',\n        'Voutezac (19130)' => '19288',\n        'Vouthon (16220)' => '16421',\n        'Vouzailles (86170)' => '86299',\n        'Vouzan (16410)' => '16422',\n        'Xaintrailles (47230)' => '47327',\n        'Xaintray (79220)' => '79357',\n        'Xambes (16330)' => '16423',\n        'Ychoux (40160)' => '40332',\n        'Ygos-Saint-Saturnin (40110)' => '40333',\n        'Yssandon (19310)' => '19289',\n        'Yversay (86170)' => '86300',\n        'Yves (17340)' => '17483',\n        'Yviers (16210)' => '16424',\n        'Yvrac (33370)' => '33554',\n        'Yvrac-et-Malleyrand (16110)' => '16425',\n        'Yzosse (40180)' => '40334'\n    ];\n}\n"
  },
  {
    "path": "bridges/AtmoOccitanieBridge.php",
    "content": "<?php\n\nclass AtmoOccitanieBridge extends BridgeAbstract\n{\n    const NAME = 'Atmo Occitanie';\n    const URI = 'https://www.atmo-occitanie.org/';\n    const DESCRIPTION = 'Fetches the latest air polution of cities in Occitanie from Atmo';\n    const MAINTAINER = 'floviolleau';\n    const PARAMETERS = [[\n        'city' => [\n            'name' => 'Ville',\n            'required' => true,\n            'exampleValue'  => 'cahors'\n        ]\n    ]];\n    const CACHE_TIMEOUT = 7200;\n\n    public function collectData()\n    {\n        $uri = self::URI . $this->getInput('city');\n\n        $html = getSimpleHTMLDOM($uri);\n\n        $generalMessage = $html->find('.landing-ville .city-banner .iqa-avertissement', 0)->innertext;\n        $recommendationsDom = $html->find('.landing-ville .recommandations', 0);\n        $recommendationsItemDom = $recommendationsDom->find('.recommandation-item .label');\n\n        $recommendationsMessage = '';\n\n        $i = 0;\n        $len = count($recommendationsItemDom);\n        foreach ($recommendationsItemDom as $key => $value) {\n            if ($i == 0) {\n                $recommendationsMessage .= trim($value->innertext) . '.';\n            } else {\n                $recommendationsMessage .= ' ' . trim($value->innertext) . '.';\n            }\n            $i++;\n        }\n\n        $lastRecommendationsDom = $recommendationsDom->find('.col-md-6', -1);\n        $informationHeaderMessage = $lastRecommendationsDom->find('.heading', 0)->innertext;\n        $indice = $lastRecommendationsDom->find('.current-indice .indice div', 0)->innertext;\n        $informationDescriptionMessage = $lastRecommendationsDom->find('.current-indice .description p', 0)->innertext;\n\n        $message = \"$generalMessage L'indice est de \" . (6 - $indice) . \"/6. $informationDescriptionMessage. $recommendationsMessage\";\n        $city = $this->getInput('city');\n\n        $item['uri'] = $uri;\n        $today = date('d/m/Y');\n        $item['title'] = \"Bulletin de l'air du $today pour la ville : $city.\";\n        $item['title'] .= ' #QualiteAir. ' . $message;\n        $item['author'] = 'floviolleau';\n        $item['content'] = $message;\n        $item['uid'] = hash('sha256', $item['title']);\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/AuctionetBridge.php",
    "content": "<?php\n\nclass AuctionetBridge extends BridgeAbstract\n{\n    const NAME = 'Auctionet';\n    const URI = 'https://www.auctionet.com';\n    const DESCRIPTION = 'Fetches info about auction objects from Auctionet (an auction platform for many European auction houses)';\n    const MAINTAINER = 'Qluxzz';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'All categories' => '',\n                'Art' => [\n                    'All' => '25-art',\n                    'Drawings' => '119-drawings',\n                    'Engravings & Prints' => '27-engravings-prints',\n                    'Other' => '30-other',\n                    'Paintings' => '28-paintings',\n                    'Photography' => '26-photography',\n                    'Sculptures & Bronzes' => '29-sculptures-bronzes',\n                ],\n                'Asiatica' => [\n                    'All' => '117-asiatica',\n                ],\n                'Books, Maps & Manuscripts' => [\n                    'All' => '50-books-maps-manuscripts',\n                    'Autographs & Manuscripts' => '206-autographs-manuscripts',\n                    'Books' => '204-books',\n                    'Maps' => '205-maps',\n                    'Other' => '207-other',\n                ],\n                'Carpets & Textiles' => [\n                    'All' => '35-carpets-textiles',\n                    'Carpets' => '36-carpets',\n                    'Textiles' => '37-textiles',\n                ],\n                'Ceramics & Porcelain' => [\n                    'All' => '9-ceramics-porcelain',\n                    'European' => '10-european',\n                    'Oriental' => '11-oriental',\n                    'Rest of the world' => '12-rest-of-the-world',\n                    'Tableware' => '210-tableware',\n                ],\n                'Clocks & Watches' => [\n                    'All' => '31-clocks-watches',\n                    'Carriage & Miniature Clocks' => '258-carriage-miniature-clocks',\n                    'Longcase clocks' => '32-longcase-clocks',\n                    'Mantel clocks' => '33-mantel-clocks',\n                    'Other clocks' => '34-other-clocks',\n                    'Pocket & Stop Watches' => '110-pocket-stop-watches',\n                    'Wall Clocks' => '127-wall-clocks',\n                    'Wristwatches' => '15-wristwatches',\n                ],\n                'Coins, Medals & Stamps' => [\n                    'All' => '46-coins-medals-stamps',\n                    'Coins' => '128-coins',\n                    'Orders & Medals' => '135-orders-medals',\n                    'Other' => '131-other',\n                    'Stamps' => '136-stamps',\n                ],\n                'Folk art' => [\n                    'All' => '58-folk-art',\n                    'Bowls & Boxes' => '121-bowls-boxes',\n                    'Furniture' => '122-furniture',\n                    'Other' => '123-other',\n                    'Tools & Gears' => '120-tools-gears',\n                ],\n                'Furniture' => [\n                    'All' => '16-furniture',\n                    'Armchairs & Chairs' => '18-armchairs-chairs',\n                    'Chests of drawers' => '24-chests-of-drawers',\n                    'Cupboards, Cabinets & Shelves' => '23-cupboards-cabinets-shelves',\n                    'Dining room furniture' => '22-dining-room-furniture',\n                    'Garden' => '21-garden',\n                    'Other' => '17-other',\n                    'Sofas & seatings' => '20-sofas-seatings',\n                    'Tables' => '19-tables',\n                ],\n                'Glass' => [\n                    'All' => '6-glass',\n                    'Art glass' => '208-art-glass',\n                    'Other' => '8-other',\n                    'Tableware' => '7-tableware',\n                    'Utility glass' => '209-utility-glass',\n                ],\n                'Jewellery & Gemstones' => [\n                    'All' => '13-jewellery-gemstones',\n                    'Alliance rings' => '113-alliance-rings',\n                    'Bracelets' => '106-bracelets',\n                    'Brooches & Pendants' => '107-brooches-pendants',\n                    'Costume Jewellery' => '259-costume-jewellery',\n                    'Cufflinks & Tie Pins' => '111-cufflinks-tie-pins',\n                    'Ear studs' => '116-ear-studs',\n                    'Earrings' => '115-earrings',\n                    'Gemstones' => '48-gemstones',\n                    'Jewellery' => '14-jewellery',\n                    'Jewellery Suites' => '109-jewellery-suites',\n                    'Necklace' => '104-necklace',\n                    'Other' => '118-other',\n                    'Rings' => '112-rings',\n                    'Signet rings' => '105-signet-rings',\n                    'Solitaire rings' => '114-solitaire-rings',\n                ],\n                'Licence weapons' => [\n                    'All' => '59-licence-weapons',\n                    'Combi/Combo' => '63-combi-combo',\n                    'Double express rifles' => '60-double-express-rifles',\n                    'Rifles' => '61-rifles',\n                    'Shotguns' => '62-shotguns',\n                ],\n                'Lighting & Lamps' => [\n                    'All' => '1-lighting-lamps',\n                    'Candlesticks' => '4-candlesticks',\n                    'Ceiling lights' => '3-ceiling-lights',\n                    'Chandeliers' => '203-chandeliers',\n                    'Floor lights' => '2-floor-lights',\n                    'Other lighting' => '5-other-lighting',\n                    'Table Lamps' => '125-table-lamps',\n                    'Wall Lights' => '124-wall-lights',\n                ],\n                'Mirrors' => [\n                    'All' => '42-mirrors',\n                ],\n                'Miscellaneous' => [\n                    'All' => '43-miscellaneous',\n                    'Fishing equipment' => '54-fishing-equipment',\n                    'Miscellaneous' => '47-miscellaneous',\n                    'Modern Tools' => '133-modern-tools',\n                    'Modern consumer electronics' => '52-modern-consumer-electronics',\n                    'Musical instruments' => '51-musical-instruments',\n                    'Technica & Nautica' => '45-technica-nautica',\n                ],\n                'Photo, Cameras & Lenses' => [\n                    'All' => '57-photo-cameras-lenses',\n                    'Cameras & accessories' => '71-cameras-accessories',\n                    'Optics' => '66-optics',\n                    'Other' => '72-other',\n                ],\n                'Silver & Metals' => [\n                    'All' => '38-silver-metals',\n                    'Other metals' => '40-other-metals',\n                    'Pewter, Brass & Copper' => '41-pewter-brass-copper',\n                    'Silver' => '39-silver',\n                    'Silver plated' => '213-silver-plated',\n                ],\n                'Toys' => [\n                    'All' => '44-toys',\n                    'Comics' => '211-comics',\n                    'Toys' => '212-toys',\n                ],\n                'Tribal art' => [\n                    'All' => '134-tribal-art',\n                ],\n                'Vehicles, Boats & Parts' => [\n                    'All' => '249-vehicles-boats-parts',\n                    'Automobilia & Transport' => '255-automobilia-transport',\n                    'Bicycles' => '132-bicycles',\n                    'Boats & Accessories' => '250-boats-accessories',\n                    'Car parts' => '253-car-parts',\n                    'Cars' => '215-cars',\n                    'Moped parts' => '254-moped-parts',\n                    'Mopeds' => '216-mopeds',\n                    'Motorcycle parts' => '252-motorcycle-parts',\n                    'Motorcycles' => '251-motorcycles',\n                    'Other' => '256-other',\n                ],\n                'Vintage & Designer Fashion' => [\n                    'All' => '49-vintage-designer-fashion',\n                ],\n                'Weapons & Militaria' => [\n                    'All' => '137-weapons-militaria',\n                    'Airguns' => '257-airguns',\n                    'Armour & Uniform' => '138-armour-uniform',\n                    'Edged weapons' => '130-edged-weapons',\n                    'Guns & Rifles' => '129-guns-rifles',\n                    'Other' => '214-other',\n                ],\n                'Wine, Port & Spirits' => [\n                    'All' => '170-wine-port-spirits',\n                ],\n            ]\n        ],\n        'sort_order' => [\n            'name' => 'Sort order',\n            'type' => 'list',\n            'values' => [\n                'Most bids' => 'bids_count_desc',\n                'Lowest bid' => 'bid_asc',\n                'Highest bid' => 'bid_desc',\n                'Last bid on' => 'bid_on',\n                'Ending soonest' => 'end_asc_active',\n                'Lowest estimate' => 'estimate_asc',\n                'Highest estimate' => 'estimate_desc',\n                'Recently added' => 'recent'\n            ],\n        ],\n        'country' => [\n            'name' => 'Country',\n            'type' => 'list',\n            'values' => [\n                'All' => '',\n                'Denmark' => 'DK',\n                'Finland' => 'FI',\n                'Germany' => 'DE',\n                'Spain' => 'ES',\n                'Sweden' => 'SE',\n                'United Kingdom' => 'GB'\n            ]\n        ],\n        'language' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'values' => [\n                'English' => 'en',\n                'Español' => 'es',\n                'Deutsch' => 'de',\n                'Svenska' => 'sv',\n                'Dansk' => 'da',\n                'Suomi' => 'fi',\n            ],\n        ],\n    ]];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    private $title;\n\n    public function collectData()\n    {\n        // Each page contains 48 auctions\n        // So we fetch 10 pages so we decrease the likelihood\n        // of missing auctions between feed refreshes\n\n        // Fetch first page and use that to get title\n        {\n            $url = $this->getUrl(1);\n            $data = getContents($url);\n\n            $title = $this->getDocumentTitle($data);\n\n            $this->items = array_merge($this->items, $this->parsePageData($data));\n        }\n\n        // Fetch remaining pages\n        for ($page = 2; $page <= 10; $page++) {\n            $url = $this->getUrl($page);\n\n            $data = getContents($url);\n\n            $this->items = array_merge($this->items, $this->parsePageData($data));\n        }\n    }\n\n    public function getName()\n    {\n        return $this->title ?: parent::getName();\n    }\n\n\n    /* HELPERS */\n\n    private function getUrl($page)\n    {\n        $category = $this->getInput('category');\n        $language = $this->getInput('language');\n        $sort_order = $this->getInput('sort_order');\n        $country = $this->getInput('country');\n\n        $url = self::URI . '/' . $language . '/search';\n\n        if ($category) {\n            $url = $url . '/' . $category;\n        }\n\n        $query = [];\n        $query['page'] = $page;\n\n        if ($sort_order) {\n            $query['order'] = $sort_order;\n        }\n\n        if ($country) {\n            $query['country_code'] = $country;\n        }\n\n        if (count($query) > 0) {\n            $url = $url . '?' . http_build_query($query);\n        }\n\n        return $url;\n    }\n\n    private function getDocumentTitle($data)\n    {\n        $title_elem = '<title>';\n        $title_elem_length = strlen($title_elem);\n        $title_start = strpos($data, $title_elem);\n        $title_end = strpos($data, '</title>', $title_start);\n        $title_length = $title_end - $title_start + strlen($title_elem);\n        $title = substr($data, $title_start + strlen($title_elem), $title_length);\n\n        return $title;\n    }\n\n    /**\n     * The auction items data is included in the HTML document\n     * as a HTML entities encoded JSON structure\n     * which is used to hydrate the React component for the list of auctions\n     */\n    private function parsePageData($data)\n    {\n        $key = 'data-react-props=\"';\n        $keyLength = strlen($key);\n\n        $start = strpos($data, $key);\n        $end = strpos($data, '\"', $start + strlen($key));\n        $length = $end - ($start + $keyLength);\n\n        $jsonString = substr($data, $start + $keyLength, $length);\n\n        $jsonData = json_decode(htmlspecialchars_decode($jsonString), false);\n\n        $items = [];\n\n        foreach ($jsonData->{'items'} as $item) {\n            $title = $item->{'longTitle'};\n            $relative_url = $item->{'url'};\n            $images = $item->{'imageUrls'};\n            $id = $item->{'auctionId'};\n\n            $items[] = [\n                'title' => $title,\n                'uri' => self::URI . $relative_url,\n                'uid' => $id,\n                'content' => count($images) > 0 ? \"<img src='$images[0]'/><br/>$title\" : $title,\n                'enclosures' => array_slice($images, 1),\n            ];\n        }\n\n        return $items;\n    }\n}\n"
  },
  {
    "path": "bridges/AutoJMBridge.php",
    "content": "<?php\n\nclass AutoJMBridge extends BridgeAbstract\n{\n    const NAME = 'AutoJM';\n    const URI = 'https://www.autojm.fr/';\n    const DESCRIPTION = 'Suivre les offres de véhicules proposés par AutoJM en fonction des critères de filtrages';\n    const MAINTAINER = 'sysadminstory';\n    const PARAMETERS = [\n        'Afficher les offres de véhicules disponible sur la recheche AutoJM' => [\n            'url' => [\n                'name' => 'URL de la page de recherche',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'URL d\\'une recherche avec filtre de véhicules sans le http://www.autojm.fr/',\n                'exampleValue' => 'recherche?brands[]=PEUGEOT&ranges[]=PEUGEOT 308'\n            ],\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600;\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://www.autojm.fr/recherche?brands%5B%5D=PEUGEOT&ranges%5B%5D=PEUGEOT%20308'\n            => ['url' => 'recherche?brands%5B%5D=PEUGEOT&ranges%5B%5D=PEUGEOT%20308',\n                'context' => 'Afficher les offres de véhicules disponible sur la recheche AutoJM'\n            ]\n    ];\n\n    public function getIcon()\n    {\n        return self::URI . 'favicon.ico';\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Afficher les offres de véhicules disponible sur la recheche AutoJM':\n                return 'AutoJM | Recherche de véhicules';\n            break;\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Afficher les offres de véhicules disponible sur la recheche AutoJM':\n                return self::URI . $this->getInput('url');\n            break;\n            default:\n                return self::URI;\n        }\n    }\n\n    public function collectData()\n    {\n        // Get the number of result for this search\n        $search_url = self::URI . $this->getInput('url') . '&open=energy&onlyFilters=false';\n\n        // Set the header 'X-Requested-With' like the website does it\n        $header = [\n            'X-Requested-With: XMLHttpRequest'\n        ];\n\n        // Get the JSON content of the form\n        $json = getContents($search_url, $header);\n\n        // Extract the HTML content from the JSON result\n        $data = json_decode($json);\n\n        $nb_results = $data->nbResults;\n        $total_pages = ceil($nb_results / 14);\n\n        // Limit the number of page to analyse to 10\n        for ($page = 1; $page <= $total_pages && $page <= 10; $page++) {\n            // Get the result the next page\n            $html = $this->getResults($page);\n\n            // Go through every car of the search\n            $list = $html->find('div[class*=card-car card-car--listing]');\n            foreach ($list as $car) {\n                // Get the info about the car offer\n                $image = $car->find('div[class=card-car__header__img]', 0)->find('img', 0)->src;\n                // Decode HTML attribute JSON data\n                $car_data = json_decode(html_entity_decode($car->{'data-layer'}));\n                $car_model = $car_data->title;\n                $availability = $car->find('div[class*=card-car__modalites]', 0)->find('div[class=col]', 0)->plaintext;\n                $warranty = $car->find('div[data-type=WarrantyCard]', 0)->plaintext;\n                $discount_html = $car->find('div[class=subtext vehicle_reference_element]', 0);\n                // Check if there is any discount info displayed\n                if ($discount_html != null) {\n                    $reference_price_value = $discount_html->find('span[data-cfg=vehicle__reference_price]', 0)->plaintext;\n                    $discount_percent_value = $discount_html->find('span[data-cfg=vehicle__discount_percent]', 0)->plaintext;\n                    $reference_price = '<li>Prix de référence : <s>' . $reference_price_value . '</s></li>';\n                    $discount_percent = '<li>Réduction : ' . $discount_percent_value . ' %</li>';\n                } else {\n                    $reference_price = '';\n                    $discount_percent = '';\n                }\n                $price = $car_data->price;\n                $kilometer = $car->find('span[data-cfg=vehicle__kilometer]', 0)->plaintext;\n                $energy = $car->find('span[data-cfg=vehicle__energy__label]', 0)->plaintext;\n                $power = $car->find('span[data-cfg=vehicle__tax_horse_power]', 0)->plaintext;\n                $seats = $car->find('span[data-cfg=vehicle__seats]', 0)->plaintext;\n                $doors = $car->find('span[data-cfg=vehicle__door__label]', 0)->plaintext;\n                $transmission = $car->find('span[data-cfg=vehicle__transmission]', 0)->plaintext;\n                $loa_html = $car->find('span[data-cfg=vehicle__loa]', 0);\n                // Check if any LOA price is displayed\n                if ($loa_html != null) {\n                    $loa_value = $car->find('span[data-cfg=vehicle__loa]', 0)->plaintext;\n                    $loa = '<li>LOA : à partir de ' . $loa_value . ' / mois </li>';\n                } else {\n                    $loa = '';\n                }\n\n                // Construct the new item\n                $item = [];\n                $item['title'] = $car_model;\n                $item['content'] = '<p><img style=\"vertical-align:middle ; padding: 10px\" src=\"' . $image . '\" />'\n                    . $car_model . '</p>';\n                $item['content'] .= '<ul><li>Disponibilité : ' . $availability . '</li>';\n                $item['content'] .= '<li>Prix : ' . $price . ' €</li>';\n                $item['content'] .= $reference_price;\n                $item['content'] .= $loa;\n                $item['content'] .= $discount_percent;\n                $item['content'] .= '<li>Garantie : ' . $warranty . '</li>';\n                $item['content'] .= '<li>Kilométrage : ' . $kilometer . ' km</li>';\n                $item['content'] .= '<li>Energie : ' . $energy . '</li>';\n                $item['content'] .= '<li>Puissance: ' . $power . ' CV Fiscaux</li>';\n                $item['content'] .= '<li>Nombre de Places : ' . $seats . ' place(s)</li>';\n                $item['content'] .= '<li>Nombre de portes : ' . $doors . '</li>';\n                $item['content'] .= '<li>Boite de vitesse : ' . $transmission . '</li></ul>';\n                $item['uri'] = $car_data->{'uri'};\n                $item['uid'] = hash('md5', $item['content']);\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function getResults(int $page)\n    {\n        $user_input = $this->getInput('url');\n        $search_data = preg_replace('#(recherche|recherche/[0-9]{1,10})\\?#', 'recherche/' . $page . '?', $user_input);\n\n        $search_url = self::URI . $search_data . '&open=energy&onlyFilters=false';\n\n        // Get the HTML content of the page\n        $html = getSimpleHTMLDOMCached($search_url);\n\n        return $html;\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n        $regex = '/^(https?:\\/\\/)?(www\\.|)autojm.fr\\/(recherche\\?.*|recherche\\/[0-9]{1,10}\\?.*)$/m';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $url = preg_replace('#(recherche|recherche/[0-9]{1,10})#', 'recherche', $matches[3]);\n\n            $params['url'] = $url;\n            $params['context'] = 'Afficher les offres de véhicules disponible sur la recheche AutoJM';\n\n            return $params;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/AwwwardsBridge.php",
    "content": "<?php\n\nclass AwwwardsBridge extends BridgeAbstract\n{\n    const NAME = 'Awwwards';\n    const URI = 'https://www.awwwards.com/';\n    const DESCRIPTION = 'Fetches the latest ten sites of the day from Awwwards';\n    const MAINTAINER = 'Paroleen';\n    const CACHE_TIMEOUT = 3600;\n\n    const SITESURI = 'https://www.awwwards.com/websites/sites_of_the_day/';\n    const SITEURI = 'https://www.awwwards.com/sites/';\n    const ASSETSURI = 'https://assets.awwwards.com/awards/media/cache/thumb_417_299/';\n\n    private $sites = [];\n\n    public function collectData()\n    {\n        $this->fetchSites();\n\n        foreach ($this->sites as $site) {\n            $item = [];\n            $item['title'] = $site['title'];\n            $item['timestamp'] = $site['createdAt'];\n            $item['categories'] = $site['tags'];\n\n            $item['content'] = '<img src=\"'\n                . self::ASSETSURI\n                . $site['images']['thumbnail']\n                . '\">';\n            $item['uri'] = self::SITEURI . $site['slug'];\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.awwwards.com/favicon.ico';\n    }\n\n    private function fetchSites()\n    {\n        $sites = getSimpleHTMLDOM(self::SITESURI);\n        foreach ($sites->find('.grid-sites li') as $li) {\n            $encodedJson = $li->attr['data-collectable-model-value'] ?? null;\n            if (!$encodedJson) {\n                continue;\n            }\n            $json = html_entity_decode($encodedJson, ENT_QUOTES, 'utf-8');\n            $site = Json::decode($json);\n            $this->sites[] = $site;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BAEBridge.php",
    "content": "<?php\n\nclass BAEBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'couraudt';\n    const NAME = 'Bourse Aux Equipiers';\n    const URI = 'https://www.bourse-aux-equipiers.com';\n    const DESCRIPTION = 'Returns the newest sailing offers.';\n    const PARAMETERS = [\n        [\n            'keyword' => [\n                'name' => 'Filtrer par mots clés',\n                'title' => 'Entrez le mot clé à filtrer ici'\n            ],\n            'type' => [\n                'name' => 'Type de recherche',\n                'title' => 'Afficher seuleument un certain type d\\'annonce',\n                'type' => 'list',\n                'values' => [\n                    'Toutes les annonces' => false,\n                    'Les embarquements' => 'boat',\n                    'Les skippers' => 'skipper',\n                    'Les équipiers' => 'crew'\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n\n        $annonces = $html->find('main article');\n        foreach ($annonces as $annonce) {\n            $detail = $annonce->find('footer a', 0);\n\n            $htmlDetail = getSimpleHTMLDOMCached(parent::getURI() . $detail->href);\n            if (!$htmlDetail) {\n                continue;\n            }\n\n            $item = [];\n\n            $item['title'] = $annonce->find('header h2', 0)->plaintext;\n            $item['uri'] = parent::getURI() . $detail->href;\n\n            $content = $htmlDetail->find('article p', 0)->innertext;\n            if (!empty($this->getInput('keyword'))) {\n                $keyword = $this->removeAccents(strtolower($this->getInput('keyword')));\n                $cleanTitle = $this->removeAccents(strtolower($item['title']));\n                if (strpos($cleanTitle, $keyword) === false) {\n                    $cleanContent = $this->removeAccents(strtolower($content));\n                    if (strpos($cleanContent, $keyword) === false) {\n                        continue;\n                    }\n                }\n            }\n\n            $content .= '<hr>';\n            $content .= $htmlDetail->find('section', 0)->innertext;\n            $item['content'] = defaultLinkTo($content, parent::getURI());\n            $image = $htmlDetail->find('#zoom', 0);\n            if ($image) {\n                $item['enclosures'] = [parent::getURI() . $image->getAttribute('src')];\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n        if (!empty($this->getInput('type'))) {\n            if ($this->getInput('type') == 'boat') {\n                $uri .= '/embarquements.html';\n            } elseif ($this->getInput('type') == 'skipper') {\n                $uri .= '/skippers.html';\n            } else {\n                $uri .= '/equipiers.html';\n            }\n        }\n\n        return $uri;\n    }\n\n    private function removeAccents($string)\n    {\n        $chars = [\n            // Decompositions for Latin-1 Supplement\n            'ª' => 'a', 'º' => 'o',\n            'À' => 'A', 'Á' => 'A',\n            'Â' => 'A', 'Ã' => 'A',\n            'Ä' => 'A', 'Å' => 'A',\n            'Æ' => 'AE', 'Ç' => 'C',\n            'È' => 'E', 'É' => 'E',\n            'Ê' => 'E', 'Ë' => 'E',\n            'Ì' => 'I', 'Í' => 'I',\n            'Î' => 'I', 'Ï' => 'I',\n            'Ð' => 'D', 'Ñ' => 'N',\n            'Ò' => 'O', 'Ó' => 'O',\n            'Ô' => 'O', 'Õ' => 'O',\n            'Ö' => 'O', 'Ù' => 'U',\n            'Ú' => 'U', 'Û' => 'U',\n            'Ü' => 'U', 'Ý' => 'Y',\n            'Þ' => 'TH', 'ß' => 's',\n            'à' => 'a', 'á' => 'a',\n            'â' => 'a', 'ã' => 'a',\n            'ä' => 'a', 'å' => 'a',\n            'æ' => 'ae', 'ç' => 'c',\n            'è' => 'e', 'é' => 'e',\n            'ê' => 'e', 'ë' => 'e',\n            'ì' => 'i', 'í' => 'i',\n            'î' => 'i', 'ï' => 'i',\n            'ð' => 'd', 'ñ' => 'n',\n            'ò' => 'o', 'ó' => 'o',\n            'ô' => 'o', 'õ' => 'o',\n            'ö' => 'o', 'ø' => 'o',\n            'ù' => 'u', 'ú' => 'u',\n            'û' => 'u', 'ü' => 'u',\n            'ý' => 'y', 'þ' => 'th',\n            'ÿ' => 'y', 'Ø' => 'O',\n            // Decompositions for Latin Extended-A\n            'Ā' => 'A', 'ā' => 'a',\n            'Ă' => 'A', 'ă' => 'a',\n            'Ą' => 'A', 'ą' => 'a',\n            'Ć' => 'C', 'ć' => 'c',\n            'Ĉ' => 'C', 'ĉ' => 'c',\n            'Ċ' => 'C', 'ċ' => 'c',\n            'Č' => 'C', 'č' => 'c',\n            'Ď' => 'D', 'ď' => 'd',\n            'Đ' => 'D', 'đ' => 'd',\n            'Ē' => 'E', 'ē' => 'e',\n            'Ĕ' => 'E', 'ĕ' => 'e',\n            'Ė' => 'E', 'ė' => 'e',\n            'Ę' => 'E', 'ę' => 'e',\n            'Ě' => 'E', 'ě' => 'e',\n            'Ĝ' => 'G', 'ĝ' => 'g',\n            'Ğ' => 'G', 'ğ' => 'g',\n            'Ġ' => 'G', 'ġ' => 'g',\n            'Ģ' => 'G', 'ģ' => 'g',\n            'Ĥ' => 'H', 'ĥ' => 'h',\n            'Ħ' => 'H', 'ħ' => 'h',\n            'Ĩ' => 'I', 'ĩ' => 'i',\n            'Ī' => 'I', 'ī' => 'i',\n            'Ĭ' => 'I', 'ĭ' => 'i',\n            'Į' => 'I', 'į' => 'i',\n            'İ' => 'I', 'ı' => 'i',\n            'Ĳ' => 'IJ', 'ĳ' => 'ij',\n            'Ĵ' => 'J', 'ĵ' => 'j',\n            'Ķ' => 'K', 'ķ' => 'k',\n            'ĸ' => 'k', 'Ĺ' => 'L',\n            'ĺ' => 'l', 'Ļ' => 'L',\n            'ļ' => 'l', 'Ľ' => 'L',\n            'ľ' => 'l', 'Ŀ' => 'L',\n            'ŀ' => 'l', 'Ł' => 'L',\n            'ł' => 'l', 'Ń' => 'N',\n            'ń' => 'n', 'Ņ' => 'N',\n            'ņ' => 'n', 'Ň' => 'N',\n            'ň' => 'n', 'ŉ' => 'n',\n            'Ŋ' => 'N', 'ŋ' => 'n',\n            'Ō' => 'O', 'ō' => 'o',\n            'Ŏ' => 'O', 'ŏ' => 'o',\n            'Ő' => 'O', 'ő' => 'o',\n            'Œ' => 'OE', 'œ' => 'oe',\n            'Ŕ' => 'R', 'ŕ' => 'r',\n            'Ŗ' => 'R', 'ŗ' => 'r',\n            'Ř' => 'R', 'ř' => 'r',\n            'Ś' => 'S', 'ś' => 's',\n            'Ŝ' => 'S', 'ŝ' => 's',\n            'Ş' => 'S', 'ş' => 's',\n            'Š' => 'S', 'š' => 's',\n            'Ţ' => 'T', 'ţ' => 't',\n            'Ť' => 'T', 'ť' => 't',\n            'Ŧ' => 'T', 'ŧ' => 't',\n            'Ũ' => 'U', 'ũ' => 'u',\n            'Ū' => 'U', 'ū' => 'u',\n            'Ŭ' => 'U', 'ŭ' => 'u',\n            'Ů' => 'U', 'ů' => 'u',\n            'Ű' => 'U', 'ű' => 'u',\n            'Ų' => 'U', 'ų' => 'u',\n            'Ŵ' => 'W', 'ŵ' => 'w',\n            'Ŷ' => 'Y', 'ŷ' => 'y',\n            'Ÿ' => 'Y', 'Ź' => 'Z',\n            'ź' => 'z', 'Ż' => 'Z',\n            'ż' => 'z', 'Ž' => 'Z',\n            'ž' => 'z', 'ſ' => 's',\n            // Decompositions for Latin Extended-B\n            'Ș' => 'S', 'ș' => 's',\n            'Ț' => 'T', 'ț' => 't',\n            // Euro Sign\n            '€' => 'E',\n            // GBP (Pound) Sign\n            '£' => '',\n            // Vowels with diacritic (Vietnamese)\n            // unmarked\n            'Ơ' => 'O', 'ơ' => 'o',\n            'Ư' => 'U', 'ư' => 'u',\n            // grave accent\n            'Ầ' => 'A', 'ầ' => 'a',\n            'Ằ' => 'A', 'ằ' => 'a',\n            'Ề' => 'E', 'ề' => 'e',\n            'Ồ' => 'O', 'ồ' => 'o',\n            'Ờ' => 'O', 'ờ' => 'o',\n            'Ừ' => 'U', 'ừ' => 'u',\n            'Ỳ' => 'Y', 'ỳ' => 'y',\n            // hook\n            'Ả' => 'A', 'ả' => 'a',\n            'Ẩ' => 'A', 'ẩ' => 'a',\n            'Ẳ' => 'A', 'ẳ' => 'a',\n            'Ẻ' => 'E', 'ẻ' => 'e',\n            'Ể' => 'E', 'ể' => 'e',\n            'Ỉ' => 'I', 'ỉ' => 'i',\n            'Ỏ' => 'O', 'ỏ' => 'o',\n            'Ổ' => 'O', 'ổ' => 'o',\n            'Ở' => 'O', 'ở' => 'o',\n            'Ủ' => 'U', 'ủ' => 'u',\n            'Ử' => 'U', 'ử' => 'u',\n            'Ỷ' => 'Y', 'ỷ' => 'y',\n            // tilde\n            'Ẫ' => 'A', 'ẫ' => 'a',\n            'Ẵ' => 'A', 'ẵ' => 'a',\n            'Ẽ' => 'E', 'ẽ' => 'e',\n            'Ễ' => 'E', 'ễ' => 'e',\n            'Ỗ' => 'O', 'ỗ' => 'o',\n            'Ỡ' => 'O', 'ỡ' => 'o',\n            'Ữ' => 'U', 'ữ' => 'u',\n            'Ỹ' => 'Y', 'ỹ' => 'y',\n            // acute accent\n            'Ấ' => 'A', 'ấ' => 'a',\n            'Ắ' => 'A', 'ắ' => 'a',\n            'Ế' => 'E', 'ế' => 'e',\n            'Ố' => 'O', 'ố' => 'o',\n            'Ớ' => 'O', 'ớ' => 'o',\n            'Ứ' => 'U', 'ứ' => 'u',\n            // dot below\n            'Ạ' => 'A', 'ạ' => 'a',\n            'Ậ' => 'A', 'ậ' => 'a',\n            'Ặ' => 'A', 'ặ' => 'a',\n            'Ẹ' => 'E', 'ẹ' => 'e',\n            'Ệ' => 'E', 'ệ' => 'e',\n            'Ị' => 'I', 'ị' => 'i',\n            'Ọ' => 'O', 'ọ' => 'o',\n            'Ộ' => 'O', 'ộ' => 'o',\n            'Ợ' => 'O', 'ợ' => 'o',\n            'Ụ' => 'U', 'ụ' => 'u',\n            'Ự' => 'U', 'ự' => 'u',\n            'Ỵ' => 'Y', 'ỵ' => 'y',\n            // Vowels with diacritic (Chinese, Hanyu Pinyin)\n            'ɑ' => 'a',\n            // macron\n            'Ǖ' => 'U', 'ǖ' => 'u',\n            // acute accent\n            'Ǘ' => 'U', 'ǘ' => 'u',\n            // caron\n            'Ǎ' => 'A', 'ǎ' => 'a',\n            'Ǐ' => 'I', 'ǐ' => 'i',\n            'Ǒ' => 'O', 'ǒ' => 'o',\n            'Ǔ' => 'U', 'ǔ' => 'u',\n            'Ǚ' => 'U', 'ǚ' => 'u',\n            // grave accent\n            'Ǜ' => 'U', 'ǜ' => 'u',\n        ];\n\n        $string = strtr($string, $chars);\n\n        return $string;\n    }\n}\n"
  },
  {
    "path": "bridges/BMDSystemhausBlogBridge.php",
    "content": "<?php\n\nclass BMDSystemhausBlogBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'cn-tools';\n    const NAME = 'BMD SYSTEMHAUS GesmbH';\n    const CACHE_TIMEOUT = 21600; //6h\n    const URI = 'https://www.bmd.com';\n    const DONATION_URI = 'https://paypal.me/cntools';\n    const DESCRIPTION = 'BMD Systemhaus - We make business easy';\n    const BMD_FAV_ICON = 'https://www.bmd.com/favicon.ico';\n\n    const ITEMSTYLE = [\n        'ilcr' => '<table width=\"100%\"><tr><td style=\"vertical-align: top;\">{data_img}</td><td style=\"vertical-align: top;\">{data_content}</td></tr></table>',\n        'clir' => '<table width=\"100%\"><tr><td style=\"vertical-align: top;\">{data_content}</td><td style=\"vertical-align: top;\">{data_img}</td></tr></table>',\n        'itcb' => '<div>{data_img}<br />{data_content}</div>',\n        'ctib' => '<div>{data_content}<br />{data_img}</div>',\n        'co' => '{data_content}',\n        'io' => '{data_img}'\n    ];\n\n    const PARAMETERS = [\n        'Blog' => [\n            'country' => [\n                'name' => 'Country',\n                'type' => 'list',\n                'values' => [\n                    'Österreich' => 'at',\n                    'Deutschland' => 'de',\n                    'Schweiz' => 'ch',\n                    'Slovensko' => 'sk',\n                    'Cesko' => 'cz',\n                    'Hungary' => 'hu',\n                ],\n                'defaultValue' => 'at',\n            ],\n            'style' => [\n                'name' => 'Style',\n                'type' => 'list',\n                'values' => [\n                    'Image left, content right' => 'ilcr',\n                    'Content left, image right' => 'clir',\n                    'Image top, content bottom' => 'itcb',\n                    'Content top, image bottom' => 'ctib',\n                    'Content only' => 'co',\n                    'Image only' => 'io',\n                ],\n                'defaultValue' => 'ilcr',\n            ]\n        ]\n    ];\n\n    //-----------------------------------------------------\n    public function collectData()\n    {\n        // get website content\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        // Convert relative links in HTML into absolute links\n        $html = defaultLinkTo($html, self::URI);\n\n        // Convert lazy-loading images and frames (video embeds) into static elements\n        $html = convertLazyLoading($html);\n\n        foreach ($html->find('div#bmdNewsList div#bmdNewsList-Item') as $element) {\n            $itemScope = $element->find('div[itemscope=itemscope]', 0);\n\n            $item = [];\n\n            // set base article data\n            $item['title'] = $this->getMetaItemPropContent($itemScope, 'headline');\n            $item['timestamp'] = strtotime($this->getMetaItemPropContent($itemScope, 'datePublished'));\n            $item['author'] = $this->getMetaItemPropContent($itemScope->find('div[itemprop=author]', 0), 'name');\n\n            // find article image\n            $imageTag = '';\n            $image = $element->find('div.mediaelement.mediaelement-image img', 0);\n            if ((!is_null($image)) and ($image->src != '')) {\n                $item['enclosures'] = [$image->src];\n                $imageTag = '<img src=\"' . $image->src . '\"/>';\n            }\n\n            // begin with right style\n            $content = self::ITEMSTYLE[$this->getInput('style')];\n\n            // render placeholder\n            $content = str_replace('{data_content}', $this->getMetaItemPropContent($itemScope, 'description'), $content);\n            $content = str_replace('{data_img}', $imageTag, $content);\n\n            // set finished content\n            $item['content'] = $content;\n\n            // get link to article\n            $link = $element->find('div#bmdNewsList-Text div#bmdNewsList-Title a', 0);\n            if (!is_null($link)) {\n                $item['uri'] = $link->href;\n            }\n\n            // init categories\n            $categories = [];\n            $tmpOne = [];\n            $tmpTwo = [];\n\n            // search first categorie span\n            $catElem = $element->find('div#bmdNewsList-Text div#bmdNewsList-Category span.news-list-category', 0);\n            $txt = trim($catElem->innertext);\n            $tmpOne = explode('/', $txt);\n\n            // split by 2 spaces\n            foreach ($tmpOne as $tmpElem) {\n                $tmpElem = trim($tmpElem);\n                $tmpData = preg_split('/  /', $tmpElem);\n                $tmpTwo = array_merge($tmpTwo, $tmpData);\n            }\n\n            // split by tabulator\n            foreach ($tmpTwo as $tmpElem) {\n                $tmpElem = trim($tmpElem);\n                $tmpData = preg_split('/\\t+/', $tmpElem);\n                $categories = array_merge($categories, $tmpData);\n            }\n\n            // trim each categorie entries\n            $categories = array_map('trim', $categories);\n\n            // remove empty entries\n            $categories = array_filter($categories, function ($value) {\n                return !is_null($value) && $value !== '';\n            });\n\n            // set categories\n            if (count($categories) > 0) {\n                $item['categories'] = $categories;\n            }\n\n            // add item\n            if (($item['title'] != '') and ($item['content'] != '') and ($item['uri'] != '')) {\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    //-----------------------------------------------------\n    public function detectParameters($url)\n    {\n        try {\n            $parsedUrl = Url::fromString($url);\n        } catch (UrlException $e) {\n            return null;\n        }\n\n        if (!in_array($parsedUrl->getHost(), ['www.bmd.com', 'bmd.com'])) {\n            return null;\n        }\n\n        $lang = '';\n\n        // extract language from url\n        $path = explode('/', $parsedUrl->getPath());\n        if (count($path) > 1) {\n            $lang = $path[1];\n\n            // validate data\n            if ($this->getURIbyCountry($lang) == '') {\n                $lang = '';\n            }\n        }\n\n        // if no country available, find language by browser\n        if ($lang == '') {\n            $srvLanguages = explode(';', $_SERVER['HTTP_ACCEPT_LANGUAGE']);\n            if (count($srvLanguages) > 0) {\n                $languages = explode(',', $srvLanguages[0]);\n                if (count($languages) > 0) {\n                    for ($i = 0; $i < count($languages); $i++) {\n                        $langDetails = explode('-', $languages[$i]);\n                        if (count($langDetails) > 1) {\n                            $lang = $langDetails[1];\n                        } else {\n                            $lang = substr($srvLanguages[0], 0, 2);\n                        }\n\n                        // validate data\n                        if ($this->getURIbyCountry($lang) == '') {\n                            $lang = '';\n                        }\n\n                        if ($lang != '') {\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n\n        // if no URL found by language, use AT as default\n        if ($this->getURIbyCountry($lang) == '') {\n            $lang = 'at';\n        }\n\n        $params = [];\n        $params['country'] = strtolower($lang);\n\n        return $params;\n    }\n\n    //-----------------------------------------------------\n    public function getURI()\n    {\n        $country = $this->getInput('country') ?? '';\n        $lURI = $this->getURIbyCountry($country);\n        return $lURI != '' ? $lURI : parent::getURI();\n    }\n\n    //-----------------------------------------------------\n    public function getIcon()\n    {\n        return self::BMD_FAV_ICON;\n    }\n\n    //-----------------------------------------------------\n    private function getMetaItemPropContent($elem, $key)\n    {\n        if (($key != '') and (!is_null($elem))) {\n            $metaElem = $elem->find('meta[itemprop=' . $key . ']', 0);\n            if (!is_null($metaElem)) {\n                return $metaElem->getAttribute('content');\n            }\n        }\n\n        return '';\n    }\n\n    //-----------------------------------------------------\n    private function getURIbyCountry($country)\n    {\n        switch (strtolower($country)) {\n            case 'at':\n                return 'https://www.bmd.com/at/ueber-bmd/blog-ohne-filter.html';\n            case 'de':\n                return 'https://www.bmd.com/de/das-ist-bmd/blog.html';\n            case 'ch':\n                return 'https://www.bmd.com/ch/das-ist-bmd/blog.html';\n            case 'sk':\n                return 'https://www.bmd.com/sk/firma/blog.html';\n            case 'cz':\n                return 'https://www.bmd.com/cz/firma/news-blog.html';\n            case 'hu':\n                return 'https://www.bmd.com/hu/rolunk/hirek.html';\n            default:\n                return '';\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BadDragonBridge.php",
    "content": "<?php\n\nclass BadDragonBridge extends BridgeAbstract\n{\n    const NAME = 'Bad Dragon';\n    const URI = 'https://bad-dragon.com/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns sales or new clearance items';\n    const MAINTAINER = 'Roliga';\n    const PARAMETERS = [\n        'Sales' => [\n        ],\n        'Clearance' => [\n            'ready_made' => [\n                'name' => 'Ready Made',\n                'type' => 'checkbox'\n            ],\n            'flop' => [\n                'name' => 'Flops',\n                'type' => 'checkbox'\n            ],\n            'skus' => [\n                'name' => 'Products',\n                'exampleValue' => 'chanceflared, crackers',\n                'title' => 'Comma separated list of product SKUs'\n            ],\n            'onesize' => [\n                'name' => 'One-Size',\n                'type' => 'checkbox'\n            ],\n            'mini' => [\n                'name' => 'Mini',\n                'type' => 'checkbox'\n            ],\n            'small' => [\n                'name' => 'Small',\n                'type' => 'checkbox'\n            ],\n            'medium' => [\n                'name' => 'Medium',\n                'type' => 'checkbox'\n            ],\n            'large' => [\n                'name' => 'Large',\n                'type' => 'checkbox'\n            ],\n            'extralarge' => [\n                'name' => 'Extra Large',\n                'type' => 'checkbox'\n            ],\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'All' => 'all',\n                    'Accessories' => 'accessories',\n                    'Merchandise' => 'merchandise',\n                    'Dildos' => 'insertable',\n                    'Masturbators' => 'penetrable',\n                    'Packers' => 'packer',\n                    'Lil\\' Squirts' => 'shooter',\n                    'Lil\\' Vibes' => 'vibrator',\n                    'Wearables' => 'wearable'\n                ],\n                'defaultValue' => 'all',\n            ],\n            'soft' => [\n                'name' => 'Soft Firmness',\n                'type' => 'checkbox'\n            ],\n            'med_firm' => [\n                'name' => 'Medium Firmness',\n                'type' => 'checkbox'\n            ],\n            'firm' => [\n                'name' => 'Firm',\n                'type' => 'checkbox'\n            ],\n            'split' => [\n                'name' => 'Split Firmness',\n                'type' => 'checkbox'\n            ],\n            'maxprice' => [\n                'name' => 'Max Price',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 300\n            ],\n            'minprice' => [\n                'name' => 'Min Price',\n                'type' => 'number',\n                'defaultValue' => 0\n            ],\n            'cumtube' => [\n                'name' => 'Cumtube',\n                'type' => 'checkbox'\n            ],\n            'suctionCup' => [\n                'name' => 'Suction Cup',\n                'type' => 'checkbox'\n            ],\n            'noAccessories' => [\n                'name' => 'No Accessories',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    /*\n     * This sets index $strFrom (or $strTo if set) in $outArr to 'on' if\n     * $inArr[$param] contains $strFrom.\n     * It is used for translating BD's shop filter URLs into something we can use.\n     *\n     * For the query '?type[]=ready_made&type[]=flop' we would have an array like:\n     * Array (\n     *     [type] => Array (\n     *             [0] => ready_made\n     *             [1] => flop\n     *         )\n     * )\n     * which could be translated into:\n     * Array (\n     *     [ready_made] => on\n     *     [flop] => on\n     * )\n     * */\n    private function setParam($inArr, &$outArr, $param, $strFrom, $strTo = null)\n    {\n        if (isset($inArr[$param]) && in_array($strFrom, $inArr[$param])) {\n            $outArr[($strTo ?: $strFrom)] = 'on';\n        }\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Sale\n        $regex = '/^(https?:\\/\\/)?bad-dragon\\.com\\/sales/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Sales';\n            return $params;\n        }\n\n        // Clearance\n        $regex = '/^(https?:\\/\\/)?bad-dragon\\.com\\/shop\\/clearance/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            parse_str(parse_url($url, PHP_URL_QUERY), $urlParams);\n\n            $this->setParam($urlParams, $params, 'type', 'ready_made');\n            $this->setParam($urlParams, $params, 'type', 'flop');\n\n            if (isset($urlParams['skus'])) {\n                $skus = [];\n                foreach ($urlParams['skus'] as $sku) {\n                    is_string($sku) && $skus[] = $sku;\n                    is_array($sku) && $skus[] = $sku[0];\n                }\n                $params['skus'] = implode(',', $skus);\n            }\n\n            $this->setParam($urlParams, $params, 'sizes', 'onesize');\n            $this->setParam($urlParams, $params, 'sizes', 'mini');\n            $this->setParam($urlParams, $params, 'sizes', 'small');\n            $this->setParam($urlParams, $params, 'sizes', 'medium');\n            $this->setParam($urlParams, $params, 'sizes', 'large');\n            $this->setParam($urlParams, $params, 'sizes', 'extralarge');\n\n            if (isset($urlParams['category'])) {\n                $params['category'] = strtolower($urlParams['category']);\n            } else {\n                $params['category'] = 'all';\n            }\n\n            $this->setParam($urlParams, $params, 'firmnessValues', 'soft');\n            $this->setParam($urlParams, $params, 'firmnessValues', 'medium', 'med_firm');\n            $this->setParam($urlParams, $params, 'firmnessValues', 'firm');\n            $this->setParam($urlParams, $params, 'firmnessValues', 'split');\n\n            if (isset($urlParams['price'])) {\n                isset($urlParams['price']['max'])\n                    && $params['maxprice'] = $urlParams['price']['max'];\n                isset($urlParams['price']['min'])\n                    && $params['minprice'] = $urlParams['price']['min'];\n            }\n\n            isset($urlParams['cumtube'])\n                && $urlParams['cumtube'] === '1'\n                && $params['cumtube'] = 'on';\n            isset($urlParams['suctionCup'])\n                && $urlParams['suctionCup'] === '1'\n                && $params['suctionCup'] = 'on';\n            isset($urlParams['noAccessories'])\n                && $urlParams['noAccessories'] === '1'\n                && $params['noAccessories'] = 'on';\n            $params['context'] = 'Clearance';\n\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Sales':\n                return 'Bad Dragon Sales';\n            case 'Clearance':\n                return 'Bad Dragon Clearance Search';\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Sales':\n                return self::URI . 'sales';\n            case 'Clearance':\n                return $this->inputToURL();\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Sales':\n                $sales = json_decode(getContents(self::URI . 'api/sales'));\n\n                foreach ($sales as $sale) {\n                    $item = [];\n\n                    $item['title'] = $sale->title;\n                    $item['timestamp'] = strtotime($sale->startDate);\n\n                    $item['uri'] = $this->getURI() . '/' . $sale->slug;\n\n                    $contentHTML = '<p><img src=\"' . $sale->image->url . '\"></p>';\n                    if (isset($sale->endDate)) {\n                        $contentHTML .= '<p><b>This promotion ends on '\n                        . gmdate('M j, Y \\a\\t g:i A T', strtotime($sale->endDate))\n                        . '</b></p>';\n                    } else {\n                        $contentHTML .= '<p><b>This promotion never ends</b></p>';\n                    }\n                    $ul = false;\n                    $content = json_decode($sale->content);\n                    foreach ($content->blocks as $block) {\n                        switch ($block->type) {\n                            case 'header-one':\n                                $contentHTML .= '<h1>' . $block->text . '</h1>';\n                                break;\n                            case 'header-two':\n                                $contentHTML .= '<h2>' . $block->text . '</h2>';\n                                break;\n                            case 'header-three':\n                                $contentHTML .= '<h3>' . $block->text . '</h3>';\n                                break;\n                            case 'unordered-list-item':\n                                if (!$ul) {\n                                    $contentHTML .= '<ul>';\n                                    $ul = true;\n                                }\n                                $contentHTML .= '<li>' . $block->text . '</li>';\n                                break;\n                            default:\n                                if ($ul) {\n                                    $contentHTML .= '</ul>';\n                                    $ul = false;\n                                }\n                                $contentHTML .= '<p>' . $block->text . '</p>';\n                                break;\n                        }\n                    }\n                    $item['content'] = $contentHTML;\n\n                    $this->items[] = $item;\n                }\n                break;\n            case 'Clearance':\n                $toyData = json_decode(getContents($this->inputToURL(true)));\n\n                $productList = json_decode(getContents(self::URI . 'api/inventory-toy/product-list'));\n\n                foreach ($toyData->toys as $toy) {\n                    $item = [];\n\n                    $item['uri'] = $this->getURI()\n                        . '#'\n                        . $toy->id;\n                    $item['timestamp'] = strtotime($toy->created);\n\n                    foreach ($productList as $product) {\n                        if ($product->sku == $toy->sku) {\n                            $item['title'] = $product->name;\n                            break;\n                        }\n                    }\n\n                    // images\n                    $content = '<p>';\n                    foreach ($toy->images as $image) {\n                        $content .= '<a href=\"'\n                        . $image->fullFilename\n                        . '\"><img src=\"'\n                        . $image->thumbFilename\n                        . '\" /></a>';\n                    }\n                    // price\n                    $content .= '</p><p><b>Price:</b> $'\n                    . $toy->price\n                    // size\n                    . '<br /><b>Size:</b> '\n                    . $toy->size\n                    // color\n                    . '<br /><b>Color:</b> '\n                    . $toy->color\n                    // features\n                    . '<br /><b>Features:</b> '\n                    . ($toy->suction_cup ? 'Suction cup' : '')\n                    . ($toy->suction_cup && $toy->cumtube ? ', ' : '')\n                    . ($toy->cumtube ? 'Cumtube' : '')\n                    . ($toy->suction_cup || $toy->cumtube ? '' : 'None');\n                    // firmness\n                    $firmnessTexts = [\n                    '2' => 'Extra soft',\n                    '3' => 'Soft',\n                    '5' => 'Medium',\n                    '8' => 'Firm'\n                    ];\n                    $firmnesses = explode('/', $toy->firmness);\n                    if (count($firmnesses) === 2) {\n                        $content .= '<br /><b>Firmness:</b> '\n                        . $firmnessTexts[$firmnesses[0]]\n                        . ', '\n                        . $firmnessTexts[$firmnesses[1]];\n                    } else {\n                        $content .= '<br /><b>Firmness:</b> '\n                        . $firmnessTexts[$firmnesses[0]];\n                    }\n                    // flop\n                    if ($toy->type === 'flop') {\n                        $content .= '<br /><b>Flop reason:</b> '\n                        . $toy->flop_reason;\n                    }\n                    $content .= '</p>';\n                    $item['content'] = $content;\n\n                    $enclosures = [];\n                    foreach ($toy->images as $image) {\n                        $enclosures[] = $image->fullFilename;\n                    }\n                    $item['enclosures'] = $enclosures;\n\n                    $categories = [];\n                    $categories[] = $toy->sku;\n                    $categories[] = $toy->type;\n                    $categories[] = $toy->size;\n                    if ($toy->cumtube) {\n                        $categories[] = 'cumtube';\n                    }\n                    if ($toy->suction_cup) {\n                        $categories[] = 'suction_cup';\n                    }\n                    $item['categories'] = $categories;\n\n                    $this->items[] = $item;\n                }\n                break;\n        }\n    }\n\n    private function inputToURL($api = false)\n    {\n        $url = self::URI;\n        $url .= ($api ? 'api/inventory-toys?' : 'shop/clearance?');\n\n        // Default parameters\n        $url .= 'limit=60';\n        $url .= '&page=1';\n        $url .= '&sort[field]=created';\n        $url .= '&sort[direction]=desc';\n\n        // Product types\n        $url .= ($this->getInput('ready_made') ? '&type[]=ready_made' : '');\n        $url .= ($this->getInput('flop') ? '&type[]=flop' : '');\n\n        // Product names\n        foreach (array_filter(explode(',', $this->getInput('skus'))) as $sku) {\n            $url .= '&skus[]=' . urlencode(trim($sku));\n        }\n\n        // Size\n        $url .= ($this->getInput('onesize') ? '&sizes[]=onesize' : '');\n        $url .= ($this->getInput('mini') ? '&sizes[]=mini' : '');\n        $url .= ($this->getInput('small') ? '&sizes[]=small' : '');\n        $url .= ($this->getInput('medium') ? '&sizes[]=medium' : '');\n        $url .= ($this->getInput('large') ? '&sizes[]=large' : '');\n        $url .= ($this->getInput('extralarge') ? '&sizes[]=extralarge' : '');\n\n        // Category\n        $url .= ($this->getInput('category') ? '&category='\n            . urlencode($this->getInput('category')) : '');\n\n        // Firmness\n        if ($api) {\n            $url .= ($this->getInput('soft') ? '&firmnessValues[]=3' : '');\n            $url .= ($this->getInput('med_firm') ? '&firmnessValues[]=5' : '');\n            $url .= ($this->getInput('firm') ? '&firmnessValues[]=8' : '');\n            if ($this->getInput('split')) {\n                $url .= '&firmnessValues[]=3/5';\n                $url .= '&firmnessValues[]=3/8';\n                $url .= '&firmnessValues[]=8/3';\n                $url .= '&firmnessValues[]=5/8';\n                $url .= '&firmnessValues[]=8/5';\n            }\n        } else {\n            $url .= ($this->getInput('soft') ? '&firmnessValues[]=soft' : '');\n            $url .= ($this->getInput('med_firm') ? '&firmnessValues[]=medium' : '');\n            $url .= ($this->getInput('firm') ? '&firmnessValues[]=firm' : '');\n            $url .= ($this->getInput('split') ? '&firmnessValues[]=split' : '');\n        }\n\n        // Price\n        $url .= ($this->getInput('maxprice') ? '&price[max]='\n            . $this->getInput('maxprice') : '&price[max]=300');\n        $url .= ($this->getInput('minprice') ? '&price[min]='\n            . $this->getInput('minprice') : '&price[min]=0');\n\n        // Features\n        $url .= ($this->getInput('cumtube') ? '&cumtube=1' : '');\n        $url .= ($this->getInput('suctionCup') ? '&suctionCup=1' : '');\n        $url .= ($this->getInput('noAccessories') ? '&noAccessories=1' : '');\n\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/BakaUpdatesMangaReleasesBridge.php",
    "content": "<?php\n\nclass BakaUpdatesMangaReleasesBridge extends BridgeAbstract\n{\n    const NAME = 'Baka Updates Manga Releases';\n    const URI = 'https://www.mangaupdates.com/';\n    const DESCRIPTION = 'Get the latest series releases';\n    const MAINTAINER = 'fulmeek, KamaleiZestri';\n    const PARAMETERS = [\n        'By series' => [\n            'series_id' => [\n                'name'      => 'Series ID',\n                'type'      => 'number',\n                'required'  => true,\n                'exampleValue'  => '188066'\n            ]\n        ],\n        'By list' => [\n            'list_id' => [\n                'name'      => 'List ID and Type',\n                'type'      => 'text',\n                'required'  => true,\n                'exampleValue'  => '4395&list=read'\n            ]\n        ]\n    ];\n    const LIMIT_COLS = 5;\n    const LIMIT_ITEMS = 10;\n    const RELEASES_URL = 'https://www.mangaupdates.com/releases.html';\n\n    private $feedName = '';\n\n    public function collectData()\n    {\n        if ($this -> queriedContext == 'By series') {\n            $this -> collectDataBySeries();\n        } else { //queriedContext == 'By list'\n            $this -> collectDataByList();\n        }\n    }\n\n    public function getURI()\n    {\n        if ($this -> queriedContext == 'By series') {\n            $series_id = $this->getInput('series_id');\n            if (!empty($series_id)) {\n                return self::URI . 'releases.html?search=' . $series_id . '&stype=series';\n            }\n        } else {  //queriedContext == 'By list'\n            return self::RELEASES_URL;\n        }\n\n        return self::URI;\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName . ' - ' . self::NAME;\n        }\n        return parent::getName();\n    }\n\n    private function getSanitizedHash($string)\n    {\n        return hash('sha1', preg_replace('/[^a-zA-Z0-9\\-\\.]/', '', ucwords(strtolower($string))));\n    }\n\n    private function filterText($text)\n    {\n        return rtrim($text, '* ');\n    }\n\n    private function filterHTML($text)\n    {\n        return $this->filterText(html_entity_decode($text));\n    }\n\n    private function findID($manga)\n    {\n        // sometimes new series are on the release list that have no ID. just drop them.\n        if (@$this -> filterHTML($manga -> find('a', 0) -> href) != null) {\n            preg_match('/id=([0-9]*)/', $this -> filterHTML($manga -> find('a', 0) -> href), $match);\n            return $match[1];\n        } else {\n            return 0;\n        }\n    }\n\n    private function collectDataBySeries()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        // content is an unstructured pile of divs, ugly to parse\n        $cols = $html->find('div#main_content div.row > div.text');\n        if (!$cols) {\n            throwServerException('No releases');\n        }\n\n        $rows = array_slice(\n            array_chunk($cols, self::LIMIT_COLS),\n            0,\n            self::LIMIT_ITEMS\n        );\n\n        if (isset($rows[0][1])) {\n            $this->feedName = $this->filterHTML($rows[0][1]->plaintext);\n        }\n\n        foreach ($rows as $cols) {\n            if (count($cols) < self::LIMIT_COLS) {\n                continue;\n            }\n\n            $item = [];\n            $title = [];\n\n            $item['content'] = '';\n\n            $objDate = $cols[0];\n            if ($objDate) {\n                $item['timestamp'] = strtotime($objDate->plaintext);\n            }\n\n            $objTitle = $cols[1];\n            if ($objTitle) {\n                $title[] = $this->filterHTML($objTitle->plaintext);\n                $item['content'] .= '<p>Series: ' . $this->filterText($objTitle->innertext) . '</p>';\n            }\n\n            $objVolume = $cols[2];\n            if ($objVolume && !empty($objVolume->plaintext)) {\n                $title[] = 'Vol.' . $objVolume->plaintext;\n            }\n\n            $objChapter = $cols[3];\n            if ($objChapter && !empty($objChapter->plaintext)) {\n                $title[] = 'Chp.' . $objChapter->plaintext;\n            }\n\n            $objAuthor = $cols[4];\n            if ($objAuthor && !empty($objAuthor->plaintext)) {\n                $item['author'] = $this->filterHTML($objAuthor->plaintext);\n                $item['content'] .= '<p>Groups: ' . $this->filterText($objAuthor->innertext) . '</p>';\n            }\n\n            $item['title'] = implode(' ', $title);\n            $item['uri'] = $this->getURI();\n            $item['uid'] = $this->getSanitizedHash($item['title'] . $item['author']);\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function collectDataByList()\n    {\n        $this -> feedName = 'Releases';\n        $list = [];\n\n        $releasesHTML = getSimpleHTMLDOM(self::RELEASES_URL);\n\n        $list_id = $this -> getInput('list_id');\n        $listHTML = getSimpleHTMLDOM('https://www.mangaupdates.com/mylist.html?id=' . $list_id);\n\n        //get ids of the manga that the user follows,\n        $parts = $listHTML -> find('table#ptable tr > td.pl');\n        foreach ($parts as $part) {\n            $list[] = $this -> findID($part);\n        }\n\n        //similar to above, but the divs are in groups of 3.\n        $cols = $releasesHTML -> find('div#main_content div.row > div.pbreak');\n        $rows = array_slice(array_chunk($cols, 3), 0);\n\n        foreach ($rows as $cols) {\n            //check if current manga is in user's list.\n            $id = $this -> findId($cols[0]);\n            if (!array_search($id, $list)) {\n                continue;\n            }\n\n            $item = [];\n            $title = [];\n\n            $item['content'] = '';\n\n            $objTitle = $cols[0];\n            if ($objTitle) {\n                $title[] = $this->filterHTML($objTitle->plaintext);\n                $item['content'] .= '<p>Series: ' . $this->filterHTML($objTitle -> innertext) . '</p>';\n            }\n\n            $objVolChap = $cols[1];\n            if ($objVolChap && !empty($objVolChap->plaintext)) {\n                $title[] = $this -> filterHTML($objVolChap -> innertext);\n            }\n\n            $objAuthor = $cols[2];\n            if ($objAuthor && !empty($objAuthor->plaintext)) {\n                $item['author'] = $this->filterHTML($objAuthor -> plaintext);\n                $item['content'] .= '<p>Groups: ' . $this->filterHTML($objAuthor -> innertext) . '</p>';\n            }\n\n            $item['title'] = implode(' ', $title);\n            $item['uri'] = self::URI . 'releases.html?search=' . $id . '&stype=series';\n            $item['uid'] = $this->getSanitizedHash($item['title'] . $item['author']);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BandcampBridge.php",
    "content": "<?php\n\nclass BandcampBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sebsauvage, Roliga';\n    const NAME = 'Bandcamp';\n    const URI = 'https://bandcamp.com/';\n    const CACHE_TIMEOUT = 600; // 10min\n    const DESCRIPTION = 'New bandcamp releases by tag, band or album';\n    const PARAMETERS = [\n        'By tag' => [\n            'tag' => [\n                'name' => 'tag',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue'  => 'hip-hop-rap'\n            ]\n        ],\n        'By band' => [\n            'band' => [\n                'name' => 'band',\n                'type' => 'text',\n                'title' => 'Band name as seen in the band page URL',\n                'required' => true,\n                'exampleValue'  => 'aesoprock'\n            ],\n            'type' => [\n                'name' => 'Articles are',\n                'type' => 'list',\n                'values' => [\n                    'Releases' => 'releases',\n                    'Releases, new one when track list changes' => 'changes',\n                    'Individual tracks' => 'tracks'\n                ],\n                'defaultValue' => 'changes'\n            ],\n            'limit' => [\n                'name' => 'limit',\n                'type' => 'number',\n                'required' => true,\n                'title' => 'Number of releases to return',\n                'defaultValue' => 5\n            ]\n        ],\n        'By label' => [\n            'label' => [\n                'name' => 'label',\n                'type' => 'text',\n                'title' => 'label name as seen in the label page URL',\n                'required' => true\n            ],\n            'type' => [\n                'name' => 'Articles are',\n                'type' => 'list',\n                'values' => [\n                    'Releases' => 'releases',\n                    'Releases, new one when track list changes' => 'changes',\n                    'Individual tracks' => 'tracks'\n                ],\n                'defaultValue' => 'changes'\n            ],\n            'limit' => [\n                'name' => 'limit',\n                'type' => 'number',\n                'title' => 'Number of releases to return',\n                'defaultValue' => 5\n            ]\n        ],\n        'By album' => [\n            'band' => [\n                'name' => 'band',\n                'type' => 'text',\n                'title' => 'Band name as seen in the album page URL',\n                'required' => true,\n                'exampleValue'  => 'aesoprock'\n            ],\n            'album' => [\n                'name' => 'album',\n                'type' => 'text',\n                'title' => 'Album name as seen in the album page URL',\n                'required' => true,\n                'exampleValue'  => 'appleseed'\n            ],\n            'type' => [\n                'name' => 'Articles are',\n                'type' => 'list',\n                'values' => [\n                    'Releases' => 'releases',\n                    'Releases, new one when track list changes' => 'changes',\n                    'Individual tracks' => 'tracks'\n                ],\n                'defaultValue' => 'tracks'\n            ]\n        ]\n    ];\n    const IMGURI = 'https://f4.bcbits.com/';\n    const IMGSIZE_300PX = 23;\n    const IMGSIZE_700PX = 16;\n\n    private $feedName;\n\n    public function getIcon()\n    {\n        return 'https://s4.bcbits.com/img/bc_favicon.ico';\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'By tag':\n                $url = self::URI . 'api/hub/1/dig_deeper';\n                $data = $this->buildRequestJson();\n                $header = [\n                    'Content-Type: application/json',\n                    'Content-Length: ' . strlen($data),\n                ];\n                $opts = [\n                    CURLOPT_CUSTOMREQUEST => 'POST',\n                    CURLOPT_POSTFIELDS => $data,\n                ];\n                $content = getContents($url, $header, $opts);\n\n                $json = json_decode($content);\n\n                if ($json->ok !== true) {\n                    throwServerException('Invalid response');\n                }\n\n                foreach ($json->items as $entry) {\n                    $url = $entry->tralbum_url;\n                    $artist = $entry->artist;\n                    $title = $entry->title;\n                    // e.g. record label is the releaser, but not the artist\n                    $releaser = $entry->band_name !== $entry->artist ? $entry->band_name : null;\n\n                    $full_title = $artist . ' - ' . $title;\n                    $full_artist = $artist;\n                    if (isset($releaser)) {\n                        $full_title .= ' (' . $releaser . ')';\n                        $full_artist .= ' (' . $releaser . ')';\n                    }\n                    $small_img = $this->getImageUrl($entry->art_id, self::IMGSIZE_300PX);\n                    $img = $this->getImageUrl($entry->art_id, self::IMGSIZE_700PX);\n\n                    $item = [\n                    'uri' => $url,\n                    'author' => $full_artist,\n                    'title' => $full_title\n                    ];\n                    $item['content'] = \"<img src='$small_img' /><br/>$full_title\";\n                    $item['enclosures'] = [$img];\n                    $this->items[] = $item;\n                }\n                break;\n            case 'By band':\n            case 'By label':\n            case 'By album':\n                $html = getSimpleHTMLDOMCached($this->getURI(), 86400);\n\n                if ($html->find('meta[name=title]', 0)) {\n                    $this->feedName = $html->find('meta[name=title]', 0)->content;\n                } else {\n                    $this->feedName = str_replace('Music | ', '', $html->find('title', 0)->plaintext);\n                }\n\n                $regex = '/band_id=(\\d+)/';\n                if (preg_match($regex, $html, $matches) == false) {\n                    throwClientException('Unable to find band ID on: ' . $this->getURI());\n                }\n                $band_id = $matches[1];\n\n                $tralbums = [];\n                switch ($this->queriedContext) {\n                    case 'By band':\n                    case 'By label':\n                        $query_data = [\n                        'band_id' => $band_id\n                        ];\n                        $band_data = $this->apiGet('mobile/22/band_details', $query_data);\n\n                        $num_albums = min(count($band_data->discography), $this->getInput('limit'));\n                        for ($i = 0; $i < $num_albums; $i++) {\n                            $album_basic_data = $band_data->discography[$i];\n\n                            // 'a' or 't' for albums and individual tracks respectively\n                            $tralbum_type = substr($album_basic_data->item_type, 0, 1);\n\n                            $query_data = [\n                            'band_id' => $band_id,\n                            'tralbum_type' => $tralbum_type,\n                            'tralbum_id' => $album_basic_data->item_id\n                            ];\n                            $tralbums[] = $this->apiGet('mobile/22/tralbum_details', $query_data);\n                        }\n                        break;\n                    case 'By album':\n                        $regex = '/album=(\\d+)/';\n                        if (preg_match($regex, $html, $matches) == false) {\n                            throwClientException('Unable to find album ID on: ' . $this->getURI());\n                        }\n                        $album_id = $matches[1];\n\n                        $query_data = [\n                        'band_id' => $band_id,\n                        'tralbum_type' => 'a',\n                        'tralbum_id' => $album_id\n                        ];\n                        $tralbums[] = $this->apiGet('mobile/22/tralbum_details', $query_data);\n\n                        break;\n                }\n\n                foreach ($tralbums as $tralbum_data) {\n                    if ($tralbum_data->type === 'a' && $this->getInput('type') === 'tracks') {\n                        foreach ($tralbum_data->tracks as $track) {\n                            $query_data = [\n                            'band_id' => $band_id,\n                            'tralbum_type' => 't',\n                            'tralbum_id' => $track->track_id\n                            ];\n                            $track_data = $this->apiGet('mobile/22/tralbum_details', $query_data);\n\n                            $this->items[] = $this->buildTralbumItem($track_data);\n                        }\n                    } else {\n                        $this->items[] = $this->buildTralbumItem($tralbum_data);\n                    }\n                }\n                break;\n        }\n    }\n\n    private function buildTralbumItem($tralbum_data)\n    {\n        $band_data = $tralbum_data->band;\n\n        // Format title like: ARTIST - ALBUM/TRACK (OPTIONAL RELEASER)\n        // Format artist/author like: ARTIST (OPTIONAL RELEASER)\n        //\n        // If the album/track is released under a label/a band other than the artist\n        // themselves, append that releaser name to the title and artist/author.\n        //\n        // This sadly doesn't always work right for individual tracks as the artist\n        // of the track is always set to the releaser.\n        $artist = $tralbum_data->tralbum_artist;\n        $full_title = $artist . ' - ' . $tralbum_data->title;\n        $full_artist = $artist;\n        if (isset($tralbum_data->label)) {\n            $full_title .= ' (' . $tralbum_data->label . ')';\n            $full_artist .= ' (' . $tralbum_data->label . ')';\n        } elseif ($band_data->name !== $artist) {\n            $full_title .= ' (' . $band_data->name . ')';\n            $full_artist .= ' (' . $band_data->name . ')';\n        }\n\n        $small_img = $this->getImageUrl($tralbum_data->art_id, self::IMGSIZE_300PX);\n        $img = $this->getImageUrl($tralbum_data->art_id, self::IMGSIZE_700PX);\n\n        $item = [\n            'uri' => $tralbum_data->bandcamp_url,\n            'author' => $full_artist,\n            'title' => $full_title,\n            'enclosures' => [$img],\n            'timestamp' => $tralbum_data->release_date\n        ];\n\n        $item['categories'] = [];\n        foreach ($tralbum_data->tags as $tag) {\n            $item['categories'][] = $tag->norm_name;\n        }\n\n        // Give articles a unique UID depending on its track list\n        // Releases should then show up as new articles when tracks are added\n        if ($this->getInput('type') === 'changes') {\n            $item['uid'] = \"bandcamp/$band_data->band_id/$tralbum_data->id/\";\n            foreach ($tralbum_data->tracks as $track) {\n                $item['uid'] .= $track->track_id;\n            }\n        }\n\n        $item['content'] = \"<img src='$small_img' /><br/>$full_title<br/>\";\n        if ($tralbum_data->type === 'a') {\n            $item['content'] .= '<ol>';\n            foreach ($tralbum_data->tracks as $track) {\n                $item['content'] .= \"<li>$track->title</li>\";\n            }\n            $item['content'] .= '</ol>';\n        }\n        if (!empty($tralbum_data->about)) {\n            $item['content'] .= '<p>'\n                . nl2br($tralbum_data->about)\n                . '</p>';\n        }\n\n        return $item;\n    }\n\n    private function buildRequestJson()\n    {\n        $requestJson = [\n            'tag' => $this->getInput('tag'),\n            'page' => 1,\n            'sort' => 'date'\n        ];\n        return json_encode($requestJson);\n    }\n\n    private function getImageUrl($id, $size)\n    {\n        return self::IMGURI . 'img/a' . $id . '_' . $size . '.jpg';\n    }\n\n    private function apiGet($endpoint, $query_data)\n    {\n        $url = self::URI . 'api/' . $endpoint . '?' . http_build_query($query_data);\n        // todo: 429 Too Many Requests happens a lot\n        $response = getContents($url);\n        $data = json_decode($response);\n        return $data;\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'By tag':\n                if (!is_null($this->getInput('tag'))) {\n                    return self::URI\n                    . 'tag/'\n                    . urlencode($this->getInput('tag'))\n                    . '?sort_field=date';\n                }\n                break;\n            case 'By label':\n                if (!is_null($this->getInput('label'))) {\n                    return 'https://'\n                    . $this->getInput('label')\n                    . '.bandcamp.com/music';\n                }\n                break;\n            case 'By band':\n                if (!is_null($this->getInput('band'))) {\n                    return 'https://'\n                    . $this->getInput('band')\n                    . '.bandcamp.com/music';\n                }\n                break;\n            case 'By album':\n                if (!is_null($this->getInput('band')) && !is_null($this->getInput('album'))) {\n                    return 'https://'\n                    . $this->getInput('band')\n                    . '.bandcamp.com/album/'\n                    . $this->getInput('album');\n                }\n                break;\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By tag':\n                if (!is_null($this->getInput('tag'))) {\n                    return $this->getInput('tag') . ' - Bandcamp Tag';\n                }\n                break;\n            case 'By band':\n                if (isset($this->feedName)) {\n                    return $this->feedName . ' - Bandcamp Band';\n                } elseif (!is_null($this->getInput('band'))) {\n                    return $this->getInput('band') . ' - Bandcamp Band';\n                }\n                break;\n            case 'By label':\n                if (isset($this->feedName)) {\n                    return $this->feedName . ' - Bandcamp Label';\n                } elseif (!is_null($this->getInput('label'))) {\n                    return $this->getInput('label') . ' - Bandcamp Label';\n                }\n                break;\n            case 'By album':\n                if (isset($this->feedName)) {\n                    return $this->feedName . ' - Bandcamp Album';\n                } elseif (!is_null($this->getInput('album'))) {\n                    return $this->getInput('album') . ' - Bandcamp Album';\n                }\n                break;\n        }\n\n        return parent::getName();\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // By tag\n        $regex = '/^(https?:\\/\\/)?bandcamp\\.com\\/tag\\/([^\\/.&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By tag';\n            $params['tag'] = urldecode($matches[2]);\n            return $params;\n        }\n\n        // By band\n        $regex = '/^(https?:\\/\\/)?([^\\/.&?\\n]+?)\\.bandcamp\\.com/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By band';\n            $params['band'] = urldecode($matches[2]);\n            return $params;\n        }\n\n        // By album\n        $regex = '/^(https?:\\/\\/)?([^\\/.&?\\n]+?)\\.bandcamp\\.com\\/album\\/([^\\/.&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By album';\n            $params['band'] = urldecode($matches[2]);\n            $params['album'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/BandcampDailyBridge.php",
    "content": "<?php\n\nclass BandcampDailyBridge extends BridgeAbstract\n{\n    const NAME = 'Bandcamp Daily';\n    const URI = 'https://daily.bandcamp.com';\n    const DESCRIPTION = 'Returns newest articles';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'Latest articles' => [],\n        'Best of' => [\n            'best-content' => [\n                'name' => 'content',\n                'type' => 'list',\n                'values' => [\n                    'Best Ambient' => 'best-ambient',\n                    'Best Beat Tapes' => 'best-beat-tapes',\n                    'Best Dance 12\\'s' => 'best-dance-12s',\n                    'Best Contemporary Classical' => 'best-contemporary-classical',\n                    'Best Electronic' => 'best-electronic',\n                    'Best Experimental' => 'best-experimental',\n                    'Best Hip-Hop' => 'best-hip-hop',\n                    'Best Jazz' => 'best-jazz',\n                    'Best Metal' => 'best-metal',\n                    'Best Punk' => 'best-punk',\n                    'Best Reissues' => 'best-reissues',\n                    'Best Soul' => 'best-soul',\n                ],\n                'defaultValue' => 'best-ambient',\n            ],\n        ],\n        'Genres' => [\n            'genres-content' => [\n                'name' => 'content',\n                'type' => 'list',\n                'values' => [\n                    'Acoustic' => 'genres/acoustic',\n                    'Alternative' => 'genres/alternative',\n                    'Ambient' => 'genres/ambient',\n                    'Blues' => 'genres/blues',\n                    'Classical' => 'genres/classical',\n                    'Comedy' => 'genres/comedy',\n                    'Country' => 'genres/country',\n                    'Devotional' => 'genres/devotional',\n                    'Electronic' => 'genres/electronic',\n                    'Experimental' => 'genres/experimental',\n                    'Folk' => 'genres/folk',\n                    'Funk' => 'genres/funk',\n                    'Hip-Hop/Rap' => 'genres/hip-hop-rap',\n                    'Jazz' => 'genres/jazz',\n                    'Kids' => 'genres/kids',\n                    'Latin' => 'genres/latin',\n                    'Metal' => 'genres/metal',\n                    'Pop' => 'genres/pop',\n                    'Punk' => 'genres/punk',\n                    'R&B/Soul' => 'genres/r-b-soul',\n                    'Reggae' => 'genres/reggae',\n                    'Rock' => 'genres/rock',\n                    'Soundtrack' => 'genres/soundtrack',\n                    'Spoken Word' => 'genres/spoken-word',\n                    'World' => 'genres/world',\n                ],\n                'defaultValue' => 'genres/acoustic',\n            ],\n        ],\n        'Franchises' => [\n            'franchises-content' => [\n                'name' => 'content',\n                'type' => 'list',\n                'values' => [\n                    'Lists' => 'lists',\n                    'Features' => 'features',\n                    'Album of the Day' => 'album-of-the-day',\n                    'Acid Test' => 'acid-test',\n                    'Bandcamp Navigator' => 'bandcamp-navigator',\n                    'Big Ups' => 'big-ups',\n                    'Certified' => 'certified',\n                    'Gallery' => 'gallery',\n                    'Hidden Gems' => 'hidden-gems',\n                    'High Scores' => 'high-scores',\n                    'Label Profile' => 'label-profile',\n                    'Lifetime Achievement' => 'lifetime-achievement',\n                    'Scene Report' => 'scene-report',\n                    'Seven Essential Releases' => 'seven-essential-releases',\n                    'The Merch Table' => 'the-merch-table',\n                ],\n                'defaultValue' => 'lists',\n            ],\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $html = defaultLinkTo($html, self::URI);\n\n        $articles = $html->find('articles-list', 0);\n\n        foreach ($articles->find('div.list-article') as $index => $article) {\n            $item = [];\n\n            $articlePath = $article->find('a.title', 0)->href;\n\n            $articlePageHtml = getSimpleHTMLDOMCached($articlePath, 3600);\n\n            $item['uri'] = $articlePath;\n            $item['title'] = $articlePageHtml->find('article-title', 0)->innertext;\n            $item['author'] = $articlePageHtml->find('article-credits > a', 0)->innertext;\n            $item['content'] = html_entity_decode($articlePageHtml->find('meta[name=\"description\"]', 0)->content, ENT_QUOTES);\n            $item['timestamp'] = $articlePageHtml->find('meta[property=\"article:published_time\"]', 0)->content;\n            $item['categories'][] = $articlePageHtml->find('meta[property=\"article:section\"]', 0)->content;\n\n            if ($articlePageHtml->find('meta[property=\"article:tag\"]', 0)) {\n                $item['categories'][] = $articlePageHtml->find('meta[property=\"article:tag\"]', 0)->content;\n            }\n\n            $item['enclosures'][] = $articlePageHtml->find('meta[name=\"twitter:image\"]', 0)->content;\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Latest articles':\n                return self::URI . '/latest';\n            case 'Best of':\n            case 'Genres':\n            case 'Franchises':\n                // TODO Switch to array_key_first once php >= 7.3\n                $contentKey = key(self::PARAMETERS[$this->queriedContext]);\n                return self::URI . '/' . $this->getInput($contentKey);\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Latest articles':\n                return $this->queriedContext . ' - Bandcamp Daily';\n            case 'Best of':\n            case 'Genres':\n            case 'Franchises':\n                $contentKey = array_key_first(self::PARAMETERS[$this->queriedContext]);\n                $contentValues = array_flip(self::PARAMETERS[$this->queriedContext][$contentKey]['values']);\n\n                return $contentValues[$this->getInput($contentKey)] . ' - Bandcamp Daily';\n            default:\n                return parent::getName();\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BarraqueiroBridgeAbstract.php",
    "content": "<?php\n\nabstract class BarraqueiroBridgeAbstract extends BridgeAbstract\n{\n    const MAINTAINER = 'FJSFerreira';\n\n    public function collectDataBarraqueiro($base_uri, $full_uri)\n    {\n        $dom = getSimpleHTMLDOM($full_uri);\n\n        $data = $dom->find('div.newsFundoGrey1, div.newsFundoGrey2');\n\n        foreach ($data as $entry) {\n            $item = [];\n\n            $text = $entry->find('span.text', 0)->plaintext;\n\n            $title = substr($text, 12);\n\n            $item['uri'] = $base_uri . $entry->find('a', 0)->href;\n            $item['title'] = $title;\n            $item['timestamp'] = DateTimeImmutable::createFromFormat('d-m-Y+', $text)->format('Y-m-d');\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BarraqueiroOesteBridge.php",
    "content": "<?php\n\nclass BarraqueiroOesteBridge extends BarraqueiroBridgeAbstract\n{\n    const NAME = 'Barraqueiro Oeste';\n    const URI = 'https://barraqueiro-oeste.pt/';\n    const DESCRIPTION = 'Barraqueiro Oeste - Informação ao Público';\n\n    public function collectData()\n    {\n        parent::collectDataBarraqueiro(self::URI, self::URI . '/barraqueirooeste/Barraqueiro-Oeste');\n    }\n}\n"
  },
  {
    "path": "bridges/BastaBridge.php",
    "content": "<?php\n\nclass BastaBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'qwertygc';\n    const NAME = 'Bastamag';\n    const URI = 'https://www.bastamag.net/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI . 'spip.php?page=backend');\n\n        $limit = 0;\n\n        foreach ($html->find('item') as $element) {\n            if ($limit < 10) {\n                $item = [];\n                $item['title'] = $element->find('title', 0)->innertext;\n                $item['uri'] = $element->find('guid', 0)->plaintext;\n                $item['timestamp'] = strtotime($element->find('dc:date', 0)->plaintext);\n\n                $html = getSimpleHTMLDOM($item['uri']);\n                $html = defaultLinkTo($html, self::URI);\n\n                $item['content'] = $html->find('div.texte', 0)->innertext;\n                $this->items[] = $item;\n                $limit++;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BazarakiBridge.php",
    "content": "<?php\n\nclass BazarakiBridge extends BridgeAbstract\n{\n    const NAME = 'Bazaraki';\n    const URI = 'https://bazaraki.com';\n    const DESCRIPTION = 'Fetch adverts from Bazaraki, a Cyprus-based classifieds website.';\n    const MAINTAINER = 'danwain';\n    const PARAMETERS = [\n        [\n            'url' => [\n                'name'         => 'URL',\n                'type'         => 'text',\n                'required'     => true,\n                'title'        => 'Enter the URL of the Bazaraki page to fetch adverts from.',\n                'exampleValue' => 'https://www.bazaraki.com/real-estate-for-sale/houses/?lat=0&lng=0&radius=100000',\n            ],\n            'limit' => [\n                'name'         => 'Limit',\n                'type'         => 'number',\n                'required'     => false,\n                'title'        => 'Enter the number of adverts to fetch. (max 50)',\n                'exampleValue' => '10',\n                'defaultValue' => 10,\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n        if (! str_starts_with($url, 'https://www.bazaraki.com/')) {\n            throw new \\Exception('Nope');\n        }\n\n        $html = getSimpleHTMLDOM($url);\n\n        $i = 0;\n        foreach ($html->find('div.advert') as $element) {\n            $i++;\n            if ($i > $this->getInput('limit') || $i > 50) {\n                break;\n            }\n\n            $item = [];\n\n            $item['uri'] = 'https://www.bazaraki.com' . $element->find('a.advert__content-title', 0)->href;\n\n            # Get the content\n            $advert = getSimpleHTMLDOM($item['uri']);\n\n            $price = trim($advert->find('div.announcement-price__cost', 0)->plaintext);\n            $name  = trim($element->find('a.advert__content-title', 0)->plaintext);\n\n            $item['title'] = $name . ' - ' . $price;\n\n            $time = trim($advert->find('span.date-meta', 0)->plaintext);\n            $time = str_replace('Posted: ', '', $time);\n\n\n            $item['content'] = $this->processAdvertContent($advert);\n            $item['timestamp'] = $this->convertRelativeTime($time);\n            $item['author'] = trim($advert->find('div.author-name', 0)->plaintext);\n            $item['uid'] = $advert->find('span.number-announcement', 0)->plaintext;\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Process the advert content to clean up HTML\n     *\n     * @param simple_html_dom $advert The SimpleHTMLDOM object for the advert page\n     * @return string Processed HTML content\n     */\n    private function processAdvertContent($advert)\n    {\n        // Get the content sections\n        $header = $advert->find('div.announcement-content-header', 0);\n        $characteristics = $advert->find('div.announcement-characteristics', 0);\n        $description = $advert->find('div.js-description', 0);\n        $images = $advert->find('div.announcement__images', 0);\n\n        // Remove all favorites divs\n        foreach ($advert->find('div.announcement-meta__favorites') as $favorites) {\n            $favorites->outertext = '';\n        }\n\n        // Replace all <a> tags with their text content\n        foreach ($advert->find('a') as $a) {\n            $a->outertext = $a->innertext;\n        }\n\n        // Format the content with section headers and dividers\n        $formattedContent = '';\n\n        // Add header section\n        $formattedContent .= $header->innertext;\n        $formattedContent .= '<hr/>';\n\n        // Add characteristics section with header\n        $formattedContent .= '<h3>Details</h3>';\n        $formattedContent .= $characteristics->innertext;\n        $formattedContent .= '<hr/>';\n\n        // Add description section with header\n        $formattedContent .= '<h3>Description</h3>';\n        $formattedContent .= $description->innertext;\n        $formattedContent .= '<hr/>';\n\n        // Add images section with header\n        $formattedContent .= '<h3>Images</h3>';\n        $formattedContent .= $images->innertext;\n\n        return $formattedContent;\n    }\n\n    /**\n     * Convert relative time strings like \"Yesterday 12:32\" to proper timestamps\n     *\n     * @param string $timeString The relative time string from the website\n     * @return string Timestamp in a format compatible with strtotime()\n     */\n    private function convertRelativeTime($timeString)\n    {\n        if (strpos($timeString, 'Yesterday') !== false) {\n            // Replace \"Yesterday\" with actual date\n            $time = str_replace('Yesterday', date('Y-m-d', strtotime('-1 day')), $timeString);\n            return date('Y-m-d H:i:s', strtotime($time));\n        } elseif (strpos($timeString, 'Today') !== false) {\n            // Replace \"Today\" with actual date\n            $time = str_replace('Today', date('Y-m-d'), $timeString);\n            return date('Y-m-d H:i:s', strtotime($time));\n        } else {\n            // For other formats, return as is and let strtotime handle it\n            return $timeString;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BinanceBridge.php",
    "content": "<?php\n\nclass BinanceBridge extends BridgeAbstract\n{\n    const NAME = 'Binance Blog';\n    const URI = 'https://www.binance.com/en/blog';\n    const DESCRIPTION = 'Subscribe to the Binance blog.';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 3600; // 1h\n\n    public function collectData()\n    {\n        $url = 'https://www.binance.com/bapi/composite/v1/public/content/blog/list?category=&tag=&page=1&size=12';\n        $json = getContents($url);\n        $data = Json::decode($json, false);\n        foreach ($data->data->blogList as $post) {\n            $item = [];\n            $item['title'] = $post->title;\n            // Url slug not in json\n            //$item['uri'] = $uri;\n            $item['timestamp'] = $post->postTimeUTC / 1000;\n            $item['author'] = 'Binance';\n            $item['content'] = $post->brief;\n            //$item['categories'] = $category;\n            $item['uid'] = $post->idStr;\n            $this->items[] = $item;\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://bin.bnbstatic.com/static/images/common/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/BlaguesDeMerdeBridge.php",
    "content": "<?php\n\nclass BlaguesDeMerdeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'superbaillot.net, logmanoriginal';\n    const NAME = 'Blagues De Merde';\n    const URI = 'http://www.blaguesdemerde.fr/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Blagues De Merde';\n\n    public function getIcon()\n    {\n        return self::URI . 'assets/img/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('div.blague') as $element) {\n            $item = [];\n\n            $item['uri'] = static::URI . '#' . $element->id;\n            $item['author'] = $element->find('div[class=\"blague-footer\"] p strong', 0)->plaintext;\n\n            // Let the title be everything up to the first <br>\n            $item['title'] = trim(explode(\"\\n\", $element->find('div.text', 0)->plaintext)[0]);\n\n            $item['content'] = strip_tags($element->find('div.text', 0));\n\n            // timestamp is part of:\n            // <p>Par <strong>{author}</strong> le {date} dans <strong>{category}</strong></p>\n            preg_match(\n                '/.+le(.+)dans.*/',\n                $element->find('div[class=\"blague-footer\"]', 0)->plaintext,\n                $matches\n            );\n\n            $item['timestamp'] = strtotime($matches[1]);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BleepingComputerBridge.php",
    "content": "<?php\n\nclass BleepingComputerBridge extends FeedExpander\n{\n    const MAINTAINER = 'csisoap';\n    const NAME = 'Bleeping Computer';\n    const URI = 'https://www.bleepingcomputer.com/';\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    public function collectData()\n    {\n        $feed = static::URI . 'feed/';\n        $this->collectExpandableDatas($feed);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article_html = getSimpleHTMLDOMCached($item['uri']);\n        if (!$article_html) {\n            $item['content'] .= '<p><em>Could not request ' . $this->getName() . ': ' . $item['uri'] . '</em></p>';\n            return $item;\n        }\n\n        $article_content = $article_html->find('div.articleBody', 0)->innertext;\n        $article_content = stripRecursiveHTMLSection($article_content, 'div', '<div class=\"cz-related-article-wrapp');\n        $item['content'] = trim($article_content);\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/BlizzardNewsBridge.php",
    "content": "<?php\n\nclass BlizzardNewsBridge extends BridgeAbstract\n{\n    const NAME = 'Blizzard News';\n    const URI = 'https://news.blizzard.com';\n    const DESCRIPTION = 'Blizzard (game company) newsfeed';\n    const MAINTAINER = 'Niehztog';\n    const PARAMETERS = [\n        '' => [\n            'locale' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'Deutsch' => 'de-de',\n                    'English (EU)' => 'en-gb',\n                    'English (US)' => 'en-us',\n                    'Español (EU)' => 'es-es',\n                    'Español (AL)' => 'es-mx',\n                    'Français' => 'fr-fr',\n                    'Italiano' => 'it-it',\n                    '日本語' => 'ja-jp',\n                    '한국어' => 'ko-kr',\n                    'Polski' => 'pl-pl',\n                    'Português (AL)' => 'pt-br',\n                    'Русский' => 'ru-ru',\n                    'ภาษาไทย' => 'th-th',\n                    '简体中文' => 'zh-cn',\n                    '繁體中文' => 'zh-tw'\n                ],\n                'defaultValue' => 'en-us',\n                'title' => 'Select your language'\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 3600;\n\n    private const PRODUCT_IDS = [\n        'blt525c436e4a1b0a97',\n        'blt54fbd3787a705054',\n        'blt2031aef34200656d',\n        'blt795c314400d7ded9',\n        'blt5cfc6affa3ca0638',\n        'blt2e50e1521bb84dc6',\n        'blt376fb94931906b6f',\n        'blt81d46fcb05ab8811',\n        'bltede2389c0a8885aa',\n        'blt24859ba8086fb294',\n        'blte27d02816a8ff3e1',\n        'blt2caca37e42f19839',\n        'blt90855744d00cd378',\n        'bltec70ad0ea4fd6d1d',\n        'blt500c1f8b5470bfdb'\n    ];\n\n    private const API_PATH = '/api/news/blizzard?';\n\n    /**\n     * Source Web page URL (should provide either HTML or XML content)\n     * @return string\n     */\n    private function getSourceUrl(): string\n    {\n        $locale = $this->getInput('locale');\n        if ('zh-cn' === $locale) {\n            $baseUrl = 'https://cn.news.blizzard.com' . self::API_PATH;\n        } else {\n            $baseUrl = 'https://news.blizzard.com/' . $locale . self::API_PATH;\n        }\n        return $baseUrl .= http_build_query([\n            'feedCxpProductIds' => self::PRODUCT_IDS\n        ]);\n    }\n\n    public function collectData()\n    {\n        $feedContent = json_decode(getContents($this->getSourceUrl()), true);\n\n        foreach ($feedContent['feed']['contentItems'] as $entry) {\n            $properties = $entry['properties'];\n\n            $item = [];\n\n            $item['title'] = $this->filterChars($properties['title']);\n            $item['content'] = $this->filterChars($properties['summary']);\n            $item['uri'] = $properties['newsUrl'];\n            $item['author'] = $this->filterChars($properties['author']);\n            $item['timestamp'] = strtotime($properties['lastUpdated']);\n            $item['enclosures'] = [$properties['staticAsset']['imageUrl']];\n            $item['categories'] = [$this->filterChars($properties['cxpProduct']['title'])];\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function filterChars($content)\n    {\n        return htmlspecialchars($content, ENT_XML1);\n    }\n\n    public function getIcon()\n    {\n        return <<<icon\nhttps://dfbmfbnnydoln.cloudfront.net/production/images/favicons/favicon.ba01bb119359d74970b02902472fd82e96b5aba7.ico\nicon;\n    }\n}\n"
  },
  {
    "path": "bridges/BlueskyBridge.php",
    "content": "<?php\n\nclass BlueskyBridge extends BridgeAbstract\n{\n    //Initial PR by [RSSBridge contributors](https://github.com/RSS-Bridge/rss-bridge/issues/4058).\n    //Modified from [©DIYgod and contributors at RSSHub](https://github.com/DIYgod/RSSHub/tree/master/lib/routes/bsky), MIT License';\n    const NAME = 'Bluesky';\n    const URI = 'https://bsky.app';\n    const DESCRIPTION = 'Fetches posts from Bluesky';\n    const MAINTAINER = 'mruac';\n    const PARAMETERS = [\n        [\n            'data_source' => [\n                'name' => 'Bluesky Data Source',\n                'type' => 'list',\n                'defaultValue' => 'Profile',\n                'values' => [\n                    'Profile' => 'getAuthorFeed',\n                ],\n                'title' => 'Select the type of data source to fetch from Bluesky.'\n            ],\n            'user_id' => [\n                'name' => 'User Handle or DID',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'did:plc:z72i7hdynmk6r22z27h6tvur',\n                'title' => 'ATProto / Bsky.app handle or DID'\n            ],\n            'feed_filter' => [\n                'name' => 'Feed type',\n                'type' => 'list',\n                'defaultValue' => 'posts_and_author_threads',\n                'values' => [\n                    'Posts feed' => 'posts_and_author_threads',\n                    'All posts and replies' => 'posts_with_replies',\n                    'Root posts only' => 'posts_no_replies',\n                    'Media only' => 'posts_with_media',\n                ]\n            ],\n\n            'include_reposts' => [\n                'name' => 'Include Reposts?',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n\n            'include_reply_context' => [\n                'name' => 'Include Reply context?',\n                'type' => 'checkbox'\n            ],\n\n            'verbose_title' => [\n                'name' => 'Use verbose feed item titles?',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    private $profile;\n\n    public function getName()\n    {\n        if (isset($this->profile)) {\n            if ($this->profile['handle'] === 'handle.invalid') {\n                return sprintf('Bluesky - %s', $this->profile['displayName']);\n            } else {\n                return sprintf('Bluesky - %s (@%s)', $this->profile['displayName'], $this->profile['handle']);\n            }\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (isset($this->profile)) {\n            if ($this->profile['handle'] === 'handle.invalid') {\n                return self::URI . '/profile/' . $this->profile['did'];\n            } else {\n                return self::URI . '/profile/' . $this->profile['handle'];\n            }\n        }\n        return parent::getURI();\n    }\n\n    public function getIcon()\n    {\n        if (isset($this->profile)) {\n            return $this->profile['avatar'];\n        }\n        return parent::getIcon();\n    }\n\n    public function getDescription()\n    {\n        if (isset($this->profile)) {\n            return $this->profile['description'];\n        }\n        return parent::getDescription();\n    }\n\n    private function parseExternal($external, $did)\n    {\n        $description = '';\n        $externalUri = $external['uri'];\n        $externalTitle = e($external['title']);\n        $externalDescription = e($external['description']);\n        $thumb = $external['thumb'] ?? null;\n\n        if (preg_match('/http(|s):\\/\\/media\\.tenor\\.com/', $externalUri)) {\n            //tenor gif embed\n            $tenorInterstitial = str_replace('media.tenor.com', 'media1.tenor.com/m', $externalUri);\n            $description .= \"<figure><a href=\\\"$tenorInterstitial\\\"><img src=\\\"$externalUri\\\"/></a><figcaption>$externalTitle</figcaption></figure>\";\n        } else {\n            //link embed preview\n            $host = parse_url($externalUri)['host'];\n            $thumbDesc = $thumb ? ('<img src=\"https://cdn.bsky.app/img/feed_thumbnail/plain/' . $did . '/' . $thumb['ref']['$link'] . '@jpeg\"/>') : '';\n            $externalDescription = strlen($externalDescription) > 0 ? \"<figcaption>($host) $externalDescription</figcaption>\" : '';\n            $description .= '<br><blockquote><b><a href=\"' . $externalUri . '\">' . $externalTitle . '</a></b>';\n            $description .= '<figure>' . $thumbDesc . $externalDescription . '</figure></blockquote>';\n        }\n        return $description;\n    }\n\n    private function textToDescription($record)\n    {\n        if (isset($record['value'])) {\n            $record = $record['value'];\n        }\n        $text = $record['text'];\n        $text_copy = $text;\n        $text = nl2br(e($text));\n        if (isset($record['facets'])) {\n            $facets = $record['facets'];\n            foreach ($facets as $facet) {\n                if ($facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link') {\n                    $substring = substr($text_copy, $facet['index']['byteStart'], $facet['index']['byteEnd'] - $facet['index']['byteStart']);\n                    $text = str_replace($substring, '<a href=\"' . $facet['features'][0]['uri'] . '\">' . $substring . '</a>', $text);\n                }\n            }\n        }\n        return $text;\n    }\n\n    public function collectData()\n    {\n        $user_id = $this->getInput('user_id');\n        $handle_match = preg_match('/(?:[a-zA-Z]*\\.)+([a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)/', $user_id, $handle_res); //gets the TLD in $handle_match[1]\n        $did_match = preg_match('/did:plc:[a-z2-7]{24}/', $user_id); //https://github.com/did-method-plc/did-method-plc#identifier-syntax\n        $exclude = ['alt', 'arpa', 'example', 'internal', 'invalid', 'local', 'localhost', 'onion']; //https://en.wikipedia.org/wiki/Top-level_domain#Reserved_domains\n        if ($handle_match == true && array_search($handle_res[1], $exclude) == false) {\n            //valid bsky handle\n            $did = $this->resolveHandle($user_id);\n        } elseif ($did_match == true) {\n            //valid DID\n            $did = $user_id;\n        } else {\n            throwClientException('Invalid ATproto handle or DID provided.');\n        }\n\n        $filter = $this->getInput('feed_filter') ?: 'posts_and_author_threads';\n        $replyContext = $this->getInput('include_reply_context');\n\n        $this->profile = $this->getProfile($did);\n        $authorFeed = $this->getAuthorFeed($did, $filter);\n\n        foreach ($authorFeed['feed'] as $post) {\n            $postRecord = $post['post']['record'];\n\n            $item = [];\n            $item['uri'] = self::URI . '/profile/' . $this->fallbackAuthor($post['post']['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1];\n            $item['title'] = $this->getInput('verbose_title') ? $this->generateVerboseTitle($post) : strtok($postRecord['text'], \"\\n\");\n            $item['timestamp'] = strtotime($postRecord['createdAt']);\n            $item['author'] = $this->fallbackAuthor($post['post']['author'], 'display');\n\n            $postAuthorDID = $post['post']['author']['did'];\n            $postAuthorHandle = $post['post']['author']['handle'] !== 'handle.invalid' ? '<i>@' . $post['post']['author']['handle'] . '</i>' : '';\n            $postDisplayName = $post['post']['author']['displayName'] ?? '';\n            $postDisplayName = e($postDisplayName);\n            $postUri = $item['uri'];\n\n            $url = explode('/', $post['post']['uri']);\n            $this->logger->debug('https://bsky.app/profile/' . $url[2] . '/post/' . $url[4]);\n\n            $description = '';\n            $description .= '<p>';\n            //post\n            $description .= $this->getPostDescription(\n                $postDisplayName,\n                $postAuthorHandle,\n                $postUri,\n                $postRecord,\n                'post'\n            );\n\n            if (isset($postRecord['embed']['$type'])) {\n                //post link embed\n                if ($postRecord['embed']['$type'] === 'app.bsky.embed.external') {\n                    $description .= $this->parseExternal($postRecord['embed']['external'], $postAuthorDID);\n                } elseif (\n                    $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                    $postRecord['embed']['media']['$type'] === 'app.bsky.embed.external'\n                ) {\n                    $description .= $this->parseExternal($postRecord['embed']['media']['external'], $postAuthorDID);\n                }\n\n                //post images\n                if (\n                    $postRecord['embed']['$type'] === 'app.bsky.embed.images' ||\n                    (\n                        $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                        $postRecord['embed']['media']['$type'] === 'app.bsky.embed.images'\n                    )\n                ) {\n                    $images = $post['post']['embed']['images'] ?? $post['post']['embed']['media']['images'];\n                    foreach ($images as $image) {\n                        $description .= $this->getPostImageDescription($image);\n                    }\n                }\n\n                //post video\n                if (\n                    $postRecord['embed']['$type'] === 'app.bsky.embed.video' ||\n                    (\n                        $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                        $postRecord['embed']['media']['$type'] === 'app.bsky.embed.video'\n                    )\n                ) {\n                    $description .= $this->getPostVideoDescription(\n                        $postRecord['embed']['video'] ?? $postRecord['embed']['media']['video'],\n                        $postAuthorDID\n                    );\n                }\n            }\n            $description .= '</p>';\n\n            //quote post\n            if (\n                isset($postRecord['embed']) &&\n                (\n                    $postRecord['embed']['$type'] === 'app.bsky.embed.record' ||\n                    $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia'\n                ) &&\n                isset($post['post']['embed']['record'])\n            ) {\n                $description .= '<p>';\n                $quotedRecord = $post['post']['embed']['record']['record'] ?? $post['post']['embed']['record'];\n\n                if (isset($quotedRecord['notFound']) && $quotedRecord['notFound']) { //deleted post\n                    $description .= 'Quoted post deleted.';\n                } elseif (isset($quotedRecord['detached']) && $quotedRecord['detached']) { //detached quote\n                    $uri_explode = explode('/', $quotedRecord['uri']);\n                    $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4];\n                    $description .= '<a href=\"' . $uri_reconstructed . '\">Quoted post detached.</a>';\n                } elseif (isset($quotedRecord['blocked']) && $quotedRecord['blocked']) { //blocked by quote author\n                    $description .= 'Author of quoted post has blocked OP.';\n                } elseif (\n                    ($quotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView' ||\n                    ($quotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#listView'\n                ) {\n                    $description .= $this->getListFeedDescription($quotedRecord);\n                } elseif (\n                    ($quotedRecord['$type'] ?? '') === 'app.bsky.graph.starterpack' ||\n                    ($quotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#starterPackViewBasic'\n                ) {\n                    $description .= $this->getStarterPackDescription($post['post']['embed']['record']);\n                } else {\n                    $quotedAuthorDid = $quotedRecord['author']['did'];\n                    $quotedDisplayName = $quotedRecord['author']['displayName'] ?? '';\n                    $quotedDisplayName = e($quotedDisplayName);\n                    $quotedAuthorHandle = $quotedRecord['author']['handle'] !== 'handle.invalid' ? '<i>@' . $quotedRecord['author']['handle'] . '</i>' : '';\n\n                    $parts = explode('/', $quotedRecord['uri']);\n                    $quotedPostId = end($parts);\n                    $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($quotedRecord['author'], 'url') . '/post/' . $quotedPostId;\n\n                    //quoted post - post\n                    $description .= $this->getPostDescription(\n                        $quotedDisplayName,\n                        $quotedAuthorHandle,\n                        $quotedPostUri,\n                        $quotedRecord,\n                        'quote'\n                    );\n\n                    if (isset($quotedRecord['value']['embed']['$type'])) {\n                        //quoted post - post link embed\n                        if ($quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') {\n                            $description .= $this->parseExternal($quotedRecord['value']['embed']['external'], $quotedAuthorDid);\n                        }\n\n                        //quoted post - post video\n                        if (\n                            $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' ||\n                            (\n                                $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                                $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video'\n                            )\n                        ) {\n                            $description .= $this->getPostVideoDescription(\n                                $quotedRecord['value']['embed']['video'] ?? $quotedRecord['value']['embed']['media']['video'],\n                                $quotedAuthorDid\n                            );\n                        }\n\n                        //quoted post - post images\n                        if (\n                            $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' ||\n                            (\n                                $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                                $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images'\n                            )\n                        ) {\n                            foreach ($quotedRecord['embeds'] as $embed) {\n                                if (\n                                    $embed['$type'] === 'app.bsky.embed.images#view' ||\n                                    ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view')\n                                ) {\n                                    $images = $embed['images'] ?? $embed['media']['images'];\n                                    foreach ($images as $image) {\n                                        $description .= $this->getPostImageDescription($image);\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n                $description .= '</p>';\n            }\n\n            //reply\n            if ($replyContext && isset($post['reply']) && isset($post['reply']['parent'])) {\n                $replyPost = $post['reply']['parent'];\n                $description .= '<hr/>';\n                $description .= '<p>';\n\n                if (isset($replyPost['notFound']) && $replyPost['notFound']) { //deleted post\n                    $description .= 'Replied to post was deleted.';\n                } elseif (isset($replyPost['blocked']) && $replyPost['blocked']) { //blocked by quote author\n                    $description .= 'Author of replied to post has blocked OP.';\n                } else {\n                    $replyPostRecord = $replyPost['record'];\n                    $replyPostAuthorDID = $replyPost['author']['did'];\n                    $replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyPost['author']['handle'] . '</i>' : '';\n                    $replyPostDisplayName = $replyPost['author']['displayName'] ?? '';\n                    $replyPostDisplayName = e($replyPostDisplayName);\n                    $replyPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyPost['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $replyPost['uri'])[1];\n\n                    // reply post\n                    $description .= $this->getPostDescription(\n                        $replyPostDisplayName,\n                        $replyPostAuthorHandle,\n                        $replyPostUri,\n                        $replyPostRecord,\n                        'reply'\n                    );\n\n                    if (isset($replyPostRecord['embed']['$type'])) {\n                        //post link embed\n                        if ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.external') {\n                            $description .= $this->parseExternal($replyPostRecord['embed']['external'], $replyPostAuthorDID);\n                        } elseif (\n                            $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                            $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.external'\n                        ) {\n                            $description .= $this->parseExternal($replyPostRecord['embed']['media']['external'], $replyPostAuthorDID);\n                        }\n\n                        //post images\n                        if (\n                            $replyPostRecord['embed']['$type'] === 'app.bsky.embed.images' ||\n                            (\n                                $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                                $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.images'\n                            )\n                        ) {\n                            $images = $replyPost['embed']['images'] ?? $replyPost['embed']['media']['images'];\n                            foreach ($images as $image) {\n                                $description .= $this->getPostImageDescription($image);\n                            }\n                        }\n\n                        //post video\n                        if (\n                            $replyPostRecord['embed']['$type'] === 'app.bsky.embed.video' ||\n                            (\n                                $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                                $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.video'\n                            )\n                        ) {\n                            $description .= $this->getPostVideoDescription(\n                                $replyPostRecord['embed']['video'] ?? $replyPostRecord['embed']['media']['video'],\n                                $replyPostAuthorDID\n                            );\n                        }\n                    }\n                    $description .= '</p>';\n\n                    //quote post\n                    if (\n                        isset($replyPostRecord['embed']) &&\n                        ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.record' || $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia') &&\n                        isset($replyPost['embed']['record'])\n                    ) {\n                        $description .= '<p>';\n                        $replyQuotedRecord = $replyPost['embed']['record']['record'] ?? $replyPost['embed']['record'];\n\n                        if (isset($replyQuotedRecord['notFound']) && $replyQuotedRecord['notFound']) { //deleted post\n                            $description .= 'Quoted post deleted.';\n                        } elseif (isset($replyQuotedRecord['detached']) && $replyQuotedRecord['detached']) { //detached quote\n                            $uri_explode = explode('/', $replyQuotedRecord['uri']);\n                            $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4];\n                            $description .= '<a href=\"' . $uri_reconstructed . '\">Quoted post detached.</a>';\n                        } elseif (isset($replyQuotedRecord['blocked']) && $replyQuotedRecord['blocked']) { //blocked by quote author\n                            $description .= 'Author of quoted post has blocked OP.';\n                        } elseif (\n                            ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView' ||\n                            ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#listView'\n                        ) {\n                            $description .= $this->getListFeedDescription($replyQuotedRecord);\n                        } elseif (\n                            ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.starterpack' ||\n                            ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#starterPackViewBasic'\n                        ) {\n                            $description .= $this->getStarterPackDescription($replyPost['embed']['record']);\n                        } else {\n                            $quotedAuthorDid = $replyQuotedRecord['author']['did'];\n                            $quotedDisplayName = $replyQuotedRecord['author']['displayName'] ?? '';\n                            $quotedDisplayName = e($quotedDisplayName);\n                            $quotedAuthorHandle = $replyQuotedRecord['author']['handle'] !== 'handle.invalid' ? '<i>@' . $replyQuotedRecord['author']['handle'] . '</i>' : '';\n\n                            $parts = explode('/', $replyQuotedRecord['uri']);\n                            $quotedPostId = end($parts);\n                            $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyQuotedRecord['author'], 'url') . '/post/' . $quotedPostId;\n\n                            //quoted post - post\n                            $description .= $this->getPostDescription(\n                                $quotedDisplayName,\n                                $quotedAuthorHandle,\n                                $quotedPostUri,\n                                $replyQuotedRecord,\n                                'quote'\n                            );\n\n                            if (isset($replyQuotedRecord['value']['embed']['$type'])) {\n                                //quoted post - post link embed\n                                if ($replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') {\n                                    $description .= $this->parseExternal($replyQuotedRecord['value']['embed']['external'], $quotedAuthorDid);\n                                }\n\n                                //quoted post - post video\n                                if (\n                                    $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' ||\n                                    (\n                                        $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                                        $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video'\n                                    )\n                                ) {\n                                    $description .= $this->getPostVideoDescription(\n                                        $replyQuotedRecord['value']['embed']['video'] ?? $replyQuotedRecord['value']['embed']['media']['video'],\n                                        $quotedAuthorDid\n                                    );\n                                }\n\n                                //quoted post - post images\n                                if (\n                                    $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' ||\n                                    (\n                                        $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' &&\n                                        $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images'\n                                    )\n                                ) {\n                                    foreach ($replyQuotedRecord['embeds'] as $embed) {\n                                        if (\n                                            $embed['$type'] === 'app.bsky.embed.images#view' ||\n                                            ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view')\n                                        ) {\n                                            $images = $embed['images'] ?? $embed['media']['images'];\n                                            foreach ($images as $image) {\n                                                $description .= $this->getPostImageDescription($image);\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        $description .= '</p>';\n                    }\n                }\n            }\n\n            $item['content'] = $description;\n            $this->items[] = $item;\n        }\n    }\n\n    private function getPostVideoDescription(array $video, $authorDID)\n    {\n        //https://video.bsky.app/watch/$did/$cid/thumbnail.jpg\n        $videoCID = $video['ref']['$link'];\n        $videoMime = $video['mimeType'];\n        $thumbnail = \"poster=\\\"https://video.bsky.app/watch/$authorDID/$videoCID/thumbnail.jpg\\\"\" ?? '';\n        $videoURL = \"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=$authorDID&cid=$videoCID\";\n        return \"<figure><video loop $thumbnail preload=\\\"none\\\" controls src=\\\"$videoURL\\\" type=\\\"$videoMime\\\"/></figure>\";\n    }\n\n    private function getPostImageDescription(array $image)\n    {\n        $thumbnailUrl = $image['thumb'];\n        $fullsizeUrl = $image['fullsize'];\n        $alt = strlen($image['alt']) > 0 ? '<figcaption>' . e($image['alt']) . '</figcaption>' : '';\n        return \"<figure><a href=\\\"$fullsizeUrl\\\"><img src=\\\"$thumbnailUrl\\\"></a>$alt</figure>\";\n    }\n\n    private function getPostDescription(\n        string $postDisplayName,\n        string $postAuthorHandle,\n        string $postUri,\n        array $postRecord,\n        string $type\n    ) {\n        $description = '';\n        if ($type === 'quote') {\n            // Quoted post/reply from bbb @bbb.com:\n            $postType = isset($postRecord['reply']) ? 'reply' : 'post';\n            $description .= \"<a href=\\\"$postUri\\\">Quoted $postType</a> from <b>$postDisplayName</b> $postAuthorHandle:<br>\";\n        } elseif ($type === 'reply') {\n            // Replying to aaa @aaa.com's post/reply:\n            $postType = isset($postRecord['reply']) ? 'reply' : 'post';\n            $description .= \"Replying to <b>$postDisplayName</b> $postAuthorHandle's <a href=\\\"$postUri\\\">$postType</a>:<br>\";\n        } else {\n            // aaa @aaa.com posted/replied:\n            $postType = isset($postRecord['reply']) ? 'replied' : 'posted';\n            $description .= \"<b>$postDisplayName</b> $postAuthorHandle <a href=\\\"$postUri\\\">$postType</a>:<br>\";\n        }\n        $description .= $this->textToDescription($postRecord);\n        return $description;\n    }\n\n    //used if handle verification fails, fallsback to displayName or DID depending on context.\n    private function fallbackAuthor($author, $reason)\n    {\n        if ($author['handle'] === 'handle.invalid') {\n            switch ($reason) {\n                case 'url':\n                    return $author['did'];\n                case 'display':\n                    $displayName = $author['displayName'] ?? '';\n                    return e($displayName);\n            }\n        }\n        return $author['handle'];\n    }\n\n    private function generateVerboseTitle($post)\n    {\n        //use \"Post by A, replying to B, quoting C\" instead of post contents\n        $title = '';\n        if (isset($post['reason']) && str_contains($post['reason']['$type'], 'reasonRepost')) {\n            $title .= 'Repost by ' . $this->fallbackAuthor($post['reason']['by'], 'display');\n            if (isset($post['reply'])) {\n                $title .= ', reply by ';\n            } else {\n                $title .= ', post by ';\n            }\n            $title .= $this->fallbackAuthor($post['post']['author'], 'display');\n        } else {\n            if (isset($post['reply'])) {\n                $title .= 'Reply by ' . $this->fallbackAuthor($post['post']['author'], 'display');\n            } else {\n                $title .= 'Post by ' . $this->fallbackAuthor($post['post']['author'], 'display');\n            }\n        }\n\n        if (isset($post['reply'])) {\n            if (isset($post['reply']['parent']['blocked'])) {\n                $replyAuthor = 'blocked user';\n            } elseif (isset($post['reply']['parent']['notFound'])) {\n                $replyAuthor = 'deleted post';\n            } else {\n                $replyAuthor = $this->fallbackAuthor($post['reply']['parent']['author'], 'display');\n            }\n            $title .= ', replying to ' . $replyAuthor;\n        }\n\n        if (\n            isset($post['post']['embed']) &&\n            isset($post['post']['embed']['record']) &&\n            //if not starter pack, feed or list\n            ($post['post']['embed']['record']['$type'] ?? '') !== 'app.bsky.feed.defs#generatorView' &&\n            ($post['post']['embed']['record']['$type'] ?? '') !== 'app.bsky.graph.defs#listView' &&\n            ($post['post']['embed']['record']['$type'] ?? '') !== 'app.bsky.graph.defs#starterPackViewBasic'\n        ) {\n            if (isset($post['post']['embed']['record']['blocked'])) {\n                $quotedAuthor = 'blocked user';\n            } elseif (isset($post['post']['embed']['record']['notFound'])) {\n                $quotedAuthor = 'deleted psost';\n            } elseif (isset($post['post']['embed']['record']['detached'])) {\n                $quotedAuthor = 'detached post';\n            } else {\n                $quotedAuthor = $this->fallbackAuthor($post['post']['embed']['record']['record']['author'] ?? $post['post']['embed']['record']['author'], 'display');\n            }\n            $title .= ', quoting ' . $quotedAuthor;\n        }\n        return $title;\n    }\n\n    private function resolveHandle($handle)\n    {\n        $uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle);\n        $response = $this->cache->get($uri) ?? json_decode(getContents($uri), true);\n        if (isset($response['did'])) {\n            $this->cache->set($uri, $response, 7 * 24 * 60 * 60);\n        }\n        return $response['did'];\n    }\n\n    private function getProfile($did)\n    {\n        $uri = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=' . urlencode($did);\n        $response = $this->cache->get($uri) ?? json_decode(getContents($uri), true);\n        if ($response['did'] === $did ?? false) {\n            $this->cache->set($uri, $response);\n        }\n        return $response;\n    }\n\n    private function getAuthorFeed($did, $filter)\n    {\n        $uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30';\n\n        $this->logger->debug($uri);\n\n        $response = json_decode(getContents($uri), true);\n        return $response;\n    }\n\n    //Embed for generated feeds and lists\n    private function getListFeedDescription(array $record): string\n    {\n        $feedViewAvatar = isset($record['avatar']) ? '<img src=\"' . preg_replace('/\\/img\\/avatar\\//', '/img/avatar_thumbnail/', $record['avatar']) . '\">' : '';\n        $feedViewName = e($record['displayName'] ?? $record['name']);\n        $feedViewDescription = e($record['description'] ?? '');\n        $authorDisplayName = e($record['creator']['displayName']);\n        $authorHandle = e($record['creator']['handle']);\n        $likeCount = isset($record['likeCount']) ? '<br>Liked by ' . e($record['likeCount']) . ' users' : '';\n        preg_match('/\\/([^\\/]+)$/', $record['uri'], $matches);\n        if (($record['purpose'] ?? '') === 'app.bsky.graph.defs#modlist') {\n            $typeURL = '/lists/';\n            $typeDesc = 'moderation list';\n        } elseif (($record['purpose'] ?? '') === 'app.bsky.graph.defs#curatelist') {\n            $typeURL = '/lists/';\n            $typeDesc = 'list';\n        } else {\n            $typeURL = '/feed/';\n            $typeDesc = 'feed';\n        }\n        $uri = e('https://bsky.app/profile/' . $record['creator']['did'] . $typeURL . $matches[1]);\n\n        return <<<END\n<blockquote>\n<b><a href=\"{$uri}\">{$feedViewName}</a></b><br/>\nBluesky {$typeDesc} by <b>{$authorDisplayName}</b> <i>@{$authorHandle}</i>\n<figure>\n{$feedViewAvatar}\n<figcaption>{$feedViewDescription}{$likeCount}</figcaption>\n</figure>\n</blockquote>\nEND;\n    }\n\n    private function getStarterPackDescription(array $record): string\n    {\n        if (!isset($record['record'])) {\n            return 'Failed to get starter pack information.';\n        }\n        $starterpackRecord = $record['record'];\n        $starterpackName = e($starterpackRecord['name']);\n        $starterpackDescription = e($starterpackRecord['description']);\n        $creatorDisplayName = e($record['creator']['displayName']);\n        $creatorHandle = e($record['creator']['handle']);\n        preg_match('/\\/([^\\/]+)$/', $starterpackRecord['list'], $matches);\n        $uri = e('https://bsky.app/starter-pack/' . $record['creator']['did'] . '/' . $matches[1]);\n        return <<<END\n<blockquote>\n<b><a href=\"{$uri}\">{$starterpackName}</a></b><br/>\nBluesky starter pack by <b>{$creatorDisplayName}</b> <i>@{$creatorHandle}</i><br/>\n{$starterpackDescription}\n</blockquote>\nEND;\n    }\n}\n"
  },
  {
    "path": "bridges/BoaViagemBridge.php",
    "content": "<?php\n\nclass BoaViagemBridge extends BarraqueiroBridgeAbstract\n{\n    const NAME = 'Boa Viagem';\n    const URI = 'https://boa-viagem.pt/';\n    const DESCRIPTION = 'Boa Viagem - Informação ao Público';\n\n    public function collectData()\n    {\n        parent::collectDataBarraqueiro(self::URI, self::URI . '/boaviagem/Boa-Viagem');\n    }\n}\n"
  },
  {
    "path": "bridges/BodaccBridge.php",
    "content": "<?php\n\nclass BodaccBridge extends BridgeAbstract\n{\n    const NAME = 'BODACC';\n    const URI = 'https://bodacc-datadila.opendatasoft.com/';\n    const DESCRIPTION = 'Fetches announces from the French Government \"Bulletin Officiel Des Annonces Civiles et Commerciales\".';\n    const CACHE_TIMEOUT = 86400;\n    const MAINTAINER = 'quent1';\n    const PARAMETERS = [\n        'Annonces commerciales' => [\n            'departement' => [\n                'name' => 'Département',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => null,\n                    'Ain' => '01',\n                    'Aisne' => '02',\n                    'Allier' => '03',\n                    'Alpes-de-Haute-Provence' => '04',\n                    'Hautes-Alpes' => '05',\n                    'Alpes-Maritimes' => '06',\n                    'Ardèche' => '07',\n                    'Ardennes' => '08',\n                    'Ariège' => '09',\n                    'Aube' => '10',\n                    'Aude' => '11',\n                    'Aveyron' => '12',\n                    'Bouches-du-Rhône' => '13',\n                    'Calvados' => '14',\n                    'Cantal' => '15',\n                    'Charente' => '16',\n                    'Charente-Maritime' => '17',\n                    'Cher' => '18',\n                    'Corrèze' => '19',\n                    'Corse-du-Sud' => '2A',\n                    'Haute-Corse' => '2B',\n                    'Côte-d\\'Or' => '21',\n                    'Côtes-d\\'Armor' => '22',\n                    'Creuse' => '23',\n                    'Dordogne' => '24',\n                    'Doubs' => '25',\n                    'Drôme' => '26',\n                    'Eure' => '27',\n                    'Eure-et-Loir' => '28',\n                    'Finistère' => '29',\n                    'Gard' => '30',\n                    'Haute-Garonne' => '31',\n                    'Gers' => '32',\n                    'Gironde' => '33',\n                    'Hérault' => '34',\n                    'Ille-et-Vilaine' => '35',\n                    'Indre' => '36',\n                    'Indre-et-Loire' => '37',\n                    'Isère' => '38',\n                    'Jura' => '39',\n                    'Landes' => '40',\n                    'Loir-et-Cher' => '41',\n                    'Loire' => '42',\n                    'Haute-Loire' => '43',\n                    'Loire-Atlantique' => '44',\n                    'Loiret' => '45',\n                    'Lot' => '46',\n                    'Lot-et-Garonne' => '47',\n                    'Lozère' => '48',\n                    'Maine-et-Loire' => '49',\n                    'Manche' => '50',\n                    'Marne' => '51',\n                    'Haute-Marne' => '52',\n                    'Mayenne' => '53',\n                    'Meurthe-et-Moselle' => '54',\n                    'Meuse' => '55',\n                    'Morbihan' => '56',\n                    'Moselle' => '57',\n                    'Nièvre' => '58',\n                    'Nord' => '59',\n                    'Oise' => '60',\n                    'Orne' => '61',\n                    'Pas-de-Calais' => '62',\n                    'Puy-de-Dôme' => '63',\n                    'Pyrénées-Atlantiques' => '64',\n                    'Hautes-Pyrénées' => '65',\n                    'Pyrénées-Orientales' => '66',\n                    'Bas-Rhin' => '67',\n                    'Haut-Rhin' => '68',\n                    'Rhône' => '69',\n                    'Haute-Saône' => '70',\n                    'Saône-et-Loire' => '71',\n                    'Sarthe' => '72',\n                    'Savoie' => '73',\n                    'Haute-Savoie' => '74',\n                    'Paris' => '75',\n                    'Seine-Maritime' => '76',\n                    'Seine-et-Marne' => '77',\n                    'Yvelines' => '78',\n                    'Deux-Sèvres' => '79',\n                    'Somme' => '80',\n                    'Tarn' => '81',\n                    'Tarn-et-Garonne' => '82',\n                    'Var' => '83',\n                    'Vaucluse' => '84',\n                    'Vendée' => '85',\n                    'Vienne' => '86',\n                    'Haute-Vienne' => '87',\n                    'Vosges' => '88',\n                    'Yonne' => '89',\n                    'Territoire de Belfort' => '90',\n                    'Essonne' => '91',\n                    'Hauts-de-Seine' => '92',\n                    'Seine-Saint-Denis' => '93',\n                    'Val-de-Marne' => '94',\n                    'Val-d\\'Oise' => '95',\n                    'Guadeloupe' => '971',\n                    'Martinique' => '972',\n                    'Guyane' => '973',\n                    'La Réunion' => '974',\n                    'Saint-Pierre-et-Miquelon' => '975',\n                    'Mayotte' => '976',\n                    'Saint-Barthélemy' => '977',\n                    'Saint-Martin' => '978',\n                    'Terres australes et antarctiques françaises' => '984',\n                    'Wallis-et-Futuna' => '986',\n                    'Polynésie française' => '987',\n                    'Nouvelle-Calédonie' => '988',\n                    'Île de Clipperton' => '989'\n                ]\n            ],\n            'famille' => [\n                'name' => 'Famille',\n                'type' => 'list',\n                'values' => [\n                    'Toutes' => null,\n                    'Annonces diverses' => 'divers',\n                    'Créations' => 'creation',\n                    'Dépôts des comptes' => 'dpc',\n                    'Immatriculations' => 'immatriculation',\n                    'Modifications diverses' => 'modification',\n                    'Procédures collectives' => 'collective',\n                    'Procédures de conciliation' => 'conciliation',\n                    'Procédures de rétablissement professionnel' => 'retablissement_professionnel',\n                    'Radiations' => 'radiation',\n                    'Ventes et cessions' => 'vente'\n                ]\n            ],\n            'type' => [\n                'name' => 'Type',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => null,\n                    'Avis initial' => 'annonce',\n                    'Avis d\\'annulation' => 'annulation',\n                    'Avis rectificatif' => 'rectificatif'\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $parameters = [\n            'select'    => 'id,dateparution,typeavis_lib,familleavis_lib,commercant,ville,cp',\n            'order_by'  => 'id desc',\n            'limit'     => 50,\n        ];\n\n        $where = [];\n        if (!empty($this->getInput('departement'))) {\n            $where[] = 'numerodepartement=\"' . $this->getInput('departement') . '\"';\n        }\n\n        if (!empty($this->getInput('famille'))) {\n            $where[] = 'familleavis=\"' . $this->getInput('famille') . '\"';\n        }\n\n        if (!empty($this->getInput('type'))) {\n            $where[] = 'typeavis=\"' . $this->getInput('type') . '\"';\n        }\n\n        if ($where !== []) {\n            $parameters['where'] = implode(' and ', $where);\n        }\n\n        $url = urljoin(self::URI, '/api/explore/v2.1/catalog/datasets/annonces-commerciales/records?' . http_build_query($parameters));\n\n        $data = Json::decode(getContents($url), false);\n\n        foreach ($data->results as $result) {\n            if (\n                !isset(\n                    $result->id,\n                    $result->dateparution,\n                    $result->typeavis_lib,\n                    $result->familleavis_lib,\n                    $result->commercant,\n                    $result->ville,\n                    $result->cp\n                )\n            ) {\n                continue;\n            }\n\n            $title = sprintf(\n                '[%s] %s - %s à %s (%s)',\n                $result->typeavis_lib,\n                $result->familleavis_lib,\n                $result->commercant,\n                $result->ville,\n                $result->cp\n            );\n\n            $this->items[] = [\n                'uid'       => $result->id,\n                'timestamp' => strtotime($result->dateparution),\n                'title'     => $title,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BookMyShowBridge.php",
    "content": "<?php\n\nclass BookMyShowBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'captn3m0';\n    const NAME = 'BookMyShow';\n    const URI = 'https://in.bookmyshow.com';\n    const MOVIES_IMAGE_BASE_FORMAT = 'https://in.bmscdn.com/iedb/movies/images/mobile/thumbnail/large/%s.jpg';\n    const DESCRIPTION = 'Returns the latest events on BookMyShow';\n\n    const TIMEZONE = 'Asia/Kolkata';\n\n    const PLAYS = 'PL';\n    const EVENTS = 'CT';\n    const MOVIES = 'MT';\n\n    const CATEGORIES = [\n        self::PLAYS => 'Plays',\n        self::EVENTS => 'Events',\n        self::MOVIES => 'Movies',\n    ];\n\n    const CITIES = [\n        // Most popular cities\n        'Mumbai' => 'MUMBAI',\n        'National Capital Region (NCR)' => 'NCR',\n        'Bengaluru' => 'BANG',\n        'Hyderabad' => 'HYD',\n        'Ahmedabad' => 'AHD',\n        'Chandigarh' => 'CHD',\n        'Chennai' => 'CHEN',\n        'Pune' => 'PUNE',\n        'Kolkata' => 'KOLK',\n        'Kochi' => 'KOCH',\n\n        // Less common cities\n        'Aalo' => 'AALU',\n        'Abohar' => 'ABOR',\n        'Abu Road' => 'ABRD',\n        'Acharapakkam' => 'ACHA',\n        'Adilabad' => 'ADIL',\n        'Agar Malwa' => 'AGOR',\n        'Agartala' => 'AGAR',\n        'Agra' => 'AGRA',\n        'Ahmedgarh' => 'AHMG',\n        'Ahmednagar' => 'AHMED',\n        'Aizawl' => 'AIZW',\n        'Ajmer' => 'AJMER',\n        'Akaltara' => 'AKAL',\n        'Akividu' => 'AKVD',\n        'Akola' => 'AKOL',\n        'Alangudi' => 'ALNI',\n        'Alappuzha' => 'ALPZ',\n        'Alathur' => 'ALAR',\n        'Alibaug' => 'ALBG',\n        'Aligarh' => 'ALI',\n        'Allagadda' => 'ALGD',\n        'Almora' => 'ALMO',\n        'Alwar' => 'ALWR',\n        'Amadalavalasa' => 'ADAM',\n        'Amalapuram' => 'AMAP',\n        'Amaravathi' => 'AVTI',\n        'Ambala' => 'AMB',\n        'Ambikapur' => 'AMBI',\n        'Ambur' => 'AMBR',\n        'Amgaon' => 'AMGN',\n        'Amravati' => 'AMRA',\n        'Amritsar' => 'AMRI',\n        'Anakapalle' => 'ANKP',\n        'Anand' => 'AND',\n        'Anantapalli' => 'ANTT',\n        'Anantapur' => 'ANAN',\n        'Anchal' => 'ANHL',\n        'Angadipuram' => 'ANDM',\n        'Angamaly' => 'ANGA',\n        'Angara' => 'ANGR',\n        'Angul' => 'ANGL',\n        'Anjad' => 'ANJA',\n        'Anjar' => 'ANJR',\n        'Anklav' => 'ANKV',\n        'Ankleshwar' => 'ANKL',\n        'Annigeri' => 'ANGI',\n        'Arakkonam' => 'ARAK',\n        'Arambagh' => 'AMBH',\n        'Aranthangi' => 'ARNT',\n        'Ariyalur' => 'ARIY',\n        'Arni' => 'ARNI',\n        'Arsikere' => 'ARSI',\n        'Aruppukottai' => 'ARUP',\n        'Asansol' => 'ASANSOL',\n        'Ashoknagar (West Bengal)' => 'ASNA',\n        'Ashoknagar' => 'AKMP',\n        'Aswaraopeta' => 'ASWA',\n        'Atpadi' => 'ATPA',\n        'Attili' => 'ATLI',\n        'Aurangabad (Bihar)' => 'AUBI',\n        'Aurangabad (West Bengal)' => 'AURW',\n        'Aurangabad' => 'AURA',\n        'Avinashi' => 'AVII',\n        'Azamgarh' => 'AZMG',\n        'B. Kothakota' => 'BKOT',\n        'Badaun' => 'BADN',\n        'Baddi' => 'BADD',\n        'Badnawar' => 'BADR',\n        'Bagbahara' => 'BBHA',\n        'Bagha Purana' => 'BAPU',\n        'Bagru' => 'BAGU',\n        'Bahadurgarh' => 'BAHD',\n        'Bahraich' => 'BHRH',\n        'Baihar' => 'BIAH',\n        'Baikunthpur' => 'BKTH',\n        'Baindur' => 'BAND',\n        'Bakhrahat' => 'BART',\n        'Balaghat' => 'BLGT',\n        'Balangir' => 'BALG',\n        'Balasore' => 'BLSR',\n        'Balijipeta' => 'BLIJ',\n        'Balod' => 'BALD',\n        'Baloda Bazar' => 'BBCH',\n        'Balotra' => 'BALO',\n        'Balrampur' => 'BLUR',\n        'Balurghat' => 'BALU',\n        'Bangarpet' => 'BAGT',\n        'Banswada' => 'BNSA',\n        'Banswara' => 'BANS',\n        'Bantumilli' => 'BANT',\n        'Barabanki' => 'BARK',\n        'Baramati' => 'BARA',\n        'Baraut' => 'BARL',\n        'Bardoli' => 'BRDL',\n        'Bareilly' => 'BARE',\n        'Bargarh' => 'BARG',\n        'Baripada' => 'BARI',\n        'Barmer' => 'BARM',\n        'Barnala' => 'BAR',\n        'Barshi' => 'BRHI',\n        'Barwani' => 'BRWN',\n        'Basna' => 'BASN',\n        'Basti' => 'BAST',\n        'Bathinda' => 'BHAT',\n        'Batlagundu' => 'BTGD',\n        'Beawar' => 'BEAW',\n        'Beed' => 'BEED',\n        'Belagavi (Belgaum)' => 'BELG',\n        'Bellampalli' => 'BELL',\n        'Bellary' => 'BLRY',\n        'Belur' => 'BELU',\n        'Bemetara' => 'BMTA',\n        'Berachampa' => 'BRAC',\n        'Berhampore' => 'BEHA',\n        'Berhampur' => 'BERP',\n        'Bestavaripeta' => 'BEST',\n        'Betul' => 'BETU',\n        'Bhadrachalam' => 'BHDR',\n        'Bhadrak' => 'BHAD',\n        'Bhadravati' => 'BDVT',\n        'Bhainsa' => 'BHAN',\n        'Bhandara' => 'BHAA',\n        'Bharamasagara' => 'BASA',\n        'Bharuch' => 'BHAR',\n        'Bhatapara' => 'BTAP',\n        'Bhatkal' => 'BAKL',\n        'Bhattiprolu' => 'BATT',\n        'Bhavnagar' => 'BHNG',\n        'Bhilai' => 'BHILAI',\n        'Bhilwara' => 'BHIL',\n        'Bhimadole' => 'BMDE',\n        'Bhimavaram' => 'BHIM',\n        'Bhiwadi' => 'BHWD',\n        'Bhiwani' => 'BHWN',\n        'Bhopal' => 'BHOP',\n        'Bhubaneswar' => 'BHUB',\n        'Bhuj' => 'BHUJ',\n        'Bhuntar' => 'BHUN',\n        'Bhupalpalle' => 'BHUP',\n        'Bhusawal' => 'BHUS',\n        'Biaora' => 'BIAR',\n        'Bidar' => 'BIDR',\n        'Bijnor' => 'BIJ',\n        'Bijoynagar' => 'BIJO',\n        'Bikaner' => 'BIK',\n        'Bilara' => 'BILR',\n        'Bilaspur (Himachal Pradesh)' => 'BIPS',\n        'Bilaspur' => 'BILA',\n        'Bilimora' => 'BILI',\n        'Biraul' => 'BIRL',\n        'Bishrampur' => 'BSRM',\n        'Bodinayakanur' => 'BODI',\n        'Boisar' => 'BOIS',\n        'Bokaro' => 'BOKA',\n        'Bolpur' => 'BLPR',\n        'Bommidi' => 'BOMM',\n        'Bongaigaon' => 'BONG',\n        'Bongaon' => 'BONI',\n        'Borsad' => 'BORM',\n        'Brahmapur' => 'KHUB',\n        'Brahmapuri' => 'BHMP',\n        'Brajrajnagar' => 'BJNG',\n        'Bulandshahr' => 'BULA',\n        'Buldana' => 'BULD',\n        'Bundu' => 'BUND',\n        'Burdwan' => 'BURD',\n        'Burhanpur' => 'BRHP',\n        'Byadagi' => 'BYAD',\n        'Chagallu' => 'CHAG',\n        'Challakere' => 'CHLA',\n        'Challapalli' => 'CHAP',\n        'Champa' => 'CHAM',\n        'Chanchal' => 'CCWC',\n        'Chandausi' => 'CHDN',\n        'Chandragiri' => 'CHAD',\n        'Chandrakona' => 'CKNA',\n        'Chandrapur' => 'CHAN',\n        'Changanassery' => 'CNSY',\n        'Channagiri' => 'CHGI',\n        'Channarayapatna' => 'CHNN',\n        'Chaygaon' => 'CHOG',\n        'Cheepurupalli' => 'CHEE',\n        'Chendrapinni' => 'CNPI',\n        'Chengannur' => 'CHEG',\n        'Chennur' => 'CHNU',\n        'Cherial' => 'CHRY',\n        'Cheyyar' => 'CHEY',\n        'Chhibramau' => 'CHHI',\n        'Chhindwara' => 'CHIN',\n        'Chickmagaluru' => 'CHKA',\n        'Chidambaram' => 'CHID',\n        'Chikkaballapur' => 'CHIK',\n        'Chikodi' => 'CHOK',\n        'Chinturu' => 'CHTN',\n        'Chirala' => 'CHIR',\n        'Chitradurga' => 'CHIT',\n        'Chittoor' => 'CHTT',\n        'Chodavaram' => 'CDVM',\n        'Chotila' => 'CHOT',\n        'Coimbatore' => 'COIM',\n        'Cooch Behar' => 'COBE',\n        'Cuddalore' => 'CUDD',\n        'Cuttack' => 'CUTT',\n        'Dabra' => 'DABR',\n        'Dahanu' => 'DHAU',\n        'Dahegam' => 'DHGM',\n        'Dahod' => 'DAHO',\n        'Dakshin Barasat' => 'DAKS',\n        'Dalli Rajhara' => 'DALL',\n        'Daman' => 'DAMA',\n        'Damoh' => 'DAMO',\n        'Darjeeling' => 'DARJ',\n        'Darsi' => 'DARS',\n        'Dasuya' => 'DASU',\n        'Dausa' => 'DAUS',\n        'Davanagere' => 'DAVA',\n        'Davuluru' => 'DVLR',\n        'Deesa' => 'DEES',\n        'Dehradun' => 'DEH',\n        'Deoghar' => 'DOGH',\n        'Devadurga' => 'DEVD',\n        'Devarakonda' => 'DEVK',\n        'Devgad' => 'DEGA',\n        'Dewas' => 'DEWAS',\n        'Dhampur' => 'DHPR',\n        'Dhamtari' => 'DHMT',\n        'Dhanbad' => 'DHAN',\n        'Dhar' => 'DARH',\n        'Dharamsala' => 'DMSL',\n        'Dharapuram' => 'DHAR',\n        'Dharmapuri' => 'DMPI',\n        'Dharmavaram' => 'DDMA',\n        'Dharwad' => 'DHAW',\n        'Dhenkanal' => 'DNAL',\n        'Dhoraji' => 'DHOR',\n        'Dhule' => 'DHLE',\n        'Dhuri' => 'DHRI',\n        'Dibrugarh' => 'DIB',\n        'Digras' => 'DIGR',\n        'Dimapur' => 'DMPR',\n        'Dindigul' => 'DIND',\n        'Doddaballapura' => 'DDBP',\n        'Domkal' => 'DMKL',\n        'Dongargarh' => 'DONG',\n        'Doraha' => 'DORH',\n        'Durg' => 'DURG',\n        'Durgapur' => 'DURGA',\n        'Edappal' => 'EDPL',\n        'Edlapadu' => 'EDLP',\n        'Eluru' => 'ELRU',\n        'Erattupetta' => 'ERAT',\n        'Ernakulam' => 'ERNK',\n        'Erode' => 'EROD',\n        'Etawah' => 'ETWH',\n        'Ettumanoor' => 'ETTU',\n        'Faizabad' => 'FAZA',\n        'Falna' => 'FALN',\n        'Faridkot' => 'DKOT',\n        'Fatehgarh Sahib' => 'FASA',\n        'Fatehpur' => 'FATE',\n        'Fatehpur(Rajasthan)' => 'FATR',\n        'Firozpur' => 'FRZR',\n        'G.Mamidada' => 'GMAD',\n        'Gadag' => 'GADG',\n        'Gadarwara' => 'GDWR',\n        'Gadchiroli' => 'GDRO',\n        'Gajendragarh' => 'GJGH',\n        'Gajwel' => 'GAJW',\n        'Ganapavaram' => 'GANP',\n        'Gandhidham' => 'GDHAM',\n        'Gandhinagar' => 'GNAGAR',\n        'Gangavati' => 'GAVT',\n        'Gangoh' => 'GANZ',\n        'Gangtok' => 'GANG',\n        'Ganjbasoda' => 'GANJ',\n        'Garla' => 'GALA',\n        'Gauribidanur' => 'GAUR',\n        'Gaya' => 'GAYA',\n        'Gingee' => 'GING',\n        'Goa' => 'GOA',\n        'Gobichettipalayam' => 'GOBI',\n        'Godavarikhani' => 'GDVK',\n        'Godhra' => 'GODH',\n        'Gokak' => 'GKGK',\n        'Gokavaram' => 'GOKM',\n        'Golaghat' => 'GHT',\n        'Gollaprolu' => 'GOLL',\n        'Gonda' => 'GOND',\n        'Gondia' => 'GNDA',\n        'Gopalganj' => 'GOPG',\n        'Gorakhpur' => 'GRKP',\n        'Gorantla' => 'GORA',\n        'Gotegaon' => 'GTGN',\n        'Gownipalli' => 'GOWP',\n        'Gudivada' => 'GUDI',\n        'Gudiyatham' => 'GDTM',\n        'Gudur' => 'GUDR',\n        'Gulaothi' => 'GULL',\n        'Guledgudda' => 'GULD',\n        'Gummadidala' => 'GUMM',\n        'Guna' => 'GUNA',\n        'Guntakal' => 'GUNL',\n        'Guntur' => 'GUNT',\n        'Gurazala' => 'GURZ',\n        'Guwahati' => 'GUW',\n        'Gwalior' => 'GWAL',\n        'Habra' => 'HARR',\n        'Hagaribommanahalli' => 'HHGG',\n        'Hajipur' => 'HAJI',\n        'Haldia' => 'HLDI',\n        'Haldwani' => 'HALD',\n        'Haliya' => 'HALI',\n        'Hampi' => 'HMPI',\n        'Hardoi' => 'HRDI',\n        'Haridwar' => 'HRDR',\n        'Harihar' => 'HRRR',\n        'Haripad' => 'HRPD',\n        'Harugeri' => 'HARU',\n        'Hasanpur' => 'HANS',\n        'Hazaribagh' => 'HAZA',\n        'Himmatnagar' => 'HIMM',\n        'Hindaun City' => 'HIND',\n        'Hisar' => 'HISR',\n        'Honnali' => 'HONV',\n        'Honnavara' => 'HNVR',\n        'Hooghly' => 'HOOG',\n        'Hoshiarpur' => 'HOSH',\n        'Hoskote' => 'HOKT',\n        'Hospet' => 'HOSP',\n        'Hosur' => 'HSUR',\n        'Howrah' => 'HWRH',\n        'Hubballi (Hubli)' => 'HUBL',\n        'Huvinahadagali' => 'HULI',\n        'Ichalkaranji' => 'ICHL',\n        'Ichchapuram' => 'ICPR',\n        'Idappadi' => 'IDPI',\n        'Idar' => 'IDAR',\n        'Indapur' => 'INDA',\n        'Indi' => 'IIND',\n        'Indore' => 'IND',\n        'Irinjalakuda' => 'IRNK',\n        'Itanagar' => 'ITNG',\n        'Itarsi' => 'ITAR',\n        'Jabalpur' => 'JABL',\n        'Jadcherla' => 'JADC',\n        'Jagalur' => 'JAGA',\n        'Jagatdal' => 'JGDL',\n        'Jagdalpur' => 'JAGD',\n        'Jaggampeta' => 'JAGG',\n        'Jaggayyapeta' => 'JGGY',\n        'Jagtial' => 'JGTL',\n        'Jaipur' => 'JAIP',\n        'Jaisalmer' => 'JSMR',\n        'Jajpur Road' => 'JAJP',\n        'Jalakandapuram' => 'JAKA',\n        'Jalalabad' => 'JLAB',\n        'Jalandhar' => 'JALA',\n        'Jalgaon' => 'JALG',\n        'Jalna' => 'JALN',\n        'Jalpaiguri' => 'JPG',\n        'Jami' => 'JAMI',\n        'Jamkhed' => 'JAMK',\n        'Jammalamadugu' => 'JAMD',\n        'Jammu' => 'JAMM',\n        'Jamnagar' => 'JAM',\n        'Jamner' => 'JAMN',\n        'Jamshedpur' => 'JMDP',\n        'Jangaon' => 'JNGN',\n        'Jangareddy Gudem' => 'JANG',\n        'Janjgir' => 'JANR',\n        'Jasdan' => 'JASD',\n        'Jaunpur' => 'JANP',\n        'Jehanabad' => 'JEHA',\n        'Jetpur' => 'JETP',\n        'Jewar' => 'JEWR',\n        'Jeypore' => 'JEYP',\n        'Jhabua' => 'JHAB',\n        'Jhajjar' => 'JHAJ',\n        'Jhansi' => 'JNSI',\n        'Jharsuguda' => 'JRSG',\n        'Jiaganj' => 'JAGJ',\n        'Jind' => 'JIND',\n        'Jodhpur' => 'JODH',\n        'Jorhat' => 'JORT',\n        'Junagadh' => 'JUGH',\n        'Kadapa' => 'KDPA',\n        'Kadi' => 'KADI',\n        'Kaikaluru' => 'KAIK',\n        'Kaithal' => 'KAIT',\n        'Kakarapalli' => 'KAAP',\n        'Kakinada' => 'KAKI',\n        'Kalaburagi (Gulbarga)' => 'GULB',\n        'Kalimpong' => 'KALI',\n        'Kallakurichi' => 'KALL',\n        'Kalol (Panchmahal)' => 'PANH',\n        'Kalwakurthy' => 'KALW',\n        'Kalyani' => 'KALY',\n        'Kamanaickenpalayam' => 'KPLA',\n        'Kamareddy' => 'KMRD',\n        'Kamavarapukota' => 'KPKT',\n        'Kambainallur' => 'KAMR',\n        'Kamptee' => 'KAMP',\n        'Kanakapura' => 'KAKP',\n        'Kanchikacherla' => 'KNCH',\n        'Kanchipuram' => 'KNPM',\n        'Kandukur' => 'KAND',\n        'Kangayam' => 'KGKM',\n        'Kangra' => 'KANG',\n        'Kanichar' => 'KANC',\n        'Kanigiri' => 'KANI',\n        'Kanipakam' => 'KAAM',\n        'Kanjirappally' => 'KNNJ',\n        'Kanker' => 'KANK',\n        'Kannauj' => 'KANJ',\n        'Kannur' => 'KANN',\n        'Kanpur' => 'KANP',\n        'Kanyakumari' => 'KAKM',\n        'Karad' => 'KARD',\n        'Karaikal' => 'KARA',\n        'Karanja Lad' => 'KLAD',\n        'Kareli' => 'KARE',\n        'Karimangalam' => 'KARI',\n        'Karimganj' => 'KRNJ',\n        'Karimnagar' => 'KARIM',\n        'Karjat' => 'KART',\n        'Karkala' => 'KARK',\n        'Karnal' => 'KARN',\n        'Karunagapally' => 'KARG',\n        'Karur' => 'KARU',\n        'Karwar' => 'KWAR',\n        'Kasdol' => 'KASD',\n        'Kasgunj' => 'KASG',\n        'Kashipur' => 'KASH',\n        'Kasibugga' => 'KSBG',\n        'Kathipudi' => 'KATP',\n        'Kathua' => 'KATH',\n        'Katihar' => 'KATI',\n        'Kattappana' => 'AWCK',\n        'Kaveripattinam' => 'KANM',\n        'Kekri' => 'KEKR',\n        'Keonjhar' => 'KNJH',\n        'Kesinga' => 'KEGA',\n        'Khachrod' => 'KHCU',\n        'Khajipet' => 'KHAJ',\n        'Khalilabad' => 'KHBD',\n        'Khamgaon' => 'KHMG',\n        'Khammam' => 'KHAM',\n        'Khandwa' => 'KHDW',\n        'Khanna' => 'KHAN',\n        'Kharagpur' => 'KGPR',\n        'Kharsia' => 'KHAS',\n        'Khed' => 'KHED',\n        'Khopoli' => 'KHOP',\n        'Khurja' => 'KHUR',\n        'Kichha' => 'KCHA',\n        'Kishanganj' => 'KSGJ',\n        'Kodad' => 'KODA',\n        'Kodagu (Coorg)' => 'COOR',\n        'Kodakara' => 'KDKR',\n        'Kodungallur' => 'KODU',\n        'Kokrajhar' => 'KKJR',\n        'Kolar' => 'OLAR',\n        'Kolhapur' => 'KOLH',\n        'Kollam' => 'KOLM',\n        'Kollengode' => 'KOLE',\n        'Komarapalayam' => 'KOMA',\n        'Kondagaon' => 'KNGN',\n        'Kondlahalli' => 'KNAI',\n        'Korba' => 'KRBA',\n        'Kosamba' => 'KOSA',\n        'Kota (AP)' => 'KOAN',\n        'Kota' => 'KOTA',\n        'Kothagudem' => 'KTGM',\n        'Kothamangalam' => 'KTMM',\n        'Kotkapura' => 'KOTK',\n        'Kotpad' => 'KTPD',\n        'Kotputli' => 'KPLI',\n        'Kottayam' => 'KTYM',\n        'Kovur (Nellore)' => 'KOVR',\n        'Kovvur' => 'KOVU',\n        'Koyyalagudem' => 'KOEM',\n        'Kozhikode' => 'KOZH',\n        'Kozhinjampara' => 'KOZA',\n        'Krishnagiri' => 'KRHN',\n        'Krishnanagar' => 'KNWB',\n        'Krosuru' => 'KRSR',\n        'Kruthivennu' => 'KRTH',\n        'Kuchaman City' => 'KHCY',\n        'Kukshi' => 'KUKS',\n        'Kulithalai' => 'KULI',\n        'Kullu' => 'KULU',\n        'Kumbakonam' => 'KUMB',\n        'Kunkuri' => 'KKRI',\n        'Kurnool' => 'KURN',\n        'Kurukshetra' => 'KURU',\n        'Kutch' => 'KTCH',\n        'Lakhimpur Kheri' => 'LKPK',\n        'Lakhimpur' => 'LAHA',\n        'Lakkavaram' => 'LRAM',\n        'Lakshmeshwara' => 'LKSH',\n        'Latur' => 'LAT',\n        'Leh' => 'LEHL',\n        'Lingasugur' => 'LING',\n        'Lohardaga' => 'LOHA',\n        'Lonavala' => 'LNVL',\n        'Loni' => 'LONI',\n        'Lucknow' => 'LUCK',\n        'Ludhiana' => 'LUDH',\n        'Macherla' => 'MACH',\n        'Machilipatnam' => 'MAPM',\n        'Madanapalle' => 'MDNP',\n        'Maddur' => 'MADD',\n        'Madhavaram' => 'MDHA',\n        'Madhepura' => 'MHEA',\n        'Madhira' => 'MADR',\n        'Madurai' => 'MADU',\n        'Magadi' => 'MAGA',\n        'Mahabubabad' => 'MAHA',\n        'Mahad' => 'MHAD',\n        'Mahbubnagar' => 'MAHB',\n        'Maheshwar' => 'MAHE',\n        'Mahishadal' => 'MMAI',\n        'Mahudha' => 'MAHU',\n        'Malebennur' => 'MEBN',\n        'Malegaon' => 'MALE',\n        'Malerkotla' => 'MALR',\n        'Mall' => 'MAAL',\n        'Malout' => 'MALO',\n        'Mamallapuram' => 'MMLL',\n        'Manali' => 'MANA',\n        'Manapparai' => 'MAPI',\n        'Manawar' => 'MANW',\n        'Mancherial' => 'MANC',\n        'Mandapeta' => 'MAND',\n        'Mandi Gobindgarh' => 'MBBH',\n        'Mandla' => 'MADL',\n        'Mandsaur' => 'MNDS',\n        'Mandya' => 'MND',\n        'Manendragarh' => 'MANE',\n        'Mangalagiri' => 'MGLR',\n        'Mangaldoi' => 'MANG',\n        'Mangaluru (Mangalore)' => 'MLR',\n        'Manikonda (AP)' => 'MNAP',\n        'Manipal' => 'MANI',\n        'Manjeri' => 'MAJR',\n        'Mannargudi' => 'MANB',\n        'Mannarkkad' => 'MKKA',\n        'Mansa' => 'MNSA',\n        'Manuguru' => 'MNGU',\n        'Maraimalai Nagar' => 'MMNR',\n        'Markapur' => 'MARK',\n        'Marripeda' => 'MARR',\n        'Marthandam' => 'MRDM',\n        'Mathura' => 'MATH',\n        'Mattannur' => 'MATT',\n        'Mavellikara' => 'MVLR',\n        'Medak' => 'MDAK',\n        'Medarametla' => 'MDRM',\n        'Meerut' => 'MERT',\n        'Mehsana' => 'MEHS',\n        'Memari' => 'MMRR',\n        'Metpally' => 'METT',\n        'Mettuppalayam' => 'MTPM',\n        'Miryalaguda' => 'MRGD',\n        'Mirzapur' => 'MIZP',\n        'Moga' => 'MOGA',\n        'Mohali' => 'MOHL',\n        'Molakalmuru' => 'MOLA',\n        'Moodbidri' => 'MOOD',\n        'Moradabad' => 'MORA',\n        'Moranhat' => 'MORH',\n        'Morbi' => 'MOBI',\n        'Morena' => 'MRMP',\n        'Motihari' => 'MOTI',\n        'Moyna' => 'MAYN',\n        'Muddebihal' => 'MUDD',\n        'Mudhol' => 'MUDL',\n        'Mughalsarai' => 'MGSI',\n        'Mukkam' => 'MUKM',\n        'Muktsar' => 'MKST',\n        'Mullanpur' => 'MULL',\n        'Mummidivaram' => 'MUMM',\n        'Mundakayam' => 'MUAM',\n        'Mundra' => 'MUDA',\n        'MUNNAR' => 'MUNN',\n        'Muradnagar' => 'MRDG',\n        'Murtizapur' => 'MUUR',\n        'Musiri' => 'MUSI',\n        'Mussoorie' => 'MSS',\n        'Muvattupuzha' => 'MUVA',\n        'Muzaffarnagar' => 'MUZ',\n        'Muzaffarpur' => 'MUZA',\n        'Mydukur' => 'MYDU',\n        'Mysuru (Mysore)' => 'MYS',\n        'Nabadwip' => 'NABB',\n        'Nadiad' => 'NADI',\n        'Nagaon' => 'NAAM',\n        'Nagapattinam' => 'NGPT',\n        'Nagari' => 'NAGI',\n        'Nagarkurnool' => 'NGKL',\n        'Nagda' => 'NAGD',\n        'Nagercoil' => 'NAGE',\n        'Nagothane' => 'NAGO',\n        'Nagpur' => 'NAGP',\n        'Naihati' => 'NHTA',\n        'Nainital' => 'NAIN',\n        'Nakhatrana' => 'NKHT',\n        'Nalgonda' => 'NALK',\n        'Namakkal' => 'NMKL',\n        'Namchi' => 'NAMI',\n        'Nanded' => 'NAND',\n        'Nandigama' => 'NDGM',\n        'Nandurbar' => 'NDNB',\n        'Nandyal' => 'NADY',\n        'Nanjanagudu' => 'NJGU',\n        'Nanpara' => 'NANP',\n        'Narasannapeta' => 'NRPT',\n        'Narasaraopet' => 'NSPT',\n        'Narayankhed' => 'NARY',\n        'Narayanpur' => 'NRYA',\n        'Nargund' => 'NRGD',\n        'Narnaul' => 'NARN',\n        'Narsampet' => 'NASP',\n        'Narsapur' => 'NARP',\n        'Narsipatnam' => 'NARS',\n        'Nashik' => 'NASK',\n        'Nathdwara' => 'NATW',\n        'Navsari' => 'NVSR',\n        'Nawalgarh' => 'NANA',\n        'Nawanshahr' => 'NAVN',\n        'Nawapara' => 'NAWA',\n        'Nazira' => 'NZRA',\n        'Nedumkandam' => 'NEDU',\n        'Neemuch' => 'NMCH',\n        'Nellimarla' => 'NLEM',\n        'Ner Parsopant' => 'NERP',\n        'New Tehri' => 'TEHR',\n        'Neyveli' => 'NYVL',\n        'Nidadavolu' => 'NDVD',\n        'Nilagiri' => 'NIGA',\n        'Nimbahera' => 'NIPA',\n        'Nipani' => 'NIPN',\n        'Nizamabad' => 'NIZA',\n        'Nokha' => 'NKHA',\n        'Nuzvid' => 'NZVD',\n        'Nyamathi' => 'NYNT',\n        'Ongole' => 'ONGL',\n        'Ooty' => 'OOTY',\n        'Osmanabad' => 'OSMA',\n        'Ottapalam' => 'OTTP',\n        'Padrauna' => 'PADR',\n        'Pakala' => 'PAKA',\n        'Pala' => 'PALL',\n        'Palakkad' => 'PLKK',\n        'Palakollu' => 'PLKL',\n        'Palakonda' => 'PALK',\n        'Palampur' => 'PALM',\n        'Palanpur' => 'PALN',\n        'Palasa' => 'PALS',\n        'Palghar' => 'PALG',\n        'Pali' => 'PAAL',\n        'Pallipalayam' => 'PLLI',\n        'Palwal' => 'PLWL',\n        'Palwancha' => 'PLWA',\n        'Pamarru' => 'PAMA',\n        'Panchkula' => 'PNCH',\n        'Pandalam' => 'PADM',\n        'Pandharpur' => 'PNDH',\n        'Panipat' => 'PAN',\n        'Panruti' => 'PANT',\n        'Papanasam' => 'PAPA',\n        'Paralakhemundi' => 'PRKM',\n        'Paratwada' => 'PARA',\n        'Parbhani' => 'PARB',\n        'Parchur' => 'PARC',\n        'Parigi (Telangana)' => 'PARI',\n        'Parvathipuram' => 'PRVT',\n        'Patan' => 'PATA',\n        'Pathalgaon' => 'PAHT',\n        'Pathanamthitta' => 'PTNM',\n        'Pathankot' => 'PATH',\n        'Pathsala' => 'PATS',\n        'Patiala' => 'PATI',\n        'Patna' => 'PATN',\n        'Pattambi' => 'PTMB',\n        'Pattukkottai' => 'PATU',\n        'Payakaraopeta' => 'PATE',\n        'Payyanur' => 'PAYY',\n        'Pedanandipadu' => 'PEDN',\n        'Peddapalli' => 'PEDA',\n        'Peddapuram' => 'PEDP',\n        'Pen' => 'PEN',\n        'Pendra' => 'PEND',\n        'Pennagaram' => 'PENM',\n        'Penuganchiprolu' => 'PENU',\n        'Penugonda' => 'PDDG',\n        'Perambalur' => 'PERA',\n        'Peringottukurissi' => 'PERN',\n        'Perinthalmanna' => 'PNTM',\n        'Phagwara' => 'PHAG',\n        'Phalodi' => 'PHLD',\n        'Phaltan' => 'PHAL',\n        'Pileru' => 'PLRU',\n        'Pipariya' => 'PIPY',\n        'Pithampur' => 'PITH',\n        'Podili' => 'PODI',\n        'Polavaram' => 'PLAB',\n        'Pollachi' => 'POLL',\n        'Pondicherry' => 'POND',\n        'Ponduru' => 'PONU',\n        'Ponnani' => 'PONN',\n        'Porumamilla' => 'PORU',\n        'Pratapgarh (Rajasthan)' => 'PTRT',\n        'Pratapgarh (UP)' => 'PRAT',\n        'Prathipadu' => 'PRTH',\n        'Prayagraj (Allahabad)' => 'ALLH',\n        'Proddatur' => 'PROD',\n        'Pulluvila' => 'PULA',\n        'Pulpally' => 'PULP',\n        'Punalur' => 'PUNA',\n        'Punganur' => 'PGNR',\n        'Purnea' => 'PURN',\n        'Purulia' => 'PURU',\n        'Pusad' => 'PUSD',\n        'Pusapatirega' => 'PREG',\n        'Puttur' => 'PUTT',\n        'Raebareli' => 'RAEB',\n        'Rahimatpur' => 'RAHI',\n        'Raibag' => 'RAIB',\n        'Raigad' => 'RAI',\n        'Raigarh' => 'RAIG',\n        'Railway Koduru' => 'RLKD',\n        'Raipur' => 'RAIPUR',\n        'Raisinghnagar' => 'RSNG',\n        'Rajamahendravaram (Rajahmundry)' => 'RJMU',\n        'Rajapalayam' => 'RAYM',\n        'Rajkot' => 'RAJK',\n        'Rajnandgaon' => 'RAJA',\n        'Rajpipla' => 'RJPA',\n        'Rajpur' => 'RAJP',\n        'Rajpura' => 'RARA',\n        'Rajula' => 'RJLA',\n        'Ramanagara' => 'RANG',\n        'Ramayampet' => 'RAMP',\n        'Ramgarhwa' => 'RGHA',\n        'Ramnagar' => 'RAMN',\n        'Rampur' => 'RAMU',\n        'Ranaghat' => 'RANA',\n        'Ranchi' => 'RANC',\n        'Ranebennur' => 'RANE',\n        'Rangia' => 'RAAA',\n        'Raniganj' => 'RNGJ',\n        'Ranipet' => 'RANI',\n        'Ratlam' => 'RATL',\n        'Ratnagiri (Odisha)' => 'RATO',\n        'Ratnagiri' => 'RATN',\n        'Ravulapalem' => 'RVPL',\n        'Raxaul' => 'RAXA',\n        'Rayachoti' => 'RYCT',\n        'Rayavaram' => 'RAYA',\n        'Renukoot' => 'RENU',\n        'Repalle' => 'REPA',\n        'Rewa' => 'RWAA',\n        'Rewari' => 'REWA',\n        'Rishikesh' => 'RKES',\n        'Rishra' => 'RSRA',\n        'Rohtak' => 'ROH',\n        'Rourkela' => 'RKOR',\n        'Routhulapudi' => 'ROUT',\n        'Rudrapur' => 'RUDP',\n        'Rupnagar' => 'RUPN',\n        'Sadasivpet' => 'SADA',\n        'Safidon' => 'SAFI',\n        'Sagar' => 'SAMP',\n        'Saharanpur' => 'SAHA',\n        'Sakleshpur' => 'SASA',\n        'Sakti' => 'SAKT',\n        'Salem' => 'SALM',\n        'Saligrama' => 'SGMA',\n        'Salihundam' => 'SAHM',\n        'Salur' => 'SALU',\n        'Samalkota' => 'SAMA',\n        'Sambalpur' => 'SAMB',\n        'Sambhal' => 'SAML',\n        'Samsi' => 'SAMS',\n        'Sanawad' => 'SNWD',\n        'Sangamner' => 'SMNE',\n        'Sangareddy' => 'SARE',\n        'Sangaria' => 'SAGR',\n        'Sangli' => 'SANG',\n        'Sangola' => 'SNGO',\n        'Santhebennur' => 'STHB',\n        'Saraipali' => 'SPAL',\n        'Sarangarh' => 'SARH',\n        'Sarangpur' => 'SARA',\n        'Sardulgarh' => 'SARD',\n        'Sarnath' => 'SART',\n        'Sarni' => 'SARN',\n        'Sasaram' => 'SARM',\n        'Satara' => 'SATA',\n        'Sathyamangalam' => 'STHY',\n        'Satna' => 'SATN',\n        'Sattenapalle' => 'SATL',\n        'Secunderabad' => 'SCBD',\n        'Seethanagaram' => 'SEET',\n        'Sehore' => 'SEHO',\n        'Semiliguda' => 'SIMI',\n        'Sendhwa' => 'SEND',\n        'Seoni Malwa' => 'SEMA',\n        'Seoni' => 'SEON',\n        'Shadnagar' => 'SHAD',\n        'Shahada' => 'SHHA',\n        'Shahdol' => 'SHAH',\n        'Shahjahanpur' => 'SHJH',\n        'Shajapur' => 'SJUR',\n        'Shankarampet' => 'SHAN',\n        'Shankarpally' => 'SKRP',\n        'Sheorinarayan' => 'SHEO',\n        'Shikaripur' => 'SHKR',\n        'Shillong' => 'SHLG',\n        'Shimla' => 'SMLA',\n        'Shirali' => 'SHIR',\n        'Shivamogga' => 'SHIA',\n        'Shivpuri' => 'SHIV',\n        'Shoranur' => 'SHNR',\n        'Shrirampur' => 'SHUR',\n        'Siddipet' => 'SDDP',\n        'Sidlaghatta' => 'SIDL',\n        'Sikar' => 'SIKR',\n        'Silchar' => 'SIL',\n        'Siliguri' => 'SILI',\n        'Silvassa' => 'SILV',\n        'Sindhanur' => 'SIND',\n        'Sindhudurg' => 'SNDH',\n        'Sinnar' => 'SINA',\n        'Sircilla' => 'SIRC',\n        'Sirohi' => 'SIRO',\n        'Sirsi' => 'SRSI',\n        'Siruguppa' => 'SPPA',\n        'Sitamarhi' => 'SIMA',\n        'Sitapur' => 'SITA',\n        'Sivakasi' => 'SIV',\n        'Sivasagar' => 'SVSG',\n        'Solan' => 'SCO',\n        'Solapur' => 'SOLA',\n        'Sompeta' => 'SOMA',\n        'Songadh' => 'SONG',\n        'Sonipat' => 'RAIH',\n        'Sonkatch' => 'SONH',\n        'Sri Ganganagar' => 'SRIG',\n        'Srikakulam' => 'SRKL',\n        'Srinagar' => 'SRNG',\n        'Srivaikuntam' => 'SRTA',\n        'Srivilliputhur' => 'SRIV',\n        'Station Ghanpur' => 'STGH',\n        'Sultanpur' => 'SLUT',\n        'Sulthan Bathery' => 'SULY',\n        'Sundargarh' => 'SUND',\n        'Surajpur' => 'SURA',\n        'Surat' => 'SURT',\n        'Surendranagar' => 'SRDN',\n        'Suryapet' => 'SURY',\n        'Tadepalligudem' => 'TADP',\n        'Tallapudi' => 'TTPP',\n        'Tallarevu' => 'TALL',\n        'Talwandi Bhai' => 'TALW',\n        'Tamluk' => 'TMLU',\n        'Tanda' => 'TNDA',\n        'Tandur' => 'TAND',\n        'Tangutur' => 'TANG',\n        'Tanuku' => 'TANK',\n        'Tatipaka' => 'TATI',\n        'Tenali' => 'TENA',\n        'Tenkasi' => 'TENK',\n        'Tezpur' => 'TEZP',\n        'Thalassery' => 'THAY',\n        'Thalayolaparambu' => 'THAL',\n        'Thamarassery' => 'TMRY',\n        'Thanipadi' => 'THPD',\n        'Thanjavur' => 'TANJ',\n        'Tharad' => 'THRD',\n        'Theni' => 'THEN',\n        'Thirubuvanai' => 'THRU',\n        'Thiruthuraipoondi' => 'THND',\n        'Thiruttani' => 'THTN',\n        'Thiruvalla' => 'THVL',\n        'Thiruvarur' => 'THVR',\n        'Thodupuzha' => 'THOD',\n        'Thorrur' => 'THOR',\n        'Thottiyam' => 'THYM',\n        'Thrissur' => 'THSR',\n        'Thullur' => 'THUL',\n        'Thuraiyur' => 'THYR',\n        'Tilda Neora' => 'TNO',\n        'Tindivanam' => 'TNVM',\n        'Tinsukia' => 'TINS',\n        'Tiptur' => 'TIPT',\n        'Tiruchirappalli' => 'TRII',\n        'Tirukoilur' => 'TRKR',\n        'Tirunelveli' => 'TIRV',\n        'Tirupati' => 'TIRU',\n        'Tirupattur' => 'TRPR',\n        'Tirupur' => 'TIRP',\n        'Tirur' => 'TRUR',\n        'Tiruvannamalai' => 'TVNM',\n        'Titagarh' => 'TTGH',\n        'Trichy' => 'TRIC',\n        'Trivandrum' => 'TRIV',\n        'Tumakuru (Tumkur)' => 'TUMK',\n        'Tuticorin' => 'TTCN',\n        'Udaipur' => 'UDAI',\n        'Udaynarayanpur' => 'UDAY',\n        'Udgir' => 'UDGR',\n        'Udumalpet' => 'UDMP',\n        'Udupi' => 'UDUP',\n        'Ujjain' => 'UJJN',\n        'Ulundurpet' => 'ULPT',\n        'Umbergaon' => 'UMER',\n        'Una' => 'BEEL',\n        'Uthamapalayam' => 'UTHM',\n        'Vadakara' => 'VDKR',\n        'Vadakkencherry' => 'VDCY',\n        'Vadalur' => 'VADA',\n        'Vadanappally' => 'VADN',\n        'Vadodara' => 'VAD',\n        'Valigonda' => 'VALI',\n        'Valluru' => 'VALL',\n        'Valsad' => 'VLSD',\n        'Vaniyambadi' => 'VANI',\n        'Vapi' => 'VAPI',\n        'Varadiyam' => 'VRYM',\n        'Varanasi' => 'VAR',\n        'Varkala' => 'VKAL',\n        'Vatsavai' => 'VAST',\n        'Vazhapadi' => 'VAZH',\n        'Veeraghattam' => 'VEER',\n        'Velangi' => 'VELG',\n        'Velanthavalam' => 'VELM',\n        'Vellakoil' => 'VELI',\n        'Vellore' => 'VELL',\n        'Vempalli' => 'VAIM',\n        'Vemulawada' => 'VERU',\n        'Venkatapuram' => 'VNKT',\n        'Veraval' => 'VRAL',\n        'Vetapalem' => 'VLEM',\n        'Vijayapura (Bengaluru Rural)' => 'VIJP',\n        'Vijayapura (Bijapur)' => 'VJPR',\n        'Vijayarai' => 'VRAI',\n        'Vijayawada' => 'VIJA',\n        'Vikarabad' => 'VKBD',\n        'Vikasnagar' => 'VKNG',\n        'Vikravandi' => 'VIVI',\n        'Villupuram' => 'VILL',\n        'Virudhachalam' => 'VIDM',\n        'Visnagar' => 'VISN',\n        'Vizag (Visakhapatnam)' => 'VIZA',\n        'Vizianagaram' => 'VIZI',\n        'Vuyyuru' => 'VYUR',\n        'Wai' => 'WAIP',\n        'Wanaparthy' => 'WANA',\n        'Wani' => 'WANI',\n        'Warangal' => 'WAR',\n        'Wardha' => 'WARD',\n        'Warora' => 'WRRA',\n        'Wyra' => 'WWAR',\n        'Yadagirigutta' => 'YADG',\n        'Yamunanagar' => 'YAMU',\n        'Yavatmal' => 'YAVA',\n        'Yelagiri' => 'YLGA',\n        'Yelburga' => 'YELB',\n        'Yellamanchili' => 'YLMN',\n        'Yellandu' => 'YRLL',\n        'Yemmiganur' => 'YEMM',\n        'Zaheerabad' => 'ZAGE',\n        'Zirakpur' => 'ZIRK',\n    ];\n\n    const PARAMETERS = [\n        [\n            'city' => [\n                'name' => 'City',\n                'type' => 'list',\n                'defaultValue' => 'MUMBAI',\n                'values' => self::CITIES,\n            ],\n\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'defaultValue' => self::MOVIES,\n                'values' => [\n                    'Plays' => self::PLAYS,\n                    'Events' => self::EVENTS,\n                    'Movies' => self::MOVIES,\n                ],\n            ],\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'defaultValue' => 'all',\n                'values' => [\n                    'All' => 'all',\n                    'Kannada' => 'Kannada',\n                    'English' => 'English',\n                    'Hindi' => 'Hindi',\n                    'Telugu' => 'Telugu',\n                    'Tamil' => 'Tamil',\n                    'Malayalam' => 'Malayalam',\n                    'Gujarati' => 'Gujarati',\n                    'Assamese' => 'Assamese',\n                ]\n            ],\n            'include_online' => [\n                'name' => 'Include Online Events',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n                'title' => 'Whether to include Online Events (applies only in case of \"Events\" category)'\n            ],\n        ]\n    ];\n\n    // Headers used in the generated table for Events/Plays\n    // Left is the BMS API Key, and right is the rendered version\n    const TABLE_HEADERS = [\n        'Genre' => 'Genre',\n        'Language' => 'Language',\n        'Length' => 'Length',\n        'EventIsGlobal' => 'Global Event',\n        'MinPrice' => 'Minimum Price',\n        // This doesn't seem to be used anywhere\n        // 'IsSuperstarExclusiveEvent' => 'SuperStar Exclusive',\n        'EventSoldOut' => 'Sold Out',\n    ];\n\n    // Picked from EventGroup entry for movies\n    // Left is BMS API Ke, and right is the rendered version\n    const MOVIE_TABLE_HEADERS = [\n        'Duration' => 'Screentime',\n        'EventCensor' => 'Rating',\n    ];\n\n    /* Common line that we want to edit out */\n    const SYNOPSIS_REGEX = '/If you [\\w\\s,]+synopsis\\@bookmyshow\\.com/';\n\n    // Picked from the ChildEvents entries inside a Event Group\n    // for Movies\n    // Left is BMS API Key, right is rendered version\n    const INNER_MOVIE_HEADERS = [\n        'EventLanguage' => 'Language',\n        'EventDimension' => 'Formats',\n        'EventIsAtmosEnabled' => 'Dolby Atmos',\n        'IsMovieClubEnabled' => 'Movie Club'\n    ];\n\n    // Primary URL for fetching information\n    // The city information is passed via a cookie\n    const URL_PREFIX = 'https://in.bookmyshow.com/serv/getData?cmd=QUICKBOOK&type=';\n\n    public function collectData()\n    {\n        $city = $this->getInput('city');\n        $category = $this->getInput('category');\n\n        $url = $this->makeUrl($category);\n        $headers = $this->makeHeaders($city);\n\n        $data = json_decode(getContents($url, $headers), true);\n\n        if ($category == self::MOVIES) {\n            $data = $data['moviesData']['BookMyShow']['arrEvents'];\n        } else {\n            $data = $data['data']['BookMyShow']['arrEvent'];\n        }\n\n        foreach ($data as $event) {\n            $item = $this->generateEventData($event, $category);\n            if ($item and $this->matchesFilters($category, $event)) {\n                $this->items[] = $item;\n            }\n        }\n\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] - $a['timestamp'];\n        });\n\n        $this->items = array_slice($this->items, 0, 15);\n    }\n\n    private function makeUrl($category)\n    {\n        return self::URL_PREFIX . $category;\n    }\n\n    private function getDatesHtml($dates)\n    {\n        $tz = new DateTimeZone(self::TIMEZONE);\n        $firstDate = DateTime::createFromFormat('Ymd', $dates[0]['ShowDateCode'], $tz)\n            ->format('D, d M Y');\n        if (count($dates) == 1) {\n            return \"<p>Date: $firstDate</p>\";\n        }\n        $lastDateIndex = count($dates) - 1;\n        $lastDate = DateTime::createFromFormat('Ymd', $dates[$lastDateIndex]['ShowDateCode'])\n            ->format('D, d M Y');\n        return \"<p>Dates: $firstDate - $lastDate</p>\";\n    }\n\n    /**\n     * Given an event array, generates corresponding HTML entry\n     * @param  array $event\n     * @see https://gist.github.com/captn3m0/6dbd539ca67579d22d6f90fab710ccd2 Sample JSON data for various events\n     */\n    private function generateEventHtml($event, $category)\n    {\n        $html = $this->getDatesHtml($event['arrDates']);\n        switch ($category) {\n            case self::MOVIES:\n                $html .= $this->generateMovieHtml($event);\n                break;\n            default:\n                $html .= $this->generateStandardHtml($event);\n        }\n\n        $html .= $this->generateVenueHtml($event['arrVenues']);\n        return $html;\n    }\n\n    /**\n     * Generates a simple Venue HTML, even for multiple venues\n     * spread across multiple dates as a description list.\n     */\n    private function generateVenueHtml($venues)\n    {\n        $html = '<h3>Venues</h3><table><thead><tr><th>Venue</th><th>Directions</th></tr></thead><tbody>';\n\n        foreach ($venues as $i => $venueData) {\n            $venueName = $venueData['VenueName'];\n            $address = $venueData['VenueAddress'];\n            $lat = $venueData['VenueLatitude'];\n            $lon = $venueData['VenueLongitude'];\n\n            $directions = $this->generateDirectionsHtml($lat, $lon, $venueName);\n            $html .= \"<tr><td>$venueName</td><td>$address<br>$directions</td></tr>\";\n        }\n\n        return \"$html</tbody></table>\";\n    }\n\n    /**\n     * Generates a simple Table with event Data\n     * @todo Add support for jsonGenre as a tags row\n     */\n    private function generateEventDetailsTable($event, $headers = self::TABLE_HEADERS)\n    {\n        $table = '';\n        foreach ($headers as $key => $header) {\n            if ($header == 'Language') {\n                $this->languages = [$event[$key]];\n            }\n\n            if ($event[$key] == 'Y') {\n                $value = 'Yes';\n            } elseif ($event[$key] == 'N') {\n                $value = 'No';\n            } else {\n                $value = $event[$key];\n            }\n\n            $table .= <<<EOT\n\t\t\t<tr>\n\t\t\t\t<th>$header</th>\n\t\t\t\t<td>$value</td>\n\t\t\t</tr>\nEOT;\n        }\n\n        return \"<table>$table</table>\";\n    }\n\n    private function generateStandardHtml($event)\n    {\n        $table = $this->generateEventDetailsTable($event);\n\n        $imgsrc = $event['BannerURL'];\n        $FShareURL = $event['FShareURL'];\n\n        return <<<EOT\n        <img title=\"Event Banner URL\" src=\"$imgsrc\">\n        <br>\n        $table\n        <br>\n        More Details are available on the <a href=\"$FShareURL\">BookMyShow website</a>.\n        EOT;\n    }\n\n    /**\n     * Converts some movie details from child entries, such as language\n     * into a single row item, either as a list, or as a Y/N\n     */\n    private function generateInnerMovieDetails($data)\n    {\n        // Show list of languages and list of formats\n        $headers = ['EventLanguage', 'EventDimension'];\n        // if any of these has a Y for any of the screenings, mark it as YES\n        $booleanHeaders = [\n            'EventIsAtmosEnabled', 'IsMovieClubEnabled'\n        ];\n\n        $items = [];\n\n        // Throw values inside $items[$headerName]\n        foreach ($data as $row) {\n            foreach ($headers as $header) {\n                $items[$header][] = $row[$header];\n            }\n            foreach ($booleanHeaders as $header) {\n                $items[$header][] = $row[$header];\n            }\n        }\n\n        // Remove duplicate values (if all screenings are 2D for eg)\n        foreach ($headers as $header) {\n            $items[$header] = array_unique($items[$header]);\n\n            if ($header == 'EventLanguage') {\n                $this->languages = $items[$header];\n            }\n        }\n\n        $html = '';\n\n        // Generate a list for first kind of entries\n        foreach ($headers as $header) {\n            $html .= self::INNER_MOVIE_HEADERS[$header] . ': ' . join(', ', $items[$header]) . '<br>';\n        }\n\n        // Put a yes for the boolean entries\n        foreach ($booleanHeaders as $header) {\n            if (in_array('Y', $items[$header])) {\n                $html .= self::INNER_MOVIE_HEADERS[$header] . ': Yes<br>';\n            }\n        }\n\n        return $html;\n    }\n\n    private function generateMovieHtml($eventGroup)\n    {\n        $data = $eventGroup['ChildEvents'][0];\n        $table = $this->generateEventDetailsTable($data, self::MOVIE_TABLE_HEADERS);\n\n        $imgsrc = sprintf(self::MOVIES_IMAGE_BASE_FORMAT, $data['EventImageCode']);\n\n        $url = $this->generateMovieUrl($eventGroup);\n\n        $innerHtml = $this->generateInnerMovieDetails($eventGroup['ChildEvents']);\n\n        $synopsis = preg_replace(self::SYNOPSIS_REGEX, '', $data['EventSynopsis']);\n\n        $eventTrailerURL = $data['EventTrailerURL'];\n        return <<<EOT\n        <img title=\"Movie Poster\" src=\"$imgsrc\"></img>\n        <div>$table</div>\n        <p>$innerHtml</p>\n        <p>$synopsis</p>\n        More Details are available on the <a href=\"$url\">BookMyShow website</a> and a trailer is available\n        <a href=\"$eventTrailerURL\" title=\"Trailer URL\">here</a>\n        EOT;\n    }\n\n    /**\n     * Generates a canonical movie URL\n     */\n    private function generateMovieUrl($eventGroup)\n    {\n        return self::URI . '/movies/' . $eventGroup['EventURLTitle'] . '/' . $eventGroup['EventCode'];\n    }\n\n    private function generateMoviesData($eventGroup)\n    {\n        // Additional data picked up from the first Child Event\n        $data = $eventGroup['ChildEvents'][0];\n        $date = new DateTime($data['EventDate']);\n\n        return [\n            'uri' => $this->generateMovieUrl($eventGroup),\n            'title' => $eventGroup['EventTitle'],\n            'timestamp' => $date->format('U'),\n            'author' => 'BookMyShow',\n            'content' => $this->generateMovieHtml($eventGroup),\n            'enclosures' => [\n                sprintf(self::MOVIES_IMAGE_BASE_FORMAT, $data['EventImageCode']),\n            ],\n            // Sample Input = |ADVENTURE|ANIMATION|COMEDY|\n            // Sample Output = ['Adventure', 'Animation', 'Comedy']\n            'categories' => array_filter(\n                explode('|', ucwords(strtolower($eventGroup['EventGrpGenre']), '|'))\n            ),\n            'uid' => $eventGroup['EventGroup']\n        ];\n    }\n\n    private function generateEventData($event, $category)\n    {\n        if ($category == self::MOVIES) {\n            return $this->generateMoviesData($event);\n        }\n\n        return $this->generateGenericEventData($event, $category);\n    }\n\n    /**\n     * Takes an event data as array and returns data for RSS Post\n     */\n    private function generateGenericEventData($event, $category)\n    {\n        $datetime = $event['Event_dtmCreated'];\n        if (empty($datetime)) {\n            return null;\n        }\n        $date = new DateTime($event['Event_dtmCreated']);\n\n        return [\n            'uri' => $event['FShareURL'],\n            'title' => $event['EventTitle'],\n            'timestamp' => $date->format('U'),\n            'author' => 'BookMyShow',\n            'content' => $this->generateEventHtml($event, $category),\n            'enclosures' => [\n                $event['BannerURL'],\n            ],\n            'categories' => array_merge(\n                [self::CATEGORIES[$category]],\n                $event['GenreArray']\n            ),\n            'uid' => $event['EventGroupCode'],\n        ];\n    }\n\n    /**\n     * Check if this is an online event. We can't rely on\n     * EventIsWebView, since that is set to Y for everything\n     */\n    private function isEventOnline($event)\n    {\n        if (isset($event['arrVenues']) && count($event['arrVenues']) === 1) {\n            if (preg_match('/(Online|Zoom)/i', $event['arrVenues'][0]['VenueName'])) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    private function matchesLanguage()\n    {\n        if ($this->getInput('language') !== 'all') {\n            $language = $this->getInput('language');\n            return in_array($language, $this->languages);\n        }\n        return true;\n    }\n\n    private function matchesOnline($event)\n    {\n        if ($this->getInput('include_online')) {\n            return true;\n        }\n        return (!$this->isEventOnline($event));\n    }\n\n    /**\n     * Currently only checks if the language filter matches\n     */\n    private function matchesFilters($category, $event)\n    {\n        return $this->matchesLanguage() and $this->matchesOnline($event);\n    }\n\n    /**\n     * Generates the RSS Feed title\n     */\n    public function getName()\n    {\n        $city = $this->getInput('city');\n        $category = $this->getInput('category');\n        if (!is_null($city) and !is_null($category)) {\n            $categoryName = self::CATEGORIES[$category];\n            $cityNames = array_flip(self::CITIES);\n            $cityName = $cityNames[$city];\n            if ($this->getInput('language') !== 'null') {\n                $l = ucwords($this->getInput('language'));\n                // Sample: English Movies in Delhi\n                return \"BookMyShow: $l $categoryName in $cityName\";\n            }\n            return \"BookMyShow: $categoryName in $cityName\";\n        }\n\n        return parent::getName();\n    }\n\n    /**\n     * Returns\n     * @param  string $city City Code\n     * @return array list of headers\n     */\n    private function makeHeaders($city)\n    {\n        $uniqid = uniqid();\n        $rgn = urlencode(\"|Code=$city|\");\n        return [\n            \"Cookie: bmsId=$uniqid; Rgn=$rgn;\"\n        ];\n    }\n\n    /**\n     * Generates various URLs as per https://tools.ietf.org/html/rfc5870\n     * and other standards\n     */\n    private function generateDirectionsHtml($lat, $long, $address = '')\n    {\n        $address = urlencode($address);\n\n        $links = [\n            'Apple Maps' => 'http://maps.apple.com/maps?q=%s,%s\"',\n            'Google Maps' => 'http://maps.google.com/maps?ll=%s,%s',\n            // 'Google Maps (Android)' => 'geo:%s,%s?q=%s',\n            // 'Google Maps (iOS)' => 'comgooglemaps://?center=%s,%s&zoom=12&views=traffic',\n            'OpenStreetMap' => 'https://www.openstreetmap.org/?mlat=%s&mlon=%s&zoom=12',\n            'GeoURI' => 'geo:%s,%s?q=%s',\n        ];\n\n        $html = '';\n\n        foreach ($links as $app => $str) {\n            $url = sprintf($str, $lat, $long, $address);\n            $locations[] = \"<a href='$url' title='$app'>$app</a>\";\n        }\n\n        $html .= implode(', ', $locations) . '</span>';\n\n        return $html;\n    }\n}\n"
  },
  {
    "path": "bridges/BooruprojectBridge.php",
    "content": "<?php\n\nclass BooruprojectBridge extends DanbooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Booruproject';\n    const URI = 'https://booru.org/';\n    const DESCRIPTION = 'Returns images from given page of booruproject';\n    const PARAMETERS = [\n        'global' => [\n            'p' => [\n                'name' => 'page',\n                'defaultValue' => 0,\n                'type' => 'number'\n            ],\n            't' => [\n                'name' => 'tags',\n                'required' => true,\n                'exampleValue'  => 'tagme',\n                'title' => 'Use \"all\" to get all posts'\n            ]\n        ],\n        'Booru subdomain (subdomain.booru.org)' => [\n            'i' => [\n                'name' => 'Subdomain',\n                'required' => true,\n                'exampleValue'  => 'rm'\n            ]\n        ]\n    ];\n\n    const PATHTODATA = '.thumb';\n    const IDATTRIBUTE = 'id';\n    const TAGATTRIBUTE = 'title';\n    const PIDBYPAGE = 20;\n\n    protected function getFullURI()\n    {\n        return $this->getURI()\n        . 'index.php?page=post&s=list&pid='\n        . ($this->getInput('p') ? ($this->getInput('p') - 1) * static::PIDBYPAGE : '')\n        . '&tags=' . urlencode($this->getInput('t'));\n    }\n\n    protected function getTags($element)\n    {\n        $tags = parent::getTags($element);\n        $tags = explode(' ', $tags);\n\n        // Remove statistics from the tags list (identified by colon)\n        foreach ($tags as $key => $tag) {\n            if (strpos($tag, ':') !== false) {\n                unset($tags[$key]);\n            }\n        }\n\n        return implode(' ', $tags);\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('i'))) {\n            return 'https://' . $this->getInput('i') . '.booru.org/';\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('i'))) {\n            return static::NAME . ' ' . $this->getInput('i');\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/BrotFuerDieWeltBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass BrotFuerDieWeltBridge extends BridgeAbstract\n{\n    const NAME = 'Brot für die Welt';\n    const URI = 'https://www.brot-fuer-die-welt.de';\n    const DESCRIPTION = 'Listet die letzten Blogeinträge bzw. Pressemitteilungen von Brot für die Welt.';\n    const MAINTAINER = 'lymnyx';\n    const PARAMETERS = [[\n        'newsType' => [\n            'name' => 'Neuigkeitentyp',\n            'type' => 'list',\n            'values' => [\n                'Blog' => 'blog',\n                'Pressemitteilungen' => 'press',\n            ],\n            'defaultValue' => 'blog',\n        ],\n    ]];\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $newsType = $this->getInput('newsType');\n        $pageURI = 'https://www.brot-fuer-die-welt.de/blog/alle-beitraege/';\n        if ($newsType == 'press') {\n            $pageURI = 'https://www.brot-fuer-die-welt.de/presse/alle-pressemitteilungen/';\n        };\n        $maxArticles = 100;\n        $html = getSimpleHTMLDOMCached($pageURI, 3600);\n\n        $articles = $html->find('body div.news div.news-list-view div.article')\n            or throwServerException('Could not find articles for: ' . $pageURI);\n\n        $articles = array_slice($articles, 0, $maxArticles);\n\n        if ($newsType == 'blog') {\n            foreach ($articles as $article) {\n                $item = [];\n\n                $category = $article->find('div.news-img-wrap div.teaser-badge', 0)->plaintext;\n\n                if ($category) {\n                    $category = ' (' . trim($category) . ')';\n                };\n\n                $item['title'] = $article->find('h3.headline', 0)->plaintext . $category;\n\n                $newsDateAuthor = $article->find('span.news-list-date', 0)->plaintext;\n\n                if ($newsDateAuthor) {\n                    $splitDateAuthor = explode(' | ', $newsDateAuthor);\n\n                    $item['timestamp'] = $splitDateAuthor[0];\n\n                    if (count($splitDateAuthor) > 1) {\n                        $item['author'] = $splitDateAuthor[1];\n                    }\n                }\n\n                $item['uri'] = urljoin('https://www.brot-fuer-die-welt.de', $article->find('div.teaser-text a.more-link', 0)->href);\n\n                $articleHTML = getSimpleHTMLDOMCached($item['uri'], 86400);\n                $description = $articleHTML->find('body div.intro-box p', 0);\n\n                if (!$description) {\n                    $description = $article->find('div.teaser-text div p', 0)->plaintext;\n                };\n\n                $item['content'] = $description;\n\n                $item['enclosures'] = [\n                    urljoin('https://www.brot-fuer-die-welt.de', $article->find('div.news-img-wrap picture img', 0)->src),\n                ];\n\n                $this->items[] = $item;\n            }\n        } else {\n            foreach (array_values($articles) as $i => $article) {\n                $item = [];\n\n                $item['title'] = $article->find('div.header h3 span', 0)->plaintext;\n                $item['timestamp'] = $article->find('div.footer span.news-list-date time', 0)->plaintext;\n                $item['author'] = 'Brot für die Welt (Evangelisches Werk für Diakonie und Entwicklung e.V.)';\n                $item['uri'] = urljoin('https://www.brot-fuer-die-welt.de', $article->find('div.teaser-text a.more-link', 0)->href);\n\n                $miniDescription = $article->find('div.teaser-text div p', 0)->plaintext;\n\n                if ($i > 19) {\n                    $description = $miniDescription . '<br><br>Weiterlesen auf <a href=\"' . $item['uri'] . '\">brot-fuer-die-welt.de</a>';\n                } else {\n                    $articleHTML = getSimpleHTMLDOMCached($item['uri'], 86400);\n                    $description = $articleHTML->find('body article.article-section div.news-text-wrap', 0);\n\n                    if (!$description) {\n                        $description = $article->find('div.teaser-text div p', 0)->plaintext;\n                    };\n                };\n                $item['content'] = $description;\n\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "bridges/BruegelBridge.php",
    "content": "<?php\n\nclass BruegelBridge extends BridgeAbstract\n{\n    const NAME = 'Bruegel';\n    const URI = 'https://www.bruegel.org';\n    const DESCRIPTION = 'European think-tank commentary and publications.';\n    const MAINTAINER = 'KappaPrajd';\n    const PARAMETERS = [\n        [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'defaultValue' => '/publications',\n                'values' => [\n                    'Publications' => '/publications',\n                    'Commentary' => '/commentary'\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return self::URI . '/themes/custom/bruegel/assets/favicon/android-icon-72x72.png';\n    }\n\n    public function collectData()\n    {\n        $url = self::URI . $this->getInput('category');\n        $html = getSimpleHTMLDOM($url);\n\n        $articles = $html->find('.c-listing__content article');\n\n        foreach ($articles as $article) {\n            $title = $article->find('.c-list-item__title a span', 0)->plaintext;\n            $content = trim($article->find('.c-list-item__description', 0)->plaintext);\n            $publishDate = $article->find('.c-list-item__date', 0)->plaintext;\n            $href = $article->find('.c-list-item__title a', 0)->getAttribute('href');\n\n            $item = [\n                'title' => $title,\n                'content' => $content,\n                'timestamp' => strtotime($publishDate),\n                'uri' => self::URI . $href,\n                'author' => $this->getAuthor($article),\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getAuthor($article)\n    {\n        $authorsElements = $article->find('.c-list-item__authors a');\n\n        $authors = array_map(function ($author) {\n            return $author->plaintext;\n        }, $authorsElements);\n\n        return join(', ', $authors);\n    }\n}"
  },
  {
    "path": "bridges/BrutBridge.php",
    "content": "<?php\n\nclass BrutBridge extends BridgeAbstract\n{\n    const NAME = 'Brut';\n    const URI = 'https://www.brut.media';\n    const DESCRIPTION = 'Returns 10 newest videos by category and edition';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'News' => 'news',\n                    'International' => 'international',\n                    'Economy' => 'economy',\n                    'Science and Technology' => 'science-and-technology',\n                    'Entertainment' => 'entertainment',\n                    'Sports' => 'sport',\n                    'Nature' => 'nature',\n                    'Health' => 'health',\n                ],\n                'defaultValue' => 'news',\n            ],\n            'edition' => [\n                'name' => ' Edition',\n                'type' => 'list',\n                    'values' => [\n                        'United States' => 'us',\n                        'United Kingdom' => 'uk',\n                        'France' => 'fr',\n                        'Spain' => 'es',\n                        'India' => 'in',\n                        'Mexico' => 'mx',\n                ],\n                'defaultValue' => 'us',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n        $regex = '/window.__PRELOADED_STATE__ = (.*);/';\n        preg_match($regex, $html, $parts);\n        $data = Json::decode($parts[1], false);\n        foreach ($data->medias->index as $uid => $media) {\n            $this->items[] = [\n                'uid'       => $uid,\n                'title'     => $media->metadata->slug,\n                'uri'       => $media->share_url,\n                'timestamp' => $media->published_at,\n            ];\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('edition')) && !is_null($this->getInput('category'))) {\n            return self::URI . '/' . $this->getInput('edition') . '/' . $this->getInput('category');\n        }\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('edition')) && !is_null($this->getInput('category'))) {\n            return $this->getKey('category') . ' - ' . $this->getKey('edition') . ' - Brut.';\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/BugzillaBridge.php",
    "content": "<?php\n\nclass BugzillaBridge extends BridgeAbstract\n{\n    const NAME = 'Bugzilla';\n    const URI = 'https://www.bugzilla.org/';\n    const DESCRIPTION = 'Bridge for any Bugzilla instance';\n    const MAINTAINER = 'Yaman Qalieh';\n    const PARAMETERS = [\n        'global' => [\n            'instance' => [\n                'name' => 'Instance URL',\n                'required' => true,\n                'exampleValue' => 'https://bugzilla.mozilla.org'\n            ]\n        ],\n        'Bug comments' => [\n            'id' => [\n                'name' => 'Bug tracking ID',\n                'type' => 'number',\n                'required' => true,\n                'title' => 'Insert bug tracking ID',\n                'exampleValue' => 121241\n            ],\n            'limit' => [\n                'name' => 'Number of comments to return',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify number of comments to return',\n                'defaultValue' => -1\n            ],\n            'skiptags' => [\n                'name' => 'Skip offtopic comments',\n                'type' => 'checkbox',\n                'title' => 'Excludes comments tagged as advocacy, metoo, or offtopic from the feed'\n            ]\n        ]\n    ];\n\n    const SKIPPED_ACTIVITY = [\n        'cc' => true,\n        'comment_tag' => true\n    ];\n\n    const SKIPPED_TAGS = ['advocacy', 'metoo', 'offtopic'];\n\n    private $instance;\n    private $bugid;\n    private $buguri;\n    private $title;\n\n    public function getName()\n    {\n        if (!is_null($this->title)) {\n            return $this->title;\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        return $this->buguri ?? parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $this->instance = rtrim($this->getInput('instance'), '/');\n        $this->bugid = $this->getInput('id');\n        $this->buguri = $this->instance . '/show_bug.cgi?id=' . $this->bugid;\n\n        $url = $this->instance . '/rest/bug/' . $this->bugid;\n        $this->getTitle($url);\n        $this->collectComments($url . '/comment');\n        $this->collectUpdates($url . '/history');\n\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] <=> $a['timestamp'];\n        });\n\n        if ($this->getInput('limit') > 0) {\n            $this->items = array_slice($this->items, 0, $this->getInput('limit'));\n        }\n    }\n\n    protected function getTitle($url)\n    {\n        // Only request the summary for a faster request\n        $json = self::getJSON($url . '?include_fields=summary');\n        $this->title = 'Bug ' . $this->bugid . ' - ' .\n                     $json['bugs'][0]['summary'] . ' - ' .\n                     // Remove https://\n                     substr($this->instance, 8);\n    }\n\n    protected function collectComments($url)\n    {\n        $json = self::getJSON($url);\n\n        // Array of comments is here\n        if (!isset($json['bugs'][$this->bugid]['comments'])) {\n            throwClientException('Cannot find REST endpoint');\n        }\n\n        foreach ($json['bugs'][$this->bugid]['comments'] as $comment) {\n            $item = [];\n            if (\n                $this->getInput('skiptags') and\n                array_intersect(self::SKIPPED_TAGS, $comment['tags'])\n            ) {\n                continue;\n            }\n            $item['categories'] = $comment['tags'];\n            $item['uri'] = $this->buguri . '#c' . $comment['count'];\n            $item['title'] = 'Comment ' . $comment['count'];\n            $item['timestamp'] = $comment['creation_time'];\n            $item['author'] = $this->getUser($comment['creator']);\n            $item['content'] = $comment['text'];\n            if (isset($comment['is_markdown']) and $comment['is_markdown']) {\n                $item['content'] = markdownToHtml($item['content']);\n            }\n            if (!is_null($comment['attachment_id'])) {\n                $item['enclosures'] = [$this->instance . '/attachment.cgi?id=' . $comment['attachment_id']];\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    protected function collectUpdates($url)\n    {\n        $json = self::getJSON($url);\n\n        // Array of changesets which contain an array of changes\n        if (!isset($json['bugs']['0']['history'])) {\n            throwClientException('Cannot find REST endpoint');\n        }\n\n        foreach ($json['bugs']['0']['history'] as $changeset) {\n            $author = $this->getUser($changeset['who']);\n            $timestamp = $changeset['when'];\n            foreach ($changeset['changes'] as $change) {\n                // Skip updates to the cc list and comment tagging\n                if (isset(self::SKIPPED_ACTIVITY[$change['field_name']])) {\n                    continue;\n                }\n\n                $item = [];\n                $item['uri'] = $this->buguri;\n                $item['title'] = 'Updated';\n                $item['timestamp'] = $timestamp;\n                $item['author'] = $author;\n                $item['content'] = ucfirst($change['field_name']) . ': ' .\n                                 ($change['removed'] === '' ? '[nothing]' : $change['removed']) . ' -> ' .\n                                 ($change['added'] === '' ? '[nothing]' : $change['added']);\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    protected function getUser($user)\n    {\n        // Check if the user endpoint is available\n        if ($this->loadCacheValue($this->instance . 'userEndpointClosed')) {\n            return $user;\n        }\n\n        $cache = $this->loadCacheValue($this->instance . $user);\n        if ($cache) {\n            return $cache;\n        }\n\n        $url = $this->instance . '/rest/user/' . $user . '?include_fields=real_name';\n        try {\n            $json = self::getJSON($url);\n            if (isset($json['error']) and $json['error']) {\n                throw new Exception();\n            }\n        } catch (Exception $e) {\n            $this->saveCacheValue($this->instance . 'userEndpointClosed', true);\n            return $user;\n        }\n\n        $username = $json['users']['0']['real_name'];\n\n        if (empty($username)) {\n            $username = $user;\n        }\n        $this->saveCacheValue($this->instance . $user, $username);\n        return $username;\n    }\n\n    protected static function getJSON($url)\n    {\n        $headers = [\n            'Accept: application/json',\n        ];\n        return json_decode(getContents($url, $headers), true);\n    }\n}\n"
  },
  {
    "path": "bridges/BukowskisBridge.php",
    "content": "<?php\n\nclass BukowskisBridge extends BridgeAbstract\n{\n    const NAME = 'Bukowskis';\n    const URI = 'https://www.bukowskis.com';\n    const DESCRIPTION = 'Fetches info about auction objects from Bukowskis auction house';\n    const MAINTAINER = 'Qluxzz';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'All categories' => '',\n                'Art' => [\n                    'All' => 'art',\n                    'Classic Art' => 'art.classic-art',\n                    'Classic Finnish Art' => 'art.classic-finnish-art',\n                    'Classic Swedish Art' => 'art.classic-swedish-art',\n                    'Contemporary' => 'art.contemporary',\n                    'Modern Finnish Art' => 'art.modern-finnish-art',\n                    'Modern International Art' => 'art.modern-international-art',\n                    'Modern Swedish Art' => 'art.modern-swedish-art',\n                    'Old Masters' => 'art.old-masters',\n                    'Other' => 'art.other',\n                    'Photographs' => 'art.photographs',\n                    'Prints' => 'art.prints',\n                    'Sculpture' => 'art.sculpture',\n                    'Swedish Old Masters' => 'art.swedish-old-masters',\n                ],\n                'Asian Ceramics & Works of Art' => [\n                    'All' => 'asian-ceramics-works-of-art',\n                    'Other' => 'asian-ceramics-works-of-art.other',\n                    'Porcelain' => 'asian-ceramics-works-of-art.porcelain',\n                ],\n                'Books & Manuscripts' => [\n                    'All' => 'books-manuscripts',\n                    'Books' => 'books-manuscripts.books',\n                ],\n                'Carpets, rugs & textiles' => [\n                    'All' => 'carpets-rugs-textiles',\n                    'European' => 'carpets-rugs-textiles.european',\n                    'Oriental' => 'carpets-rugs-textiles.oriental',\n                    'Rest of the world' => 'carpets-rugs-textiles.rest-of-the-world',\n                    'Scandinavian' => 'carpets-rugs-textiles.scandinavian',\n                ],\n                'Ceramics & porcelain' => [\n                    'All' => 'ceramics-porcelain',\n                    'Ceramic ware' => 'ceramics-porcelain.ceramic-ware',\n                    'European' => 'ceramics-porcelain.european',\n                    'Rest of the world' => 'ceramics-porcelain.rest-of-the-world',\n                    'Scandinavian' => 'ceramics-porcelain.scandinavian',\n                ],\n                'Collectibles' => [\n                    'All' => 'collectibles',\n                    'Advertising & Retail' => 'collectibles.advertising-retail',\n                    'Memorabilia' => 'collectibles.memorabilia',\n                    'Movies & music' => 'collectibles.movies-music',\n                    'Other' => 'collectibles.other',\n                    'Retro & Popular Culture' => 'collectibles.retro-popular-culture',\n                    'Technica & Nautica' => 'collectibles.technica-nautica',\n                    'Toys' => 'collectibles.toys',\n                ],\n                'Design' => [\n                    'All' => 'design',\n                    'Art glass' => 'design.art-glass',\n                    'Furniture' => 'design.furniture',\n                    'Other' => 'design.other',\n                ],\n                'Folk art' => [\n                    'All' => 'folk-art',\n                    'All categories' => 'lots',\n                ],\n                'Furniture' => [\n                    'All' => 'furniture',\n                    'Armchairs & Sofas' => 'furniture.armchairs-sofas',\n                    'Cabinets & Bureaus' => 'furniture.cabinets-bureaus',\n                    'Chairs' => 'furniture.chairs',\n                    'Garden furniture' => 'furniture.garden-furniture',\n                    'Mirrors' => 'furniture.mirrors',\n                    'Other' => 'furniture.other',\n                    'Shelves & Book cases' => 'furniture.shelves-book-cases',\n                    'Tables' => 'furniture.tables',\n                ],\n                'Glassware' => [\n                    'All' => 'glassware',\n                    'Glassware' => 'glassware.glassware',\n                    'Other' => 'glassware.other',\n                ],\n                'Jewellery' => [\n                    'All' => 'jewellery',\n                    'Bracelets' => 'jewellery.bracelets',\n                    'Brooches' => 'jewellery.brooches',\n                    'Earrings' => 'jewellery.earrings',\n                    'Necklaces & Pendants' => 'jewellery.necklaces-pendants',\n                    'Other' => 'jewellery.other',\n                    'Rings' => 'jewellery.rings',\n                ],\n                'Lighting' => [\n                    'All' => 'lighting',\n                    'Candle sticks & Candelabras' => 'lighting.candle-sticks-candelabras',\n                    'Ceiling lights' => 'lighting.ceiling-lights',\n                    'Chandeliers' => 'lighting.chandeliers',\n                    'Floor lights' => 'lighting.floor-lights',\n                    'Other' => 'lighting.other',\n                    'Table lights' => 'lighting.table-lights',\n                    'Wall lights' => 'lighting.wall-lights',\n                ],\n                'Militaria' => [\n                    'All' => 'militaria',\n                    'Honors & Medals' => 'militaria.honors-medals',\n                    'Other militaria' => 'militaria.other-militaria',\n                    'Weaponry' => 'militaria.weaponry',\n                ],\n                'Miscellaneous' => [\n                    'All' => 'miscellaneous',\n                    'Brass, Copper & Pewter' => 'miscellaneous.brass-copper-pewter',\n                    'Nickel silver' => 'miscellaneous.nickel-silver',\n                    'Oriental' => 'miscellaneous.oriental',\n                    'Other' => 'miscellaneous.other',\n                ],\n                'Silver' => [\n                    'All' => 'silver',\n                    'Candle sticks' => 'silver.candle-sticks',\n                    'Cups & Bowls' => 'silver.cups-bowls',\n                    'Cutlery' => 'silver.cutlery',\n                    'Other' => 'silver.other',\n                ],\n                'Timepieces' => [\n                    'All' => 'timepieces',\n                    'Other' => 'timepieces.other',\n                    'Pocket watches' => 'timepieces.pocket-watches',\n                    'Table clocks' => 'timepieces.table-clocks',\n                    'Wrist watches' => 'timepieces.wrist-watches',\n                ],\n                'Vintage & Fashion' => [\n                    'All' => 'vintage-fashion',\n                    'Accessories' => 'vintage-fashion.accessories',\n                    'Bags & Trunks' => 'vintage-fashion.bags-trunks',\n                    'Clothes' => 'vintage-fashion.clothes',\n                ],\n            ]\n        ],\n        'sort_order' => [\n            'name' => 'Sort order',\n            'type' => 'list',\n            'values' => [\n                'Ending soon' => 'ending',\n                'Most recent' => 'recent',\n                'Most bids' => 'most',\n                'Fewest bids' => 'fewest',\n                'Lowest price' => 'lowest',\n                'Highest price' => 'highest',\n                'Lowest estimate' => 'low',\n                'Highest estimate' => 'high',\n                'Alphabetical' => 'alphabetical',\n            ],\n        ],\n        'language' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'values' => [\n                'English' => 'en',\n                'Swedish' => 'sv',\n                'Finnish' => 'fi'\n            ],\n        ],\n    ]];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    private $title;\n\n    public function collectData()\n    {\n        $baseUrl = 'https://www.bukowskis.com';\n        $category = $this->getInput('category');\n        $language = $this->getInput('language');\n        $sort_order = $this->getInput('sort_order');\n\n        $url = $baseUrl . '/' . $language . '/lots';\n\n        if ($category) {\n            $url = $url . '/category/' . $category;\n        }\n\n        if ($sort_order) {\n            $url = $url . '/sort/' . $sort_order;\n        }\n\n        $html = getSimpleHTMLDOM($url);\n\n        $this->title = htmlspecialchars_decode($html->find('title', 0)->innertext);\n\n        foreach ($html->find('div.c-lot-index-lot') as $lot) {\n            $title = $lot->find('a.c-lot-index-lot__title', 0)->plaintext;\n            $relative_url = $lot->find('a.c-lot-index-lot__link', 0)->href;\n            $images = json_decode(\n                htmlspecialchars_decode(\n                    $lot\n                        ->find('img.o-aspect-ratio__image', 0)\n                        ->getAttribute('data-thumbnails')\n                )\n            );\n\n            $this->items[] = [\n                'title' => $title,\n                'uri' => $baseUrl . $relative_url,\n                'uid' => $relative_url,\n                'content' => count($images) > 0 ? \"<img src='$images[0]'/><br/>$title\" : $title,\n                'enclosures' => array_slice($images, 1),\n            ];\n        }\n    }\n\n    public function getName()\n    {\n        return $this->title ?: parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/BundesbankBridge.php",
    "content": "<?php\n\nclass BundesbankBridge extends BridgeAbstract\n{\n    const PARAM_LANG = 'lang';\n\n    const LANG_EN = 'en';\n    const LANG_DE = 'de';\n\n    const NAME = 'Bundesbank';\n    const URI = 'https://www.bundesbank.de/';\n    const DESCRIPTION = 'Returns the latest studies of the Bundesbank (Germany)';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 86400; // 24 hours\n\n    const PARAMETERS = [\n        [\n            self::PARAM_LANG => [\n                'name' => 'Language',\n                'type' => 'list',\n                'defaultValue' => self::LANG_DE,\n                'values' => [\n                    'English' => self::LANG_EN,\n                    'Deutsch' => self::LANG_DE\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return self::URI . 'resource/crblob/1890/a7f48ee0ae35348748121770ba3ca009/mL/favicon-ico-data.ico';\n    }\n\n    public function getURI()\n    {\n        switch ($this->getInput(self::PARAM_LANG)) {\n            case self::LANG_EN:\n                return self::URI . 'en/publications/reports/studies';\n            case self::LANG_DE:\n                return self::URI . 'de/publikationen/berichte/studien';\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $html = defaultLinkTo($html, $this->getURI());\n\n        foreach ($html->find('ul.resultlist li') as $study) {\n            $item = [];\n\n            $item['uri'] = $study->find('.teasable__link', 0)->href;\n\n            // Get title without child elements (i.e. subtitle)\n            $title = $study->find('.teasable__title div.h2', 0);\n\n            foreach ($title->children as &$child) {\n                $child->outertext = '';\n            }\n\n            $item['title'] = $title->innertext;\n\n            // Add subtitle to the content if it exists\n            $item['content'] = '';\n\n            if ($subtitle = $study->find('.teasable__subtitle', 0)) {\n                $item['content'] .= '<strong>' . $study->find('.teasable__subtitle', 0)->plaintext . '</strong>';\n            }\n\n            $teasable = $study->find('.teasable__text', 0);\n            $teasableText = $teasable->plaintext ?? '';\n            $item['content'] .= '<p>' . $teasableText . '</p>';\n\n            $item['timestamp'] = strtotime($study->find('.teasable__date', 0)->plaintext);\n\n            // Downloads and older studies don't have images\n            if ($study->find('.teasable__image', 0)) {\n                $item['enclosures'] = [\n                    $study->find('.teasable__image img', 0)->src\n                ];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/BundestagParteispendenBridge.php",
    "content": "<?php\n\nclass BundestagParteispendenBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mibe';\n    const NAME = 'Deutscher Bundestag - Parteispenden';\n    const URI = 'https://www.bundestag.de/parlament/praesidium/parteienfinanzierung/fundstellen50000';\n\n    const CACHE_TIMEOUT = 86400; // 24h\n    const DESCRIPTION = 'Returns the latest \"soft money\" donations to parties represented in the German Bundestag.';\n    const CONTENT_TEMPLATE = <<<TMPL\n<p><b>Partei:</b><br>%s</p>\n<p><b>Spendenbetrag:</b><br>%s</p>\n<p><b>Spender:</b><br>%s</p>\n<p><b>Eingang der Spende:</b><br>%s</p>\nTMPL;\n\n    public function getIcon()\n    {\n        return 'https://www.bundestag.de/static/appdata/includes/images/layout/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $ajaxUri = self::URI;\n\n        // Get the main page\n        $html = getSimpleHTMLDOMCached($ajaxUri, self::CACHE_TIMEOUT);\n\n        // Apply default base URL for relative links\n        defaultLinkTo($html, $this->getURI());\n\n        // Build the URL from the first anchor element. The list is sorted by year, descending, so the first element is the current year.\n        $firstAnchor = $html->find('a.e-linkListItem__anchor', 0)\n            or throwServerException('Could not find the proper HTML element.');\n\n        $url = $firstAnchor->href;\n\n        // Get the actual page with the soft money donations\n        $html = getSimpleHTMLDOMCached($url, self::CACHE_TIMEOUT);\n\n        defaultLinkTo($html, $url);\n\n        $rows = $html->find('table.table > tbody > tr')\n            or throwServerException('Could not find the proper HTML elements.');\n\n        foreach ($rows as $row) {\n            $item = $this->generateItemFromRow($row);\n            if (is_array($item)) {\n                $item['uri'] = $url;\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function generateItemFromRow(simple_html_dom_node $row)\n    {\n        // The row must have 5 columns. There are monthly header rows, which are ignored here.\n        if (count($row->children) != 5) {\n            return null;\n        }\n\n        $item = [];\n\n        //              | column     | paragraph inside column\n        $party  = $row->children[0]->children[0]->innertext;\n        $amount = $row->children[1]->children[0]->innertext . ' €';\n        $donor  = $row->children[2]->children[0]->innertext;\n        $date   = $row->children[3]->children[0]->innertext;\n        $dip    = $row->children[4]->children[0]->find('a.dipLink', 0);\n\n        // Strip whitespace from date string.\n        $date = str_replace(' ', '', $date);\n\n        $content = sprintf(self::CONTENT_TEMPLATE, $party, $amount, $donor, $date);\n\n        $item = [\n            'title' => $party . ': ' . $amount,\n            'content' => $content,\n            'uid' => sha1($content),\n            ];\n\n        // Try to get the link to the official document\n        if ($dip != null) {\n            $item['enclosures'] = [$dip->href];\n        }\n\n        // Try to parse the date\n        $dateTime = DateTime::createFromFormat('d.m.Y', $date);\n        if ($dateTime !== false) {\n            $item['timestamp'] = $dateTime->getTimestamp();\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/BundesverbandFuerFreieKammernBridge.php",
    "content": "<?php\n\nclass BundesverbandFuerFreieKammernBridge extends XPathAbstract\n{\n    const NAME = 'Bundesverband für freie Kammern e.V.';\n    const URI = 'https://www.bffk.de/aktuelles/aktuelle-nachrichten.html';\n    const DESCRIPTION = 'Aktuelle Nachrichten';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://www.bffk.de/aktuelles/aktuelle-nachrichten.html';\n    //const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"icon\"]/@href';\n    const XPATH_EXPRESSION_ITEM = '//ul[@class=\"article-list\"]/li';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/a/text()';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/a/text()';\n    const XPATH_EXPRESSION_ITEM_URI = './/a/@href';\n    //const XPATH_EXPRESSION_ITEM_AUTHOR = './/';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/span/i';\n    //const XPATH_EXPRESSION_ITEM_ENCLOSURES = './';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n\n    protected function formatItemTimestamp($value)\n    {\n        $value = trim($value, '()');\n        $dti = DateTimeImmutable::createFromFormat('d.m.Y', $value);\n        $dti = $dti->setTime(0, 0, 0);\n        return $dti->getTimestamp();\n    }\n}\n"
  },
  {
    "path": "bridges/CBCEditorsBlogBridge.php",
    "content": "<?php\n\nclass CBCEditorsBlogBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'quickwick';\n    const NAME = 'CBC Editors Blog';\n    const URI = 'https://www.cbc.ca/news/editorsblog';\n    const DESCRIPTION = 'Recent CBC Editor\\'s Blog posts';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        // Loop on each blog post entry\n        foreach ($html->find('div.contentListCards', 0)->find('a[data-test=type-story]') as $element) {\n            $headline = ($element->find('.headline', 0))->innertext;\n            $timestamp = ($element->find('time', 0))->datetime;\n            $articleUri = 'https://www.cbc.ca' . $element->href;\n            $summary = ($element->find('div.description', 0))->innertext;\n            $thumbnailUris = ($element->find('img[loading=lazy]', 0))->srcset;\n            $thumbnailUri = rtrim(explode(',', $thumbnailUris)[0], ' 300w');\n\n            // Fill item\n            $item = [];\n            $item['uri'] = $articleUri;\n            $item['id'] = $item['uri'];\n            $item['timestamp'] = $timestamp;\n            $item['title'] = $headline;\n            $item['content'] = '<img src=\"'\n            . $thumbnailUri . '\" /><br>' . $summary;\n            $item['author'] = 'Editor\\'s Blog';\n\n            if (isset($item['title'])) {\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CMetropolitanaBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass CMetropolitanaBridge extends BridgeAbstract\n{\n    const NAME = 'CMetropolitana';\n    const URI = 'https://carrismetropolitana.pt';\n    const DESCRIPTION = 'CMetropolitana | Alertas';\n    const MAINTAINER = 'FJSFerreira';\n\n    public function collectData()\n    {\n        $json = getContents('https://api.carrismetropolitana.pt/v2/alerts');\n\n        $data = Json::decode($json);\n\n        foreach ($data as $entry) {\n            $item = [];\n\n            $item['uri'] = self::URI . '/alerts/' . $entry['alert_id'];\n            $item['title'] = $entry['header_text']['translation'][0]['text'];\n            $item['timestamp'] = $entry['active_period'][0]['start'];\n            $item['content'] = $entry['description_text']['translation'][0]['text'];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CNETBridge.php",
    "content": "<?php\n\nclass CNETBridge extends SitemapBridge\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'CNET News';\n    const URI = 'https://www.cnet.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns the newest articles.';\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'name' => 'Topic',\n                'type' => 'list',\n                'values' => [\n                    'All articles' => '',\n                    'Tech' => 'tech',\n                    'Money' => 'personal-finance',\n                    'Home' => 'home',\n                    'Wellness' => 'health',\n                    'Energy' => 'home/energy-and-utilities',\n                    'Deals' => 'deals',\n                    'Computing' => 'tech/computing',\n                    'Mobile' => 'tech/mobile',\n                    'Science' => 'science',\n                    'Services' => 'tech/services-and-software'\n                ]\n            ],\n            'limit' => self::LIMIT\n        ]\n    ];\n\n    public function collectData()\n    {\n        $topic = $this->getInput('topic');\n        $limit = $this->getInput('limit');\n        $limit = empty($limit) ? 10 : $limit;\n\n        $url_pattern = empty($topic) ? '' : self::URI . $topic;\n        $sitemap_latest = self::URI . 'sitemaps/article/' . date('Y/m') . '.xml';\n        $sitemap_previous = self::URI . 'sitemaps/article/' . date('Y/m', strtotime('last day of previous month')) . '.xml';\n\n        $links = array_merge(\n            $this->sitemapXmlToList($this->getSitemapXml($sitemap_latest, true), $url_pattern, $limit),\n            $this->sitemapXmlToList($this->getSitemapXml($sitemap_previous, true), $url_pattern, $limit)\n        );\n\n        if ($limit > 0 && count($links) > $limit) {\n            $links = array_slice($links, 0, $limit);\n        }\n\n        if (empty($links)) {\n            throwClientException('Failed to retrieve article list');\n        }\n\n        foreach ($links as $article_uri) {\n            $article_dom = convertLazyLoading(getSimpleHTMLDOMCached($article_uri));\n            $title = trim($article_dom->find('h1', 0)->plaintext);\n            $author = $article_dom->find('span.c-assetAuthor_name', 0);\n            $headline = $article_dom->find('p.c-contentHeader_description', 0);\n            $content = $article_dom->find('div.c-pageArticle_content, div.single-article__content, div.article-main-body', 0);\n            $date = null;\n            $enclosure = null;\n\n            foreach ($article_dom->find('script[type=application/ld+json]') as $ldjson) {\n                $datePublished = extractFromDelimiters($ldjson->innertext, '\"datePublished\":\"', '\"');\n                if ($datePublished !== false) {\n                    $date = strtotime($datePublished);\n                }\n                $imageObject = extractFromDelimiters($ldjson->innertext, 'ImageObject\",\"url\":\"', '\"');\n                if ($imageObject !== false) {\n                    $enclosure = $imageObject;\n                }\n            }\n\n            foreach ($content->find('div.c-shortcodeGallery') as $cleanup) {\n                $cleanup->outertext = '';\n            }\n\n            foreach ($content->find('figure') as $figure) {\n                $img = $figure->find('img', 0);\n                if ($img) {\n                    $figure->outertext = $img->outertext;\n                }\n            }\n\n            $content = $content->innertext;\n\n            if ($enclosure) {\n                $content = \"<div><img src=\\\"$enclosure\\\" /></div>\" . $content;\n            }\n\n            if ($headline) {\n                $content = '<p><b>' . $headline->plaintext . '</b></p><br />' . $content;\n            }\n\n            $item = [];\n            $item['uri'] = $article_uri;\n            $item['title'] = $title;\n\n            if ($author) {\n                $item['author'] = $author->plaintext;\n            }\n\n            $item['content'] = $content;\n\n            if (!is_null($date)) {\n                $item['timestamp'] = $date;\n            }\n\n            if (!is_null($enclosure)) {\n                $item['enclosures'] = [$enclosure];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CNETFranceBridge.php",
    "content": "<?php\n\nclass CNETFranceBridge extends FeedExpander\n{\n    const MAINTAINER = 'leomaradan';\n    const NAME = 'CNET France';\n    const URI = 'https://www.cnetfrance.fr/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'CNET France RSS with filters';\n    const PARAMETERS = [\n        'filters' => [\n            'title' => [\n                'name' => 'Exclude by title',\n                'required' => false,\n                'title' => 'Title term, separated by semicolon (;)',\n                'exampleValue' => 'bon plan;bons plans;au meilleur prix;des meilleures offres;Amazon Prime Day;RED by SFR ou B&You'\n            ],\n            'url' => [\n                'name' => 'Exclude by url',\n                'required' => false,\n                'title' => 'URL term, separated by semicolon (;)',\n                'exampleValue' => 'bon-plan;bons-plans'\n            ]\n        ]\n    ];\n\n    private $bannedTitle = [];\n    private $bannedURL = [];\n\n    public function collectData()\n    {\n        $title = $this->getInput('title');\n        $url = $this->getInput('url');\n\n        if ($title !== null) {\n            $this->bannedTitle = explode(';', $title);\n        }\n\n        if ($url !== null) {\n            $this->bannedURL = explode(';', $url);\n        }\n\n        $this->collectExpandableDatas('https://www.cnetfrance.fr/feeds/rss/news/');\n    }\n\n    protected function parseItem(array $item)\n    {\n        foreach ($this->bannedTitle as $term) {\n            if (preg_match('/' . $term . '/mi', $item['title']) === 1) {\n                return null;\n            }\n        }\n\n        foreach ($this->bannedURL as $term) {\n            if (preg_match('#' . $term . '#mi', $item['uri'])) {\n                return null;\n            }\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/CVEDetailsBridge.php",
    "content": "<?php\n\n// CVE Details is a collection of CVEs, taken from the National Vulnerability\n// Database (NVD) and other sources like the Exploit DB and Metasploit. The\n// website categorizes it by vendor and product and attach the CWE category.\n// There is a Atom feed available, but only logged in users can use it,\n// it is not reliable and contain no useful information. This bridge create a\n// sane feed with additional information like tags and a link to the CWE\n// a description of the vulnerability.\nclass CVEDetailsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Aaron Fischer';\n    const NAME = 'CVE Details';\n    const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours\n    const DESCRIPTION = 'Report new CVE vulnerabilities for a given vendor (and product)';\n    const URI = 'https://www.cvedetails.com';\n\n    const PARAMETERS = [[\n        // The Vendor ID can be taken from the URL\n        'vendor_id' => [\n            'name' => 'Vendor ID',\n            'type' => 'number',\n            'required' => true,\n            'exampleValue' => 74, // PHP\n        ],\n        // The optional Product ID can be taken from the URL as well\n        'product_id' => [\n            'name' => 'Product ID',\n            'type' => 'number',\n            'required' => false,\n            'exampleValue' => 128, // PHP\n        ],\n    ]];\n\n    private $html = null;\n    private $vendor = '';\n    private $product = '';\n\n    public function collectData()\n    {\n        if ($this->html == null) {\n            $this->fetchContent();\n        }\n\n        $var = $this->html->find('#searchresults > div > div.row');\n        foreach ($var as $i => $tr) {\n            $uri = $tr->find('h3 > a', 0)->href ?? null;\n            $title = $tr->find('h3 > a', 0)->innertext;\n            $content = $tr->find('.cvesummarylong', 0)->innertext ?? '';\n            $timestamp = $tr->find('[data-tsvfield=\"publishDate\"]', 0)->innertext ?? 0;\n\n            $this->items[] = [\n                'uri'           => $uri,\n                'title'         => $title,\n                'timestamp'     => $timestamp,\n                'content'       => $content,\n                'categories'    => [$this->vendor],\n                'enclosures'    => [],\n                'uid'           => $title,\n            ];\n            if (count($this->items) >= 30) {\n                break;\n            }\n        }\n    }\n\n    // Make the actual request to cvedetails.com and stores the response\n    // (HTML) for later use and extract vendor and product from it.\n    private function fetchContent()\n    {\n        // build url\n        // Return the URL to query.\n        // Because of the optional product ID, we need to attach it if it is\n        // set. The search result page has the exact same structure (with and\n        // without the product ID).\n        $url = self::URI . '/vulnerability-list/vendor_id-' . $this->getInput('vendor_id');\n        if ($this->getInput('product_id') !== '') {\n            $url .= '/product_id-' . $this->getInput('product_id');\n        }\n        // Sadly, there is no way (prove me wrong please) to sort the search\n        // result by publish date. So the nearest alternative is the CVE\n        // number, which should be mostly accurate.\n        $url .= '?order=1'; // Order by CVE number DESC\n\n        $html = getSimpleHTMLDOM($url);\n        $this->html = defaultLinkTo($html, self::URI);\n\n        $vendor = $html->find('#contentdiv h1 > a', 0);\n        if ($vendor == null) {\n            throwServerException('Invalid Vendor ID ' . $this->getInput('vendor_id') . ' or Product ID ' . $this->getInput('product_id'));\n        }\n        $this->vendor = $vendor->innertext;\n\n        $product = $html->find('#contentdiv h1 > a', 1);\n        if ($product != null) {\n            $this->product = $product->innertext;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('vendor_id') == '') {\n            return self::NAME;\n        }\n\n        if ($this->html == null) {\n            $this->fetchContent();\n        }\n\n        $name = 'CVE Vulnerabilities for ' . $this->vendor;\n        if ($this->product != '') {\n            $name .= '/' . $this->product;\n        }\n\n        return $name;\n    }\n}\n"
  },
  {
    "path": "bridges/CachetBridge.php",
    "content": "<?php\n\nclass CachetBridge extends BridgeAbstract\n{\n    const NAME = 'Cachet';\n    const URI = 'https://cachethq.io/';\n    const DESCRIPTION = 'Returns status updates from any Cachet installation';\n    const MAINTAINER  = 'klimplant';\n    const PARAMETERS = [\n        [\n            'host' => [\n                'name' => 'Cachet installation',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'The URL of the Cachet installation',\n                'exampleValue' => 'https://demo.cachethq.io/',\n            ], 'additional_info' => [\n                'name' => 'Additional Timestamps',\n                'type' => 'checkbox',\n                'title' => 'Whether to include the given timestamps'\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 300;\n\n    private $componentCache = [];\n\n    public function getURI()\n    {\n        return $this->getInput('host') === null ? 'https://cachethq.io/' : $this->getInput('host');\n    }\n\n    /**\n     * Validates the ping request to the cache API\n     *\n     * @param string $ping\n     * @return boolean\n     */\n    private function validatePing($ping)\n    {\n        $ping = json_decode($ping);\n        if ($ping === null) {\n            return false;\n        }\n        return $ping->data === 'Pong!';\n    }\n\n    /**\n     * Returns the component name of a cachat component\n     *\n     * @param integer $id\n     * @return string\n     */\n    private function getComponentName($id)\n    {\n        if ($id === 0) {\n            return '';\n        }\n        if (array_key_exists($id, $this->componentCache)) {\n            return $this->componentCache[$id];\n        }\n\n        $component = getContents($this->getURI() . '/api/v1/components/' . $id);\n        $component = json_decode($component);\n        if ($component === null) {\n            return '';\n        }\n        return $component->data->name;\n    }\n\n    public function collectData()\n    {\n        $ping = getContents(urljoin($this->getURI(), '/api/v1/ping'));\n        if (!$this->validatePing($ping)) {\n            throwClientException('Provided URI is invalid!');\n        }\n\n        $url = urljoin($this->getURI(), '/api/v1/incidents?sort=id&order=desc');\n        $incidents = getContents($url);\n        $incidents = json_decode($incidents);\n        if ($incidents === null) {\n            throwClientException('/api/v1/incidents returned no valid json');\n        }\n\n        usort($incidents->data, function ($a, $b) {\n            $timeA = strtotime($a->updated_at);\n            $timeB = strtotime($b->updated_at);\n            return $timeA > $timeB ? -1 : 1;\n        });\n\n        foreach ($incidents->data as $incident) {\n            if (isset($incident->permalink)) {\n                $permalink = $incident->permalink;\n            } else {\n                $permalink = urljoin($this->getURI(), '/incident/' . $incident->id);\n            }\n\n            $title = $incident->human_status . ': ' . $incident->name;\n            $message = '';\n            if ($this->getInput('additional_info')) {\n                if (isset($incident->occurred_at)) {\n                    $message .= 'Occurred at: ' . $incident->occurred_at . \"\\r\\n\";\n                }\n                if (isset($incident->scheduled_at)) {\n                    $message .= 'Scheduled at: ' . $incident->scheduled_at . \"\\r\\n\";\n                }\n                if (isset($incident->created_at)) {\n                    $message .= 'Created at: ' . $incident->created_at . \"\\r\\n\";\n                }\n                if (isset($incident->updated_at)) {\n                    $message .= 'Updated at: ' . $incident->updated_at . \"\\r\\n\\r\\n\";\n                }\n            }\n\n            $message .= $incident->message;\n            $content = nl2br($message);\n            $componentName = $this->getComponentName($incident->component_id);\n            $uidOrig = $permalink . $incident->created_at;\n            $uid = hash('sha512', $uidOrig);\n            $timestamp = strtotime($incident->created_at);\n            $categories = [];\n            $categories[] = $incident->human_status;\n            if ($componentName !== '') {\n                $categories[] = $componentName;\n            }\n\n            $item = [];\n            $item['uri'] = $permalink;\n            $item['title'] = $title;\n            $item['timestamp'] = $timestamp;\n            $item['content'] = $content;\n            $item['uid'] = $uid;\n            $item['categories'] = $categories;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CarThrottleBridge.php",
    "content": "<?php\n\nclass CarThrottleBridge extends BridgeAbstract\n{\n    const NAME = 'Car Throttle';\n    const URI = 'https://www.carthrottle.com/';\n    const DESCRIPTION = 'Get the latest car-related news from Car Throttle.';\n    const MAINTAINER = 't0stiman';\n    const DONATION_URI = 'https://ko-fi.com/tostiman';\n\n    const PARAMETERS = [\n        'Show articles from these categories:' => [\n            'news' => [\n                'name' => 'news',\n                'type' => 'checkbox'\n            ],\n            'reviews' => [\n                'name' => 'reviews',\n                'type' => 'checkbox'\n            ],\n            'features' => [\n                'name' => 'features',\n                'type' => 'checkbox'\n            ],\n            'videos' => [\n                'name' => 'videos',\n                'type' => 'checkbox'\n            ],\n            'gaming' => [\n                'name' => 'gaming',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $this->items = [];\n\n        $this->handleCategory('news');\n        $this->handleCategory('reviews');\n        $this->handleCategory('features');\n        $this->handleCategory2('videos', 'video');\n        $this->handleCategory('gaming');\n    }\n\n    private function handleCategory($category)\n    {\n        if ($this->getInput($category)) {\n            $this->getArticles($category);\n        }\n    }\n\n    private function handleCategory2($categoryParameter, $categoryURLname)\n    {\n        if ($this->getInput($categoryParameter)) {\n            $this->getArticles($categoryURLname);\n        }\n    }\n\n    private function getArticles($category)\n    {\n        $categoryPage = getSimpleHTMLDOMCached(self::URI . $category);\n\n        //for each post\n        foreach ($categoryPage->find('div.cmg-card') as $post) {\n            $item = [];\n\n            $titleElement = $post->find('a.title')[0];\n            $post_uri = self::URI . $titleElement->getAttribute('href');\n\n            if (!isset($post_uri) || $post_uri == '') {\n                continue;\n            }\n\n            $item['uri'] = $post_uri;\n            $item['title'] = $titleElement->innertext;\n\n            $articlePage = getSimpleHTMLDOMCached($item['uri']);\n\n            $item['author'] = $this->parseAuthor($articlePage);\n\n            $articleImage = $articlePage->find('figure')[0];\n            $article = $articlePage->find('div.first-column div.body')[0];\n\n            //remove ads\n            foreach ($article->find('aside') as $ad) {\n                $ad->outertext = '';\n            }\n\n            $summary = $articlePage->find('div.summary')[0];\n\n            //these are supposed to be hidden\n            foreach ($article->find('.visually-hidden') as $found) {\n                $found->outertext = '';\n            }\n\n            $item['content'] = $summary . $articleImage . $article;\n\n            array_push($this->items, $item);\n        }\n    }\n\n    private function parseAuthor($articlePage)\n    {\n        $authorDivs = $articlePage->find('div address');\n        if (!$authorDivs) {\n            return '';\n        }\n\n        $a = $authorDivs[0]->find('a')[0];\n        if ($a) {\n            return $a->innertext;\n        }\n\n        return $authorDivs[0]->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/CaschyBridge.php",
    "content": "<?php\n\nclass CaschyBridge extends FeedExpander\n{\n    const MAINTAINER = 'Tone866';\n    const NAME = 'Caschys Blog';\n    const URI = 'https://stadt-bremerhaven.de/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'Alle News'\n                => 'https://stadt-bremerhaven.de/feed/'\n            ]\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 5\n        ]\n    ]];\n    const LIMIT = 5;\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(\n            $this->getInput('category'),\n            $this->getInput('limit') ?: static::LIMIT\n        );\n    }\n\n    protected function parseItem(array $item)\n    {\n        if (strpos($item['uri'], 'https://stadt-bremerhaven.de/') !== 0) {\n            return $item;\n        }\n\n        $article = getSimpleHTMLDOMCached($item['uri']);\n\n        if ($article) {\n            $article = defaultLinkTo($article, $item['uri']);\n            $item = $this->addArticleToItem($item, $article);\n        }\n\n        return $item;\n    }\n\n    private function addArticleToItem($item, $article)\n    {\n        // remove unwanted stuff\n        foreach (\n            $article->find('div.aawp, p.aawp-disclaimer, iframe.wp-embedded-content, \n            div.wp-embed, p.wp-caption-text, script') as $element\n        ) {\n            $element->remove();\n        }\n        // reload html, as remove() is buggy\n        $article = str_get_html($article->outertext);\n\n        $categories = $article->find('div.post-category a');\n        foreach ($categories as $category) {\n            $item['categories'][] = $category->plaintext;\n        }\n\n        $content = $article->find('div.entry-inner', 0);\n        $item['content'] = $content;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/CastorusBridge.php",
    "content": "<?php\n\nclass CastorusBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Castorus';\n    const URI = 'https://www.castorus.com';\n    const CACHE_TIMEOUT = 600; // 10min\n    const DESCRIPTION = 'Returns the latest changes';\n\n    const PARAMETERS = [\n        'Get latest changes' => [],\n        'Get latest changes via ZIP code' => [\n            'zip' => [\n                'name' => 'ZIP code',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '7',\n                'title' => 'Insert ZIP code (complete or partial). e.g: 78125 OR 781 OR 7'\n            ]\n        ],\n        'Get latest changes via city name' => [\n            'city' => [\n                'name' => 'City name',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'Paris',\n                'title' => 'Insert city name (complete or partial). e.g: Paris OR Par OR P'\n            ]\n        ]\n    ];\n\n    // Extracts the title from an actitiy\n    private function extractActivityTitle($activity)\n    {\n        $title = $activity->find('a', 0);\n\n        if (!$title) {\n            throwServerException('Cannot find title!');\n        }\n\n        return trim($title->plaintext);\n    }\n\n    // Extracts the url from an actitiy\n    private function extractActivityUrl($activity)\n    {\n        $url = $activity->find('a', 0);\n\n        if (!$url) {\n            throwServerException('Cannot find url!');\n        }\n\n        return self::URI . $url->href;\n    }\n\n    // Extracts the time from an activity\n    private function extractActivityTime($activity)\n    {\n        // Unfortunately the time is part of the parent node,\n        // so we have to clear all child nodes first\n        $nodes = $activity->find('*');\n\n        if (!$nodes) {\n            throwServerException('Cannot find nodes!');\n        }\n\n        foreach ($nodes as $node) {\n            $node->outertext = '';\n        }\n\n        return strtotime($activity->innertext);\n    }\n\n    // Extracts the price change\n    private function extractActivityPrice($activity)\n    {\n        $price = $activity->find('span', 1);\n\n        if (!$price) {\n            throwServerException('Cannot find price!');\n        }\n\n        return $price->innertext;\n    }\n\n    public function collectData()\n    {\n        $zip_filter = trim($this->getInput('zip'));\n        $city_filter = trim($this->getInput('city'));\n\n        $html = getSimpleHTMLDOM(self::URI);\n\n        if (!$html) {\n            throwServerException('Could not load data from ' . self::URI . '!');\n        }\n\n        $activities = $html->find('div#activite > li');\n\n        if (!$activities) {\n            throwServerException('Failed to find activities!');\n        }\n\n        foreach ($activities as $activity) {\n            $item = [];\n\n            $item['title'] = $this->extractActivityTitle($activity);\n            $item['uri'] = $this->extractActivityUrl($activity);\n            $item['timestamp'] = $this->extractActivityTime($activity);\n            $item['content'] = '<a href=\"'\n            . $item['uri']\n            . '\">'\n            . $item['title']\n            . '</a><br><p>'\n            . $this->extractActivityPrice($activity)\n            . '</p>';\n\n            if (\n                isset($zip_filter)\n                && !(substr($item['title'], 0, strlen($zip_filter)) === $zip_filter)\n            ) {\n                continue; // Skip this item\n            }\n\n            if (\n                isset($city_filter)\n                && !(substr($item['title'], strpos($item['title'], ' ') + 1, strlen($city_filter)) === $city_filter)\n            ) {\n                continue; // Skip this item\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CdactionBridge.php",
    "content": "<?php\n\nclass CdactionBridge extends BridgeAbstract\n{\n    const NAME = 'CD-ACTION';\n    const URI = 'https://cdaction.pl';\n    const DESCRIPTION = 'Fetches the latest posts from given category.';\n    const MAINTAINER = 'tomaszkane';\n    const PARAMETERS = [ [\n        'category' => [\n            'name' => 'Kategoria',\n            'type' => 'list',\n            'values' => [\n                'Najnowsze (wszystkie)' => 'najnowsze',\n                'Newsy' => 'newsy',\n                'Recenzje' => 'recenzje',\n                'Teksty' => [\n                    'Publicystyka' => 'teksty',\n                    'Zapowiedzi' => 'zapowiedzi',\n                    'Już graliśmy' => 'juz-gralismy',\n                    'Retro' => 'retro',\n                ],\n                'Kultura' => 'kultura',\n                'Technologie' => [\n                    'Artykuły' => 'artykuly',\n                    'Technologie' => 'technologie',\n                    'Testy' => 'testy',\n                ],\n                'Na luzie' => [\n                    'Nadgodziny' => 'nadgodziny',\n                ]\n            ]\n        ]]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI() . '/' . $this->getInput('category'));\n\n        $mainArticles = $html->find('ul.news-list .first-item');\n        $this->processArticles($mainArticles);\n        $articles = $html->find('.news-list li.article');\n        $this->processArticles($articles);\n    }\n\n    private function processArticles(array $articles): void\n    {\n        /** @var simple_html_dom_node $article */\n        foreach ($articles as $article) {\n            $item = [];\n            $item['uri'] = $article->find('a.article-link', 0)->getAttribute('href');\n            $item['title'] = $article->find('h3 .title-desktop', 0)->innertext;\n            $item['uid'] = $item['uri'];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CentreFranceBridge.php",
    "content": "<?php\n\nclass CentreFranceBridge extends BridgeAbstract\n{\n    const NAME = 'Centre France Newspapers';\n    const URI = 'https://www.centrefrance.com/';\n    const DESCRIPTION = 'Common bridge for all Centre France group newspapers.';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const MAINTAINER = 'quent1';\n    const PARAMETERS = [\n        'global' => [\n            'newspaper' => [\n                'name' => 'Newspaper',\n                'type' => 'list',\n                'values' => [\n                    'La Montagne' => 'lamontagne.fr',\n                    'Le Populaire du Centre' => 'lepopulaire.fr',\n                    'La République du Centre' => 'larep.fr',\n                    'Le Berry Républicain' => 'leberry.fr',\n                    'L\\'Yonne Républicaine' => 'lyonne.fr',\n                    'L\\'Écho Républicain' => 'lechorepublicain.fr',\n                    'Le Journal du Centre' => 'lejdc.fr',\n                    'L\\'Éveil de la Haute-Loire' => 'leveil.fr',\n                    'Le Pays' => 'le-pays.fr'\n                ]\n            ],\n            'remove-reserved-for-subscribers-articles' => [\n                'name' => 'Remove reserved for subscribers articles',\n                'type' => 'checkbox',\n                'title' => 'Filter out articles that are only available to subscribers'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'title' => 'How many articles to fetch. 0 to disable.',\n                'required' => true,\n                'defaultValue' => 15\n            ]\n        ],\n        'Local news' => [\n            'locality-slug' => [\n                'name' => 'Locality slug',\n                'type' => 'text',\n                'required' => false,\n                'title' => 'Fetch articles for a specific locality. If not set, headlines from the front page will be used instead.',\n                'exampleValue' => 'moulins-03000'\n            ],\n        ]\n    ];\n\n    private static array $monthNumberByFrenchName = [\n        'janvier' => 1, 'février' => 2, 'mars' => 3, 'avril' => 4, 'mai' => 5, 'juin' => 6, 'juillet' => 7,\n        'août' => 8, 'septembre' => 9, 'octobre' => 10, 'novembre' => 11, 'décembre' => 12\n    ];\n\n    public function collectData()\n    {\n        $value = $this->getInput('limit');\n        if (is_numeric($value) && (int)$value >= 0) {\n            $limit = $value;\n        } else {\n            $limit = static::PARAMETERS['global']['limit']['defaultValue'];\n        }\n\n        if (empty($this->getInput('newspaper'))) {\n            return;\n        }\n\n        $localitySlug = $this->getInput('locality-slug') ?? '';\n        $alreadyFoundArticlesURIs = [];\n\n        $newspaperUrl = 'https://www.' . $this->getInput('newspaper') . '/' . $localitySlug . '/';\n        $html = getSimpleHTMLDOM($newspaperUrl);\n\n        // Articles are detected through a standard tag\n        foreach ($html->find('article') as $articleDOMElement) {\n            $articleLinkDOMElement = $articleDOMElement->find('a', 0);\n            $articleURI = $articleLinkDOMElement->href;\n\n            // If the URI has already been processed, ignore it\n            if (in_array($articleURI, $alreadyFoundArticlesURIs, true)) {\n                continue;\n            }\n\n            // If news are filtered for a specific locality, filter out article for other localities\n            if ($localitySlug !== '' && !str_contains($articleURI, $localitySlug)) {\n                continue;\n            }\n\n            $articleTitle = '';\n\n            // If article is reserved for subscribers\n            if ($articleLinkDOMElement->find('span.premium-icon', 0)) {\n                if ($this->getInput('remove-reserved-for-subscribers-articles') === true) {\n                    continue;\n                }\n\n                $articleTitle .= '🔒 ';\n            }\n\n            if ($limit > 0 && count($this->items) === $limit) {\n                break;\n            }\n\n            // Loop through each possible title class name\n            for ($i = 1; $i <= 3; $i++) {\n                foreach ($articleLinkDOMElement->find('.typo-card-heading-' . $i) as $articleTitleDOMElement) {\n                    if ($articleTitleDOMElement->hasClass('font-sans')) {\n                        continue;\n                    }\n\n                    $articleTitle .= $articleTitleDOMElement->text();\n                    break 2;\n                }\n            }\n\n            $articleFullURI = urljoin('https://www.' . $this->getInput('newspaper') . '/', $articleURI);\n            $item = [\n                'title' => $articleTitle,\n                'uri' => $articleFullURI,\n                ...$this->collectArticleData($articleFullURI)\n            ];\n            $this->items[] = $item;\n\n            $alreadyFoundArticlesURIs[] = $articleURI;\n        }\n    }\n\n    private function collectArticleData($uri): array\n    {\n        $html = getSimpleHTMLDOMCached($uri, 86400 * 90); // 90d\n\n        $item = [\n            'enclosures' => [],\n        ];\n\n        $articleInformations = $html->find('#content hgroup > div.typo-p3 > *');\n        if (is_array($articleInformations) && $articleInformations !== []) {\n            $publicationDateIndex = 0;\n\n            // Article author\n            $probableAuthorName = strip_tags($articleInformations[0]->innertext);\n            if (str_starts_with($probableAuthorName, 'Par ')) {\n                $publicationDateIndex = 1;\n                $item['author'] = substr($probableAuthorName, 4);\n            }\n\n            // Article publication date\n            preg_match('/Publié le (\\d{2}) (.+) (\\d{4})( à (\\d{2})h(\\d{2}))?/', strip_tags($articleInformations[$publicationDateIndex]->innertext), $articleDateParts);\n            if ($articleDateParts !== [] && array_key_exists($articleDateParts[2], self::$monthNumberByFrenchName)) {\n                $articleDate = new \\DateTime('midnight');\n                $articleDate->setDate($articleDateParts[3], self::$monthNumberByFrenchName[$articleDateParts[2]], $articleDateParts[1]);\n\n                if (count($articleDateParts) === 7) {\n                    $articleDate->setTime($articleDateParts[5], $articleDateParts[6]);\n                }\n\n                $item['timestamp'] = $articleDate->getTimestamp();\n            }\n        }\n\n        $articleContent = $html->find('#content>div.flex+div.grid section>.z-10')[0] ?? null;\n        if ($articleContent instanceof \\simple_html_dom_node) {\n            $articleHiddenParts = $articleContent->find('.ad-slot, #cf-digiteka-player');\n            if (is_array($articleHiddenParts)) {\n                foreach ($articleHiddenParts as $articleHiddenPart) {\n                    $articleContent->removeChild($articleHiddenPart);\n                }\n            }\n\n            $item['content'] = $articleContent->innertext;\n        }\n\n        $articleIllustration  = $html->find('#content>div.flex+div.grid section>figure>img');\n        if (is_array($articleIllustration) && count($articleIllustration) === 1) {\n            $item['enclosures'][] = $articleIllustration[0]->getAttribute('src');\n        }\n\n        $articleAudio = $html->find('audio[src^=\"https://api.octopus.saooti.com/\"]');\n        if (is_array($articleAudio) && count($articleAudio) === 1) {\n            $item['enclosures'][] = $articleAudio[0]->getAttribute('src');\n        }\n\n        $articleTags = $html->find('#content>div.flex+div.grid section>.bg-gray-light>a.border-gray-dark');\n        if (is_array($articleTags)) {\n            $item['categories'] = array_map(static fn ($articleTag) => html_entity_decode($articleTag->innertext), $articleTags);\n        }\n\n        $explode = explode('_', $uri);\n        $array_reverse = array_reverse($explode);\n        $string = $array_reverse[0];\n        $uid = rtrim($string, '/');\n        if (is_numeric($uid)) {\n            $item['uid'] = $uid;\n        }\n\n        if (!isset($item['content'])) {\n            $item['content'] = '';\n        }\n\n        // If the article is a \"grand format\", we use another parsing strategy\n        if ($item['content'] === '' && $html->find('article') !== []) {\n            $articleContent = $html->find('article > section');\n            foreach ($articleContent as $contentPart) {\n                if ($contentPart->find('#journo') !== []) {\n                    $item['author'] = $contentPart->find('#journo')->innertext;\n                    continue;\n                }\n\n                $item['content'] .= $contentPart->innertext;\n            }\n        }\n\n        $item['content'] = str_replace('<span class=\"p-premium\">premium</span>', '🔒', $item['content']);\n        $item['content'] = trim($item['content']);\n\n        return $item;\n    }\n\n    public function getName()\n    {\n        if (empty($this->getInput('newspaper'))) {\n            return static::NAME;\n        }\n\n        $newspaperNameByDomain = array_flip(self::PARAMETERS['global']['newspaper']['values']);\n        if (!isset($newspaperNameByDomain[$this->getInput('newspaper')])) {\n            return static::NAME;\n        }\n\n        $completeTitle = $newspaperNameByDomain[$this->getInput('newspaper')];\n\n        if (!empty($this->getInput('locality-slug'))) {\n            $localityName = explode('-', $this->getInput('locality-slug'));\n            array_pop($localityName);\n            $completeTitle .= ' ' . ucfirst(implode('-', $localityName));\n        }\n\n        return $completeTitle;\n    }\n\n    public function getIcon()\n    {\n        if (empty($this->getInput('newspaper'))) {\n            return static::URI . '/favicon.ico';\n        }\n\n        return 'https://www.' . $this->getInput('newspaper') . '/favicon.ico';\n    }\n\n    public function detectParameters($url)\n    {\n        $regex = '/^(https?:\\/\\/)?(www\\.)?([a-z-]+\\.fr)(\\/)?([a-z-]+-[0-9]{5})?(\\/)?$/';\n        $url = strtolower($url);\n\n        if (preg_match($regex, $url, $urlMatches) === 0) {\n            return null;\n        }\n\n        if (!in_array($urlMatches[3], self::PARAMETERS['global']['newspaper']['values'], true)) {\n            return null;\n        }\n\n        return [\n            'newspaper' => $urlMatches[3],\n            'locality-slug' => empty($urlMatches[5]) ? null : $urlMatches[5]\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/CeskaTelevizeBridge.php",
    "content": "<?php\n\nclass CeskaTelevizeBridge extends BridgeAbstract\n{\n    const NAME = 'Česká televize';\n    const URI = 'https://www.ceskatelevize.cz';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'Return newest videos';\n    const MAINTAINER = 'kolarcz';\n\n    const PARAMETERS = [\n        [\n            'url' => [\n                'name' => 'url to the show',\n                'required' => true,\n                'exampleValue' => 'https://www.ceskatelevize.cz/porady/1097181328-udalosti/'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n\n        $validUrl = '/^(https:\\/\\/www\\.ceskatelevize\\.cz\\/porady\\/\\d+-[a-z0-9-]+\\/)(bonus\\/)?$/';\n        if (!preg_match($validUrl, $url, $match)) {\n            throwServerException('Invalid url');\n        }\n\n        $category = $match[4] ?? 'nove';\n        $fixedUrl = \"{$match[1]}dily/{$category}/\";\n\n        $html = getSimpleHTMLDOM($fixedUrl);\n\n        $this->feedUri = $fixedUrl;\n        $this->feedName = str_replace('Přehled dílů — ', '', $this->fixChars($html->find('title', 0)->plaintext));\n        if ($category !== 'nove') {\n            $this->feedName .= \" ({$category})\";\n        }\n\n        foreach ($html->find('#episodeListSection a[data-testid=card]') as $element) {\n            $itemContent = $element->find('p[class^=content-]', 0);\n            $itemDate = $element->find('div[class^=playTime-] span, [data-testid=episode-item-broadcast] span', 0);\n\n            // Remove special characters and whitespace\n            $cleanDate = preg_replace('/[^0-9.]/', '', $itemDate->plaintext);\n\n            $item = [\n                'title'     => $this->fixChars($element->find('h3', 0)->plaintext),\n                'uri'       => self::URI . $element->getAttribute('href'),\n                'content'   => '<img src=\"' . $element->find('img', 0)->getAttribute('srcset') . '\" /><br />' . $this->fixChars($itemContent->plaintext),\n                'timestamp' => $this->getUploadTimeFromString($cleanDate),\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getUploadTimeFromString($string)\n    {\n        if (strpos($string, 'dnes') !== false) {\n            return strtotime('today');\n        } elseif (strpos($string, 'včera') !== false) {\n            return strtotime('yesterday');\n        } elseif (!preg_match('/(\\d+).(\\d+).((\\d+))?/', $string, $match)) {\n            throwServerException('Could not get date from Česká televize string');\n        }\n\n        $date = sprintf('%04d-%02d-%02d', $match[3] ?? date('Y'), $match[2], $match[1]);\n        return strtotime($date);\n    }\n\n    private function fixChars($text)\n    {\n        return html_entity_decode($text, ENT_QUOTES, 'UTF-8');\n    }\n\n    public function getURI()\n    {\n        return $this->feedUri ?? parent::getURI();\n    }\n\n    public function getName()\n    {\n        return $this->feedName ?? parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/CodebergBridge.php",
    "content": "<?php\n\nclass CodebergBridge extends BridgeAbstract\n{\n    const NAME = 'Codeberg';\n    const URI = 'https://codeberg.org/';\n    const DESCRIPTION = 'Returns commits, issues, pull requests or releases for a repository.';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'Commits' => [\n            'branch' => [\n                'name' => 'branch',\n                'type' => 'text',\n                'exampleValue' => 'main',\n                'required' => false,\n                'title' => 'Optional, main branch is used by default.',\n            ],\n        ],\n        'Issues' => [],\n        'Issue Comments' => [\n            'issueId' => [\n                'name' => 'Issue ID',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '513',\n            ]\n        ],\n        'Pull Requests' => [],\n        'Releases' => [],\n        'Tags' => [],\n        'global' => [\n            'username' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'exampleValue' => 'Codeberg',\n                'title' => 'Username of account that the repository belongs to.',\n                'required' => true,\n            ],\n            'repo' => [\n                'name' => 'Repository',\n                'type' => 'text',\n                'exampleValue' => 'Community',\n                'required' => true,\n            ]\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 1800;\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://codeberg.org/Codeberg/Community/issues/507' => [\n            'context' => 'Issue Comments', 'username' => 'Codeberg', 'repo' => 'Community', 'issueId' => '507'\n        ],\n        'https://codeberg.org/Codeberg/Community/issues' => [\n            'context' => 'Issues', 'username' => 'Codeberg', 'repo' => 'Community'\n        ],\n        'https://codeberg.org/Codeberg/Community/pulls' => [\n            'context' => 'Pull Requests', 'username' => 'Codeberg', 'repo' => 'Community'\n        ],\n        'https://codeberg.org/Codeberg/Community/releases' => [\n            'context' => 'Releases', 'username' => 'Codeberg', 'repo' => 'Community'\n        ],\n        'https://codeberg.org/Codeberg/Community/commits/branch/master' => [\n            'context' => 'Commits', 'username' => 'Codeberg', 'repo' => 'Community', 'branch' => 'master'\n        ],\n        'https://codeberg.org/Codeberg/Community/commits' => [\n            'context' => 'Commits', 'username' => 'Codeberg', 'repo' => 'Community'\n        ]\n    ];\n\n    private $defaultBranch = 'main';\n    private $issueTitle = '';\n\n    private $urlRegex = '/codeberg\\.org\\/([\\w]+)\\/([\\w]+)(?:\\/commits\\/branch\\/([\\w]+))?/';\n    private $issuesUrlRegex = '/codeberg\\.org\\/([\\w]+)\\/([\\w]+)\\/issues/';\n    private $pullsUrlRegex = '/codeberg\\.org\\/([\\w]+)\\/([\\w]+)\\/pulls/';\n    private $releasesUrlRegex = '/codeberg\\.org\\/([\\w]+)\\/([\\w]+)\\/releases/';\n    private $issueCommentsUrlRegex = '/codeberg\\.org\\/([\\w]+)\\/([\\w]+)\\/issues\\/([0-9]+)/';\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n        $html = defaultLinkTo($html, $url);\n\n        switch ($this->queriedContext) {\n            case 'Commits':\n                $this->extractCommits($html);\n                break;\n            case 'Issues':\n                $this->extractIssues($html);\n                break;\n            case 'Issue Comments':\n                $this->extractIssueComments($html);\n                break;\n            case 'Pull Requests':\n                $this->extractPulls($html);\n                break;\n            case 'Releases':\n                $this->extractReleases($html);\n                break;\n            case 'Tags':\n                $this->extractTags($html);\n                break;\n            default:\n                throw new \\Exception('Invalid context: ' . $this->queriedContext);\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Commits':\n                if ($this->getBranch() === $this->defaultBranch) {\n                    return $this->getRepo() . ' Commits';\n                }\n\n                return $this->getRepo() . ' Commits (' . $this->getBranch() . ' branch) - ' . self::NAME;\n            case 'Issues':\n                return $this->getRepo() . ' Issues - ' . self::NAME;\n            case 'Issue Comments':\n                return $this->issueTitle . ' - Issue Comments - ' . self::NAME;\n            case 'Pull Requests':\n                return $this->getRepo() . ' Pull Requests - ' . self::NAME;\n            case 'Releases':\n                return $this->getRepo() . ' Releases - ' . self::NAME;\n            case 'Tags':\n                return $this->getRepo() . ' Tags - ' . self::NAME;\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Commits':\n                return self::URI . $this->getRepo() . '/commits/branch/' . $this->getBranch();\n            case 'Issues':\n                return self::URI . $this->getRepo() . '/issues/';\n            case 'Issue Comments':\n                return self::URI . $this->getRepo() . '/issues/' . $this->getInput('issueId');\n            case 'Pull Requests':\n                return self::URI . $this->getRepo() . '/pulls';\n            case 'Releases':\n                return self::URI . $this->getRepo() . '/releases';\n            case 'Tags':\n                return self::URI . $this->getRepo() . '/tags';\n            default:\n                return parent::getURI();\n        }\n    }\n\n    private function getBranch()\n    {\n        if ($this->getInput('branch')) {\n            return $this->getInput('branch');\n        }\n\n        return $this->defaultBranch;\n    }\n\n    private function getRepo()\n    {\n        return $this->getInput('username') . '/' . $this->getInput('repo');\n    }\n\n    /**\n     * Extract commits\n     */\n    private function extractCommits($html)\n    {\n        $table = $html->find('table#commits-table', 0);\n        $tbody = $table->find('tbody.commit-list', 0);\n\n        foreach ($tbody->find('tr') as $tr) {\n            $item = [];\n\n            $message = $tr->find('td.message', 0);\n\n            $item['title'] = $message->find('span.message-wrapper', 0)->plaintext;\n            $item['uri'] = $tr->find('td.sha', 0)->find('a', 0)->href;\n            $item['author'] = $tr->find('td.author', 0)->plaintext;\n\n            $var = $tr->find('td', 3);\n            $var1 = $var->find('span', 0);\n            if ($var1) {\n                $item['timestamp'] = $var1->title;\n            }\n\n            if ($message->find('pre.commit-body', 0)) {\n                $message->find('pre.commit-body', 0)->style = '';\n\n                $item['content'] = $message->find('pre.commit-body', 0);\n            } else {\n                $item['content'] = '<blockquote>' . $item['title'] . '</blockquote>';\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extract issues\n     */\n    private function extractIssues($html)\n    {\n        $issueList = $html->find('div#issue-list', 0);\n\n        foreach ($issueList->find('div.flex-item') as $div) {\n            $item = [];\n\n            $number = trim($div->find('a.index,ml-0.mr-2', 0)->plaintext);\n\n            $item['title'] = $div->find('a.issue-title', 0)->plaintext . ' (' . $number . ')';\n            $item['uri'] = $div->find('a.issue-title', 0)->href;\n\n            $time = $div->find('relative-time.time-since', 0);\n            if ($time) {\n                $item['timestamp'] = $time->datetime;\n            }\n\n            //$item['author'] = $li->find('div.desc', 0)->find('a', 1)->plaintext;\n\n            // Fetch issue page\n            $issuePage = getSimpleHTMLDOMCached($item['uri'], 3600);\n            $issuePage = defaultLinkTo($issuePage, self::URI);\n\n            $item['content'] = $issuePage->find('div.timeline-item.comment.first', 0)->find('div.render-content.markup', 0);\n\n            foreach ($div->find('a.ui.label') as $label) {\n                $item['categories'][] = $label->plaintext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extract issue comments\n     */\n    private function extractIssueComments($html)\n    {\n        $this->issueTitle = $html->find('span#issue-title', 0)->plaintext\n            . ' (' . $html->find('span.index', 0)->plaintext . ')';\n\n        foreach ($html->find('div.timeline-item.comment') as $div) {\n            $item = [];\n\n            if ($div->class === 'timeline-item comment merge box') {\n                continue;\n            }\n\n            $item['title'] = $this->ellipsisTitle($div->find('div.render-content.markup', 0)->plaintext);\n            $item['uri'] = $div->find('span.text.grey', 0)->find('a', 1)->href;\n            $item['content'] = $div->find('div.render-content.markup', 0);\n\n            if ($div->find('div.dropzone-attachments', 0)) {\n                $item['content'] .= $div->find('div.dropzone-attachments', 0);\n            }\n\n            $item['author'] = $div->find('a.author', 0)->innertext;\n\n            $timeSince = $div->find('span.time-since', 0);\n            if ($timeSince) {\n                $item['timestamp'] = $timeSince->title;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extract pulls\n     */\n    private function extractPulls($html)\n    {\n        $div = $html->find('div#issue-list', 0);\n\n        $var2 = $div->find('div.flex-item');\n        foreach ($var2 as $li) {\n            $item = [];\n\n            $number = trim($li->find('a.index,ml-0.mr-2', 0)->plaintext);\n\n            $a = $li->find('a.issue-title', 0);\n            $item['title'] = $a->plaintext . ' (' . $number . ')';\n            $item['uri'] = $a->href;\n\n            $time = $li->find('relative-time.time-since', 0);\n            if ($time) {\n                $item['timestamp'] = $time->datetime;\n            }\n\n            // Extracting the author is a bit awkward after they changed their html\n            //$desc = $li->find('div.desc', 0);\n            //$item['author'] = $desc->find('a', 1)->plaintext;\n\n            // Fetch pull request page\n            $pullRequestPage = getSimpleHTMLDOMCached($item['uri'], 3600);\n            $pullRequestPage = defaultLinkTo($pullRequestPage, self::URI);\n\n            $var = $pullRequestPage->find('ui.timeline', 0);\n            if ($var) {\n                $var1 = $var->find('div.render-content.markup', 0);\n                $item['content'] = $var1;\n            }\n\n            foreach ($li->find('a.ui.label') as $label) {\n                $item['categories'][] = $label->plaintext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extract releases\n     */\n    private function extractReleases($html)\n    {\n        $ul = $html->find('ul#release-list', 0);\n\n        $lis = $ul->find('li.ui.grid');\n        if ($lis === []) {\n            throw new \\Exception('Found zero releases');\n        }\n        foreach ($lis as $li) {\n            $item = [];\n            $item['title'] = $li->find('h4', 0)->plaintext;\n            $item['uri'] = $li->find('h4', 0)->find('a', 0)->href;\n\n            $tag = $this->stripSvg($li->find('span.tag', 0));\n            $commit = $this->stripSvg($li->find('span.commit', 0));\n            $downloads = $this->extractDownloads($li->find('details.download', 0));\n\n            $item['content'] = $li->find('div.markup.desc', 0);\n            $item['content'] .= <<<HTML\n<strong>Tag</strong>\n<p>{$tag}</p>\n<strong>Commit</strong>\n<p>{$commit}</p>\n{$downloads}\nHTML;\n\n            $item['timestamp'] = $li->find('span.time', 0)->find('span', 0)->title;\n            $item['author'] = $li->find('span.author', 0)->find('a', 0)->plaintext;\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function extractTags($html)\n    {\n        $tags = $html->find('td.tag');\n        if ($tags === []) {\n            throw new \\Exception('Found zero tags');\n        }\n        foreach ($tags as $tag) {\n            $this->items[] = [\n                'title' => $tag->find('a', 0)->plaintext,\n                'uri' => $tag->find('a', 0)->href,\n                'content' => $tag->innertext,\n            ];\n        }\n    }\n\n    /**\n     * Extract downloads for a releases\n     */\n    private function extractDownloads($html, $skipFirst = false)\n    {\n        $downloads = '';\n\n        foreach ($html->find('a') as $index => $a) {\n            if ($skipFirst === true && $index === 0) {\n                continue;\n            }\n\n            $downloads .= <<<HTML\n<a href=\"{$a->herf}\">{$a->plaintext}</a><br>\nHTML;\n        }\n\n        return <<<EOD\n<strong>Downloads</strong>\n<p>{$downloads}</p>\nEOD;\n    }\n\n    /**\n     * Ellipsis title to first 100 characters\n     */\n    private function ellipsisTitle($text)\n    {\n        $length = 100;\n\n        if (strlen($text) > $length) {\n            $text = explode('<br>', wordwrap($text, $length, '<br>'));\n            return $text[0] . '...';\n        }\n        return $text;\n    }\n\n    /**\n     * Strip SVG tag\n     */\n    private function stripSvg($html)\n    {\n        if ($html === null) {\n            return null;\n        }\n        if ($html->find('svg', 0)) {\n            $html->find('svg', 0)->outertext = '';\n        }\n\n        return $html;\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Issue Comments\n        if (preg_match($this->issueCommentsUrlRegex, $url, $matches)) {\n            $params['context'] = 'Issue Comments';\n            $params['username'] = $matches[1];\n            $params['repo'] = $matches[2];\n            $params['issueId'] = $matches[3];\n\n            return $params;\n        }\n\n        // Issues\n        if (preg_match($this->issuesUrlRegex, $url, $matches)) {\n            $params['context'] = 'Issues';\n            $params['username'] = $matches[1];\n            $params['repo'] = $matches[2];\n\n            return $params;\n        }\n\n        // Pull Requests\n        if (preg_match($this->pullsUrlRegex, $url, $matches)) {\n            $params['context'] = 'Pull Requests';\n            $params['username'] = $matches[1];\n            $params['repo'] = $matches[2];\n\n            return $params;\n        }\n\n        // Releases\n        if (preg_match($this->releasesUrlRegex, $url, $matches)) {\n            $params['context'] = 'Releases';\n            $params['username'] = $matches[1];\n            $params['repo'] = $matches[2];\n\n            return $params;\n        }\n\n        // Commits\n        if (preg_match($this->urlRegex, $url, $matches)) {\n            $params['context'] = 'Commits';\n            $params['username'] = $matches[1];\n            $params['repo'] = $matches[2];\n\n            if (isset($matches[3])) {\n                $params['branch'] = $matches[3];\n            }\n\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/CollegeDeFranceBridge.php",
    "content": "<?php\n\nclass CollegeDeFranceBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'pit-fgfjiudghdf';\n    const NAME = 'CollegeDeFrance';\n    const URI = 'https://www.college-de-france.fr/';\n    const CACHE_TIMEOUT = 10800; // 3h\n    const DESCRIPTION = 'Returns the latest audio and video from CollegeDeFrance';\n\n    public function collectData()\n    {\n        $months = [\n            '01' => 'janv.',\n            '02' => 'févr.',\n            '03' => 'mars',\n            '04' => 'avr.',\n            '05' => 'mai',\n            '06' => 'juin',\n            '07' => 'juil.',\n            '08' => 'août',\n            '09' => 'sept.',\n            '10' => 'oct.',\n            '11' => 'nov.',\n            '12' => 'déc.'\n        ];\n\n        // The \"API\" used by the site returns a list of partial HTML in this form\n        /* <li>\n         *  <a href=\"/site/thomas-romer/guestlecturer-2016-04-15-14h30.htm\" data-target=\"after\">\n         *      <span class=\"date\"><span class=\"list-icon list-icon-video\"></span>\n         *      <span class=\"list-icon list-icon-audio\"></span>15 avr. 2016</span>\n         *      <span class=\"lecturer\">Christopher Hays</span>\n         *      <span class='title'>Imagery of Divine Suckling in the Hebrew Bible and the Ancient Near East</span>\n         *  </a>\n         * </li>\n         */\n        $html = getSimpleHTMLDOM(self::URI\n        . 'components/search-audiovideo.jsp?fulltext=&siteid=1156951719600&lang=FR&type=all');\n\n        foreach ($html->find('a[data-target]') as $element) {\n            $item = [];\n            $item['title'] = $element->find('.title', 0)->plaintext;\n\n            // Most relative URLs contains an hour in addition to the date, so let's use it\n            // <a href=\"/site/yann-lecun/course-2016-04-08-11h00.htm\" data-target=\"after\">\n            //\n            // Sometimes there's an __1, perhaps it signifies an update\n            // \"/site/patrick-boucheron/seminar-2016-05-03-18h00__1.htm\"\n            //\n            // But unfortunately some don't have any hours info\n            // <a href=\"/site/institut-physique/\n            // The-Mysteries-of-Decoherence-Sebastien-Gleyzes-[Video-3-35].htm\" data-target=\"after\">\n            $timezone = new DateTimeZone('Europe/Paris');\n\n            // strpos($element->href, '201') will break in 2020 but it'll\n            // probably break prior to then due to site changes anyway\n            $d = DateTime::createFromFormat(\n                '!Y-m-d-H\\hi',\n                substr($element->href, strpos($element->href, '201'), 16),\n                $timezone\n            );\n\n            if (!$d) {\n                $d = DateTime::createFromFormat(\n                    '!d m Y',\n                    trim(str_replace(\n                        array_values($months),\n                        array_keys($months),\n                        $element->find('.date', 0)->plaintext\n                    )),\n                    $timezone\n                );\n            }\n\n            $item['timestamp'] = $d->format('U');\n            $item['content'] = $element->find('.lecturer', 0)->innertext\n            . ' - '\n            . $element->find('.title', 0)->innertext;\n\n            $item['uri'] = self::URI . $element->href;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ComboiosDePortugalBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass ComboiosDePortugalBridge extends BridgeAbstract\n{\n    const NAME = 'CP | Avisos';\n    const URI = 'https://www.cp.pt';\n    const DESCRIPTION = 'Comboios de Portugal | Avisos';\n    const MAINTAINER = 'FJSFerreira';\n\n    const PARAMETERS = [\n        [\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'Português' => 'pt-PT',\n                    'English' => 'en-US'\n                ],\n                'defaultValue' => 'pt-PT'\n            ],\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'All categories' => 0,\n                    'Alfa Pendular' => 50540,\n                    'Intercidades' => 57687,\n                    'Internacional' => 57690,\n                    'Regional' => 57693,\n                    'Turísticos / Históricos' => 57696,\n                    'Urbanos de Coimbra' => 57699,\n                    'Urbanos de Lisboa' => 57702,\n                    'Urbanos do Porto' => 57705\n                ],\n                'defaultValue' => 0\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $json = getContents(self::URI . '/bei/getContentsList?path=PWA/Homepage/Avisos&order=dateModified:desc&categoryId=' . $this->getInput('category'));\n\n        $data = Json::decode($json);\n\n        foreach ($data['item'] as $entry) {\n            $item = [];\n\n            // language defaults to portuguese\n            $item['title'] = $entry['title'][$this->getInput('language')] ?? $entry['title'][$this->getInput('pt-PT')];\n            $item['uri'] = self::URI . '/pt/detalhe-aviso/' . $entry['friendlyUrlPath'];\n            $item['timestamp'] = $entry['dateModified'];\n            $item['content'] = $entry['description'][$this->getInput('language')] ?? $entry['description'][$this->getInput('pt-PT')];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ComickBridge.php",
    "content": "<?php\n\nclass ComickBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'phantop';\n    const NAME = 'Comick';\n    const URI = 'https://comick.io/';\n    const DESCRIPTION = 'Returns the latest chapters for a manga on comick.io.';\n    const PARAMETERS = [[\n        'slug' => [\n            'name' => 'Manga Slug',\n            'type' => 'text',\n            'required' => true,\n            'title' => 'The part of the URL after /comic/',\n            'exampleValue' => '00-kusuriya-no-hitorigoto-maomao-no-koukyuu-nazotoki-techou'\n        ],\n        'lang' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'title' => 'Language for comic (list is # of comics, descending)',\n            'values' => [\n                'English' => 'en',\n                'Brazilian Portuguese' => 'pt-br',\n                'Spanish Latin American' => 'es-la',\n                'Russian' => 'ru',\n                'Vietnamese' => 'vi',\n                'French' => 'fr',\n                'Polish' => 'pl',\n                'Indonesian' => 'id',\n                'Turkish' => 'tr',\n                'Italian' => 'it',\n                'Spanish; Castilian' => 'es',\n                'Ukrainian' => 'uk',\n                'Arabic' => 'ar',\n                'Hong Kong (Traditional Chinese)' => 'zh-hk',\n                'Hungarian' => 'hu',\n                'Chinese' => 'zh',\n                'German' => 'de',\n                'Korean' => 'ko',\n                'Thai' => 'th',\n                'Catalan; Valencian' => 'ca',\n                'Bulgarian' => 'bg',\n                'Persian' => 'fa',\n                'Romanian, Moldavian, Moldovan' => 'ro',\n                'Czech' => 'cs',\n                'Mongolian' => 'mn',\n                'Portuguese' => 'pt',\n                'Hebrew (modern)' => 'he',\n                'Hindi' => 'hi',\n                'Filipino/Tagalog' => 'tl',\n                'Finnish' => 'fi',\n                'Malay' => 'ms',\n                'Basque' => 'eu',\n                'Kazakh' => 'kk',\n                'Serbian' => 'sr',\n                'Burmese' => 'my',\n                'Japanese' => 'ja',\n                'Greek, Modern' => 'el',\n                'Dutch' => 'nl',\n                'Bengali' => 'bn',\n                'Uzbek' => 'uz',\n                'Esperanto' => 'eo',\n                'Lithuanian' => 'lt',\n                'Georgian' => 'ka',\n                'Danish' => 'da',\n                'Tamil' => 'ta',\n                'Swedish' => 'sv',\n                'Belarusian' => 'be',\n                'Chuvash' => 'cv',\n                'Croatian' => 'hr',\n                'Latin' => 'la',\n                'Nepali' => 'ne',\n                'Urdu' => 'ur',\n                'Galician' => 'gl',\n                'Norwegian' => 'no',\n                'Albanian' => 'sq',\n                'Irish' => 'ga',\n                'Javanese' => 'jv',\n                'Telugu' => 'te',\n                'Slovene' => 'sl',\n                'Estonian' => 'et',\n                'Azerbaijani' => 'az',\n                'Slovak' => 'sk',\n                'Afrikaans' => 'af',\n                'Latvian' => 'lv',\n            ],\n            'defaultValue' => 'en'\n        ],\n        'fetch' => [\n            'name' => 'Fetch chapter page images',\n            'type' => 'list',\n            'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',\n            'defaultValue' => 'c',\n            'values' => [\n                'None' => 'n',\n                'Content' => 'c',\n                'Enclosure' => 'e'\n            ]\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'title' => 'Maximum number of chapters to return',\n            'defaultValue' => 10\n        ]\n    ]];\n\n    private $title;\n\n    private function getComick($url)\n    {\n        $API = 'https://api.comick.fun';\n\n        // Need a non-cURL UA, otherwise we get Cloudflare 403'd\n        $opts = [\n            CURLOPT_USERAGENT => 'rss-bridge (https://github.com/RSS-Bridge/rss-bridge)'\n        ];\n        $content = getContents(\"$API/$url\", [], $opts);\n        return json_decode($content, true);\n    }\n\n    public function collectData()\n    {\n        $slug = $this->getInput('slug');\n        $lang = $this->getInput('lang');\n        $limit = $this->getInput('limit');\n\n        $manga = $this->getComick(\"comic/$slug\");\n        $hid = $manga['comic']['hid'];\n        $this->title = $manga['comic']['title'];\n        $manga = $this->getComick(\"comic/$hid/chapters?lang=$lang&limit=$limit\");\n\n        foreach ($manga['chapters'] as $chapter) {\n            $hid = $chapter['hid'];\n            $item['author'] = implode(', ', $chapter['group_name']);\n            $item['timestamp'] = strtotime($chapter['created_at']);\n            $item['uri'] = $this->getURI() . '/' . $hid;\n\n            $item['title'] = '';\n            if ($chapter['vol']) {\n                $item['title'] .= ' Vol. ' . $chapter['vol'];\n            }\n            if ($chapter['chap']) {\n                $item['title'] .= ' Ch. ' . $chapter['chap'];\n            }\n            if ($chapter['title']) {\n                $item['title'] .= ' - ' . $chapter['title'];\n            }\n\n\n            if ($this->getInput('fetch') != 'n') {\n                $chapter = $this->getComick(\"chapter/$hid\");\n                if (isset($chapter['chapter']['md_images'])) {\n                    $item['content'] = '';\n                    foreach ($chapter['chapter']['md_images'] as $image) {\n                        $img = 'https://meo.comick.pictures/' . $image['b2key'];\n                        if ($this->getInput('fetch') == 'c') {\n                            $item['content'] .= '<img src=\"' . $img . '\" />';\n                        }\n                        if ($this->getInput('fetch') == 'e') {\n                            $item['enclosures'][] = $img;\n                        }\n                    }\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->title) {\n            return parent::getName() . ' - ' . $this->title;\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('slug')) {\n            return self::URI . 'comic/' . $this->getInput('slug');\n        }\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/ComicsKingdomBridge.php",
    "content": "<?php\n\nclass ComicsKingdomBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'TReKiE';\n    // const MAINTAINER = 'stjohnjohnson';\n    const NAME = 'Comics Kingdom Unofficial RSS';\n    const URI = 'https://wp.comicskingdom.com/wp-json/wp/v2/ck_comic';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Comics Kingdom Unofficial RSS';\n    const PARAMETERS = [ [\n        'comicname' => [\n            'name' => 'Name of comic',\n            'type' => 'text',\n            'exampleValue' => 'mutts',\n            'title' => 'The name of the comic in the URL after https://comicskingdom.com/',\n            'required' => true\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'title' => 'The number of recent comics to get',\n            'defaultValue' => 10\n        ]\n    ]];\n\n    protected $comicName;\n\n    public function collectData()\n    {\n        $json = getContents($this->getURI());\n        $data = json_decode($json, false);\n\n        if (isset($data[0]->_embedded->{'wp:term'}[0][0])) {\n            $this->comicName = $data[0]->_embedded->{'wp:term'}[0][0]->name;\n        }\n\n        foreach ($data as $comicitem) {\n            $item = [];\n\n            $item['id'] = $comicitem->id;\n            $item['uri'] = $comicitem->yoast_head_json->og_url;\n            $item['author'] = str_ireplace('By ', '', $comicitem->ck_comic_byline);\n            $item['title'] = $comicitem->yoast_head_json->title;\n            $item['timestamp'] = $comicitem->date;\n            $item['content'] = '<img src=\"' . $comicitem->yoast_head_json->og_image[0]->url . '\" />';\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('comicname'))) {\n            $params = [\n                'ck_feature'        => $this->getInput('comicname'),\n                'per_page'          => $this->getInput('limit'),\n                'date_inclusive'    => 'true',\n                'order'             => 'desc',\n                'page'              => '1',\n                '_embed'            => 'true'\n            ];\n\n            return self::URI . '?' . http_build_query($params);\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if ($this->comicName) {\n            return $this->comicName . ' - Comics Kingdom';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/CommonDreamsBridge.php",
    "content": "<?php\n\nclass CommonDreamsBridge extends FeedExpander\n{\n    const MAINTAINER = 'nyutag';\n    const NAME = 'CommonDreams';\n    const URI = 'https://www.commondreams.org/';\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('http://www.commondreams.org/rss.xml', 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['content'] = $this->extractContent($item['uri']);\n        return $item;\n    }\n\n    private function extractContent($url)\n    {\n        $dom = getSimpleHTMLDOMCached($url);\n        $summary = $dom->find('div.node__body', 0);\n        $text = $summary->innertext;\n        $dom->clear();\n        unset($dom);\n        return $text;\n    }\n}\n"
  },
  {
    "path": "bridges/CopieDoubleBridge.php",
    "content": "<?php\n\nclass CopieDoubleBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'superbaillot.net';\n    const NAME = 'CopieDouble';\n    const URI = 'http://www.copie-double.com/';\n    const CACHE_TIMEOUT = 14400; // 4h\n    const DESCRIPTION = 'CopieDouble';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $table = $html->find('table table', 2);\n\n        foreach ($table->find('tr') as $element) {\n            $td = $element->find('td', 0);\n\n            if ($td->class === 'couleur_1') {\n                $item = [];\n                $title = $td->innertext;\n                $pos = strpos($title, '<a');\n                $title = substr($title, 0, $pos);\n                $item['title'] = $title;\n            } elseif (strpos($element->innertext, '/images/suivant.gif') === false) {\n                $a = $element->find('a', 0);\n                $item['uri'] = self::URI . $a->href;\n                $content = str_replace('src=\"/', 'src=\"/' . self::URI, $element->find('td', 0)->innertext);\n                $content = str_replace('href=\"/', 'href=\"' . self::URI, $content);\n                $item['content'] = $content;\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CorreioDaFeiraBridge.php",
    "content": "<?php\n\nclass CorreioDaFeiraBridge extends BridgeAbstract\n{\n    const NAME = 'Correio da Feira';\n    const URI = 'https://www.correiodafeira.pt/';\n    const DESCRIPTION = 'Returns news from the Portuguese local newspaper Correio da Feira';\n    const MAINTAINER = 'rmscoelho';\n    const CACHE_TIMEOUT = 86400;\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'News Feed',\n                'type' => 'list',\n                'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT',\n                'values' => [\n                    'Cultura' => 'cultura',\n                    'Desporto' => 'desporto',\n                    'Economia' => 'economia',\n                    'Entrevista' => 'entrevista',\n                    'Freguesias' => 'freguesias',\n                    'Justiça' => 'justica',\n                    'Opinião' => 'opiniao',\n                    'Política' => 'politica',\n                    'Reportagem' => 'reportagem',\n                    'Sociedade' => 'sociedade',\n                    'Tecnologia' => 'tecnologia',\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.correiodafeira.pt/wp-content/uploads/base_reporter-200x200.jpg';\n    }\n\n    public function getName()\n    {\n        return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME;\n    }\n\n    public function getURI()\n    {\n        return self::URI . $this->getInput('feed');\n    }\n\n    public function collectData()\n    {\n        $url = sprintf('https://www.correiodafeira.pt/categoria/%s', $this->getInput('feed'));\n        $dom = getSimpleHTMLDOM($url);\n        $dom = $dom->find('main', 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('div.post') as $article) {\n            $a = $article->find('div.blog-box', 0);\n            //Get date and time of publishing\n            $time = $a->find('.post-date > :nth-child(2)', 0)->plaintext;\n            $datetime = explode('/', $time);\n            $year = $datetime[2];\n            $month = $datetime[1];\n            $day = $datetime[0];\n            $timestamp = mktime(0, 0, 0, $month, $day, $year);\n            $this->items[] = [\n                'title' => $a->find('h2.entry-title > a', 0)->plaintext,\n                'uri' => $a->find('h2.entry-title > a', 0)->href,\n                'author' => $a->find('li.post-author > a', 0)->plaintext,\n                'content' => $a->find('.entry-content > p', 0)->plaintext,\n                'timestamp' => $timestamp,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CourrierInternationalBridge.php",
    "content": "<?php\n\nclass CourrierInternationalBridge extends FeedExpander\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'Courrier International';\n    const URI = 'https://www.courrierinternational.com/';\n    const CACHE_TIMEOUT = 300; // 5 min\n    const DESCRIPTION = 'Returns the newest articles';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(static::URI . 'feed/all/rss.xml', 20);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOMCached($item['uri']);\n        $content = $articlePage->find('.article-text, depeche-text', 0);\n        if (!$content) {\n            return $item;\n        }\n        $item['content'] = sanitize($content);\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/CraigslistBridge.php",
    "content": "<?php\n\nclass CraigslistBridge extends BridgeAbstract\n{\n    const NAME = 'Craigslist';\n    const URI = 'https://craigslist.org/';\n    const DESCRIPTION = 'Returns craigslist search results';\n\n    const PARAMETERS = [\n        [\n            'region' => [\n                'name' => 'Region',\n                'title' => 'The subdomain before craigslist.org in the URL',\n                'exampleValue' => 'sfbay',\n                'required' => true\n            ],\n            'search' => [\n                'name' => 'Search Query',\n                'title' => 'Everything in the URL after /search/',\n                'exampleValue' => 'sya?query=laptop',\n                'required' => true\n            ],\n            'limit' => [\n                'name' => 'Number of Posts',\n                'type' => 'number',\n                'title' => 'The maximum number of posts is 120. Use 0 for unlimited posts.',\n                'defaultValue' => '25'\n            ]\n        ]\n    ];\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://sfbay.craigslist.org/search/sya?query=laptop' => [\n            'region' => 'sfbay',\n            'search' => 'sya?query=laptop'\n        ],\n        'https://newyork.craigslist.org/search/sss?query=32gb+flash+drive&bundleDuplicates=1&max_price=20' => [\n            'region' => 'newyork',\n            'search' => 'sss?query=32gb+flash+drive&bundleDuplicates=1&max_price=20'\n        ],\n    ];\n\n    const URL_REGEX = '/^https:\\/\\/(?<region>\\w+).craigslist.org\\/search\\/(?<search>.+)/';\n\n    public function detectParameters($url)\n    {\n        if (preg_match(self::URL_REGEX, $url, $matches)) {\n            $params = [];\n            $params['region'] = $matches['region'];\n            $params['search'] = $matches['search'];\n            return $params;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('region'))) {\n            $domain = 'https://' . $this->getInput('region') . '.craigslist.org/search/';\n            return urljoin($domain, $this->getInput('search'));\n        }\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $uri = $this->getURI();\n        $html = getSimpleHTMLDOM($uri);\n\n        $results = $html->find('.cl-static-search-result');\n        $queryResultsImages = $this->getQueryResultsImages($html);\n\n        // Limit the number of posts\n        if ($this->getInput('limit') > 0) {\n            $results = array_slice($results, 0, $this->getInput('limit'));\n        }\n\n        $i = 0;\n        foreach ($results as $post) {\n            $item = [];\n\n            $itemUri = $post->find('a', 0)->href;\n\n            $item['uri'] = $itemUri;\n            $item['title'] = $post->getAttribute('title');\n            $item['uid'] = $itemUri;\n\n            $price = $post->find('.price', 0)->plaintext ?? '';\n            $location = $post->find('.location', 0)->plaintext ?? '';\n            $item['content'] = sprintf('%s %s', $price, $location);\n\n            $images = $queryResultsImages[$i] ?? [];\n            if (!empty($images)) {\n                $item['content'] .= '<br>';\n                foreach ($images as $imageUrl) {\n                    $item['content'] .= '<img src=\"' . $imageUrl . '\">';\n                    $item['enclosures'][] = $imageUrl;\n                }\n            }\n\n            $i++;\n            $this->items[] = $item;\n        }\n    }\n\n    private function getQueryResultsImages($html): array\n    {\n        $images = [];\n\n        // Find the JSON-LD script tag containing search results\n        $jsonLdScript = $html->find('script#ld_searchpage_results', 0);\n\n        if ($jsonLdScript) {\n            $jsonContent = trim($jsonLdScript->innertext);\n            $jsonData = json_decode($jsonContent);\n\n            if (isset($jsonData->itemListElement) && is_array($jsonData->itemListElement)) {\n                foreach ($jsonData->itemListElement as $item) {\n                    if (isset($item->item->image) && is_array($item->item->image) && isset($item->position)) {\n                        $productImages = [];\n                        foreach ($item->item->image as $imageUrl) {\n                            $productImages[] = $imageUrl;\n                        }\n                        if (!empty($productImages)) {\n                            $images[$item->position] = $productImages;\n                        }\n                    }\n                }\n            }\n        }\n\n        return $images;\n    }\n}"
  },
  {
    "path": "bridges/CrewbayBridge.php",
    "content": "<?php\n\nclass CrewbayBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'couraudt';\n    const NAME = 'Crewbay';\n    const URI = 'https://www.crewbay.com';\n    const DESCRIPTION = 'Returns the newest sailing offers.';\n    const PARAMETERS = [\n        [\n            'keyword' => [\n                'name' => 'Filter by keyword',\n                'title' => 'Enter the keyword to filter here'\n            ],\n            'type' => [\n                'name' => 'Type of search',\n                'title' => 'Choose between finding a boat or a crew',\n                'type' => 'list',\n                'values' => [\n                    'Find a boat' => 'boats',\n                    'Find a crew' => 'crew'\n                ]\n            ],\n            'status' => [\n                'name' => 'Status on the boat',\n                'title' => 'Choose between recreational or professional classified ads',\n                'type' => 'list',\n                'values' => [\n                    'Recreational' => 'recreational',\n                    'Professional' => 'professional'\n                ]\n            ],\n            'recreational_position' => [\n                'name' => 'Recreational position wanted',\n                'title' => 'Filter by recreational position you wanted aboard',\n                'required' => false,\n                'type' => 'list',\n                'values' => [\n                    '' => '',\n                    'Amateur Crew' => 'Amateur Crew',\n                    'Friendship' => 'Friendship',\n                    'Competent Crew' => 'Competent Crew',\n                    'Racing' => 'Racing',\n                    'Voluntary work' => 'Voluntary work',\n                    'Mile building' => 'Mile building'\n                ]\n            ],\n            'professional_position' => [\n                'name' => 'Professional position wanted',\n                'title' => 'Filter by professional position you wanted aboard',\n                'required' => false,\n                'type' => 'list',\n                'values' => [\n                    '' => '',\n                    '1st Engineer' => '1st Engineer',\n                    '1st Mate' => '1st Mate',\n                    'Beautician' => 'Beautician',\n                    'Bosun' => 'Bosun',\n                    'Captain' => 'Captain',\n                    'Chef' => 'Chef',\n                    'Steward(ess)' => 'Steward(ess)',\n                    'Deckhand' => 'Deckhand',\n                    'Delivery Crew' => 'Delivery Crew',\n                    'Dive Instructor' => 'Dive Instructor',\n                    'Masseur' => 'Masseur',\n                    'Medical Staff' => 'Medical Staff',\n                    'Nanny' => 'Nanny',\n                    'Navigator' => 'Navigator',\n                    'Racing Crew' => 'Racing Crew',\n                    'Teacher' => 'Teacher',\n                    'Electrical Engineer' => 'Electrical Engineer',\n                    'Fitter' => 'Fitter',\n                    '2nd Engineer' => '2nd Engineer',\n                    '3rd Engineer' => '3rd Engineer',\n                    'Lead Deckhand' => 'Lead Deckhand',\n                    'Security Officer' => 'Security Officer',\n                    'O.O.W' => 'O.O.W',\n                    '1st Officer' => '1st Officer',\n                    '2nd Officer' => '2nd Officer',\n                    '3rd Officer' => '3rd Officer',\n                    'Captain/Engineer' => 'Captain/Engineer',\n                    'Hairdresser' => 'Hairdresser',\n                    'Fitness Trainer' => 'Fitness Trainer',\n                    'Laundry' => 'Laundry',\n                    'Solo Steward/ess' => 'Solo Steward/ess',\n                    'Stew/Deck' => 'Stew/Deck',\n                    '2nd Steward/ess' => '2nd Steward/ess',\n                    '3rd Steward/ess' => '3rd Steward/ess',\n                    'Chief Steward/ess' => 'Chief Steward/ess',\n                    'Head Housekeeper' => 'Head Housekeeper',\n                    'Purser' => 'Purser',\n                    'Cook' => 'Cook',\n                    'Cook/Stew' => 'Cook/Stew',\n                    '2nd Chef' => '2nd Chef',\n                    'Head Chef' => 'Head Chef',\n                    'Administrator' => 'Administrator',\n                    'P.A' => 'P.A',\n                    'Villa staff' => 'Villa staff',\n                    'Housekeeping/Stew' => 'Housekeeping/Stew',\n                    'Stew/Beautician' => 'Stew/Beautician',\n                    'Stew/Masseuse' => 'Stew/Masseuse',\n                    'Manager' => 'Manager',\n                    'Sailing instructor' => 'Sailing instructor'\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n\n        $annonces = $html->find('#SearchResults div.result');\n        $limit = 0;\n\n        foreach ($annonces as $annonce) {\n            $detail = $annonce->find('.btn--profile', 0);\n            $htmlDetail = getSimpleHTMLDOMCached($detail->href);\n\n            if (!empty($this->getInput('recreational_position')) || !empty($this->getInput('professional_position'))) {\n                if ($this->getInput('type') == 'boats') {\n                    if ($this->getInput('status') == 'professional') {\n                        $positions = [$annonce->find('.title .position', 0)->plaintext];\n                    } else {\n                        $positions = [str_replace('Wanted:', '', $annonce->find('.content li', 0)->plaintext)];\n                    }\n                } else {\n                    $list = $htmlDetail->find('.viewer-details .viewer-list');\n                    $positions = explode(\"\\r\\n\", end($list)->find('span.value', 0)->plaintext);\n                }\n\n                $found = false;\n                $keyword = $this->getInput('status') == 'professional' ? 'professional_position' : 'recreational_position';\n                foreach ($positions as $position) {\n                    if (strpos(trim($position), $this->getInput($keyword)) !== false) {\n                        $found = true;\n                        break;\n                    }\n                }\n\n                if (!$found) {\n                    continue;\n                }\n            }\n\n            $item = [];\n\n            if ($this->getInput('type') == 'boats') {\n                $titleSelector = '.title h2';\n            } else {\n                $titleSelector = '.layout__item h2';\n            }\n            $userName = $annonce->find('.result--description a', 0)->plaintext;\n            $annonceTitle = trim($annonce->find($titleSelector, 0)->plaintext);\n            if (empty($annonceTitle)) {\n                $item['title'] = $userName;\n            } else {\n                $item['title'] = $userName . ' - ' . $annonceTitle;\n            }\n\n            $item['uri'] = $detail->href;\n            $images = $annonce->find('.avatar img');\n            $item['enclosures'] = [end($images)->getAttribute('src')];\n\n            $content = $htmlDetail->find('.viewer-intro--info', 0)->innertext;\n\n            $sections = $htmlDetail->find('.viewer-container .viewer-section');\n            foreach ($sections as $section) {\n                if ($section->find('.viewer-section-title', 0)) {\n                    $class = str_replace('viewer-', '', explode(' ', $section->getAttribute('class'))[0]);\n                    if (!in_array($class, ['apply', 'photos', 'reviews', 'contact', 'experience', 'qa'])) {\n                        // Basic sections\n                        $content .= $section->find('.viewer-section-title h3', 0)->outertext;\n                        $content .= $section->find('.viewer-section-content', 0)->innertext;\n                    }\n                } else {\n                    // Info section\n                    $content .= $section->find('.viewer-section-content h3', 0)->outertext;\n                    $content .= $section->find('.viewer-section-content p', 0)->outertext;\n                }\n            }\n\n            if (!empty($this->getInput('keyword'))) {\n                $keyword = strtolower($this->getInput('keyword'));\n                if (strpos(strtolower($item['title']), $keyword) === false) {\n                    if (strpos(strtolower($content), $keyword) === false) {\n                        continue;\n                    }\n                }\n            }\n\n            $item['content'] = $content;\n\n            $tags = $htmlDetail->find('li.viewer-tags--tag');\n            foreach ($tags as $tag) {\n                if (!isset($item['categories'])) {\n                    $item['categories'] = [];\n                }\n                $text = trim($tag->plaintext);\n                if (!in_array($text, $item['categories'])) {\n                    $item['categories'][] = $text;\n                }\n            }\n\n            $this->items[] = $item;\n            $limit += 1;\n\n            if ($limit == 10) {\n                break;\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n\n        if ($this->getInput('type') == 'boats') {\n            $uri .= '/boats';\n        } else {\n            $uri .= '/crew';\n        }\n\n        if ($this->getInput('status') == 'professional') {\n            $uri .= '/professional';\n        } else {\n            $uri .= '/recreational';\n        }\n\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/CryptomeBridge.php",
    "content": "<?php\n\nclass CryptomeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'BoboTiG';\n    const NAME = 'Cryptome';\n    const URI = 'https://cryptome.org/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns the N most recent documents.';\n    const PARAMETERS = [ [\n        'n' => [\n            'name' => 'number of elements',\n            'type' => 'number',\n            'required' => true,\n            'exampleValue' => 10\n        ]\n    ]];\n\n    public function getIcon()\n    {\n        return self::URI . '/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $number = $this->getInput('n');\n        if (!empty($number)) {\n            $num = min($number, 20);\n        }\n        $i = 0;\n        foreach ($html->find('pre', 1)->find('b') as $element) {\n            foreach ($element->find('a') as $element1) {\n                $item = [];\n                $item['uri'] = $element1->href;\n                $item['title'] = $element->plaintext;\n                $this->items[] = $item;\n\n                if ($i > $num) {\n                    break 2;\n                }\n                $i++;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CssSelectorBridge.php",
    "content": "<?php\n\nclass CssSelectorBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'CSS Selector';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge/';\n    const DESCRIPTION = 'Convert any site to RSS feed using CSS selectors (Advanced Users)';\n    const PARAMETERS = [\n        [\n            'home_page' => [\n                'name' => 'Site URL: Home page with latest articles',\n                'exampleValue' => 'https://example.com/blog/',\n                'required' => true\n            ],\n            'url_selector' => [\n                'name' => 'Selector for article links or their parent elements',\n                'title' => <<<EOT\n                    This bridge works using CSS selectors, e.g. \"a.article\" will match all <a class=\"article\" \n                    href=\"URL\">TITLE</a> on home page, each one being treated as a feed item. &#10;&#13;\n                    Instead of just a link you can selet one of its parent element. Everything inside that\n                    element becomes feed item content, e.g. image and summary present on home page.\n                    When doing so, the first link inside the selected element becomes feed item URL/Title.\n                    EOT,\n                'exampleValue' => 'a.article',\n                'required' => true\n            ],\n            'url_pattern' => [\n                'name' => '[Optional] Pattern for site URLs to keep in feed',\n                'title' => 'Optionally filter items by applying a regular expression on their URL',\n                'exampleValue' => '/blog/article/.*',\n            ],\n            'content_selector' => [\n                'name' => '[Optional] Selector to expand each article content',\n                'title' => <<<EOT\n                    When specified, the bridge will fetch each article from its URL\n                    and extract content using the provided selector (Slower!)\n                    EOT,\n                'exampleValue' => 'article.content',\n            ],\n            'content_cleanup' => [\n                'name' => '[Optional] Content cleanup: List of items to remove',\n                'title' => 'Selector for unnecessary elements to remove inside article contents.',\n                'exampleValue' => 'div.ads, div.comments',\n            ],\n            'title_cleanup' => [\n                'name' => '[Optional] Text to remove from expanded article title',\n                'title' => <<<EOT\n                    When fetching each article page, feed item title comes from page title. \n                    Specify here some text from page title that need to be removed, e.g. \" | BlogName\".\n                    EOT,\n                'exampleValue' => ' | BlogName',\n            ],\n            'discard_thumbnail' => [\n                'name' => '[Optional] Discard thumbnail set by site author',\n                'title' => 'Some sites set their logo as thumbnail for every article. Use this option to discard it.',\n                'type' => 'checkbox',\n            ],\n            'thumbnail_as_header' => [\n                'name' => '[Optional] Insert thumbnail as article header',\n                'title' => 'Insert article main image on top of article contents.',\n                'type' => 'checkbox',\n            ],\n            'limit' => self::LIMIT\n        ]\n    ];\n\n    protected $feedName = '';\n    protected $homepageUrl = '';\n\n    public function getURI()\n    {\n        $url = $this->homepageUrl;\n        if (empty($url)) {\n            $url = parent::getURI();\n        }\n        return $url;\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName;\n        }\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $this->homepageUrl = $this->getInput('home_page');\n        $url_selector = $this->getInput('url_selector');\n        $url_pattern = $this->getInput('url_pattern');\n        $content_selector = $this->getInput('content_selector');\n        $content_cleanup = $this->getInput('content_cleanup');\n        $title_cleanup = $this->getInput('title_cleanup');\n        $discard_thumbnail = $this->getInput('discard_thumbnail');\n        $thumbnail_as_header = $this->getInput('thumbnail_as_header');\n        $limit = $this->getInput('limit') ?? 10;\n\n        $html = defaultLinkTo(getSimpleHTMLDOM($this->homepageUrl), $this->homepageUrl);\n        $this->feedName = $this->titleCleanup($this->getPageTitle($html), $title_cleanup);\n        $items = $this->htmlFindEntries($html, $url_selector, $url_pattern, $limit, $content_cleanup);\n\n        if (empty($content_selector)) {\n            $this->items = $items;\n        } else {\n            foreach ($items as $item) {\n                $item = $this->expandEntryWithSelector(\n                    $item['uri'],\n                    $content_selector,\n                    $content_cleanup,\n                    $title_cleanup,\n                    $item['title']\n                );\n                if ($discard_thumbnail && isset($item['enclosures'])) {\n                    unset($item['enclosures']);\n                }\n                if ($thumbnail_as_header && isset($item['enclosures'][0])) {\n                    $item['content'] = '<p><img src=\"' . $item['enclosures'][0] . '\" /></p>' . $item['content'];\n                }\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    /**\n     * Filter a list of URLs using a pattern and limit\n     * @param array $links List of URLs\n     * @param string $url_pattern Pattern to look for in URLs\n     * @param int $limit Optional maximum amount of URLs to return\n     * @return array Array of URLs\n     */\n    protected function filterUrlList($links, $url_pattern, $limit = 0)\n    {\n        if (!empty($url_pattern)) {\n            $url_pattern = '/' . str_replace('/', '\\/', $url_pattern) . '/';\n            $links = array_filter($links, function ($url) use ($url_pattern) {\n                return preg_match($url_pattern, $url) === 1;\n            });\n        }\n\n        if ($limit > 0 && count($links) > $limit) {\n            $links = array_slice($links, 0, $limit);\n        }\n\n        return $links;\n    }\n\n    /**\n     * Retrieve title from webpage URL or DOM\n     * @param string|object $page URL or DOM to retrieve title from\n     * @return string Webpage title\n     */\n    protected function getPageTitle($page)\n    {\n        if (is_string($page)) {\n            $page = getSimpleHTMLDOMCached($page);\n        }\n        $title = html_entity_decode($page->find('title', 0)->plaintext);\n        return $title;\n    }\n\n    /**\n     * Clean Article title. Remove constant part that appears in every title such as blog name.\n     * @param string $title Title to clean, e.g. \"Article Name | BlogName\"\n     * @param string $title_cleanup string to remove from webpage title, e.g. \" | BlogName\"\n     * @return string Cleaned Title\n     */\n    protected function titleCleanup($title, $title_cleanup)\n    {\n        if (!empty($title) && !empty($title_cleanup)) {\n            return trim(str_replace($title_cleanup, '', $title));\n        }\n        return $title;\n    }\n\n    /**\n     * Remove all elements from HTML content matching cleanup selector\n     * @param string|object $content HTML content as HTML object or string\n     * @return string|object Cleaned content (same type as input)\n     */\n    protected function cleanArticleContent($content, $cleanup_selector)\n    {\n        $string_convert = false;\n        if (is_string($content)) {\n            $string_convert = true;\n            $content = str_get_html($content);\n        }\n\n        if (!empty($cleanup_selector)) {\n            foreach ($content->find($cleanup_selector) as $item_to_clean) {\n                $item_to_clean->outertext = '';\n            }\n        }\n\n        if ($string_convert) {\n            $content = $content->outertext;\n        }\n        return $content;\n    }\n\n    /**\n     * Retrieve first N link+title+truncated-content from webpage URL or DOM satisfying the specified criteria\n     * @param string|object $page URL or DOM to retrieve feed items from\n     * @param string $url_selector DOM selector for matching links or their parent element\n     * @param string $url_pattern Optional filter to keep only links matching the pattern\n     * @param int $limit Optional maximum amount of URLs to return\n     * @param string $content_cleanup Optional selector for removing elements, e.g. \"div.ads, div.comments\"\n     * @return array of items {'uri': entry_url, 'title': entry_title, ['content': when present in DOM] }\n     */\n    protected function htmlFindEntries($page, $url_selector, $url_pattern = '', $limit = 0, $content_cleanup = null)\n    {\n        if (is_string($page)) {\n            $page = getSimpleHTMLDOM($page);\n        }\n\n        $links = $page->find($url_selector);\n\n        if (empty($links)) {\n            throwClientException('No results for URL selector');\n        }\n\n        $link_to_item = [];\n        foreach ($links as $link) {\n            $item = [];\n            if ($link->innertext != $link->plaintext) {\n                $item['content'] = $link->innertext;\n            }\n            if ($link->tag != 'a') {\n                $link = $link->find('a', 0);\n                if (is_null($link)) {\n                    continue;\n                }\n            }\n\n            $item['uri'] = html_entity_decode($link->href);\n            $item['title'] = html_entity_decode($link->plaintext);\n\n            if (isset($item['content'])) {\n                $item['content'] = convertLazyLoading($item['content']);\n                $item['content'] = defaultLinkTo($item['content'], $item['uri']);\n                $item['content'] = $this->cleanArticleContent($item['content'], $content_cleanup);\n            }\n            $link_to_item[$link->href] = $item;\n        }\n\n        if (empty($link_to_item)) {\n            throwClientException('The provided URL selector matches some elements, but they do not contain links.');\n        }\n\n        $links = $this->filterUrlList(array_keys($link_to_item), $url_pattern, $limit);\n\n        if (empty($links)) {\n            throwClientException('No results for URL pattern');\n        }\n\n        $items = [];\n        foreach ($links as $link) {\n            $items[] = $link_to_item[$link];\n        }\n\n        return $items;\n    }\n\n    /**\n     * Retrieve article content from its URL using content selector and return a feed item\n     * @param string $entry_url URL to retrieve article from\n     * @param string $content_selector HTML selector for extracting content, e.g. \"article.content\"\n     * @param string $content_cleanup Optional selector for removing elements, e.g. \"div.ads, div.comments\"\n     * @param string $title_cleanup Optional string to remove from article title, e.g. \" | BlogName\"\n     * @param string $title_default Optional title to use when could not extract title reliably\n     * @return array Entry data: uri, title, content\n     */\n    protected function expandEntryWithSelector($entry_url, $content_selector, $content_cleanup = null, $title_cleanup = null, $title_default = null)\n    {\n        if (empty($content_selector)) {\n            throwClientException('Please specify a content selector');\n        }\n\n        $entry_html = getSimpleHTMLDOMCached($entry_url);\n        $item = html_find_seo_metadata($entry_html);\n\n        if (empty($item['uri'])) {\n            $item['uri'] = $entry_url;\n        }\n\n        if (empty($item['title'])) {\n            $article_title = $this->getPageTitle($entry_html, $title_cleanup);\n            if (!empty($title_default) && (empty($article_title) || $article_title === $this->feedName)) {\n                $article_title = $title_default;\n            }\n            $item['title'] = $article_title;\n        }\n\n        $item['title'] = $this->titleCleanup($item['title'], $title_cleanup);\n\n        $article_content = $entry_html->find($content_selector);\n\n        if (!empty($article_content)) {\n            $article_content = $article_content[0];\n            $article_content = convertLazyLoading($article_content);\n            $article_content = defaultLinkTo($article_content, $entry_url);\n            $article_content = $this->cleanArticleContent($article_content, $content_cleanup);\n            $item['content'] = $article_content;\n        } else if (!empty($item['content'])) {\n            $item['content'] .= '<br /><p><em>Could not extract full content, selector may need to be updated.</em></p>';\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/CssSelectorComplexBridge.php",
    "content": "<?php\n\nclass CssSelectorComplexBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Lars Stegman';\n    const NAME = 'CSS Selector Complex';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge/';\n    const DESCRIPTION = <<<EOT\n        Convert any site to RSS feed using CSS selectors (Advanced Users). The bridge first selects \n        the element describing the article entries. It then extracts the links to the articles from \n        these elements. It then, depending on the setting \"Load article from page\", either parses \n        the selected elements, or downloads the page for each article and parses those. Parsing the \n        elements or page is done using the provided selectors.\n        EOT;\n    const PARAMETERS = [\n        [\n            'home_page' => [\n                'name' => 'Site URL: Page with latest articles',\n                'exampleValue' => 'https://example.com/blog/',\n                'required' => true\n            ],\n            'cookie' => [\n                'name' => '[Optional] Cookie',\n                'title' => <<<EOT\n                Use when the website does not send the page contents, unless a static cookie is included.\n                EOT,\n                'exampleValue' => 'sessionId=deadb33f'\n            ],\n            'title_cleanup' => [\n                'name' => '[Optional] Text to remove from feed title',\n                'title' => <<<EOT\n                Text to remove from the feed title, which is read from the article list page.\n                EOT,\n                'exampleValue' => ' | BlogName',\n            ],\n            'entry_element_selector' => [\n                'name' => 'Selector for article entry elements',\n                'title' => <<<EOT\n                This bridge works using CSS selectors, e.g. \"div.article\" will match all \n                <div class=\"article\">...</div> on home page, each one being treated as a feed item.\n\n                Use the URL selector option to select the `a` element with the\n                `href` to the article link. If this option is not configured, the first encountered \n                `a` element is used.\n                EOT,\n                'exampleValue' => 'div.article',\n                'required' => true\n            ],\n            'url_selector' => [\n                'name' => '[Optional] Selector for link elements',\n                'title' => <<<EOT\n                    The selector to find `a` elements in the entry element. If empty,\n                    the first encountered `a` element is used. The `href` property\n                    is used to create entries in the feed.\n                    EOT,\n                'exampleValue' => 'a.article',\n                'defaultValue' => 'a'\n            ],\n            'url_pattern' => [\n                'name' => '[Optional] Pattern for site URLs to keep in feed',\n                'title' => 'Optionally filter items by applying a regular expression on their URL',\n                'exampleValue' => '/blog/article/.*',\n            ],\n            'limit' => self::LIMIT,\n            'use_article_pages' => [\n                'name' => 'Load article from page',\n                'title' => <<<EOT\n                If true, the article page is load and parsed to get the article contents using \n                the css selectors. (Slower!)\n                Otherwise, the element selected by the article entry selector is used.\n                EOT,\n                'type' => 'checkbox'\n            ],\n            'article_page_content_selector' => [\n                'name' => '[Optional] Selector to select article element',\n                'title' => 'Extract the article from its page using the provided selector',\n                'exampleValue' => 'article.content',\n            ],\n            'content_cleanup' => [\n                'name' => '[Optional] Content cleanup: selector for items to remove',\n                'title' => 'Selector for unnecessary elements to remove inside article contents.',\n                'exampleValue' => 'div.ads, div.comments',\n            ],\n            'title_selector' => [\n                'name' => '[Optional] Selector for the article title',\n                'title' => 'Selector to select the article title',\n                'defaultValue' => 'h1'\n            ],\n            'category_selector' => [\n                'name' => '[Optional] Categories',\n                'title' => <<<EOT\n                Selector to extract the catgories the article has\n                EOT,\n                'exampleValue' => 'span.category, #main-category'\n            ],\n            'author_selector' => [\n                'name' => '[Optional] Author',\n                'title' => <<<EOT\n                Selector to extract the author of the article. If multiple elements are selected\n                the first one is used.\n                EOT,\n                'exampleValue' => 'span#author'\n            ],\n            'time_selector' => [\n                'name' => '[Optional] Time selector',\n                'title' => <<<EOT\n                Selector to extract the timestamp of the article. If the element \n                is an html5 `time` element, the value for the `datetime` attribute is used.\n                EOT,\n            ],\n            'time_format' => [\n                'name' => '[Optional] Format string for parsing time',\n                'title' => <<<EOT\n                The format to use to parse the timestamp. See \n                https://www.php.net/manual/en/datetimeimmutable.createfromformat.php\n                for the format specification.\n                EOT\n            ],\n            'remove_styling' => [\n                'name' => '[Optional] Remove styling',\n                'title' => 'Remove class and style attributes from the page elements',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    private $feedName = '';\n\n    public function getURI()\n    {\n        $url = $this->getInput('home_page');\n        if (empty($url)) {\n            $url = parent::getURI();\n        }\n        return $url;\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName;\n        }\n        return parent::getName();\n    }\n\n    protected function getHeaders()\n    {\n        $headers = [];\n        $cookie = $this->getInput('cookie');\n        if (!empty($cookie)) {\n            $headers[] = 'Cookie: ' . $cookie;\n        }\n\n        return $headers;\n    }\n\n    public function collectData()\n    {\n        $url = $this->getInput('home_page');\n        $headers = $this->getHeaders();\n\n        $entry_element_selector = $this->getInput('entry_element_selector');\n        $url_selector = $this->getInput('url_selector');\n        $url_pattern = $this->getInput('url_pattern');\n        $limit = $this->getInput('limit') ?? 10;\n\n        $use_article_pages = $this->getInput('use_article_pages');\n        $article_page_content_selector = $this->getInput('article_page_content_selector');\n        $content_cleanup = $this->getInput('content_cleanup');\n        $title_selector = $this->getInput('title_selector');\n        $title_cleanup = $this->getInput('title_cleanup');\n        $time_selector = $this->getInput('time_selector');\n        $time_format = $this->getInput('time_format');\n\n        $category_selector = $this->getInput('category_selector');\n        $author_selector = $this->getInput('author_selector');\n        $remove_styling = $this->getInput('remove_styling');\n\n        $html = defaultLinkTo(getSimpleHTMLDOM($url, $headers), $url);\n        $this->feedName = $this->getTitle($html, $title_cleanup);\n        $entry_elements = $this->htmlFindEntryElements($html, $entry_element_selector, $url_selector, $url_pattern, $limit);\n\n        if (empty($entry_elements)) {\n            return;\n        }\n\n        // Fetch the elements from the article pages.\n        if ($use_article_pages) {\n            if (empty($article_page_content_selector)) {\n                throwClientException('`Article selector` is required when `Load article page` is enabled');\n            }\n\n            foreach (array_keys($entry_elements) as $uri) {\n                $entry_elements[$uri] = $this->fetchArticleElementFromPage($uri, $article_page_content_selector);\n            }\n        }\n\n        foreach ($entry_elements as $uri => $element) {\n            $entry = $this->parseEntryElement(\n                $element,\n                $title_selector,\n                $author_selector,\n                $category_selector,\n                $time_selector,\n                $time_format,\n                $content_cleanup,\n                $this->feedName,\n                $remove_styling\n            );\n\n            $entry['uri'] = $uri;\n            $this->items[] = $entry;\n        }\n    }\n\n    /**\n     * Filter a list of URLs using a pattern and limit\n     * @param array $links List of URLs\n     * @param string $url_pattern Pattern to look for in URLs\n     * @param int $limit Optional maximum amount of URLs to return\n     * @return array Array of URLs\n     */\n    protected function filterUrlList($links, $url_pattern, $limit = 0)\n    {\n        if (!empty($url_pattern)) {\n            $url_pattern = '/' . str_replace('/', '\\/', $url_pattern) . '/';\n            $links = array_filter($links, function ($url) use ($url_pattern) {\n                return preg_match($url_pattern, $url) === 1;\n            });\n        }\n\n        if ($limit > 0 && count($links) > $limit) {\n            $links = array_slice($links, 0, $limit);\n        }\n\n        return $links;\n    }\n\n    /**\n     * Retrieve title from webpage URL or DOM\n     * @param string|object $page URL or DOM to retrieve title from\n     * @param string $title_cleanup optional string to remove from webpage title, e.g. \" | BlogName\"\n     * @return string Webpage title\n     */\n    protected function getTitle($page, $title_cleanup)\n    {\n        if (is_string($page)) {\n            $page = getSimpleHTMLDOMCached($page, 86400, $this->getHeaders());\n        }\n        $title = html_entity_decode($page->find('title', 0)->plaintext);\n        if (!empty($title)) {\n            $title = trim(str_replace($title_cleanup, '', $title));\n        }\n\n        return $title;\n    }\n\n    /**\n     * Remove all elements from HTML content matching cleanup selector\n     * @param string|object $content HTML content as HTML object or string\n     * @return string|object Cleaned content (same type as input)\n     */\n    protected function cleanArticleContent($content, $cleanup_selector, $remove_styling)\n    {\n        $string_convert = false;\n        if (is_string($content)) {\n            $string_convert = true;\n            $content = str_get_html($content);\n        }\n\n        if (!empty($cleanup_selector)) {\n            foreach ($content->find($cleanup_selector) as $item_to_clean) {\n                $item_to_clean->outertext = '';\n            }\n        }\n\n        if ($remove_styling) {\n            foreach (['class', 'style'] as $attribute_to_remove) {\n                foreach ($content->find('[' . $attribute_to_remove . ']') as $item_to_clean) {\n                    $item_to_clean->removeAttribute($attribute_to_remove);\n                }\n            }\n        }\n\n        if ($string_convert) {\n            $content = $content->outertext;\n        }\n        return $content;\n    }\n\n\n    /**\n     * Retrieve first N link+element from webpage URL or DOM satisfying the specified criteria\n     * @param string|object $page URL or DOM to retrieve feed items from\n     * @param string $entry_selector DOM selector for matching HTML elements that contain article\n     *  entries\n     * @param string $url_selector DOM selector for matching links\n     * @param string $url_pattern Optional filter to keep only links matching the pattern\n     * @param int $limit Optional maximum amount of URLs to return\n     * @return array of items { <uri> => <html-element> }\n     */\n    protected function htmlFindEntryElements($page, $entry_selector, $url_selector, $url_pattern = '', $limit = 0)\n    {\n        if (is_string($page)) {\n            $page = getSimpleHTMLDOM($page, $this->getHeaders());\n        }\n\n        $entryElements = $page->find($entry_selector);\n        if (empty($entryElements)) {\n            throwClientException('No entry elements for entry selector');\n        }\n\n        // Extract URIs with the associated entry element\n        $links_with_elements = [];\n        foreach ($entryElements as $entry) {\n            $url_element = $entry->find($url_selector, 0);\n            if (is_null($url_element)) {\n                // No `a` element found in this entry\n                if ($entry->tag == 'a') {\n                    $url_element = $entry;\n                } else {\n                    continue;\n                }\n            }\n\n            $links_with_elements[$url_element->href] = $entry;\n        }\n\n        if (empty($links_with_elements)) {\n            throwClientException('The provided URL selector matches some elements, but they do not \n                contain links.');\n        }\n\n        // Filter using the URL pattern\n        $filtered_urls = $this->filterUrlList(array_keys($links_with_elements), $url_pattern, $limit);\n\n        if (empty($filtered_urls)) {\n            throwClientException('No results for URL pattern');\n        }\n\n        $items = [];\n        foreach ($filtered_urls as $link) {\n            $items[$link] = $links_with_elements[$link];\n        }\n\n        return $items;\n    }\n\n\n    /**\n     * Retrieve article element from its URL using content selector and return the DOM element\n     * @param string $entry_url URL to retrieve article from\n     * @param string $content_selector HTML selector for extracting content, e.g. \"article.content\"\n     * @return article DOM element\n     */\n    protected function fetchArticleElementFromPage($entry_url, $content_selector)\n    {\n        $entry_html = getSimpleHTMLDOMCached($entry_url, 86400, $this->getHeaders());\n        $article_content = $entry_html->find($content_selector, 0);\n\n        if (is_null($article_content)) {\n            throwClientException('Could not get article content at URL: ' . $entry_url);\n        }\n\n        $article_content = defaultLinkTo($article_content, $entry_url);\n        return $article_content;\n    }\n\n    protected function parseTimeStrAsTimestamp($timeStr, $format)\n    {\n        $date = date_parse_from_format($format, $timeStr);\n        if ($date['error_count'] != 0) {\n            throwClientException('Error while parsing time string');\n        }\n\n        $timestamp = mktime(\n            $date['hour'],\n            $date['minute'],\n            $date['second'],\n            $date['month'],\n            $date['day'],\n            $date['year']\n        );\n\n        if ($timestamp == false) {\n            throwClientException('Error while creating timestamp');\n        }\n\n        return $timestamp;\n    }\n\n    /**\n     * Retrieve article content from its URL using content selector and return a feed item\n     * @param object $entry_html A DOM element containing the article\n     * @param string $title_selector A selector to the article title from the article\n     * @param string $author_selector A selector to find the article author\n     * @param string $time_selector A selector to get the article publication time.\n     * @param string $time_format The format to parse the time_selector.\n     * @param string $content_cleanup Optional selector for removing elements, e.g. \"div.ads,\n     *  div.comments\"\n     * @param string $title_default Optional title to use when could not extract title reliably\n     * @param bool $remove_styling Whether to remove class and style attributes from the HTML\n     * @return array Entry data: uri, title, content\n     */\n    protected function parseEntryElement(\n        $entry_html,\n        $title_selector = null,\n        $author_selector = null,\n        $category_selector = null,\n        $time_selector = null,\n        $time_format = null,\n        $content_cleanup = null,\n        $title_default = null,\n        $remove_styling = false\n    ) {\n        $article_content = convertLazyLoading($entry_html);\n\n        $article_title = '';\n        if (is_null($title_selector)) {\n            $article_title = $title_default;\n        } else {\n            $titleElement = $entry_html->find($title_selector, 0);\n            if ($titleElement) {\n                $article_title = trim($titleElement->innertext);\n            }\n        }\n\n        $author = null;\n        if (!is_null($author_selector) && $author_selector != '') {\n            $author = trim($entry_html->find($author_selector, 0)->innertext);\n        }\n\n        $categories = [];\n        if (!is_null($category_selector && $category_selector != '')) {\n            $category_elements = $entry_html->find($category_selector);\n            foreach ($category_elements as $category_element) {\n                $categories[] = trim($category_element->innertext);\n            }\n        }\n\n        $time = null;\n        if (!is_null($time_selector) && $time_selector != '') {\n            $time_element = $entry_html->find($time_selector, 0);\n            $time = $time_element->getAttribute('datetime');\n            if (empty($time)) {\n                $time = $time_element->innertext;\n            }\n\n            $time = $this->parseTimeStrAsTimestamp($time, $time_format);\n        }\n\n        $article_content = $this->cleanArticleContent($article_content, $content_cleanup, $remove_styling);\n\n        $item = [];\n        $item['title'] = $article_title;\n        $item['content'] = $article_content;\n        $item['categories'] = $categories;\n        $item['timestamp'] = $time;\n        $item['author'] = $author;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/CssSelectorFeedExpanderBridge.php",
    "content": "<?php\n\nclass CssSelectorFeedExpanderBridge extends CssSelectorBridge\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'CSS Selector Feed Expander';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge/';\n    const DESCRIPTION = 'Expand any site RSS feed using CSS selectors (Advanced Users)';\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'Feed: URL of truncated RSS feed',\n                'exampleValue' => 'https://example.com/feed.xml',\n                'required' => true\n            ],\n            'content_selector' => [\n                'name' => 'Selector for each article content',\n                'title' => <<<EOT\n                    This bridge works using CSS selectors, e.g. \"div.article\" will match <div class=\"article\">.\n                    Everything inside that element becomes feed item content.\n                    EOT,\n                'exampleValue' => 'article.content',\n                'required' => true\n            ],\n            'content_cleanup' => [\n                'name' => '[Optional] Content cleanup: List of items to remove',\n                'title' => 'Selector for unnecessary elements to remove inside article contents.',\n                'exampleValue' => 'div.ads, div.comments',\n            ],\n            'dont_expand_metadata' => [\n                'name' => '[Optional] Don\\'t expand metadata',\n                'title' => \"This bridge will attempt to fill missing fields using metadata from the webpage.\\nCheck to disable.\",\n                'type' => 'checkbox',\n            ],\n            'discard_thumbnail' => [\n                'name' => '[Optional] Discard thumbnail set by site author',\n                'title' => 'Some sites set their logo as thumbnail for every article. Use this option to discard it.',\n                'type' => 'checkbox',\n            ],\n            'thumbnail_as_header' => [\n                'name' => '[Optional] Insert thumbnail as article header',\n                'title' => 'Insert article main image on top of article contents.',\n                'type' => 'checkbox',\n            ],\n            'limit' => self::LIMIT\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getInput('feed');\n        $content_selector = $this->getInput('content_selector');\n        $content_cleanup = $this->getInput('content_cleanup');\n        $dont_expand_metadata = $this->getInput('dont_expand_metadata');\n        $discard_thumbnail = $this->getInput('discard_thumbnail');\n        $thumbnail_as_header = $this->getInput('thumbnail_as_header');\n        $limit = $this->getInput('limit');\n\n        $feedParser = new FeedParser();\n        $xml = getContents($url);\n        $source_feed = $feedParser->parseFeed($xml);\n        $items = $source_feed['items'];\n\n        // Map Homepage URL (Default: Root page)\n        if (isset($source_feed['uri'])) {\n            $this->homepageUrl = $source_feed['uri'];\n        } else {\n            $this->homepageUrl = urljoin($url, '/');\n        }\n\n        // Map Feed Name (Default: Domain name)\n        if (isset($source_feed['title'])) {\n            $this->feedName = $source_feed['title'];\n        } else {\n            $this->feedName = explode('/', urljoin($url, '/'))[2];\n        }\n\n        // Apply item limit (Default: Global limit)\n        if ($limit > 0) {\n            $items = array_slice($items, 0, $limit);\n        }\n\n        // Expand feed items (CssSelectorBridge)\n        foreach ($items as $item_from_feed) {\n            $item_expanded = $this->expandEntryWithSelector(\n                $item_from_feed['uri'],\n                $content_selector,\n                $content_cleanup\n            );\n\n            if ($dont_expand_metadata) {\n                // Take feed item, only replace content from expanded data\n                $content = $item_expanded['content'];\n                $item_expanded = $item_from_feed;\n                $item_expanded['content'] = $content;\n            } else {\n                // Take expanded item, but give priority to metadata already in source item\n                foreach ($item_from_feed as $field => $val) {\n                    if ($field !== 'content' && !empty($val)) {\n                        $item_expanded[$field] = $val;\n                    }\n                }\n            }\n\n            if ($discard_thumbnail && isset($item_expanded['enclosures'])) {\n                unset($item_expanded['enclosures']);\n            }\n\n            if ($thumbnail_as_header && isset($item_expanded['enclosures'][0])) {\n                $item_expanded['content'] = '<p><img src=\"'\n                    . $item_expanded['enclosures'][0]\n                    . '\" /></p>'\n                    . $item_expanded['content'];\n            }\n\n            $this->items[] = $item_expanded;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/CubariBridge.php",
    "content": "<?php\n\nclass CubariBridge extends BridgeAbstract\n{\n    const NAME = 'Cubari';\n    const URI = 'https://cubari.moe';\n    const DESCRIPTION = 'Parses given cubari-formatted JSON file for updates.';\n    const MAINTAINER = 'KamaleiZestri';\n    const PARAMETERS = [[\n        'gist' => [\n            'name' => 'Gist/Raw Url',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'https://raw.githubusercontent.com/kurisumx/baka/main/ikedan'\n        ]\n    ]];\n\n    private $mangaTitle = '';\n\n    public function getName()\n    {\n        if (!empty($this->mangaTitle)) {\n            return $this->mangaTitle . ' - ' . self::NAME;\n        } else {\n            return self::NAME;\n        }\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('gist') != '') {\n            return self::URI . '/read/gist/' . $this->getEncodedGist();\n        } else {\n            return self::URI;\n        }\n    }\n\n    /**\n     * The Cubari bridge.\n     *\n     * Cubari urls are base64 encodes of a given github raw or gist link described as below:\n     * https://cubari.moe/read/gist/${bаse64.url_encode(raw/<rest of the url...>)}/\n     * https://cubari.moe/read/gist/${bаse64.url_encode(gist/<rest of the url...>)}/\n     * https://cubari.moe/read/gist/${gitio shortcode}\n     *\n     * This bridge uses just the raw/gist and generates matching cubari urls.\n     */\n    public function collectData()\n    {\n        // TODO: fix trivial SSRF\n        $json = getContents($this->getInput('gist'));\n\n        $jsonFile = Json::decode($json);\n\n        $this->mangaTitle = $jsonFile['title'];\n\n        $chapters = $jsonFile['chapters'];\n\n        foreach ($chapters as $chapnum => $chapter) {\n            $item = $this->getItemFromChapter($chapnum, $chapter);\n            $this->items[] = $item;\n        }\n\n        array_multisort(array_column($this->items, 'timestamp'), SORT_DESC, $this->items);\n    }\n\n    protected function getEncodedGist()\n    {\n        $url = $this->getInput('gist');\n\n        if (preg_match('/\\/([a-z]*)\\.githubusercontent.com(.*)/', $url, $matches)) {\n            // raw or gist is first match.\n            $unencoded = $matches[1] . $matches[2];\n            return base64_encode($unencoded);\n        } else {\n            // todo: fix this\n            return '';\n        }\n    }\n\n    private function getSanitizedHash($string)\n    {\n        return hash('sha1', preg_replace('/[^a-zA-Z0-9\\-\\.]/', '', ucwords(strtolower($string))));\n    }\n\n    protected function getItemFromChapter($chapnum, $chapter)\n    {\n        $item = [];\n\n        $item['uri'] = $this->getURI() . '/' . $chapnum;\n        $item['title'] = 'Chapter ' . $chapnum . ' - ' . $chapter['title'] . ' - ' . $this->mangaTitle;\n        foreach ($chapter['groups'] as $key => $value) {\n            $item['author'] = $key;\n        }\n        $item['timestamp'] = $chapter['last_updated'];\n\n        $item['content'] = '<p>Manga: <a href=' . $this->getURI() . '>' . $this->mangaTitle . '</a> </p>\n\t\t\t<p>Chapter Number: ' . $chapnum . '</p>\n\t\t\t<p>Chapter Title: <a href=' . $item['uri'] . '>' . $chapter['title'] . '</a></p>\n\t\t\t<p>Group: ' . $item['author'] . '</p>';\n\n        $item['uid'] = $this->getSanitizedHash($item['title'] . $item['author']);\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/CubariProxyBridge.php",
    "content": "<?php\n\nclass CubariProxyBridge extends BridgeAbstract\n{\n    const NAME = 'Cubari Proxy';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://cubari.moe';\n    const DESCRIPTION = 'Returns chapters from Cubari.';\n    const PARAMETERS = [[\n        'service' => [\n            'name' => 'Content service',\n            'type' => 'list',\n            'defaultValue' => 'mangadex',\n            'values' => [\n                'MangAventure' => 'mangadventure',\n                'MangaDex' => 'mangadex',\n                'MangaKatana' => 'mangakatana',\n                'WeebCentral' => 'weebcentral',\n            ]\n        ],\n        'series' => [\n            'name' => 'Series ID/Name',\n            'exampleValue' => '8c1d7d0c-e0b7-4170-941d-29f652c3c19d', # KnH\n            'required' => true,\n        ],\n        'fetch' => [\n            'name' => 'Fetch chapter page images',\n            'type' => 'list',\n            'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',\n            'defaultValue' => 'c',\n            'values' => [\n                'None' => 'n',\n                'Content' => 'c',\n                'Enclosure' => 'e'\n            ]\n        ],\n        'limit' => self::LIMIT\n    ]];\n\n    private $title;\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit') ?? 10;\n\n        $url = parent::getURI() . '/read/api/' . $this->getInput('service') . '/series/' . $this->getInput('series');\n        $json = Json::decode(getContents($url));\n        $this->title = $json['title'];\n\n        $chapters = $json['chapters'];\n        krsort($chapters);\n\n        $count = 0;\n        foreach ($chapters as $number => $element) {\n            $item = [];\n            $item['uri'] = $this->getURI() . '/' . $number;\n\n            if ($element['title']) {\n                $item['title'] = $number . ' - ' . $element['title'];\n            } else {\n                $item['title'] = 'Volume ' . $element['volume'] . ' Chapter ' . $number;\n            }\n\n            $group = '1';\n            if (isset($element['release_date'])) {\n                $dates = $element['release_date'];\n                $date = max($dates);\n                $item['timestamp'] = $date;\n                $group = array_keys($dates, $date)[0];\n            }\n            $page = $element['groups'][$group];\n            $item['author'] = $json['groups'][$group];\n            $api = parent::getURI() . $page;\n            $item['uid'] = $page;\n            $item['comments'] = $api;\n\n            if ($this->getInput('fetch') != 'n') {\n                $pages = [];\n                try {\n                    $jsonp = getContents($api);\n                    $pages = Json::decode($jsonp);\n                } catch (HttpException $e) {\n                    // allow error 500, as it's effectively a 429\n                    if ($e->getCode() != 500) {\n                        throw $e;\n                    }\n                }\n                if ($this->getInput('fetch') == 'e') {\n                    $item['enclosures'] = $pages;\n                }\n                if ($this->getInput('fetch') == 'c') {\n                    $item['content'] = '';\n                    foreach ($pages as $img) {\n                        $item['content'] .= '<img src=\"' . $img . '\"/>';\n                    }\n                }\n            }\n\n            if ($count++ == $limit) {\n                break;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if (isset($this->title)) {\n            $name .= ' - ' . $this->title;\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n        if ($this->getInput('service')) {\n            $uri .= '/read/' . $this->getInput('service') . '/' . $this->getInput('series');\n        }\n        return $uri;\n    }\n\n    public function getIcon()\n    {\n        return parent::getURI() . '/static/favicon.png';\n    }\n}\n"
  },
  {
    "path": "bridges/CybernewsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass CybernewsBridge extends BridgeAbstract\n{\n    const NAME          = 'Cybernews';\n    const URI           = 'https://cybernews.com';\n    const DESCRIPTION   = 'Fetches the latest news from Cybernews';\n    const MAINTAINER    = 'tillcash';\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const MAX_ARTICLES  = 5;\n\n    public function collectData()\n    {\n        $urls = get_sitemap('https://cybernews.com/news-sitemap.xml');\n\n        foreach ($urls as $entry) {\n            $url     = $entry['loc'];\n            $lastmod = $entry['lastmod'];\n\n            if (!$url) {\n                continue;\n            }\n\n            $pathParts = explode('/', trim(parse_url($url, PHP_URL_PATH), '/'));\n            $category  = isset($pathParts[0]) && $pathParts[0] !== '' ? $pathParts[0] : '';\n\n            // Skip non-English versions\n            // if (in_array($category, ['nl', 'de', 'es', 'it'], true)) {\n            //     continue;\n            // }\n\n            $title      = '';\n\n            if (isset($entry['news'])) {\n                $news = $entry['news'];\n                if ($news) {\n                    $title = trim((string) $news['title']);\n                }\n            }\n\n            if (!$title) {\n                continue;\n            }\n\n            $this->items[] = [\n                'title'      => $title,\n                'uri'        => $url,\n                'uid'        => $url,\n                'timestamp'  => strtotime($lastmod),\n                'categories' => $category ? [$category] : [],\n                'content'    => $this->fetchFullArticle($url),\n            ];\n\n            if (count($this->items) >= self::MAX_ARTICLES) {\n                break;\n            }\n        }\n    }\n\n    private function fetchFullArticle(string $url): string\n    {\n        $html = getSimpleHTMLDOMCached($url);\n        if (!$html) {\n            return 'Unable to fetch article content';\n        }\n\n        $article = $html->find('article', 0);\n        if (!$article) {\n            return 'Unable to parse article content';\n        }\n\n        $removeSelectors = [\n            'script',\n            'style',\n            'div.links-bar',\n            'div.google-news-cta',\n            'div.a-wrapper',\n            'div.embed_youtube',\n        ];\n\n        foreach ($removeSelectors as $selector) {\n            foreach ($article->find($selector) as $element) {\n                $element->outertext = '';\n            }\n        }\n\n        // Handle lazy-loaded images\n        foreach ($article->find('img') as $img) {\n            if (!empty($img->{'data-src'})) {\n                $img->src = $img->{'data-src'};\n                unset($img->{'data-src'});\n            }\n        }\n\n        return $article->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/DRKBlutspendeBridge.php",
    "content": "<?php\n\nclass DRKBlutspendeBridge extends FeedExpander\n{\n    const MAINTAINER = 'User123698745';\n    const NAME = 'DRK-Blutspende';\n    const BASE_URI = 'https://www.drk-blutspende.de';\n    const URI = self::BASE_URI;\n    const CACHE_TIMEOUT = 60 * 60 * 1; // 1 hour\n    const DESCRIPTION = 'German Red Cross (Deutsches Rotes Kreuz) blood donation service feed with more details';\n    const CONTEXT_APPOINTMENTS = 'Termine';\n    const PARAMETERS = [\n        self::CONTEXT_APPOINTMENTS => [\n            'term' => [\n                'name' => 'PLZ / Ort',\n                'required' => true,\n                'exampleValue' => '12555',\n            ],\n            'radius' => [\n                'name' => 'Umkreis in km',\n                'type' => 'number',\n                'exampleValue' => 10,\n            ],\n            'limit_days' => [\n                'name' => 'Limit von Tagen',\n                'title' => 'Nur Termine innerhalb der nächsten x Tagen',\n                'type' => 'number',\n                'exampleValue' => 28,\n            ],\n            'limit_items' => [\n                'name' => 'Limit von Terminen',\n                'title' => 'Nicht mehr als x Termine',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 20,\n            ]\n        ]\n    ];\n\n    const OFFER_LOW_PRIORITIES = [\n        'Imbiss nach der Blutspende',\n        'Registrierung als Stammzellspender',\n        'Typisierung möglich!',\n        'Allgemeine Informationen',\n        'Krankenkassen belohnen Blutspender',\n        'Wer benötigt eigentlich eine Blutspende?',\n        'Win-Win-Situation für die Gesundheit!',\n        'Terminreservierung',\n        'Du möchtest das erste Mal Blut spenden?',\n        'Spende-Check',\n        'Sie haben Fragen vor Ihrer Blutspende?'\n    ];\n\n    const IMAGE_PRIORITIES = [\n        'DRK',\n        'Imbiss',\n        'Obst',\n    ];\n\n    public function collectData()\n    {\n        $limitItems = intval($this->getInput('limit_items'));\n        $this->collectExpandableDatas(self::buildAppointmentsURI(), $limitItems);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n\n        $detailsElement = $html->find('.details', 0);\n\n        $dateLines = self::explodeLines($detailsElement->find('.datum', 0)->plaintext);\n        $addressLines = self::explodeLines($detailsElement->find('.adresse', 0)->plaintext);\n\n        $infoElement = $detailsElement->find('.angebote > h4 + p', 0);\n        $info = $infoElement ? trim($infoElement->plaintext) : '';\n\n        $offers = self::parseOffers($detailsElement->find('.angebote .item'));\n\n        $images = self::parseImages($detailsElement->find('.fotos', 0));\n        usort($images, function ($imageA, $imageB): int {\n            list($titleA) = $imageA;\n            list($titleB) = $imageB;\n            $prioA = 0;\n            $prioB = 0;\n            foreach (self::IMAGE_PRIORITIES as $prioIndex => $prioTitleNeedle) {\n                if (stripos($titleA, $prioTitleNeedle) !== false) {\n                    $prioA = $prioIndex + 1;\n                }\n                if (stripos($titleB, $prioTitleNeedle) !== false) {\n                    $prioB = $prioIndex + 1;\n                }\n            }\n            return $prioA - $prioB;\n        });\n\n        $itemContent = <<<HTML\n        <div>\n            <p>\n                <b>{$dateLines[0]} {$dateLines[1]}</b><br>\n                {$addressLines[3]}\n            </p>\n            <p>\n                <b>{$addressLines[0]}</b><br>\n                {$addressLines[1]}<br>\n                {$addressLines[2]}\n            </p>\n        </div>\n        HTML;\n\n        if ($info) {\n            $itemContent .= <<<HTML\n            <div>\n                <h3>Infos</h3>\n                <p>{$info}</p>\n            </div>\n            HTML;\n        }\n\n        $majorOffers = array_filter($offers, fn($title): bool => !in_array($title, self::OFFER_LOW_PRIORITIES), ARRAY_FILTER_USE_KEY);\n        foreach ($majorOffers as $offerTitle => list($offerText, $offerImages)) {\n            $itemContent .= <<<HTML\n            <div>\n                <h3>{$offerTitle}</h3>\n                <p>{$offerText}</p>\n            HTML;\n            foreach ($offerImages as list($imageTitle, $imageUrl)) {\n                $itemContent .= <<<HTML\n                <figure>\n                    <img src=\"{$imageUrl}\">\n                    <figcaption>{$imageTitle}</figcaption>\n                </figure>\n                HTML;\n            }\n            $itemContent .= <<<HTML\n            </div>\n            HTML;\n        }\n\n        if (count($images) > 0) {\n            $itemContent .= <<<HTML\n            <div>\n                <h3>Fotos</h3>\n            HTML;\n            foreach ($images as list($imageTitle, $imageUrl)) {\n                $itemContent .= <<<HTML\n                <figure>\n                    <img src=\"{$imageUrl}\">\n                    <figcaption>{$imageTitle}</figcaption>\n                </figure>\n                HTML;\n            }\n            $itemContent .= <<<HTML\n            </div>\n            HTML;\n        }\n\n        $minorOffers = array_filter($offers, fn($title): bool => in_array($title, self::OFFER_LOW_PRIORITIES), ARRAY_FILTER_USE_KEY);\n        foreach ($minorOffers as $offerTitle => list($offerText)) {\n            $itemContent .= <<<HTML\n            <div>\n                <h3>{$offerTitle}</h3>\n                <p>{$offerText}</p>\n            </div>\n            HTML;\n        }\n\n        $item['title'] = $dateLines[0] . ' ' . $dateLines[1] . ' ' . $addressLines[0] . ' - ' . $addressLines[1];\n        $item['content'] = $itemContent;\n        $item['description'] = null;\n        $item['enclosures'] = array_map(\n            function ($image): string {\n                list($title, $url) = $image;\n                return $url . '#' . urlencode(str_replace(' ', '_', $title));\n            },\n            $images\n        );\n\n        return $item;\n    }\n\n    public function getURI()\n    {\n        if ($this->queriedContext === self::CONTEXT_APPOINTMENTS) {\n            return str_replace('.rss?', '?', self::buildAppointmentsURI());\n        }\n        return parent::getURI();\n    }\n\n    private function buildAppointmentsURI()\n    {\n        $term = $this->getInput('term') ?? '';\n        $radius = $this->getInput('radius') ?? '';\n        $limitDays = intval($this->getInput('limit_days'));\n        $dateTo = $limitDays > 0 ? date('Y-m-d', time() + (60 * 60 * 24 * $limitDays)) : '';\n        return self::BASE_URI . '/blutspendetermine/termine.rss?date_to=' . $dateTo . '&radius=' . $radius . '&term=' . $term;\n    }\n\n    private function parseImages($parentElement): array\n    {\n        $images = [];\n\n        if ($parentElement) {\n            $elements = $parentElement->find('a[data-lightbox]');\n            foreach ($elements as $i => $element) {\n                $url = trim($element->getAttribute('href'));\n                if (!$url) {\n                    continue;\n                }\n\n                $title = trim($element->getAttribute('title'));\n                if (!$title) {\n                    $number = $i + 1;\n                    $title = \"Foto {$number}\";\n                }\n\n                $images[] = [$title, $url];\n            }\n        }\n\n        return $images;\n    }\n\n    private function parseOffers($offerElements): array\n    {\n        $offers = [];\n\n        foreach ($offerElements as $element) {\n            $title = self::getCleanPlainText($element->find(':is(h1,h2,h3,h4,h5,h6)', 0));\n            $text = trim(substr(self::getCleanPlainText($element), strlen($title)));\n            if (!$title || !$text) {\n                continue;\n            }\n\n            $linkElements = $element->find('a');\n            foreach ($linkElements as $linkElement) {\n                $linkText = trim($linkElement->plaintext);\n                $linkUrl = trim($linkElement->getAttribute('href'));\n                if (!$linkText || !$linkUrl) {\n                    continue;\n                }\n\n                $linkHtml = <<<HTML\n                <a href=\"{$linkUrl}\" target=\"_blank\">{$linkText}</a>\n                HTML;\n                $text = str_replace($linkText, $linkHtml, $text);\n            }\n\n            $offers[$title] = [$text, self::parseImages($element)];\n        }\n\n        return $offers;\n    }\n\n    private function getCleanPlainText($htmlElement): string\n    {\n        return $htmlElement ? trim(preg_replace('/\\s+/', ' ', html_entity_decode($htmlElement->plaintext))) : '';\n    }\n\n    /**\n     * Returns an array of strings, each of which is a substring of string formed by splitting it on boundaries formed by line breaks.\n     */\n    private function explodeLines(string $text): array\n    {\n        return array_map('trim', preg_split('/(\\s*(\\r\\n|\\n|\\r)\\s*)+/', $text));\n    }\n}\n"
  },
  {
    "path": "bridges/DacksnackBridge.php",
    "content": "<?PHP\n\nclass DacksnackBridge extends BridgeAbstract\n{\n    const NAME = 'Däcksnack';\n    const URI = 'https://www.tidningendacksnack.se';\n    const DESCRIPTION = 'Latest news by the magazine Däcksnack';\n    const MAINTAINER = 'ajain-93';\n\n    public function getIcon()\n    {\n        return self::URI . '/upload/favicon/2591047722.png';\n    }\n\n    private function parseSwedishDates($dateString)\n    {\n        // Mapping of Swedish month names to English month names\n        $monthNames = [\n            'januari' => '01',\n            'februari' => '02',\n            'mars' => '03',\n            'april' => '04',\n            'maj' => '05',\n            'juni' => '06',\n            'juli' => '07',\n            'augusti' => '08',\n            'september' => '09',\n            'oktober' => '10',\n            'november' => '11',\n            'december' => '12'\n        ];\n\n        // Split the date string into parts\n        list($day, $monthName, $year) = explode(' ', $dateString);\n\n        // Convert month name to month number\n        $month = $monthNames[$monthName];\n\n        // Format to a string recognizable by DateTime\n        $formattedDate = sprintf('%04d-%02d-%02d', $year, $month, $day);\n\n        // Create a DateTime object\n        $dateValue = new DateTime($formattedDate);\n\n        if ($dateValue) {\n            $dateValue->setTime(0, 0); // Set time to 00:00\n            return $dateValue->getTimestamp();\n        }\n\n        return $dateValue ? $dateValue->getTimestamp() : false;\n    }\n\n    public function collectData()\n    {\n        $NEWSURL = self::URI;\n        $html = getSimpleHTMLDOMCached($NEWSURL, 18000);\n\n        foreach ($html->find('a.main-news-item') as $element) {\n            // Debug::log($element);\n\n            $title = trim($element->find('h2', 0)->plaintext);\n            $category = trim($element->find('.category-tag', 0)->plaintext);\n            $url = self::URI . $element->getAttribute('href');\n            $published = $this->parseSwedishDates(trim($element->find('.published', 0)->plaintext));\n\n            $article_html = getSimpleHTMLDOMCached($url, 18000);\n            $article_content = $article_html->find('#ctl00_ContentPlaceHolder1_NewsArticleVeiw_pnlArticle', 0);\n\n            $figure = self::URI . $article_content->find('img.news-image', 0)->getAttribute('src');\n            $figure_caption = $article_content->find('.image-description', 0)->plaintext;\n            $author = $article_content->find('span.main-article-author', 0)->plaintext;\n            $preamble = $article_content->find('h4.main-article-ingress', 0)->plaintext;\n\n            $article_text = '';\n            foreach ($article_content->find('div') as $div) {\n                if (!$div->hasAttribute('class')) {\n                    $article_text = $div;\n                }\n            }\n\n            // Use a regular expression to extract the name\n            if (preg_match('/Text:\\s*(.*?)\\s*Foto:/', $author, $matches)) {\n                $author = $matches[1]; // This will contain 'Jonna Jansson'\n            }\n\n            $content = '<b> [' . $category . '] <i>' . $preamble . '</i></b><br/><br/>';\n            $content .= '<figure>';\n            $content .= '<img src=' . $figure . '>';\n            $content .= '<figcaption>' . $figure_caption . '</figcaption>';\n            $content .= '</figure>';\n            $content .= $article_text;\n\n            $this->items[] = [\n                'uri' => $url,\n                'title' => $title,\n                'author' => $author,\n                'timestamp' => $published,\n                'content' => trim($content),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DagensNyheterDirektBridge.php",
    "content": "<?PHP\n\nclass DagensNyheterDirektBridge extends BridgeAbstract\n{\n    const NAME          = 'Dagens Nyheter Direkt';\n    const URI           = 'https://www.dn.se/direkt/';\n    const BASEURL       = 'https://www.dn.se';\n    const DESCRIPTION   = 'Latest news summarised by Dagens Nyheter';\n    const MAINTAINER    = 'ajain-93';\n    const LIMIT         = 20;\n\n    public function getIcon()\n    {\n        return 'https://cdn.dn-static.se/images/favicon__c2dd3284b46ffdf4d520536e526065fa8.svg';\n    }\n\n    public function collectData()\n    {\n        $NEWSURL = self::BASEURL . '/ajax/direkt/';\n\n        $html = getSimpleHTMLDOM($NEWSURL);\n\n        foreach ($html->find('article') as $element) {\n            $link = $element->find('button', 0)->getAttribute('data-link');\n            $datetime = $element->getAttribute('data-publication-time');\n            $url = self::BASEURL . $link;\n            $title = $element->find('h2', 0)->plaintext;\n            $author = $element->find('div.ds-byline__titles', 0)->plaintext;\n\n            $article_content = $element->find('div.direkt-post__content', 0);\n            $article_html = '';\n\n            $figure = $element->find('figure', 0);\n\n            if ($figure) {\n                $article_html = $figure->find('img', 0) . '<p><i>' . $figure->find('figcaption', 0) . '</i></p>';\n            }\n\n            foreach ($article_content->find('p') as $p) {\n                $article_html = $article_html . $p;\n            }\n\n            $this->items[] = [\n                'uri' => $url,\n                'title' => $title,\n                'author' => trim($author),\n                'timestamp' => $datetime,\n                'content' => trim($article_html),\n            ];\n\n            if (count($this->items) > self::LIMIT) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DailymotionBridge.php",
    "content": "<?php\n\nclass DailymotionBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Dailymotion';\n    const URI = 'https://www.dailymotion.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns the 5 newest videos by username/playlist or search';\n\n    const PARAMETERS = [\n        'By username' => [\n            'u' => [\n                'name' => 'username',\n                'required' => true,\n                'exampleValue' => 'moviepilot',\n            ]\n        ],\n        'By playlist id' => [\n            'p' => [\n                'name' => 'playlist id',\n                'required' => true,\n                'exampleValue' => 'x6xyc6',\n            ]\n        ],\n        'From search results' => [\n            's' => [\n                'name' => 'Search keyword',\n                'required' => true,\n                'exampleValue' => 'matrix',\n            ],\n            'pa' => [\n                'name' => 'Page',\n                'type' => 'number',\n                'defaultValue' => 1,\n            ]\n        ]\n    ];\n\n    private $feedName = '';\n\n    private $apiUrl = 'https://api.dailymotion.com';\n    private $apiFields = 'created_time,description,id,owner.screenname,tags,thumbnail_url,title,url';\n\n    public function getIcon()\n    {\n        return 'https://static1.dmcdn.net/neon-user-ssr/prod/favicons/apple-icon-60x60.831b96ed0a8eca7f6539.png';\n    }\n\n    public function collectData()\n    {\n        $apiJson = getContents($this->getApiUrl());\n        $apiData = json_decode($apiJson, true);\n\n        if ($this->queriedContext === 'By playlist id') {\n            $this->feedName = $this->getPlaylistTitle($this->getInput('p'));\n        }\n\n        foreach ($apiData['list'] as $apiItem) {\n            $item = [];\n\n            $item['uri'] = $apiItem['url'];\n            $item['uid'] = $apiItem['id'];\n            $item['title'] = $apiItem['title'];\n            $item['timestamp'] = $apiItem['created_time'];\n            $item['author'] = $apiItem['owner.screenname'];\n            $item['content'] = '<p><a href=\"' . $apiItem['url'] . '\">\n\t\t\t\t<img src=\"' . $apiItem['thumbnail_url'] . '\"></a></p><p>' . $apiItem['description'] . '</p>';\n            $item['categories'] = $apiItem['tags'];\n            $item['enclosures'][] = $apiItem['thumbnail_url'];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By username':\n                $specific = $this->getInput('u');\n                break;\n            case 'By playlist id':\n                $specific = strtok($this->getInput('p'), '_');\n\n                if ($this->feedName) {\n                    $specific = $this->feedName;\n                }\n\n                break;\n            case 'From search results':\n                $specific = $this->getInput('s');\n                break;\n            default:\n                return parent::getName();\n        }\n\n        return $specific . ' : Dailymotion';\n    }\n\n    public function getURI()\n    {\n        $uri = self::URI;\n\n        switch ($this->queriedContext) {\n            case 'By username':\n                $uri .= 'user/' . urlencode($this->getInput('u'));\n                break;\n            case 'By playlist id':\n                $uri .= 'playlist/' . urlencode(strtok($this->getInput('p'), '_'));\n                break;\n            case 'From search results':\n                $uri .= 'search/' . urlencode($this->getInput('s'));\n\n                if (!is_null($this->getInput('pa'))) {\n                    $pa = $this->getInput('pa');\n\n                    if ($this->getInput('pa') < 1) {\n                        $pa = 1;\n                    }\n\n                    $uri .= '/' . $pa;\n                }\n                break;\n            default:\n                return parent::getURI();\n        }\n        return $uri;\n    }\n\n    private function getPlaylistTitle($id)\n    {\n        $apiJson = getContents($this->apiUrl . '/playlist/' . $this->getInput('p'));\n        $apiData = json_decode($apiJson, true);\n        return $apiData['name'];\n    }\n\n    private function getApiUrl()\n    {\n        switch ($this->queriedContext) {\n            case 'By username':\n                return $this->apiUrl . '/user/' . $this->getInput('u')\n                    . '/videos?fields=' . urlencode($this->apiFields) . '&availability=1&sort=recent&limit=5';\n                break;\n            case 'By playlist id':\n                return $this->apiUrl . '/playlist/' . $this->getInput('p')\n                    . '/videos?fields=' . urlencode($this->apiFields) . '&limit=5';\n                break;\n            case 'From search results':\n                return $this->apiUrl . '/videos?search=' . $this->getInput('s') . '&fields=' . urlencode($this->apiFields) . '&limit=5';\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DailythanthiBridge.php",
    "content": "<?php\n\nclass DailythanthiBridge extends BridgeAbstract\n{\n    const NAME = 'Dailythanthi';\n    const URI = 'https://www.dailythanthi.com';\n    const DESCRIPTION = 'Retrieve news from dailythanthi.com';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'name' => 'topic',\n                'type' => 'list',\n                'values' => [\n                    'news' => [\n                        'tamilnadu' => 'news/state',\n                        'india' => 'news/india',\n                        'world' => 'news/world',\n                        'sirappu-katturaigal' => 'news/sirappukatturaigal',\n                    ],\n                    'cinema' => [\n                        'news' => 'cinema/cinemanews',\n                    ],\n                    'sports' => [\n                        'sports' => 'sports',\n                        'cricket' => 'sports/cricket',\n                        'football' => 'sports/football',\n                        'tennis' => 'sports/tennis',\n                        'hockey' => 'sports/hockey',\n                        'other-sports' => 'sports/othersports',\n                    ],\n                    'devotional' => [\n                        'devotional' => 'others/devotional',\n                        'aalaya-varalaru' => 'aalaya-varalaru',\n                    ],\n                ],\n            ],\n        ],\n    ];\n\n    public function getName()\n    {\n        $topic = $this->getKey('topic');\n        return self::NAME . ($topic ? ' - ' . ucfirst($topic) : '');\n    }\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(self::URI . '/' . $this->getInput('topic'));\n\n        foreach ($dom->find('div.ListingNewsWithMEDImage') as $element) {\n            $slug = $element->find('a', 1);\n            $title = $element->find('h3', 0);\n            if (!$slug || !$title) {\n                continue;\n            }\n\n            $url = self::URI . $slug->href;\n            $date = $element->find('span', 1);\n            $date = $date ? $date->{'data-datestring'} : '';\n\n            $this->items[] = [\n                'content'   => $this->constructContent($url),\n                'timestamp' => $date ? $date . 'UTC' : '',\n                'title'     => $title->plaintext,\n                'uid'       => $slug->href,\n                'uri'       => $url,\n            ];\n        }\n    }\n\n    private function constructContent($url)\n    {\n        $dom = getSimpleHTMLDOMCached($url);\n\n        $article = $dom->find('div.details-content-story', 0);\n        if (!$article) {\n            return 'Content Not Found';\n        }\n\n        // Remove ads\n        foreach ($article->find('div[id*=\"_ad\"]') as $remove) {\n            $remove->outertext = '';\n        }\n\n        // Correct image tag in $article\n        foreach ($article->find('h-img') as $img) {\n            $img->parent->outertext = sprintf('<p><img src=\"%s\"></p>', $img->src);\n        }\n\n        $image = $dom->find('div.main-image-caption-container img', 0);\n        $image = $image ? '<p>' . $image->outertext . '</p>' : '';\n\n        return $image . $article;\n    }\n}\n"
  },
  {
    "path": "bridges/DanbooruBridge.php",
    "content": "<?php\n\nclass DanbooruBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mitsukarenai, logmanoriginal';\n    const NAME = 'Danbooru';\n    const URI = 'https://danbooru.donmai.us/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns images from given page';\n\n    const PARAMETERS = [\n        'global' => [\n            'p' => [\n                'name' => 'page',\n                'defaultValue' => 1,\n                'type' => 'number'\n            ],\n            't' => [\n                'type' => 'text',\n                'name' => 'tags',\n                'exampleValue' => 'cosplay',\n            ]\n        ],\n        0 => []\n    ];\n\n    const PATHTODATA = 'article';\n    const IDATTRIBUTE = 'data-id';\n    const TAGATTRIBUTE = 'alt';\n\n    protected function getFullURI()\n    {\n        return $this->getURI()\n        . 'posts?&page=' . $this->getInput('p')\n        . '&tags=' . urlencode($this->getInput('t'));\n    }\n\n    protected function getTags($element)\n    {\n        return $element->find('img', 0)->getAttribute(static::TAGATTRIBUTE);\n    }\n\n    protected function getItemFromElement($element)\n    {\n        // Fix links\n        defaultLinkTo($element, $this->getURI());\n\n        $item = [];\n        $item['uri'] = html_entity_decode($element->find('a', 0)->href);\n        $item['postid'] = (int)preg_replace('/[^0-9]/', '', $element->getAttribute(static::IDATTRIBUTE));\n        $item['timestamp'] = time();\n        $thumbnailUri = $element->find('img', 0)->src;\n        $item['categories'] = array_filter(explode(' ', $this->getTags($element)));\n        $item['title'] = $this->getName() . ' | ' . $item['postid'];\n        $item['content'] = '<a href=\"'\n        . $item['uri']\n        . '\"><img src=\"'\n        . $thumbnailUri\n        . '\" /></a><br>Tags: '\n        . $this->getTags($element);\n\n        return $item;\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getFullURI());\n\n        foreach ($html->find(static::PATHTODATA) as $element) {\n            $this->items[] = $this->getItemFromElement($element);\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DarkReadingBridge.php",
    "content": "<?php\n\nclass DarkReadingBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Dark Reading';\n    const URI = 'https://www.darkreading.com/';\n    const DESCRIPTION = 'Returns the newest articles from Dark Reading';\n\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed (NOT IN USE)',\n            'type' => 'list',\n            'values' => [\n                'All Dark Reading Stories' => '000_AllArticles',\n                'Attacks/Breaches' => '644_Attacks/Breaches',\n                'Application Security' => '645_Application%20Security',\n                'Database Security' => '646_Database%20Security',\n                'Cloud' => '647_Cloud',\n                'Endpoint' => '648_Endpoint',\n                'Authentication' => '649_Authentication',\n                'Privacy' => '650_Privacy',\n                'Mobile' => '651_Mobile',\n                'Perimeter' => '652_Perimeter',\n                'Risk' => '653_Risk',\n                'Compliance' => '654_Compliance',\n                'Operations' => '655_Operations',\n                'Careers and People' => '656_Careers%20and%20People',\n                'Identity and Access Management' => '657_Identity%20and%20Access%20Management',\n                'Analytics' => '658_Analytics',\n                'Threat Intelligence' => '659_Threat%20Intelligence',\n                'Security Monitoring' => '660_Security%20Monitoring',\n                'Vulnerabilities / Threats' => '661_Vulnerabilities%20/%20Threats',\n                'Advanced Threats' => '662_Advanced%20Threats',\n                'Insider Threats' => '663_Insider%20Threats',\n                'Vulnerability Management' => '664_Vulnerability%20Management',\n            ]\n        ],\n        'limit' => self::LIMIT,\n    ]];\n\n    public function collectData()\n    {\n        $feed_url = 'https://www.darkreading.com/rss.xml';\n        $limit = $this->getInput('limit') ?? 10;\n        $this->collectExpandableDatas($feed_url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article = getSimpleHTMLDOMCached($item['uri']);\n        $item['content'] = $this->extractArticleContent($article);\n        $item['enclosures'] = []; //remove author profile picture\n        $image = $article->find('meta[property=\"og:image\"]', 0);\n        if (is_object($image)) {\n            $image = $image->content;\n            $item['enclosures'] = [$image];\n        }\n        return $item;\n    }\n\n    private function extractArticleContent($article)\n    {\n        $content = $article->find('div.ContentModule-Wrapper', 0)->innertext;\n\n        foreach (\n            [\n            '<div class=\"divsplitter',\n            '<div style=\"float: left; margin-right: 2px;',\n            '<div class=\"more-insights',\n            '<div id=\"more-insights',\n            ] as $div_start\n        ) {\n            $content = stripRecursiveHTMLSection($content, 'div', $div_start);\n        }\n\n        return convertLazyLoading($content);\n    }\n}\n"
  },
  {
    "path": "bridges/DauphineLibereBridge.php",
    "content": "<?php\n\nclass DauphineLibereBridge extends FeedExpander\n{\n    const MAINTAINER = 'qwertygc';\n    const NAME = 'Dauphine';\n    const URI = 'https://www.ledauphine.com/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    const PARAMETERS = [ [\n        'u' => [\n            'name' => 'Catégorie de l\\'article',\n            'type' => 'list',\n            'values' => [\n                'À la une' => '',\n                'France Monde' => 'france-monde',\n                'Faits Divers' => 'faits-divers',\n                'Économie et Finance' => 'economie-et-finance',\n                'Politique' => 'politique',\n                'Sport' => 'sport',\n                'Ain' => 'ain',\n                'Alpes-de-Haute-Provence' => 'haute-provence',\n                'Hautes-Alpes' => 'hautes-alpes',\n                'Ardèche' => 'ardeche',\n                'Drôme' => 'drome',\n                'Isère Sud' => 'isere-sud',\n                'Savoie' => 'savoie',\n                'Haute-Savoie' => 'haute-savoie',\n                'Vaucluse' => 'vaucluse'\n            ]\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = self::URI . 'rss';\n\n        if (empty($this->getInput('u'))) {\n            $url = self::URI . $this->getInput('u') . '/rss';\n        }\n\n        $this->collectExpandableDatas($url, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['content'] = $this->extractContent($item['uri']);\n        return $item;\n    }\n\n    private function extractContent($url)\n    {\n        $html2 = getSimpleHTMLDOMCached($url);\n        foreach ($html2->find('.noprint, link, script, iframe, .shareTool, .contentInfo') as $remove) {\n            $remove->outertext = '';\n        }\n        return $html2->find('div.content', 0)->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/DealabsBridge.php",
    "content": "<?php\n\nclass DealabsBridge extends PepperBridgeAbstract\n{\n    const NAME = 'Dealabs';\n    const URI = 'https://www.dealabs.com/';\n    const DESCRIPTION = 'Affiche les Deals de Dealabs';\n    const MAINTAINER = 'sysadminstory';\n    const PARAMETERS = [\n        'Recherche par Mot(s) clé(s)' => [\n            'q' => [\n                'name' => 'Mot(s) clé(s)',\n                'type' => 'text',\n                'exampleValue' => 'lampe',\n                'required' => true\n            ],\n            'hide_expired' => [\n                'name' => 'Masquer les éléments expirés',\n                'type' => 'checkbox',\n            ],\n            'hide_local' => [\n                'name' => 'Masquer les deals locaux',\n                'type' => 'checkbox',\n                'title' => 'Masquer les deals en magasins physiques',\n            ],\n            'priceFrom' => [\n                'name' => 'Prix minimum',\n                'type' => 'text',\n                'title' => 'Prix mnimum en euros',\n                'required' => false\n            ],\n            'priceTo' => [\n                'name' => 'Prix maximum',\n                'type' => 'text',\n                'title' => 'Prix maximum en euros',\n                'required' => false\n            ],\n        ],\n\n        'Deals par groupe' => [\n            'group' => [\n                'name' => 'Groupe',\n                'type' => 'text',\n                'exampleValue' => 'abonnements-internet',\n                '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 \"?\"\nExemple : Si l\\'URL du groupe affichées dans le navigateur est :\nhttps://www.dealabs.com/groupe/abonnements-internet?sortBy=lowest_price\nIl faut alors saisir :\nabonnements-internet',\n                ],\n            'subgroups' => [\n                'name' => 'Catégorie',\n                'type' => 'text',\n                'exampleValue' => '1071',\n                '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 \"&\"\nExemple : Si l\\'URL du groupe affichées dans le navigateur est :\nhttps://www.dealabs.com/groupe/telecommunications?groups=1071%2C1070&sortBy=new\nIl faut alors saisir :\n1071%2C1070',\n                ],\n            'order' => [\n                'name' => 'Trier par',\n                'type' => 'list',\n                'title' => 'Ordre de tri des deals',\n                'values' => [\n                    'Du deal le plus Hot au moins Hot' => '-hot',\n                    'Du deal le plus récent au plus ancien' => '-nouveaux',\n                ]\n            ]\n        ],\n        'Surveillance Discussion' => [\n            'url' => [\n                'name' => 'URL de la discussion',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'URL discussion à surveiller: https://www.dealabs.com/discussions/titre-1234',\n                'exampleValue' => 'https://www.dealabs.com/discussions/jeux-steam-gratuits-gleam-woobox-etc-1071415',\n                ],\n\n            'only_with_url' => [\n                'name' => 'Exclure les commentaires sans URL',\n                'type' => 'checkbox',\n                'title' => 'Exclure les commentaires ne contenant pas d\\'URL dans le flux',\n                'defaultValue' => false,\n                ]\n\n\n            ]\n\n    ];\n\n    public $lang = [\n        'bridge-uri' => self::URI,\n        'bridge-name' => self::NAME,\n        'context-keyword' => 'Recherche par Mot(s) clé(s)',\n        'context-group' => 'Deals par groupe',\n        'context-talk' => 'Surveillance Discussion',\n        'uri-group' => 'groupe/',\n        'uri-deal' => 'bons-plans/',\n        'uri-merchant' => 'search/bons-plans?merchant-id=',\n        'image-host' => 'https://static-pepper.dealabs.com/',\n        'request-error' => 'Impossible de joindre Dealabs',\n        'thread-error' => 'Impossible de déterminer l\\'ID de la discussion. Vérifiez l\\'URL que vous avez entré',\n        'currency' => '€',\n        'price' => 'Prix',\n        'shipping' => 'Livraison',\n        'origin' => 'Origine',\n        'discount' => 'Réduction',\n        'title-keyword' => 'Recherche',\n        'title-group' => 'Groupe',\n        'title-talk' => 'Surveillance Discussion',\n        'deal-type' => 'Type de deal',\n        'localdeal' => 'Deal Local',\n        'context-hot' => '-hot',\n        'context-new' => '-nouveaux',\n    ];\n}\n"
  },
  {
    "path": "bridges/DemoBridge.php",
    "content": "<?php\n\nclass DemoBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'DemoBridge';\n    const URI = 'https://github.com/rss-bridge/rss-bridge';\n    const DESCRIPTION = 'Bridge used for demos';\n    const CACHE_TIMEOUT = 15;\n\n    const PARAMETERS = [\n        'testCheckbox' => [\n            'testCheckbox' => [\n                'type' => 'checkbox',\n                'name' => 'test des checkbox'\n            ]\n        ],\n        'testList' => [\n            'testList' => [\n                'type' => 'list',\n                'name' => 'test des listes',\n                'values' => [\n                    'Test' => 'test',\n                    'Test 2' => 'test2'\n                ]\n            ]\n        ],\n        'testNumber' => [\n            'testNumber' => [\n                'type' => 'number',\n                'name' => 'test des numéros',\n                'exampleValue' => '1515632'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $item = [];\n        $item['author'] = 'Me!';\n        $item['title'] = 'Test';\n        $item['content'] = 'Awesome content !';\n        $item['id'] = 'Lalala';\n        $item['uri'] = 'http://example.com/test';\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/DemosBerlinBridge.php",
    "content": "<?php\n\nclass DemosBerlinBridge extends BridgeAbstract\n{\n    const NAME = 'Demos Berlin';\n    const URI = 'https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/';\n    const CACHE_TIMEOUT = 3 * 60 * 60;\n    const DESCRIPTION = 'Angezeigte Versammlungen und Aufzüge in Berlin';\n    const MAINTAINER = 'knrdl';\n    const PARAMETERS = [[\n        'days' => [\n            'name' => 'Tage',\n            'type' => 'number',\n            'title' => 'Einträge für die nächsten Tage zurückgeben',\n            'required' => true,\n            'defaultValue' => 7,\n        ]\n    ]];\n\n    public function getIcon()\n    {\n        return 'https://www.berlin.de/i9f/r1/images/favicon/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $url = 'https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/index.php/index/all.json';\n        $json = getContents($url);\n        $jsonFile = json_decode($json, true);\n\n        $daysInterval = DateInterval::createFromDateString($this->getInput('days') . ' day');\n        $maxTargetDate = date_add(new DateTime('now'), $daysInterval);\n\n        foreach ($jsonFile['index'] as $entry) {\n            $entryDay = implode('-', array_reverse(explode('.', $entry['datum']))); // dd.mm.yyyy to yyyy-mm-dd\n            $ts = (new DateTime())->setTimestamp(strtotime($entryDay));\n            if ($ts <= $maxTargetDate) {\n                $item = [];\n                $item['uri'] = 'https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/index.php/detail/' . $entry['id'];\n                $item['timestamp'] = $entryDay . ' ' . $entry['von'];\n                $item['title'] = $entry['thema'];\n                $location = $entry['strasse_nr'] . ' ' . $entry['plz'];\n                $locationQuery = http_build_query(['query' => $location]);\n                $item['content'] = <<<HTML\n                <h1>{$entry['thema']}</h1>\n                <p>📅 <time datetime=\"{$item['timestamp']}\">{$entry['datum']} {$entry['von']} - {$entry['bis']}</time></p>\n                <a href=\"https://www.openstreetmap.org/search?$locationQuery\">\n                📍 {$location}\n                </a>\n                <p>{$entry['aufzugsstrecke']}</p>\n                HTML;\n                $item['uid'] = $this->getSanitizedHash($entry['datum'] . '-' . $entry['von'] . '-' . $entry['bis'] . '-' . $entry['thema']);\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function getSanitizedHash($string)\n    {\n        return hash('sha1', preg_replace('/[^a-zA-Z0-9]/', '', strtolower($string)));\n    }\n}\n"
  },
  {
    "path": "bridges/DerpibooruBridge.php",
    "content": "<?php\n\nclass DerpibooruBridge extends BridgeAbstract\n{\n    const NAME = 'Derpibooru';\n    const URI = 'https://derpibooru.org/';\n    const DESCRIPTION = 'Returns newest images from a Derpibooru search';\n    const CACHE_TIMEOUT = 300; // 5min\n    const MAINTAINER = 'Roliga';\n\n    const PARAMETERS = [\n        [\n            'f' => [\n                'name' => 'Filter',\n                'type' => 'list',\n                'values' => [\n                    'Everything' => 56027,\n                    '18+ R34' => 37432,\n                    'Legacy Default' => 37431,\n                    '18+ Dark' => 37429,\n                    'Maximum Spoilers' => 37430,\n                    'Default' => 100073\n                ],\n                'defaultValue' => 56027\n\n            ],\n            'q' => [\n                'name' => 'Query',\n                'required' => true,\n                'exampleValue' => 'dog',\n            ]\n        ]\n    ];\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Search page e.g. https://derpibooru.org/search?q=cute\n        $regex = '/^(https?:\\/\\/)?(www\\.)?derpibooru.org\\/search.+q=([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['q'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        // Tag page, e.g. https://derpibooru.org/tags/artist-colon-devinian\n        $regex = '/^(https?:\\/\\/)?(www\\.)?derpibooru.org\\/tags\\/([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['q'] = str_replace('-colon-', ':', urldecode($matches[3]));\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('q'))) {\n            return 'Derpibooru search for: '\n                . $this->getInput('q');\n        } else {\n            return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('f')) && !is_null($this->getInput('q'))) {\n            return self::URI\n            . 'search?filter_id='\n            . urlencode($this->getInput('f'))\n            . '&q='\n            . urlencode($this->getInput('q'));\n        } else {\n            return parent::getURI();\n        }\n    }\n\n    public function collectData()\n    {\n        $url = self::URI . 'api/v1/json/search/images?filter_id=' . urlencode($this->getInput('f')) . '&q=' . urlencode($this->getInput('q'));\n\n        $queryJson = json_decode(getContents($url));\n\n        foreach ($queryJson->images as $post) {\n            $item = [];\n\n            $postUri = self::URI . $post->id;\n\n            $item['uri'] = $postUri;\n            $item['title'] = $post->name;\n            $item['timestamp'] = strtotime($post->created_at);\n            $item['author'] = $post->uploader;\n            $item['enclosures'] = [$post->view_url];\n            $item['categories'] = $post->tags;\n\n            $item['content'] = '<p><a href=\"' // image preview\n                . $postUri\n                . '\"><img src=\"'\n                . $post->representations->medium\n                . '\"></a></p><p>' // description\n                . $post->description\n                . '</p><p><b>Size:</b> ' // image size\n                . $post->width\n                . 'x'\n                . $post->height;\n            // source link\n            if ($post->source_url != null) {\n                $item['content'] .= '<br><b>Source:</b> <a href=\"'\n                . $post->source_url\n                . '\">'\n                . $post->source_url\n                . '</a></p>';\n            };\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DesoutterBridge.php",
    "content": "<?php\n\nclass DesoutterBridge extends BridgeAbstract\n{\n    const CATEGORY_NEWS = 'News & Events';\n    const CATEGORY_INDUSTRY = 'Industry 4.0 News';\n\n    const NAME = 'Desoutter';\n    const URI = 'https://www.desouttertools.com';\n    const DESCRIPTION = 'Returns feeds for news from Desoutter';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 86400; // 24 hours\n\n    const PARAMETERS = [\n        self::CATEGORY_NEWS => [\n            'news_lang' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'title' => 'Select your language',\n                'defaultValue' => 'https://www.desouttertools.com/about-desoutter/news-events',\n                'values' => [\n                    'Corporate'\n                    => 'https://www.desouttertools.com/about-desoutter/news-events',\n                    'Česko'\n                    => 'https://www.desouttertools.cz/o-desoutter/aktuality-udalsoti',\n                    'Deutschland'\n                    => 'https://www.desoutter.de/ueber-desoutter/news-events',\n                    'España'\n                    => 'https://www.desouttertools.es/sobre-desoutter/noticias-eventos',\n                    'México'\n                    => 'https://www.desouttertools.mx/acerca-desoutter/noticias-eventos',\n                    'France'\n                    => 'https://www.desouttertools.fr/a-propos-de-desoutter/actualites-evenements',\n                    'Magyarország'\n                    => 'https://www.desouttertools.hu/a-desoutter-vallalatrol/hirek-esemenyek',\n                    'Italia'\n                    => 'https://www.desouttertools.it/su-desoutter/news-eventi',\n                    '日本'\n                    => 'https://www.desouttertools.jp/desotanituite/niyusu-ibento',\n                    '대한민국'\n                    => 'https://www.desouttertools.co.kr/desoteoe-daehaeseo/nyuseu-mic-ibenteu',\n                    'Polska'\n                    => 'https://www.desouttertools.pl/o-desoutter/aktualnosci-wydarzenia',\n                    'Brasil'\n                    => 'https://www.desouttertools.com.br/sobre-desoutter/noti%C2%ADcias-eventos',\n                    'Portugal'\n                    => 'https://www.desouttertools.pt/sobre-desoutter/notIcias-eventos',\n                    'România'\n                    => 'https://www.desouttertools.ro/despre-desoutter/noutati-evenimente',\n                    'Российская Федерация'\n                    => 'https://www.desouttertools.com.ru/o-desoutter/novosti-mieropriiatiia',\n                    'Slovensko'\n                    => 'https://www.desouttertools.sk/o-spolocnosti-desoutter/novinky-udalosti',\n                    'Slovenija'\n                    => 'https://www.desouttertools.si/o-druzbi-desoutter/novice-dogodki',\n                    'Sverige'\n                    => 'https://www.desouttertools.se/om-desoutter/nyheter-evenemang',\n                    'Türkiye'\n                    => 'https://www.desoutter.com.tr/desoutter-hakkinda/haberler-etkinlikler',\n                    '中国'\n                    => 'https://www.desouttertools.com.cn/guan-yu-ma-tou/xin-wen-he-huo-dong',\n                ]\n            ],\n        ],\n        self::CATEGORY_INDUSTRY => [\n            'industry_lang' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'title' => 'Select your language',\n                'defaultValue' => 'Corporate',\n                'values' => [\n                    'Corporate'\n                    => 'https://www.desouttertools.com/industry-4-0/news',\n                    'Česko'\n                    => 'https://www.desouttertools.cz/prumysl-4-0/novinky',\n                    'Deutschland'\n                    => 'https://www.desoutter.de/industrie-4-0/news',\n                    'España'\n                    => 'https://www.desouttertools.es/industria-4-0/noticias',\n                    'México'\n                    => 'https://www.desouttertools.mx/industria-4-0/noticias',\n                    'France'\n                    => 'https://www.desouttertools.fr/industrie-4-0/actualites',\n                    'Magyarország'\n                    => 'https://www.desouttertools.hu/industry-4-0/hirek',\n                    'Italia'\n                    => 'https://www.desouttertools.it/industry-4-0/news',\n                    '日本'\n                    => 'https://www.desouttertools.jp/industry-4-0/news',\n                    '대한민국'\n                    => 'https://www.desouttertools.co.kr/industry-4-0/news',\n                    'Polska'\n                    => 'https://www.desouttertools.pl/przemysl-4-0/wiadomosci',\n                    'Brasil'\n                    => 'https://www.desouttertools.com.br/industria-4-0/noticias',\n                    'Portugal'\n                    => 'https://www.desouttertools.pt/industria-4-0/noticias',\n                    'România'\n                    => 'https://www.desouttertools.ro/industry-4-0/noutati',\n                    'Российская Федерация'\n                    => 'https://www.desouttertools.com.ru/industry-4-0/news',\n                    'Slovensko'\n                    => 'https://www.desouttertools.sk/priemysel-4-0/novinky',\n                    'Slovenija'\n                    => 'https://www.desouttertools.si/industrija-4-0/novice',\n                    'Sverige'\n                    => 'https://www.desouttertools.se/industri-4-0/nyheter',\n                    'Türkiye'\n                    => 'https://www.desoutter.com.tr/endustri-4-0/haberler',\n                    '中国'\n                    => 'https://www.desouttertools.com.cn/industry-4-0/news',\n                ]\n            ],\n        ],\n        'global' => [\n            'full' => [\n                'name' => 'Load full articles',\n                'type' => 'checkbox',\n                'title' => 'Enable to load the full article for each item'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 3,\n                'title' => \"Maximum number of items to return in the feed.\\n0 = unlimited\"\n            ]\n        ]\n    ];\n\n    private $title;\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case self::CATEGORY_NEWS:\n                return $this->getInput('news_lang') ?: parent::getURI();\n            case self::CATEGORY_INDUSTRY:\n                return $this->getInput('industry_lang') ?: parent::getURI();\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        return isset($this->title) ? $this->title . ' - ' . parent::getName() : parent::getName();\n    }\n\n    public function collectData()\n    {\n        // Uncomment to generate list of languages automtically (dev mode)\n        /*\n        switch($this->queriedContext) {\n            case self::CATEGORY_NEWS:\n                $this->extractNewsLanguages(); die;\n            case self::CATEGORY_INDUSTRY:\n                $this->extractIndustryLanguages(); die;\n        }\n        */\n\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $html = defaultLinkTo($html, $this->getURI());\n\n        $this->title = html_entity_decode($html->find('title', 0)->plaintext, ENT_QUOTES);\n\n        $limit = $this->getInput('limit') ?: 0;\n\n        foreach ($html->find('article') as $article) {\n            $item = [];\n\n            $item['uri'] = $article->find('a', 0)->href;\n            $item['title'] = $article->find('a[title]', 0)->title;\n\n            if ($this->getInput('full')) {\n                $item['content'] = $this->getFullNewsArticle($item['uri']);\n            } else {\n                $item['content'] = $article->find('div.tile-body p', 0)->plaintext;\n            }\n\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    private function getFullNewsArticle($uri)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n\n        $html = defaultLinkTo($html, $this->getURI());\n\n        return $html->find('section.article', 0);\n    }\n\n    /**\n     * Generates a HTML page with a PHP formatted array of languages,\n     * pointing to the corresponding news pages. Implementation is based\n     * on the 'Corporate' site.\n     * @return void\n     */\n    private function extractNewsLanguages()\n    {\n        $html = getSimpleHTMLDOMCached('https://www.desouttertools.com/about-desoutter/news-events');\n\n        $html = defaultLinkTo($html, static::URI);\n\n        $items = $html->find('ul[class=\"dropdown-menu\"] li');\n\n        $list = \"\\t'Corporate'\\n\\t=> 'https://www.desouttertools.com/about-desoutter/news-events',\\n\";\n\n        foreach ($items as $item) {\n            $lang = trim($item->plaintext);\n            $uri = $item->find('a', 0)->href;\n\n            $list .= \"\\t'{$lang}'\\n\\t=> '{$uri}',\\n\";\n        }\n\n        echo $list;\n    }\n\n    /**\n     * Generates a HTML page with a PHP formatted array of languages,\n     * pointing to the corresponding news pages. Implementation is based\n     * on the 'Corporate' site.\n     * @return void\n     */\n    private function extractIndustryLanguages()\n    {\n        $html = getSimpleHTMLDOMCached('https://www.desouttertools.com/industry-4-0/news');\n\n        $html = defaultLinkTo($html, static::URI);\n\n        $items = $html->find('ul[class=\"dropdown-menu\"] li');\n\n        $list = \"\\t'Corporate'\\n\\t=> 'https://www.desouttertools.com/industry-4-0/news',\\n\";\n\n        foreach ($items as $item) {\n            $lang = trim($item->plaintext);\n            $uri = $item->find('a', 0)->href;\n\n            $list .= \"\\t'{$lang}'\\n\\t=> '{$uri}',\\n\";\n        }\n\n        echo $list;\n    }\n}\n"
  },
  {
    "path": "bridges/DeutscheWelleBridge.php",
    "content": "<?php\n\nclass DeutscheWelleBridge extends FeedExpander\n{\n    const MAINTAINER = 'No maintainer';\n    const NAME = 'Deutsche Welle';\n    const URI = 'https://www.dw.com';\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [[\n        'feed' => [\n            'name' => 'feed',\n            'type' => 'list',\n            'values' => [\n                'All Top Stories and News Updates'\n                => 'http://rss.dw.com/atom/rss-en-all',\n                'Top Stories'\n                => 'http://rss.dw.com/atom/rss-en-top',\n                'Germany'\n                => 'http://rss.dw.com/atom/rss-en-ger',\n                'World'\n                => 'http://rss.dw.com/atom/rss-en-world',\n                'Europe'\n                => 'http://rss.dw.com/atom/rss-en-eu',\n                'Business'\n                => 'http://rss.dw.com/atom/rss-en-bus',\n                'Science'\n                => 'http://rss.dw.com/atom/rss_en_science',\n                'Environment'\n                => 'http://rss.dw.com/atom/rss_en_environment',\n                'Culture & Lifestyle'\n                => 'http://rss.dw.com/atom/rss-en-cul',\n                'Sports'\n                => 'http://rss.dw.de/atom/rss-en-sports',\n                'Visit Germany'\n                => 'http://rss.dw.com/atom/rss-en-visitgermany',\n                'Asia'\n                => 'http://rss.dw.com/atom/rss-en-asia',\n                'Deutsche Welle Gesamt'\n                => 'http://rss.dw.com/atom/rss-de-all',\n                'Themen des Tages'\n                => 'http://rss.dw.com/atom/rss-de-top',\n                'Nachrichten'\n                => 'http://rss.dw.com/atom/rss-de-news',\n                'Wissenschaft'\n                => 'http://rss.dw.com/atom/rss-de-wissenschaft',\n                'Sport'\n                => 'http://rss.dw.com/atom/rss-de-sport',\n                'Deutschland entdecken'\n                => 'http://rss.dw.com/atom/rss-de-deutschlandentdecken',\n                'Presse'\n                => 'http://rss.dw.com/atom/presse',\n                'Politik'\n                => 'http://rss.dw.com/atom/rss_de_politik',\n                'Wirtschaft'\n                => 'http://rss.dw.com/atom/rss-de-eco',\n                'Kultur & Leben'\n                => 'http://rss.dw.com/atom/rss-de-cul',\n                'Kultur & Leben: Buch'\n                => 'http://rss.dw.com/atom/rss-de-cul-buch',\n                'Kultur & Leben: Film'\n                => 'http://rss.dw.com/atom/rss-de-cul-film',\n                'Kultur & Leben: Musik'\n                => 'http://rss.dw.com/atom/rss-de-cul-musik',\n            ]\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas($this->getInput('feed'));\n    }\n\n    protected function parseItem(array $item)\n    {\n        $parsedUri = parse_url($item['uri']);\n        unset($parsedUri['query']);\n        $item['uri'] = $this->unparseUrl($parsedUri);\n\n        $page = getSimpleHTMLDOM($item['uri']);\n        $page = defaultLinkTo($page, $item['uri']);\n\n        $article = $page->find('article', 0);\n\n        // author\n        $author = $article->find('.author-link > span', 0);\n        if ($author) {\n            $item['author'] = $author->text();\n        }\n\n        $teaser = $article->find('.teaser-text', 0);\n        if (!is_null($teaser)) {\n            $item['content'] = $teaser->outertext();\n        } else {\n            $item['content'] = '';\n        }\n\n        // remove unneeded elements\n        foreach (\n            $article->find(\n                'header, .advertisement, [data-tracking-name=\"sharing-icons-inline\"], a.external-link > svg, picture > source, .vjs-wrapper, .dw-widget, footer'\n            ) as $bad\n        ) {\n            $bad->remove();\n        }\n        // reload html as remove() is buggy\n        $article = str_get_html($article->outertext());\n\n        // remove width and height values from img tags\n        foreach ($article->find('img') as $img) {\n            $img->width = null;\n            $img->height = null;\n        }\n\n        // Remove inline SVG icons that are not part of the article content\n        foreach ($article->find('svg') as $svg) {\n            $svg->outertext = '';\n        }\n\n        // replace lazy-loaded images\n        foreach ($article->find('figure.placeholder-image') as $figure) {\n            $img = $figure->find('img', 0);\n            $img->src = str_replace('${formatId}', '906', $img->getAttribute('data-url'));\n            $img->style = null;\n        }\n\n        $item['content'] .= $article->save();\n\n        return $item;\n    }\n\n    // https://www.php.net/manual/en/function.parse-url.php#106731\n    private function unparseUrl($parsed_url)\n    {\n        $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : '';\n        $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';\n        $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';\n        $user = isset($parsed_url['user']) ? $parsed_url['user'] : '';\n        $pass = isset($parsed_url['pass']) ? $parsed_url['pass'] : '';\n        $pass = ($user || $pass) ? \"$pass@\" : '';\n        $path = isset($parsed_url['path']) ? $parsed_url['path'] : '';\n        $query = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : '';\n        $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : '';\n        return \"$scheme$user$pass$host$port$path$query$fragment\";\n    }\n}\n"
  },
  {
    "path": "bridges/DeutscherAeroClubBridge.php",
    "content": "<?php\n\nclass DeutscherAeroClubBridge extends XPathAbstract\n{\n    const NAME = 'Deutscher Aero Club';\n    const URI = 'https://www.daec.de/news/';\n    const DESCRIPTION = 'News aus Luftsport und Dachverband';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://www.daec.de/news/';\n    const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"icon\"][1]/@href';\n    const XPATH_EXPRESSION_ITEM = '//div[contains(@class, \"news-list-view\")]/div[contains(@class, \"article\")]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/span[@itemprop=\"headline\"]';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/div[@itemprop=\"description\"]/p';\n    const XPATH_EXPRESSION_ITEM_URI = './/div[@class=\"news-header\"]//a/@href';\n    //const XPATH_EXPRESSION_ITEM_AUTHOR = './/';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/time/@datetime';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@src';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n\n    protected function formatItemTimestamp($value)\n    {\n        $dti = DateTimeImmutable::createFromFormat('Y-m-d', $value);\n        $dti = $dti->setTime(0, 0, 0);\n        return $dti->getTimestamp();\n    }\n}\n\n"
  },
  {
    "path": "bridges/DevToBridge.php",
    "content": "<?php\n\nclass DevToBridge extends BridgeAbstract\n{\n    const CONTEXT_BY_TAG = 'By tag';\n    const CONTEXT_BY_USER = 'By user';\n\n    const NAME = 'dev.to';\n    const URI = 'https://dev.to';\n    const DESCRIPTION = 'Returns feeds for tags';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 10800; // 15 min.\n\n    const PARAMETERS = [\n        self::CONTEXT_BY_TAG => [\n            'tag' => [\n                'name' => 'Tag',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert your tag',\n                'exampleValue' => 'python'\n            ],\n            'full' => [\n                'name' => 'Full article',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Enable to receive the full article for each item'\n            ]\n            ],\n        self::CONTEXT_BY_USER => [\n            'user' => [\n                'name' => 'User',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert your username',\n                'exampleValue' => 'n3wt0n'\n            ],\n            'full' => [\n                'name' => 'Full article',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Enable to receive the full article for each item'\n            ]\n        ]\n    ];\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_BY_TAG:\n                if ($tag = $this->getInput('tag')) {\n                    return static::URI . '/t/' . urlencode($tag);\n                }\n                break;\n            case self::CONTEXT_BY_USER:\n                if ($user = $this->getInput('user')) {\n                    return static::URI . '/' . urlencode($user);\n                }\n                break;\n        }\n\n        return parent::getURI();\n    }\n\n    public function getIcon()\n    {\n        return 'https://practicaldev-herokuapp-com.freetls.fastly.net/assets/\napple-icon-5c6fa9f2bce280428589c6195b7f1924206a53b782b371cfe2d02da932c8c173.png';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI());\n\n        $html = defaultLinkTo($html, static::URI);\n\n        $articles = $html->find('div.crayons-story')\n            or throwServerException('Could not find articles!');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $item['uri'] = $article->find('a[id*=article-link]', 0)->href;\n            $item['title'] = $article->find('h2 > a', 0)->plaintext;\n\n            $item['timestamp'] = $article->find('time', 0)->datetime;\n            $item['author'] = $article->find('a.crayons-story__secondary.fw-medium', 0)->plaintext;\n\n            // Profile image\n            $item['enclosures'] = [$article->find('img', 0)->src];\n\n            if ($this->getInput('full')) {\n                $fullArticle = $this->getFullArticle($item['uri']);\n                $item['content'] = <<<EOD\n<p>{$fullArticle}</p>\nEOD;\n            } else {\n                $item['content'] = <<<EOD\n<img src=\"{$item['enclosures'][0]}\" alt=\"{$item['author']}\">\n<p>{$item['title']}</p>\nEOD;\n            }\n\n            // categories\n            foreach ($article->find('a.crayons-tag') as $tag) {\n                $item['categories'][] = str_replace('#', '', $tag->plaintext);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('tag'))) {\n            return ucfirst($this->getInput('tag')) . ' - dev.to';\n        }\n\n        return parent::getName();\n    }\n\n    private function getFullArticle($url)\n    {\n        $html = getSimpleHTMLDOMCached($url);\n\n        $html = defaultLinkTo($html, static::URI);\n\n        if ($html->find('div.crayons-article__cover', 0)) {\n            return $html->find('div.crayons-article__cover', 0) . $html->find('[id=\"article-body\"]', 0);\n        }\n\n        return $html->find('[id=\"article-body\"]', 0);\n    }\n}\n"
  },
  {
    "path": "bridges/DeveloppezDotComBridge.php",
    "content": "<?php\n\nclass DeveloppezDotComBridge extends FeedExpander\n{\n    const MAINTAINER = 'Binnette';\n    const NAME = 'Developpez.com Actus (FR)';\n    const URI = 'https://www.developpez.com/';\n    const DOMAIN = '.developpez.com/';\n    const RSS_URL = 'index/rss';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns complete posts from developpez.com';\n    // Encodings used by Developpez.com in their articles body\n    const ENCONDINGS = ['Windows-1252', 'UTF-8'];\n    const PARAMETERS = [\n        [\n            'limit' => [\n                'name' => 'Max items',\n                'type' => 'number',\n                'defaultValue' => 5,\n            ],\n            // list of the differents RSS availables\n            'domain' => [\n                'type' => 'list',\n                'name' => 'Domaine',\n                'title' => 'Chosissez un sous-domaine',\n                'values' => [\n                    '= Domaine principal =' => 'www',\n                    '4d' => '4d',\n                    'abbyy' => 'abbyy',\n                    'access' => 'access',\n                    'agile' => 'agile',\n                    'ajax' => 'ajax',\n                    'algo' => 'algo',\n                    'alm' => 'alm',\n                    'android' => 'android',\n                    'apache' => 'apache',\n                    'applications' => 'applications',\n                    'arduino' => 'arduino',\n                    'asm' => 'asm',\n                    'asp' => 'asp',\n                    'aspose' => 'aspose',\n                    'bacasable' => 'bacasable',\n                    'big-data' => 'big-data',\n                    'bpm' => 'bpm',\n                    'bsd' => 'bsd',\n                    'business-intelligence' => 'business-intelligence',\n                    'c' => 'c',\n                    'cloud-computing' => 'cloud-computing',\n                    'club' => 'club',\n                    'cms' => 'cms',\n                    'cpp' => 'cpp',\n                    'crm' => 'crm',\n                    'css' => 'css',\n                    'd' => 'd',\n                    'dart' => 'dart',\n                    'data-science' => 'data-science',\n                    'db2' => 'db2',\n                    'delphi' => 'delphi',\n                    'dotnet' => 'dotnet',\n                    'droit' => 'droit',\n                    'eclipse' => 'eclipse',\n                    'edi' => 'edi',\n                    'embarque' => 'embarque',\n                    'emploi' => 'emploi',\n                    'etudes' => 'etudes',\n                    'excel' => 'excel',\n                    'firebird' => 'firebird',\n                    'flash' => 'flash',\n                    'go' => 'go',\n                    'green-it' => 'green-it',\n                    'gtk' => 'gtk',\n                    'hardware' => 'hardware',\n                    'hpc' => 'hpc',\n                    'humour' => 'humour',\n                    'ibmcloud' => 'ibmcloud',\n                    'intelligence-artificielle' => 'intelligence-artificielle',\n                    'interbase' => 'interbase',\n                    'ios' => 'ios',\n                    'java' => 'java',\n                    'javascript' => 'javascript',\n                    'javaweb' => 'javaweb',\n                    'jetbrains' => 'jetbrains',\n                    'jeux' => 'jeux',\n                    'kotlin' => 'kotlin',\n                    'labview' => 'labview',\n                    'laravel' => 'laravel',\n                    'latex' => 'latex',\n                    'lazarus' => 'lazarus',\n                    'linux' => 'linux',\n                    'mac' => 'mac',\n                    'matlab' => 'matlab',\n                    'megaoffice' => 'megaoffice',\n                    'merise' => 'merise',\n                    'microsoft' => 'microsoft',\n                    'mobiles' => 'mobiles',\n                    'mongodb' => 'mongodb',\n                    'mysql' => 'mysql',\n                    'netbeans' => 'netbeans',\n                    'nodejs' => 'nodejs',\n                    'nosql' => 'nosql',\n                    'objective-c' => 'objective-c',\n                    'office' => 'office',\n                    'open-source' => 'open-source',\n                    'openoffice-libreoffice' => 'openoffice-libreoffice',\n                    'oracle' => 'oracle',\n                    'outlook' => 'outlook',\n                    'pascal' => 'pascal',\n                    'perl' => 'perl',\n                    'php' => 'php',\n                    'portail-emploi' => 'portail-emploi',\n                    'portail-projets' => 'portail-projets',\n                    'postgresql' => 'postgresql',\n                    'powerpoint' => 'powerpoint',\n                    'preprod-emploi' => 'preprod-emploi',\n                    'programmation' => 'programmation',\n                    'project' => 'project',\n                    'purebasic' => 'purebasic',\n                    'pyqt' => 'pyqt',\n                    'python' => 'python',\n                    'qt-creator' => 'qt-creator',\n                    'qt' => 'qt',\n                    'r' => 'r',\n                    'raspberry-pi' => 'raspberry-pi',\n                    'reseau' => 'reseau',\n                    'ruby' => 'ruby',\n                    'rust' => 'rust',\n                    'sap' => 'sap',\n                    'sas' => 'sas',\n                    'scilab' => 'scilab',\n                    'securite' => 'securite',\n                    'sgbd' => 'sgbd',\n                    'sharepoint' => 'sharepoint',\n                    'solutions-entreprise' => 'solutions-entreprise',\n                    'spring' => 'spring',\n                    'sqlserver' => 'sqlserver',\n                    'stages' => 'stages',\n                    'supervision' => 'supervision',\n                    'swift' => 'swift',\n                    'sybase' => 'sybase',\n                    'symfony' => 'symfony',\n                    'systeme' => 'systeme',\n                    'talend' => 'talend',\n                    'typescript' => 'typescript',\n                    'uml' => 'uml',\n                    'unix' => 'unix',\n                    'vb' => 'vb',\n                    'vba' => 'vba',\n                    'virtualisation' => 'virtualisation',\n                    'visualstudio' => 'visualstudio',\n                    'web-semantique' => 'web-semantique',\n                    'web' => 'web',\n                    'webmarketing' => 'webmarketing',\n                    'wind' => 'wind',\n                    'windows-azure' => 'windows-azure',\n                    'windows' => 'windows',\n                    'windowsphone' => 'windowsphone',\n                    'word' => 'word',\n                    'xhtml' => 'xhtml',\n                    'xml' => 'xml',\n                    'zend-framework' => 'zend-framework'\n                ],\n            ]\n        ]\n    ];\n\n    /**\n     * Grabs the RSS item from Developpez.com\n     */\n    public function collectData()\n    {\n        $url = $this->getRssUrl();\n        $this->collectExpandableDatas($url, 20);\n    }\n\n    /**\n     * Parse the content of every RSS item. And will try to get the full article\n     * pointed by the item URL intead of the default abstract.\n     */\n    protected function parseItem(array $item)\n    {\n        if (count($this->items) >= $this->getInput('limit')) {\n            return null;\n        }\n\n        // There is a bug in Developpez RSS, coma are writtent as '~?' in the\n        // title, so I have to fix it manually\n        $item['title'] = $this->fixComaInTitle($item['title']);\n\n        // We get the content of the full article behind the RSS item URL\n        $articleHTMLContent = getSimpleHTMLDOMCached($item['uri']);\n\n        // Here we call our custom parser\n        $fullText = $this->extractFullText($articleHTMLContent);\n        if (!is_null($fullText)) {\n            // if we manage to parse the page behind the url of the RSS item\n            // then we set it as the new content. Otherwise we keep the default\n            // content to avoid RSS Bridge to return an empty item\n            $item['content'] = $fullText;\n        }\n\n        // Now we will attach video url in item\n        $videosUrl = $this->getAllVideoUrl($articleHTMLContent);\n        if (!empty($videosUrl)) {\n            $item['enclosures'] = array_merge($item['enclosures'], $videosUrl);\n        }\n\n        // Now we can look for the blog writer/creator\n        $author = $articleHTMLContent->find('[itemprop=\"creator\"]', 0);\n        if (!empty($author)) {\n            $item['author'] = $author->outertext;\n        }\n\n        return $item;\n    }\n\n    /**\n     * Return the RSS url for selected domain\n     */\n    private function getRssUrl()\n    {\n        $domain = $this->getInput('domain');\n        if (!empty($domain)) {\n            return 'https://' . $domain . self::DOMAIN . self::RSS_URL;\n        }\n\n        return self::URI . self::RSS_URL;\n    }\n\n    /**\n     * Replace '~?' by a proper coma ','\n     */\n    private function fixComaInTitle($txt)\n    {\n        return str_replace('~?', ',', $txt);\n    }\n\n    /**\n     * Return the full article pointed by the url in the RSS item\n     * Since Developpez.com only provides a short abstract of the article, we\n     * use the url to retrieve the complete article and return it as the content\n     */\n    private function extractFullText($articleHTMLContent)\n    {\n        // All blog entry contains a div with the class 'content'. This div\n        // contains the complete blog article. But the RSS can also return\n        // announcement and not a blog article. So the next if, should take\n        // care of the \"non blog\" entry\n        $divArticleEntry = $articleHTMLContent->find('div.content', 0);\n        if (is_null($divArticleEntry)) {\n            // Didn't find the div with class content. It is probably not a blog\n            // entry. It is probably just an announcement for an ebook, a PDF,\n            // etc. So we can use the default RSS item content.\n            return null;\n        }\n\n        // The following code is a bit hacky, but I really manage to get the\n        // full content of articles without any encoding issues. What is very\n        // weird and ugly in Developpez.com is the fact the some paragraphs of\n        // the article will be encoded as UTF-8 and some other paragraphs will\n        // be encoded as Windows-1252. So we can NOT decode the full article\n        // with only one encoding. We have to check every paragraph and\n        // determine its encoding\n\n        // This contains all the 'paragraphs' of the article. It includes the\n        // pictures, the text and the links at the bottom of the article\n        $paragraphs = $divArticleEntry->nodes;\n        // This will store the complete decoded content\n        $fullText = '';\n\n        // For each paragraph, we will identify the encoding, then decode it\n        // and finally store the decoded content in $text\n        foreach ($paragraphs as $paragraph) {\n            // We have to recreate a new DOM document from the current node\n            // otherwise the find function will look in the complet article and\n            // not only in the current paragraph. This is an ugly behavior of\n            // the library Simple HTML DOM Parser...\n            $html = str_get_html($paragraph->outertext);\n            $fullText .= $this->decodeParagraph($html);\n        }\n\n        // Finally we return the full 'well' enconded content of the article\n        return $fullText;\n    }\n\n    /**\n     *\n     */\n    private function decodeParagraph($p)\n    {\n        // First we check if this paragraph is a video\n        $videoUrl = $this->getVideoUrl($p);\n        if (!empty($videoUrl)) {\n            // If this is a video, we just return a link to the video\n            // &#128250; => 🎞️\n            return  '<p>\n\t\t\t\t\t\t<b>&#128250; <a href=\"' . $videoUrl . '\">Voir la vidéo</a></b>\n\t\t\t\t\t</p>';\n        }\n\n        // We take outertext to get the complete paragraph not only the text\n        // inside it. That way we still graph block <img> and so on.\n        $pTxt = $p->outertext;\n        // This will store the decoded text if we manage to decode it\n        $decodedTxt = '';\n\n        // This is the only way to properly decode each paragraph. I tried\n        // many stuffs but this is the only working way I found.\n        foreach (self::ENCONDINGS as $enc) {\n            // We check the encoding of the current paragraph\n            if (mb_check_encoding($pTxt, $enc)) {\n                // If the encoding is well recognized, we can convert from\n                // this encoding to UTF-8\n                $decodedTxt = iconv($enc, 'UTF-8', $pTxt);\n            }\n        }\n\n        // We should not trim the strings to avoid the <a> to be glued to the\n        // text like: the software<a href=\"...\">started</a>to...\n        if (!empty($decodedTxt)) {\n            // We manage to decode the text, so we take the decoded version\n            return $this->formatParagraph($decodedTxt);\n        } else {\n            // Otherwise we take the non decoded version and hope it will\n            // be displayed not too ugly in the fulltext content\n            return $this->formatParagraph($pTxt);\n        }\n    }\n\n    /**\n     * Return true in $txt is a HTML tag and not plain text\n     */\n    private function isHtmlTagNotTxt($txt)\n    {\n        if ($txt === '') {\n            return false;\n        }\n        $html = str_get_html($txt);\n        return $html && $html->root && count($html->root->children) > 0;\n    }\n\n    /**\n     * Will add a space before paragraph when needed\n     */\n    private function formatParagraph($txt)\n    {\n        // If the paragraph is an html tag, we add a space before\n        if ($this->isHtmlTagNotTxt($txt)) {\n            // the first element is an html tag and not a text, so we can add a\n            // space before it\n            return ' ' . $txt;\n        }\n        // If the text start with word (not punctation), we had a space\n        $pattern = '/^\\w/';\n        if (preg_match($pattern, $txt)) {\n            return ' ' . $txt;\n        }\n        return $txt;\n    }\n\n    /**\n     * Retrieve all video url in the article\n     */\n    private function getAllVideoUrl($item)\n    {\n        // Array of video url\n        $url = [];\n\n        // Developpez use a div with the class video-container\n        $divsVideo = $item->find('div.video-container');\n        if (empty($divsVideo)) {\n            return $url;\n        }\n\n        // get the url of the video\n        foreach ($divsVideo as $div) {\n            $html = str_get_html($div->outertext);\n            $url[] = $this->getVideoUrl($html);\n        }\n\n        return $url;\n    }\n\n    /**\n     * Retrieve URL video. We have to check for the src of an iframe\n     * Work for Youtube. Will have to test for other video platform\n     */\n    private function getVideoUrl($p)\n    {\n        $divVideo = $p->find('div.video-container', 0);\n        if (empty($divVideo)) {\n            return null;\n        }\n        $iframe = $divVideo->find('iframe', 0);\n        if (empty($iframe)) {\n            return null;\n        }\n        $src = trim($iframe->getAttribute('src'));\n        if (empty($src)) {\n            return null;\n        }\n        if (str_starts_with($src, '//')) {\n            $src = 'https:' . $src;\n        }\n        return $src;\n    }\n}\n"
  },
  {
    "path": "bridges/DiarioDeNoticiasBridge.php",
    "content": "<?php\n\nclass DiarioDeNoticiasBridge extends BridgeAbstract\n{\n    const NAME = 'Diário de Notícias (PT)';\n    const URI = 'https://dn.pt';\n    const DESCRIPTION = 'Diário de Notícias (DN.PT)';\n    const MAINTAINER = 'somini';\n    const PARAMETERS = [\n        'Tag' => [\n            'n' => [\n                'name' => 'Tag Name',\n                'required' => true,\n                'exampleValue' => 'rogerio-casanova',\n            ]\n        ]\n    ];\n\n    const MONPT = [\n        'jan',\n        'fev',\n        'mar',\n        'abr',\n        'mai',\n        'jun',\n        'jul',\n        'ago',\n        'set',\n        'out',\n        'nov',\n        'dez',\n    ];\n\n    public function getIcon()\n    {\n        return 'https://static.globalnoticias.pt/dn/common/images/favicons/favicon-128.png';\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Tag':\n                $name = self::NAME . ' | Tag | ' . $this->getInput('n');\n                break;\n            default:\n                $name = self::NAME;\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Tag':\n                $url = self::URI . '/tag/' . $this->getInput('n') . '.html';\n                break;\n            default:\n                $url = self::URI;\n        }\n        return $url;\n    }\n\n    public function collectData()\n    {\n        $archives = $this->getURI();\n        $html = getSimpleHTMLDOMCached($archives);\n\n        foreach ($html->find('article') as $element) {\n            $item = [];\n\n            $title = $element->find('.t-am-title', 0);\n            $link = $element->find('a.t-am-text', 0);\n\n            $item['title'] = $title->plaintext;\n            $item['uri'] = self::URI . $link->href;\n\n            $snippet = $element->find('.t-am-lead', 0);\n            if ($snippet) {\n                $item['content'] = $snippet->plaintext;\n            }\n            preg_match('|edicao-do-dia\\\\/(?P<day>\\d\\d)-(?P<monpt>\\w\\w\\w)-(?P<year>\\d\\d\\d\\d)|', $link->href, $d);\n            if ($d) {\n                $item['timestamp'] = sprintf('%s-%s-%s', $d['year'], array_search($d['monpt'], self::MONPT) + 1, $d['day']);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DiarioDoAlentejoBridge.php",
    "content": "<?php\n\nclass DiarioDoAlentejoBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'somini';\n    const NAME = 'Diário do Alentejo';\n    const URI = 'https://www.diariodoalentejo.pt';\n    const DESCRIPTION = 'Semanário Regionalista Independente';\n    const CACHE_TIMEOUT = 28800; // 8h\n\n    /* This is used to hack around obtaining a timestamp. It's just a list of Month names in Portuguese ... */\n    const PT_MONTH_NAMES = [\n        'janeiro',\n        'fevereiro',\n        'março',\n        'abril',\n        'maio',\n        'junho',\n        'julho',\n        'agosto',\n        'setembro',\n        'outubro',\n        'novembro',\n        'dezembro'];\n\n    public function getIcon()\n    {\n        return 'https://www.diariodoalentejo.pt/images/favicon/apple-touch-icon.png';\n    }\n\n    public function collectData()\n    {\n        /* This is slow as molasses (>30s!), keep the cache timeout high to avoid killing the host */\n        $html = getSimpleHTMLDOMCached($this->getURI() . '/pt/noticias-listagem.aspx');\n\n        foreach ($html->find('.list_news .item') as $element) {\n            $item = [];\n\n            $item_link = $element->find('.body h2.title a', 0);\n            /* Another broken URL, see also `bridges/ComboiosDePortugalBridge.php` */\n            $item['uri'] = self::URI . implode('/', array_map('urlencode', explode('/', $item_link->href)));\n            $item['title'] = $item_link->innertext;\n\n            $item['timestamp'] = str_ireplace(\n                array_map(function ($name) {\n                    return ' ' . $name . ' ';\n                }, self::PT_MONTH_NAMES),\n                array_map(function ($num) {\n                    return sprintf('-%02d-', $num);\n                }, range(1, count(self::PT_MONTH_NAMES))),\n                $element->find('span.date', 0)->innertext\n            );\n\n            /* Fix the Image URL */\n            $item_image = $element->find('img.thumb', 0);\n            $item_image->src = preg_replace('/.*&img=([^&]+).*/', '\\1', $item_image->getAttribute('data-src'));\n\n            /* Content: */\n            /* - Image */\n            /* - Category */\n            $content = $item_image .\n                '<center>' . $element->find('a.category', 0) . '</center>';\n            $item['content'] = defaultLinkTo($content, self::URI);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DiceBridge.php",
    "content": "<?php\n\nclass DiceBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'rogerdc';\n    const NAME = 'Dice Unofficial RSS';\n    const URI = 'https://www.dice.com/';\n    const DESCRIPTION = 'The Unofficial Dice RSS';\n    // const CACHE_TIMEOUT = 86400; // 1 day\n\n    const PARAMETERS = [[\n        'for_one' => [\n            'name' => 'With at least one of the words',\n            'required' => false,\n        ],\n        'for_all' => [\n            'name' => 'With all of the words',\n            'required' => false,\n        ],\n        'for_exact' => [\n            'name' => 'With the exact phrase',\n            'required' => false,\n        ],\n        'for_none' => [\n            'name' => 'With none of these words',\n            'required' => false,\n        ],\n        'for_jt' => [\n            'name' => 'Within job title',\n            'required' => false,\n        ],\n        'for_com' => [\n            'name' => 'Within company name',\n            'required' => false,\n        ],\n        'for_loc' => [\n            'name' => 'City, State, or ZIP code',\n            'required' => false,\n        ],\n        'radius' => [\n            'name' => 'Radius in miles',\n            'type' => 'list',\n            'required' => false,\n            'values' => [\n                'Exact Location' => 'El',\n                'Within 5 miles' => '5',\n                'Within 10 miles' => '10',\n                'Within 20 miles' => '20',\n                'Within 30 miles' => '0',\n                'Within 40 miles' => '40',\n                'Within 50 miles' => '50',\n                'Within 75 miles' => '75',\n                'Within 100 miles' => '100',\n            ],\n            'defaultValue' => '0',\n        ],\n        'jtype' => [\n            'name' => 'Job type',\n            'type' => 'list',\n            'required' => false,\n            'values' => [\n                'Full-Time' => 'Full Time',\n                'Part-Time' => 'Part Time',\n                'Contract - Independent' => 'Contract Independent',\n                'Contract - W2' => 'Contract W2',\n                'Contract to Hire - Independent' => 'C2H Independent',\n                'Contract to Hire - W2' => 'C2H W2',\n                'Third Party - Contract - Corp-to-Corp' => 'Contract Corp-To-Corp',\n                'Third Party - Contract to Hire - Corp-to-Corp' => 'C2H Corp-To-Corp',\n            ],\n            'defaultValue' => 'Full Time',\n        ],\n        'telecommute' => [\n            'name' => 'Telecommute',\n            'type' => 'checkbox',\n        ],\n    ]];\n\n    public function getIcon()\n    {\n        return 'https://assets.dice.com/techpro/img/favicons/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $uri = 'https://www.dice.com/jobs/advancedResult.html';\n        $uri .= '?for_one=' . urlencode($this->getInput('for_one'));\n        $uri .= '&for_all=' . urlencode($this->getInput('for_all'));\n        $uri .= '&for_exact=' . urlencode($this->getInput('for_exact'));\n        $uri .= '&for_none=' . urlencode($this->getInput('for_none'));\n        $uri .= '&for_jt=' . urlencode($this->getInput('for_jt'));\n        $uri .= '&for_com=' . urlencode($this->getInput('for_com'));\n        $uri .= '&for_loc=' . urlencode($this->getInput('for_loc'));\n        if ($this->getInput('jtype')) {\n            $uri .= '&jtype=' . urlencode($this->getInput('jtype'));\n        }\n        $uri .= '&sort=date&limit=100';\n        $uri .= '&radius=' . urlencode($this->getInput('radius'));\n        if ($this->getInput('telecommute')) {\n            $uri .= '&telecommute=true';\n        }\n\n        $html = getSimpleHTMLDOM($uri);\n        foreach ($html->find('div.complete-serp-result-div') as $element) {\n            $item = [];\n            // Title\n            $masterLink = $element->find('a[id^=position]', 0);\n            $item['title'] = $masterLink->title;\n            // URL\n            $uri = $masterLink->href;\n            // $uri = substr($uri, 0, strrpos($uri, '?'));\n            $item['uri'] = substr($uri, 0, strrpos($uri, '?'));\n            // ID\n            $item['id'] = $masterLink->value;\n            // Image\n            $image = $element->find('img', 0);\n            if ($image) {\n                $item['image'] = $image->getAttribute('src');\n            }\n            // Content\n            $shortdesc = $element->find('.shortdesc', '0');\n            $shortdesc = ($shortdesc) ? $shortdesc->innertext : '';\n            $item['content'] = $shortdesc;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DiscogsBridge.php",
    "content": "<?php\n\nclass DiscogsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'DiscogsBridge';\n    const URI = 'https://www.discogs.com/';\n    const DESCRIPTION = 'Returns releases from discogs';\n    const PARAMETERS = [\n        'Artist Releases' => [\n            'artistid' => [\n                'name' => 'Artist ID',\n                'type' => 'number',\n                'required' => true,\n                'exampleValue' => '28104',\n                'title' => 'Only the ID from an artist page. EG /artist/28104-Aesop-Rock is 28104'\n            ],\n            'image' => [\n                'name' => 'Include Image',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n                'title' => 'Whether to include image (if bridge is configured with a personal access token)',\n            ]\n        ],\n        'Label Releases' => [\n            'labelid' => [\n                'name' => 'Label ID',\n                'type' => 'number',\n                'required' => true,\n                'exampleValue' => '8201',\n                'title' => 'Only the ID from a label page. EG /label/8201-Rhymesayers-Entertainment is 8201'\n            ],\n            'image' => [\n                'name' => 'Include Image',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n                'title' => 'Whether to include image (if bridge is configured with a personal access token)',\n            ]\n        ],\n        'User Wantlist' => [\n            'username_wantlist' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'TheBlindMaster',\n            ],\n            'image' => [\n                'name' => 'Include Image',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n                'title' => 'Whether to include image (if bridge is configured with a personal access token)',\n            ]\n        ],\n        'User Folder' => [\n            'username_folder' => [\n                'name' => 'Username',\n                'type' => 'text',\n            ],\n            'folderid' => [\n                'name' => 'Folder ID',\n                'type' => 'number',\n            ],\n            'image' => [\n                'name' => 'Include Image',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n                'title' => 'Whether to include image (if bridge is configured with a personal access token)',\n            ]\n        ],\n    ];\n    const CONFIGURATION = [\n        /**\n         * When a personal access token is provided, Discogs' API will\n         * return images as part of artist and label information.\n         *\n         * @see https://www.discogs.com/settings/developers\n         */\n        'personal_access_token' => [\n            'required' => false,\n        ],\n    ];\n\n    public function collectData()\n    {\n        $headers = [];\n\n        if ($this->getOption('personal_access_token')) {\n            $headers = ['Authorization: Discogs token=' . $this->getOption('personal_access_token')];\n        }\n\n        if (!empty($this->getInput('artistid')) || !empty($this->getInput('labelid'))) {\n            if (!empty($this->getInput('artistid'))) {\n                $url = 'https://api.discogs.com/artists/'\n                . $this->getInput('artistid')\n                . '/releases?sort=year&sort_order=desc';\n                $data = getContents($url, $headers);\n            } elseif (!empty($this->getInput('labelid'))) {\n                $url = 'https://api.discogs.com/labels/'\n                . $this->getInput('labelid')\n                . '/releases?sort=year&sort_order=desc';\n                $data = getContents($url, $headers);\n            }\n\n            $jsonData = json_decode($data, true);\n\n            foreach ($jsonData['releases'] as $release) {\n                $item = [];\n                $item['author'] = $release['artist'];\n                $item['title'] = $release['title'];\n                $item['id'] = $release['id'];\n                $resId = array_key_exists('main_release', $release) ? $release['main_release'] : $release['id'];\n                $item['uri'] = self::URI . $this->getInput('artistid') . '/release/' . $resId;\n\n                if (isset($release['year'])) {\n                    $item['timestamp'] = DateTime::createFromFormat('Y', $release['year'])->getTimestamp();\n                }\n\n                $item['content'] = $item['author'] . ' - ' . $item['title'];\n\n                if (isset($release['thumb']) && $this->getInput('image') === true) {\n                    $item['content'] = sprintf(\n                        '<img src=\"%s\"/><br/><br/>%s',\n                        $release['thumb'],\n                        $item['content'],\n                    );\n                }\n\n                $this->items[] = $item;\n            }\n        } elseif (!empty($this->getInput('username_wantlist')) || !empty($this->getInput('username_folder'))) {\n            if (!empty($this->getInput('username_wantlist'))) {\n                $url = 'https://api.discogs.com/users/'\n                . $this->getInput('username_wantlist')\n                . '/wants?sort=added&sort_order=desc';\n                $data = getContents($url, $headers);\n                $jsonData = json_decode($data, true)['wants'];\n            } elseif (!empty($this->getInput('username_folder'))) {\n                $url = 'https://api.discogs.com/users/'\n                . $this->getInput('username_folder')\n                . '/collection/folders/'\n                . $this->getInput('folderid')\n                . '/releases?sort=added&sort_order=desc';\n                $data = getContents($url, $headers);\n                $jsonData = json_decode($data, true)['releases'];\n            }\n            foreach ($jsonData as $element) {\n                $infos = $element['basic_information'];\n                $item = [];\n                $item['title'] = $infos['title'];\n                $item['author'] = $infos['artists'][0]['name'];\n                $item['id'] = $infos['artists'][0]['id'];\n                $item['uri'] = self::URI . $infos['artists'][0]['id'] . '/release/' . $infos['id'];\n                $item['timestamp'] = strtotime($element['date_added']);\n                $item['content'] = $item['author'] . ' - ' . $item['title'];\n\n                if (isset($infos['thumb']) && $this->getInput('image') === true) {\n                    $item['content'] = sprintf(\n                        '<img src=\"%s\"/><br/><br/>%s',\n                        $infos['thumb'],\n                        $item['content'],\n                    );\n                }\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        return self::URI;\n    }\n\n    public function getName()\n    {\n        return static::NAME;\n    }\n}\n"
  },
  {
    "path": "bridges/DjMagDotComBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass DjMagDotComBridge extends BridgeAbstract\n{\n    const NAME = 'DJMag.com News';\n    const URI = 'https://www.djmag.com/';\n    const DESCRIPTION = 'News from DJMag.com';\n    const MAINTAINER = 'skrollme';\n    const CACHE_TIMEOUT = 60 * 60; // 1 hours\n    const PARAMETERS = [[\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'title' => 'The number of news to get (max: 20)',\n                'defaultValue' => 10\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://djmag.com/sites/default/files/favicons/favicon-32x32.png?v=2024';\n    }\n\n    public function getURI()\n    {\n        return self::URI . 'news';\n    }\n\n    private function parseDateString($dateString)\n    {\n        // Expect formats like \"30 December 2025, 12:10\"\n        $dateString = trim($dateString);\n\n        // Try a strict parse first: day (no leading zero) monthname year, 24h:minute\n        $dt = DateTime::createFromFormat('j F Y, H:i', $dateString);\n        if ($dt instanceof DateTime) {\n            return $dt->getTimestamp();\n        }\n\n        // Try with leading zero day\n        $dt = DateTime::createFromFormat('d F Y, H:i', $dateString);\n        if ($dt instanceof DateTime) {\n            return $dt->getTimestamp();\n        }\n\n        // Fallback to strtotime which handles many human-readable formats\n        $ts = strtotime($dateString);\n        if ($ts !== false) {\n            return $ts;\n        }\n\n        return null;\n    }\n\n    private function fetchArticleDetails($uri, $image, $title)\n    {\n        $itemHtml = getSimpleHTMLDOM($uri);\n\n        $content = '<h2>' . $itemHtml->find('article div.article--standfirst p', 0)->plaintext . '</h2><br>';\n        $content .= '<img src=\"' . $image . '\" alt=\"' . htmlentities($title) . '\" /><p/>';\n        $content .= '<p>' . trim(nl2br(htmlentities($itemHtml->find('article div.content-column-wrap-oh > div > div.field--name-field-content > div', 0)->plaintext))) . '</p>';\n\n        $metaFields = $itemHtml->find('article div.pane-author-info', 1);\n        // contains a timestamp in a format like 30 December 2025, 12:10\n        $rawTimestamp = $metaFields->find('div', 1)->plaintext;\n        $timestamp = $this->parseDateString($rawTimestamp);\n\n        $author = trim($metaFields->find('div', 0)->plaintext);\n\n        return [$timestamp, $content, $author];\n    }\n\n    public function collectData()\n    {\n        $limit = max(0, min($this->getInput('limit'), 20));\n        $url = $this->getUri();\n\n        $mainHtml = getSimpleHTMLDOM($url);\n\n        // fetch first/latest news item separately as it is structured differently due to being featured\n        $firstNewsItemHtml = $mainHtml->find('div.attachment-before div.view-content', 0);\n        $title = trim($firstNewsItemHtml->find('h1 > a', 0)->plaintext);\n        $uri = self::URI . $firstNewsItemHtml->find('h1 > a', 0)->href;\n        $image = rtrim(self::URI, '/') . $firstNewsItemHtml->find('.teaser-media source', 0)->srcset;\n\n        list($timestamp, $content, $author) = $this->fetchArticleDetails($uri, $image, $title);\n\n        $this->items[] = [\n            'title' => $title,\n            'uri' => $uri,\n            'uid' => sha1($uri),\n            'thumbnail' => $image,\n            'content' => $content,\n            'timestamp' => $timestamp,\n            'author' => $author,\n            'categories' => ['NEWS'],\n            'enclosures' => [$image],\n        ];\n\n        // continue with the rest of the news items\n        foreach ($mainHtml->find('div#views-bootstrap-listing-news-page > div.row article') as $newsItem) {\n            if ($limit-- <= 0) {\n                break;\n            }\n\n            $title = trim($newsItem->find('h1 > a', 0)->plaintext);\n            $uri = self::URI . $newsItem->find('a', 0)->href;\n            $image = rtrim(self::URI, '/') . $newsItem->find('source', 0)->srcset;\n\n            list($timestamp, $content, $author) = $this->fetchArticleDetails($uri, $image, $title);\n\n            $this->items[] = [\n                'title' => $title,\n                'uri' => $uri,\n                'uid' => sha1($uri),\n                'thumbnail' => $image,\n                'content' => $content,\n                'timestamp' => $timestamp,\n                'author' => $author,\n                'categories' => ['NEWS'],\n                'enclosures' => [$image],\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DockerHubBridge.php",
    "content": "<?php\n\nclass DockerHubBridge extends BridgeAbstract\n{\n    const NAME = 'Docker Hub';\n    const URI = 'https://hub.docker.com';\n    const DESCRIPTION = 'Returns new images for a container';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'User Submitted Image' => [\n            'user' => [\n                'name' => 'User',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'rssbridge',\n            ],\n            'repo' => [\n                'name' => 'Repository',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'rss-bridge',\n            ],\n            'filter' => [\n                'name' => 'Filter tag',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => 'latest',\n            ]\n        ],\n        'Official Image' => [\n            'repo' => [\n                'name' => 'Repository',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'postgres',\n            ],\n            'filter' => [\n                'name' => 'Filter tag',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => 'alpine3.17',\n            ]\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    private $apiURL = 'https://hub.docker.com/v2/repositories/';\n    private $imageUrlRegex = '/hub\\.docker\\.com\\/r\\/([\\w]+)\\/([\\w-]+)\\/?/';\n    private $officialImageUrlRegex = '/hub\\.docker\\.com\\/_\\/([\\w-]+)\\/?/';\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // user submitted image\n        if (preg_match($this->imageUrlRegex, $url, $matches)) {\n            $params['context'] = 'User Submitted Image';\n            $params['user'] = $matches[1];\n            $params['repo'] = $matches[2];\n            return $params;\n        }\n\n        // official image\n        if (preg_match($this->officialImageUrlRegex, $url, $matches)) {\n            $params['context'] = 'Official Image';\n            $params['repo'] = $matches[1];\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function collectData()\n    {\n        $json = getContents($this->getApiUrl());\n\n        $data = json_decode($json, false);\n\n        foreach ($data->results as $result) {\n            $item = [];\n\n            $lastPushed = date('Y-m-d H:i:s', strtotime($result->tag_last_pushed));\n\n            $item['title'] = $result->name;\n            $item['uid'] = $result->id;\n            $item['uri'] = $this->getTagUrl($result->name);\n            $item['author'] = $result->last_updater_username;\n            $item['timestamp'] = $result->tag_last_pushed;\n            $item['content'] = <<<EOD\n<Strong>Tag</strong><br>\n<p>{$result->name}</p>\n<Strong>Last pushed</strong><br>\n<p>{$lastPushed}</p>\n<Strong>Images</strong><br>\n{$this->getImagesTable($result)}\nEOD;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n\n        if ($this->queriedContext === 'Official Image') {\n            $uri = self::URI . '/_/' . $this->getRepo();\n        }\n\n        if ($this->queriedContext === 'User Submitted Image') {\n            $uri = '/r/' . $this->getRepo();\n        }\n\n        if ($this->getInput('filter')) {\n            $uri .= '/tags/?&page=1&name=' . $this->getInput('filter');\n        }\n\n        return $uri;\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('repo')) {\n            $name = $this->getRepo();\n\n            if ($this->getInput('filter')) {\n                $name .= ':' . $this->getInput('filter');\n            }\n\n            return $name . ' - Docker Hub';\n        }\n\n        return parent::getName();\n    }\n\n    private function getRepo()\n    {\n        if ($this->queriedContext === 'Official Image') {\n            return $this->getInput('repo');\n        }\n\n        return $this->getInput('user') . '/' . $this->getInput('repo');\n    }\n\n    private function getApiUrl()\n    {\n        $url = '';\n\n        if ($this->queriedContext === 'Official Image') {\n            $url = $this->apiURL . 'library/' . $this->getRepo() . '/tags/?page_size=25&page=1';\n        }\n\n        if ($this->queriedContext === 'User Submitted Image') {\n            $url = $this->apiURL . $this->getRepo() . '/tags/?page_size=25&page=1';\n        }\n\n        if ($this->getInput('filter')) {\n            $url .= '&name=' . $this->getInput('filter');\n        }\n\n        return $url;\n    }\n\n    private function getLayerUrl($name, $digest)\n    {\n        if ($this->queriedContext === 'Official Image') {\n            return self::URI . '/layers/' . $this->getRepo() . '/library/' .\n                $this->getRepo() . '/' . $name . '/images/' . $digest;\n        }\n\n        return self::URI . '/layers/' . $this->getRepo() . '/' . $name . '/images/' . $digest;\n    }\n\n    private function getTagUrl($name)\n    {\n        $url = '';\n\n        if ($this->queriedContext === 'Official Image') {\n            $url = self::URI . '/_/' . $this->getRepo();\n        }\n\n        if ($this->queriedContext === 'User Submitted Image') {\n            $url = self::URI . '/r/' . $this->getRepo();\n        }\n\n        return $url . '/tags/?&name=' . $name;\n    }\n\n    private function getImagesTable($result)\n    {\n        $data = '';\n\n        foreach ($result->images as $image) {\n            $layersUrl = $this->getLayerUrl($result->name, $image->digest);\n            $id = $this->getShortDigestId($image->digest);\n            $size = format_bytes($image->size);\n            $data .= <<<EOD\n            <tr>\n                <td><a href=\"{$layersUrl}\">{$id}</a></td>\n                <td>{$image->os}/{$image->architecture}</td>\n                <td>{$size}</td>\n            </tr>\nEOD;\n        }\n\n        return <<<EOD\n<table style=\"width:400px;\">\n    <thead>\n        <tr style=\"text-align: left;\">\n            <th>Digest</th>\n            <th>OS/architecture</th>\n            <th>Compressed Size</th>\n        </tr>\n    </thead>\n    </tbody>\n        {$data}\n    </tbody>\n</table>\nEOD;\n    }\n\n    private function getShortDigestId($digest)\n    {\n        $parts = explode(':', $digest);\n        return substr($parts[1], 0, 12);\n    }\n}\n"
  },
  {
    "path": "bridges/DonnonsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Retourne les dons d'une recherche filtrée sur le site Donnons.org\n * Example: https://donnons.org/Sport/Ile-de-France\n */\nclass DonnonsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Binnette';\n    const NAME = 'Donnons.org';\n    const URI = 'https://donnons.org';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Retourne les dons depuis le site Donnons.org.';\n\n    const PARAMETERS = [\n        [\n            'q' => [\n                'name' => 'Url de recherche',\n                'required' => true,\n                'exampleValue' => '/Sport/Ile-de-France',\n                'pattern' => '\\/.*',\n                'title' => 'Faites une recherche sur le site. Puis copiez ici la fin de l’url. Doit commencer par /',\n            ],\n            'p' => [\n                'name' => 'Nombre de pages à scanner',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 5,\n                'title' => 'Indique le nombre de pages de donnons.org qui seront scannées'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $pages = $this->getInput('p');\n\n        for ($i = 1; $i <= $pages; $i++) {\n            $this->collectDataByPage($i);\n        }\n    }\n\n    private function collectDataByPage($page)\n    {\n        $uri = $this->getPageURI($page);\n\n        $dom = getSimpleHTMLDOM($uri);\n\n        $searchDiv = $dom->find('div[id=search]', 0);\n\n        if (! $searchDiv) {\n            return;\n        }\n\n        $elements = $searchDiv->find('a.lst-annonce');\n        foreach ($elements as $element) {\n            $item = [];\n\n            // Lien vers le don\n            $item['uri'] = self::URI . $element->href;\n            // Id de l'objet\n            $item['uid'] = $element->getAttribute('data-id');\n\n            // Grab info from json\n            $jsonString = $element->find('script', 0)->innertext;\n            $json = json_decode($jsonString, true);\n\n            $name = $json['name'];\n            $category = $json['category'];\n            $date = $json['availabilityStarts'];\n            $description = $json['description'];\n            $city = $json['availableAtOrFrom']['address']['addressLocality'];\n            $region = $json['availableAtOrFrom']['address']['addressRegion'];\n\n            // Grab info from HTML\n            $imageSrc = $element->find('img.ima-center', 0)->getAttribute('src');\n            // Use large image instead of small one\n            $imageSrc = str_replace('/xs/', '/lg/', $imageSrc);\n            $image = self::URI . $imageSrc;\n            $author = $element->find('div.avatar-holder', 0)->plaintext;\n\n            $content = '\n                <img style=\"margin-right:1em;\" src=\"' . $image . '\">\n                <div>\n                    <h1>' . $name . '</h1>\n                    <p>' . $description . '</p>\n                    <p>Lieu : <b>' . $city . '</b> - ' . $region . '</p>\n                    <p>Par : ' . $author . '</p>\n                    <p>Date : ' . $date . '</p>\n                </div>\n            ';\n\n            // Titre du don\n            $item['title'] = '[' . $category . '] ' . $name;\n            $item['timestamp'] = $date;\n            $item['author'] = $author;\n            $item['content'] = $content;\n            $item['enclosures'] = [$image];\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getPageURI($page)\n    {\n        $uri = $this->getURI();\n        $haveQueryParams = strpos($uri, '?') !== false;\n\n        if ($haveQueryParams) {\n            return $uri . '&page=' . $page;\n        } else {\n            return $uri . '?page=' . $page;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('q'))) {\n            return self::URI . $this->getInput('q');\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('q'))) {\n            return 'Donnons.org - ' . $this->getInput('q');\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/DoujinStyleBridge.php",
    "content": "<?php\n\nclass DoujinStyleBridge extends BridgeAbstract\n{\n    const NAME = 'DoujinStyle';\n    const URI = 'https://doujinstyle.com/';\n    const DESCRIPTION = 'Returns submissions from DoujinStyle';\n    const MAINTAINER = 'mrtnvgr';\n\n    // TODO: \"Games\" support\n\n    const PARAMETERS = [\n        'Most recent submissions' => [],\n        'Randomly selected items' => [],\n        'From search results' => [\n            'query' => [\n                'name' => 'Search query',\n                'required' => true,\n                'exampleValue' => 'FELT',\n            ],\n            'flac' => [\n                'name' => 'Include FLAC',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n            ],\n            'mp3' => [\n                'name' => 'Include MP3',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n            ],\n            'tta' => [\n                'name' => 'Include TTA',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n            ],\n            'opus' => [\n                'name' => 'Include Opus',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n            ],\n            'ogg' => [\n                'name' => 'Include OGG',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $html = defaultLinkTo($html, $this->getURI());\n\n        $submissions = $html->find('.gridBox .gridDetails');\n        foreach ($submissions as $submission) {\n            $item = [];\n\n            $item['uri'] = $submission->find('a', 0)->href;\n\n            $content = getSimpleHTMLDOM($item['uri']);\n            $content = defaultLinkTo($content, $this->getURI());\n\n            $title = $content->find('h2', 0)->plaintext;\n\n            $cover = $content->find('#imgClick a', 0);\n            if (is_null($cover)) {\n                $cover = $content->find('.coverWrap', 0)->src;\n            } else {\n                $cover = $cover->href;\n            }\n\n            $item['content'] = \"<img src='$cover'/>\";\n\n            $keys = [];\n            foreach ($content->find('.pageWrap .pageSpan1') as $key) {\n                $keys[] = $key->plaintext;\n            }\n\n            $values = $content->find('.pageWrap .pageSpan2');\n            $metadata = array_combine($keys, $values);\n\n            $format = 'Unknown';\n\n            foreach ($metadata as $key => $value) {\n                switch ($key) {\n                    case 'Artist':\n                        $artist = $value->find('a', 0)->plaintext;\n                        $item['title'] = \"$artist - $title\";\n                        $item['content'] .= \"<br>Artist: $artist\";\n                        break;\n                    case 'Tags:':\n                        $item['categories'] = [];\n                        foreach ($value->find('a') as $tag) {\n                            $tag = str_replace('&#45;', '-', $tag->plaintext);\n                            $item['categories'][] = $tag;\n                        }\n\n                        $item['content'] .= '<br>Tags: ' . join(', ', $item['categories']);\n                        break;\n                    case 'Format:':\n                        $item['content'] .= \"<br>Format: $value->plaintext\";\n                        break;\n                    case 'Date Added:':\n                        $item['timestamp'] = $value->plaintext;\n                        break;\n                    case 'Provided By:':\n                        $item['author'] = $value->find('a', 0)->plaintext;\n                        break;\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $url = self::URI;\n\n        switch ($this->queriedContext) {\n            case 'From search results':\n                $url .= '?p=search&type=blanket';\n                $url .= '&result=' . $this->getInput('query');\n\n                if ($this->getInput('flac') == 1) {\n                    $url .= '&format0=on';\n                }\n                if ($this->getInput('mp3') == 1) {\n                    $url .= '&format1=on';\n                }\n                if ($this->getInput('tta') == 1) {\n                    $url .= '&format2=on';\n                }\n                if ($this->getInput('opus') == 1) {\n                    $url .= '&format3=on';\n                }\n                if ($this->getInput('ogg') == 1) {\n                    $url .= '&format4=on';\n                }\n                break;\n            case 'Randomly selected items':\n                $url .= '?p=random';\n                break;\n        }\n\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/DribbbleBridge.php",
    "content": "<?php\n\nclass DribbbleBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'quentinus95';\n    const NAME = 'Dribbble popular shots';\n    const URI = 'https://dribbble.com';\n    const CACHE_TIMEOUT = 1800;\n    const DESCRIPTION = 'Returns the newest popular shots from Dribbble.';\n\n    public function getIcon()\n    {\n        return 'https://cdn.dribbble.com/assets/\nfavicon-63b2904a073c89b52b19aa08cebc16a154bcf83fee8ecc6439968b1e6db569c7.ico';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $data = $this->fetchData($html);\n\n        foreach ($html->find('li[id^=\"screenshot-\"]') as $shot) {\n            $item = [];\n\n            $additional_data = $this->findJsonForShot($shot, $data);\n            if ($additional_data === null) {\n                $item['uri'] = self::URI . $shot->find('a', 0)->href;\n                $item['title'] = $shot->find('.shot-title', 0)->plaintext;\n            } else {\n                $item['timestamp'] = strtotime($additional_data['published_at']);\n                $item['uri'] = self::URI . $additional_data['path'];\n                $item['title'] = $additional_data['title'];\n            }\n\n            $item['author'] = trim($shot->find('.user-information .display-name', 0)->plaintext);\n\n            $description = $shot->find('.comment', 0);\n            $item['content'] = $description === null ? '' : $description->plaintext;\n\n            $preview_path = $shot->find('figure img', 1)->attr['data-srcset'];\n            $item['content'] .= $this->getImageTag($preview_path, $item['title']);\n            $item['enclosures'] = [$this->getFullSizeImagePath($preview_path)];\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function fetchData($html)\n    {\n        $scripts = $html->find('script');\n\n        foreach ($scripts as $script) {\n            if (strpos($script->innertext, 'newestShots') !== false) {\n                // fix single quotes\n                $script->innertext = preg_replace('/\\'(.*)\\'(,?)$/im', '\"\\1\"\\2', $script->innertext);\n\n                // fix JavaScript JSON (why do they not adhere to the standard?)\n                $script->innertext = preg_replace('/^(\\s*)(\\w+):/im', '\\1\"\\2\":', $script->innertext);\n\n                // fix relative dates, so they are recognized by strtotime\n                $script->innertext = preg_replace('/\"about ([0-9]+ hours? ago)\"(,?)$/im', '\"\\1\"\\2', $script->innertext);\n\n                // find beginning of JSON array\n                $start = strpos($script->innertext, '[');\n\n                // find end of JSON array, compensate for missing character!\n                $end = strpos($script->innertext, '];') + 1;\n\n                // convert JSON to PHP array\n                $json = substr($script->innertext, $start, $end - $start);\n\n                try {\n                    // TODO: fix broken json\n                    return Json::decode($json);\n                } catch (\\JsonException $e) {\n                    return [];\n                }\n            }\n        }\n        return [];\n    }\n\n    private function findJsonForShot($shot, $json)\n    {\n        foreach ($json as $element) {\n            if (strpos($shot->getAttribute('id'), (string)$element['id']) !== false) {\n                return $element;\n            }\n        }\n\n        return null;\n    }\n\n    private function getImageTag($preview_path, $title)\n    {\n        return sprintf(\n            '<br /> <a href=\"%s\"><img srcset=\"%s\" alt=\"%s\" /></a>',\n            $this->getFullSizeImagePath($preview_path),\n            $preview_path,\n            $title\n        );\n    }\n\n    private function getFullSizeImagePath($preview_path)\n    {\n        // Get last image from srcset\n        $src_set_urls = explode(',', $preview_path);\n        $url = end($src_set_urls);\n        $url = explode(' ', $url)[1];\n\n        return htmlspecialchars_decode($url);\n    }\n}\n"
  },
  {
    "path": "bridges/Drive2ruBridge.php",
    "content": "<?php\n\nclass Drive2ruBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dotter-ak';\n    const NAME = 'Drive2.ru';\n    const URI = 'https://drive2.ru/';\n    const DESCRIPTION = 'Лента новостей и тестдрайвов, бортжурналов по выбранной марке или модели\n\t\t(также работает с фильтром по категориям), блогов пользователей и публикаций по темам.';\n    const PARAMETERS = [\n        'Новости и тест-драйвы' => [],\n        'Бортжурналы (По модели или марке)' => [\n            'url' => [\n                'name' => 'Ссылка на страницу с бортжурналом',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Например: https://www.drive2.ru/experience/suzuki/g4895/',\n                'exampleValue' => 'https://www.drive2.ru/experience/suzuki/g4895/'\n            ],\n        ],\n        'Личные блоги' => [\n            'username' => [\n                'name' => 'Никнейм пользователя на сайте',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Например: Mickey',\n                'exampleValue' => 'Mickey'\n            ]\n        ],\n        'Публикации по темам (Стоит почитать)' => [\n            'topic' => [\n                'name' => 'Темы',\n                'type' => 'list',\n                'values' => [\n                    'Автозвук' => '16',\n                    'Автомобильный дизайн' => '10',\n                    'Автоспорт' => '11',\n                    'Автошоу, музеи, выставки' => '12',\n                    'Безопасность' => '18',\n                    'Беспилотные автомобили' => '15',\n                    'Видеосюжеты' => '20',\n                    'Вне дорог' => '21',\n                    'Встречи' => '22',\n                    'Выбор и покупка машины' => '23',\n                    'Гаджеты' => '30',\n                    'Гибридные машины' => '32',\n                    'Грузовики, автобусы, спецтехника' => '31',\n                    'Доработка интерьера' => '35',\n                    'Законодательство' => '40',\n                    'История автомобилестроения' => '50',\n                    'Мототехника' => '60',\n                    'Новые модели и концепты' => '85',\n                    'Обучение вождению' => '70',\n                    'Путешествия' => '80',\n                    'Ремонт и обслуживание' => '90',\n                    'Реставрация ретро-авто' => '91',\n                    'Сделай сам' => '104',\n                    'Смешное' => '103',\n                    'Спорткары' => '102',\n                    'Стайлинг' => '101',\n                    'Тест-драйвы' => '110',\n                    'Тюнинг' => '111',\n                    'Фотосессии' => '120',\n                    'Шины и диски' => '140',\n                    'Электрика' => '130',\n                    'Электромобили' => '131'\n                ],\n                'defaultValue' => '16',\n            ]\n        ],\n        'global' => [\n            'full_articles' => [\n                'name' => 'Загружать в ленту полный текст',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    private $title;\n\n    private function getUserContent($url)\n    {\n        $html = getSimpleHTMLDOM($url);\n        $this->title = $html->find('title', 0)->innertext;\n        $articles = $html->find('div.js-entity');\n        foreach ($articles as $article) {\n            $item = [];\n            $item['title'] = $article->find('a.c-link--text', 0)->plaintext;\n            $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 0)->href);\n            if ($this->getInput('full_articles')) {\n                $item['content'] = $this->addCommentsLink(\n                    $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.c-post__body', 0))->innertext,\n                    $item['uri']\n                );\n            } else {\n                $item['content'] = $this->addReadMoreLink($article->find('div.c-post-preview__lead', 0), $item['uri']);\n            }\n            $item['author'] = $article->find('a.c-username--wrap', 0)->plaintext;\n            if (!is_null($article->find('img', 1))) {\n                $item['enclosures'][] = $article->find('img', 1)->src;\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    private function getLogbooksContent($url)\n    {\n        $html = getSimpleHTMLDOM($url);\n        $this->title = $html->find('title', 0)->innertext;\n        $articles = $html->find('div.js-entity');\n        foreach ($articles as $article) {\n            $item = [];\n            $item['title'] = $article->find('a.c-link--text', 1)->plaintext;\n            $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 1)->href);\n            if ($this->getInput('full_articles')) {\n                $item['content'] = $this->addCommentsLink(\n                    $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.c-post__body', 0))->innertext,\n                    $item['uri']\n                );\n            } else {\n                $item['content'] = $this->addReadMoreLink($article->find('div.c-post-preview__lead', 0), $item['uri']);\n            }\n            $item['author'] = $article->find('a.c-username--wrap', 0)->plaintext;\n            if (!is_null($article->find('img', 1))) {\n                $item['enclosures'][] = $article->find('img', 1)->src;\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    private function getNews()\n    {\n        $html = getSimpleHTMLDOM('https://www.drive2.ru/editorial/');\n        $this->title = $html->find('title', 0)->innertext;\n        $articles = $html->find('div.c-article-card');\n        foreach ($articles as $article) {\n            $item = [];\n            $item['title'] = $article->find('a.c-link--text', 0)->plaintext;\n            $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 0)->href);\n            if ($this->getInput('full_articles')) {\n                $item['content'] = $this->addCommentsLink(\n                    $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.article', 0))->innertext,\n                    $item['uri']\n                );\n            } else {\n                $item['content'] = $this->addReadMoreLink($article->find('div.c-article-card__lead', 0), $item['uri']);\n            }\n            $item['author'] = 'Новости и тест-драйвы на Drive2.ru';\n            if (!is_null($article->find('img', 0))) {\n                $item['enclosures'][] = $article->find('img', 0)->src;\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    private function adjustContent($content)\n    {\n        foreach ($content->find('div.o-group') as $node) {\n            $node->outertext = '';\n        }\n        foreach ($content->find('div, span') as $attrs) {\n            foreach ($attrs->getAllAttributes() as $attr => $val) {\n                $attrs->removeAttribute($attr);\n            }\n        }\n        foreach ($content->getElementsByTagName('figcaption') as $attrs) {\n            $attrs->setAttribute(\n                'style',\n                'font-style: italic; font-size: small; margin: 0 100px 75px;'\n            );\n        }\n        foreach ($content->find('script') as $node) {\n            $node->outertext = '';\n        }\n        foreach ($content->find('iframe') as $node) {\n            $node->outertext = handleYoutube($node->src);\n        }\n        return $content;\n    }\n\n    private function addCommentsLink($content, $url)\n    {\n        return $content . '<br><a href=\"' . $url . '#comments\">Перейти к комментариям</a>';\n    }\n\n    private function addReadMoreLink($content, $url)\n    {\n        if (!is_null($content)) {\n            return preg_replace('!\\s+!', ' ', str_replace('Читать дальше', '', $content->plaintext)) .\n                '<br><a href=\"' . $url . '\">Читать далее</a>';\n        } else {\n            return '';\n        }\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            default:\n            case 'Новости и тест-драйвы':\n                $this->getNews();\n                break;\n            case 'Бортжурналы (По модели или марке)':\n                if (!preg_match('/^https:\\/\\/www.drive2.ru\\/experience/', $this->getInput('url'))) {\n                    throwServerException('Invalid url');\n                }\n                $this->getLogbooksContent($this->getInput('url'));\n                break;\n            case 'Личные блоги':\n                if (!preg_match('/^[a-zA-Z0-9-]{3,16}$/', $this->getInput('username'))) {\n                    throwServerException('Invalid username');\n                }\n                $this->getUserContent('https://www.drive2.ru/users/' . $this->getInput('username'));\n                break;\n            case 'Публикации по темам (Стоит почитать)':\n                $this->getUserContent('https://www.drive2.ru/topics/' . $this->getInput('topic'));\n                break;\n        }\n    }\n\n    public function getName()\n    {\n        return $this->title ?: parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.drive2.ru/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/DuckDuckGoBridge.php",
    "content": "<?php\n\nclass DuckDuckGoBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Astalaseven';\n    const NAME = 'DuckDuckGo';\n    const URI = 'https://duckduckgo.com/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns results from DuckDuckGo.';\n\n    const SORT_DATE = ' sort:date';\n    const SORT_RELEVANCE = '';\n\n    const PARAMETERS = [ [\n        'u' => [\n            'name' => 'keyword',\n            'exampleValue' => 'duck',\n            'required' => true\n        ],\n        'sort' => [\n            'name' => 'sort by',\n            'type' => 'list',\n            'required' => false,\n            'values' => [\n                'date' => self::SORT_DATE,\n                'relevance' => self::SORT_RELEVANCE\n            ],\n            'defaultValue' => self::SORT_DATE\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $query = [\n            'kd' => '-1',\n            'q'  => $this->getInput('u') . $this->getInput('sort'),\n        ];\n        $url = 'https://duckduckgo.com/html/?' . http_build_query($query);\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('div.result') as $element) {\n            $item = [];\n            $item['uri'] = $element->find('a.result__a', 0)->href;\n            $item['title'] = $element->find('h2.result__title', 0)->plaintext;\n\n            $snippet = $element->find('a.result__snippet', 0);\n            if ($snippet) {\n                $item['content'] = $snippet->plaintext;\n            }\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/DuvarOrgBridge.php",
    "content": "<?php\n\nclass DuvarOrgBridge extends BridgeAbstract\n{\n    const NAME = 'Duvar.org - Haberler';\n    const MAINTAINER = 'yourname';\n    const URI = 'https://duvar.org';\n    const DESCRIPTION = 'Returns the latest articles from Duvar.org - News from Turkey and the world';\n    const CACHE_TIMEOUT = 3600; // 60min\n\n    const PARAMETERS = [[\n        'postcount' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => true,\n            'title' => 'Maximum number of items to return',\n            'defaultValue' => 20,\n        ],\n        'urlsuffix' => [\n            'name' => 'URL Suffix',\n            'type' => 'list',\n            'title' => 'Suffix for the URL to scrape a specific section',\n            'defaultValue' => 'Main',\n            'values' => [\n                'Main' => '',\n                'Balanced' => '/uyumlu',\n                'Protest' => '/muhalif',\n                'Center' => '/merkez',\n                'Alternative' => '/alternatif',\n                'Global' => '/global',\n            ],\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $postCount = $this->getInput('postcount');\n        $urlSuffix = $this->getInput('urlsuffix');\n        $url = self::URI . $urlSuffix;\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('article.news-item') as $data) {\n            if ($data === null) {\n                continue;\n            }\n\n            try {\n                $item = [];\n                $linkElement = $data->find('h2.news-title a', 0);\n                $titleElement = $data->find('h2.news-title a', 0);\n                $timestampElement = $data->find('time.meta-tag.date-tag', 0);\n                $contentElement = $data->find('div.news-description', 0);\n\n                if ($linkElement) {\n                    $item['uri'] = $linkElement->getAttribute('href');\n                } else {\n                    continue;\n                }\n                if ($titleElement) {\n                    $item['title'] = trim($titleElement->plaintext);\n                } else {\n                    continue;\n                }\n                if ($timestampElement) {\n                    $item['timestamp'] = strtotime($timestampElement->plaintext);\n                } else {\n                    $item['timestamp'] = time();\n                }\n                if ($contentElement) {\n                    $item['content'] = trim($contentElement->plaintext);\n                } else {\n                    $item['content'] = '';\n                }\n                $item['uid'] = hash('sha256', $item['title']);\n\n                $this->items[] = $item;\n\n                if (count($this->items) >= $postCount) {\n                    break;\n                }\n            } catch (Exception $e) {\n                continue;\n            }\n        }\n    }\n}"
  },
  {
    "path": "bridges/EASeedBridge.php",
    "content": "<?php\n\nclass EASeedBridge extends BridgeAbstract\n{\n    const NAME = 'EA Seed Blog';\n    const URI = 'https://www.ea.com/seed';\n    const DESCRIPTION = 'Posts from the EA Seed blog';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 86400; // 24h\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(static::URI);\n        $dom = $dom->find('ea-grid', 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('ea-tile') as $article) {\n            $a = $article->find('a', 0);\n            $date = $article->find('div', 1)->plaintext;\n            $title = $article->find('h3', 0)->plaintext;\n            $author = $article->find('div', 0)->plaintext;\n\n            $entry = getSimpleHTMLDOMCached($a->href, static::CACHE_TIMEOUT * 7 * 4);\n\n            $content = $entry->find('main', 0);\n\n            // remove header and links to other posts\n            $content->find('ea-header', 0)->outertext = '';\n            $content->find('ea-section', -1)->outertext = '';\n\n            $this->items[] = [\n                'title' => $title,\n                'author' => $author,\n                'uri' => $a->href,\n                'content' => $content,\n                'timestamp' => strtotime($date),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/EBayBridge.php",
    "content": "<?php\n\nclass EBayBridge extends BridgeAbstract\n{\n    const NAME = 'eBay';\n    const DESCRIPTION = 'Returns the search results from the eBay auctioning platforms';\n    const URI = 'https://www.eBay.com';\n    const MAINTAINER = 'NotsoanoNimus, wrobelda';\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Search URL',\n            'title' => 'Copy the URL from your browser\\'s address bar after searching for your items and paste it here',\n            '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)\\/.*$',\n            'exampleValue' => 'https://www.ebay.com/sch/i.html?_nkw=atom+rss',\n            'required' => true,\n        ],\n        'includesSearchLink' => [\n            'name' => 'Include Original Search Link',\n            'title' => 'Whether or not each feed item should include the original search query link to eBay which was used to find the given listing.',\n            'type' => 'checkbox',\n            'defaultValue' => false,\n        ],\n    ]];\n\n    public function getURI()\n    {\n        if ($this->getInput('url')) {\n            # make sure we order by the most recently listed offers\n            $uri = trim(preg_replace('/([?&])_sop=[^&]+(&|$)/', '$1', $this->getInput('url')), '?&/');\n            $uri .= (parse_url($uri, PHP_URL_QUERY) ? '&' : '?') . '_sop=10';\n\n            // Ensure the List View is used instead of the Gallery View.\n            $uri = trim(preg_replace('/[?&]_dmd=[^&]+(&|$)/i', '$1', $uri), '?&/');\n            $uri .= '&_dmd=1';\n\n            return $uri;\n        } else {\n            return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        $url = $this->getInput('url');\n        if (!$url) {\n            return parent::getName();\n        }\n        $urlQueries = explode('&', parse_url($url, PHP_URL_QUERY));\n\n        $searchQuery = array_reduce($urlQueries, function ($q, $p) {\n            if (preg_match('/^_nkw=(.+)$/i', $p, $matches)) {\n                $q[] = str_replace('+', ' ', urldecode($matches[1]));\n            }\n\n            return $q;\n        });\n\n        if ($searchQuery) {\n            return 'eBay - ' . $searchQuery[0];\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        // Remove any unsolicited results, e.g. \"Results matching fewer words\"\n        foreach ($html->find('ul.srp-results > li.srp-river-answer--REWRITE_START ~ li') as $inexactMatches) {\n            $inexactMatches->remove();\n        }\n\n        // Remove \"NEW LISTING\" labels: we sort by the newest, so this is redundant.\n        foreach ($html->find('.LIGHT_HIGHLIGHT') as $new_listing_label) {\n            $new_listing_label->remove();\n        }\n\n        $results = $html->find('ul.srp-results > li.s-card');\n        foreach ($results as $listing) {\n            $item = [];\n\n            // Define a closure to shorten the ugliness of querying the current listing.\n            $find = function ($query, $altText = '') use ($listing) {\n                return $listing->find($query, 0)->plaintext ?? $altText;\n            };\n\n            $item['title'] = $find('.s-card__title');\n            if (!$item['title']) {\n                // Skip entries where the title cannot be found (for w/e reason).\n                continue;\n            }\n\n            // It appears there may be more than a single 'subtitle' subclass in the listing. Collate them.\n            $subtitles = $listing->find('.s-card__subtitle');\n            if (is_array($subtitles)) {\n                $subtitle = trim(implode(' ', array_column($subtitles, 'plaintext')));\n            } else {\n                $subtitle = trim($subtitles->plaintext ?? '');\n            }\n\n            // Get the listing's link and uid.\n            $itemUri = $listing->find('.s-card__link', 0);\n            if ($itemUri) {\n                $item['uri'] = $itemUri->href;\n            }\n            if (preg_match('/.*\\/itm\\/(\\d+).*/i', $item['uri'], $matches)) {\n                $item['uid'] = $matches[1];\n            }\n\n            // Price should be fetched on its own so we can provide the alt text without complication.\n            $price = $find('.s-card__price', '[NO PRICE]');\n\n            // Map a list of dynamic variable names to their subclasses within the listing.\n            //   This is just a bit of sugar to make this cleaner and more maintainable.\n            $propertyMappings = [\n                'additionalPrice'   => '.s-card__additional-price',\n                'discount'          => '.s-card__discount',\n                'shippingFree'      => '.s-card__freeXDays',\n                'localDelivery'     => '.s-card__localDelivery',\n                'logisticsCost'     => '.s-card__logisticsCost',\n                'location'          => '.s-card__location',\n                'obo'               => '.s-card__formatBestOfferEnabled',\n                'sellerInfo'        => '.s-card__seller-info-text',\n                'bids'              => '.s-card__bidCount',\n                'timeLeft'          => '.s-card__time-left',\n                'timeEnd'           => '.s-card__time-end',\n            ];\n\n            foreach ($propertyMappings as $k => $v) {\n                $$k = $find($v);\n            }\n\n            // When an additional price detail or discount is defined, create the 'discountLine'.\n            if ($additionalPrice || $discount) {\n                $discountLine = '<br /><em>('\n                    . trim($additionalPrice ?? '')\n                    . '; ' . trim($discount ?? '')\n                    . ')</em>';\n            } else {\n                $discountLine = '';\n            }\n\n            // Prepend the time-left info with a comma if the right details were found.\n            $timeInfo = trim($timeLeft . ' ' . $timeEnd);\n            if ($timeInfo) {\n                $timeInfo = ', ' . $timeInfo;\n            }\n\n            // Set the listing type.\n            if ($bids) {\n                $listingTypeDetails = \"Auction: {$bids}{$timeInfo}\";\n            } else {\n                $listingTypeDetails = 'Buy It Now';\n            }\n\n            // Acquire the listing's primary image and atach it.\n            $image = $listing->find('.s-card__media-wrapper img', 0);\n            if ($image) {\n                // Not quite sure why append fragment here\n                $imageUrl = $image->src . '#.image';\n                $item['enclosures'] = [$imageUrl];\n            }\n\n            // Include the original search link, if specified.\n            if ($this->getInput('includesSearchLink')) {\n                $searchLink = '<p><small><a target=\"_blank\" href=\"' . e($this->getURI()) . '\">View Search</a></small></p>';\n            } else {\n                $searchLink = '';\n            }\n\n            // Build the final item's content to display and add the item onto the list.\n            $item['content'] = <<<CONTENT\n<p>$sellerInfo $location</p>\n<p><strong>$price</strong> $obo ($listingTypeDetails)\n    $discountLine\n    <br /><small>$shippingFree $localDelivery $logisticsCost</small></p>\n<p>{$subtitle}</p>\n$searchLink\nCONTENT;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/EDDHPiRepsBridge.php",
    "content": "<?php\n\nclass EDDHPiRepsBridge extends BridgeAbstract\n{\n    const NAME = 'EDDH.de PIREPs';\n    const URI = 'https://eddh.de/info/pireps_08days.php';\n    const DESCRIPTION = 'Erfahrungen und Tipps von Piloten für Piloten: Die Einträge der letzten 8 Tage';\n    const MAINTAINER = 'hleskien';\n    //const PARAMETERS = [];\n    //const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(self::URI);\n        foreach ($dom->find('table table table td') as $itemnode) {\n            $texts = $this->extractTexts($itemnode->find('text, br'));\n            $timestamp = $itemnode->find('.su_dat', 0)->innertext();\n            $uri = $itemnode->find('.pir_hd a', 0)->href;\n            $this->items[] = [\n                'timestamp' => $this->formatItemTimestamp($timestamp),\n                'title' => $this->formatItemTitle($texts),\n                'uri' => $this->formatItemUri($uri),\n                'author' => $this->formatItemAuthor($texts),\n                'content' => $this->formatItemContent($texts)\n            ];\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://eddh.de/favicon.ico';\n    }\n\n    private function extractTexts($nodes)\n    {\n        $texts = [];\n        $i = 0;\n        foreach ($nodes as $node) {\n            $text = trim($node->outertext());\n            if ($node->tag == 'br') {\n                $texts[$i++] = \"\\n\";\n            } elseif (($node->tag == 'text') && ($text != '')) {\n                $text = iconv('Windows-1252', 'UTF-8', $text);\n                $text = str_replace('&nbsp;', '', $text);\n                $texts[$i++] = $text;\n            }\n        }\n        return $texts;\n    }\n\n    protected function formatItemAuthor($texts)\n    {\n        $pos = array_search('Name:', $texts);\n        return $texts[$pos + 1];\n    }\n\n    protected function formatItemContent($texts)\n    {\n        $pos1 = array_search('Bemerkungen:', $texts);\n        $pos2 = array_search('Bewertung:', $texts);\n        $content = '';\n        for ($i = $pos1 + 1; $i < $pos2; $i++) {\n            $content .= $texts[$i];\n        }\n        return trim($content);\n    }\n\n    protected function formatItemTitle($texts)\n    {\n        $texts[5] = ltrim($texts[5], '(');\n        return implode(' ', [$texts[1], $texts[2], $texts[3], $texts[5]]);\n    }\n\n    protected function formatItemTimestamp($value)\n    {\n        $value = str_replace('Eintrag vom', '', $value);\n        $value = trim($value);\n        return strtotime($value);\n    }\n\n    protected function formatItemUri($value)\n    {\n        return 'https://eddh.de/info/' . $value;\n    }\n}\n"
  },
  {
    "path": "bridges/EDDHPresseschauBridge.php",
    "content": "<?php\n\nclass EDDHPresseschauBridge extends XPathAbstract\n{\n    const NAME = 'EDDH.de Presseschau';\n    const URI = 'https://eddh.de/presse/presseschau.php';\n    const DESCRIPTION = 'Luftfahrt-Presseschau: Presse-Artikel aus der Luftfahrt';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://eddh.de/presse/presseschau.php';\n    //const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"icon\"]/@href';\n    const XPATH_EXPRESSION_ITEM = '//table//table[.//p[@class=\"pressnews\"]]//td';\n    const XPATH_EXPRESSION_ITEM_TITLE = './h4';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './p[@class=\"pressnews\"]';\n    const XPATH_EXPRESSION_ITEM_URI = './p[@class=\"pressnews\"]/a/@href';\n    const XPATH_EXPRESSION_ITEM_AUTHOR = './p[@class=\"quelle\"]';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './p[@class=\"quelle\"]';\n    //const XPATH_EXPRESSION_ITEM_ENCLOSURES = './';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n\n    public function getIcon()\n    {\n        return 'https://eddh.de/favicon.ico';\n    }\n\n    protected function formatItemAuthor($value)\n    {\n        $parts = explode('(', $value);\n        $author = trim($parts[0]);\n        return $author;\n    }\n\n    protected function formatItemTimestamp($value)\n    {\n        $parts = explode('(', $value);\n        $ws = [\"\\n\", \"\\t\", ' ', ')'];\n        $value = str_replace($ws, '', $parts[1]);\n        $dti = DateTimeImmutable::createFromFormat('d.m.Y', $value);\n        $dti = $dti->setTime(0, 0, 0);\n        return $dti->getTimestamp();\n    }\n}\n"
  },
  {
    "path": "bridges/EZTVBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass EZTVBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'alexAubin';\n    const NAME = 'EZTV';\n    const URI = 'https://eztvstatus.com';\n    const DESCRIPTION = 'Search for torrents by IMDB id. You can find IMDB id in the url of a tv show.';\n\n    const PARAMETERS = [\n        [\n            'ids' => [\n                'name' => 'IMDB ids',\n                'exampleValue' => '8740790,1733785',\n                'required' => true,\n                'title' => 'One or more IMDB ids'\n            ],\n            'no480' => [\n                'name' => 'No 480p',\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude 480p torrents'\n            ],\n            'no720' => [\n                'name' => 'No 720p',\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude 720p torrents'\n            ],\n            'no1080' => [\n                'name' => 'No 1080p',\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude 1080p torrents'\n            ],\n            'no2160' => [\n                'name' => 'No 2160p',\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude 2160p torrents'\n            ],\n            'noUnknownRes' => [\n                'name' => 'No Unknown resolution',\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude unknown resolution torrents'\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $eztv_uri = $this->getEztvUri();\n        $ids = explode(',', trim($this->getInput('ids')));\n        foreach ($ids as $id) {\n            $url = sprintf('%s/api/get-torrents?imdb_id=%s', $eztv_uri, $id);\n            $json = getContents($url);\n            $data = json_decode($json);\n            if (!isset($data->torrents)) {\n                // No results\n                continue;\n            }\n            foreach ($data->torrents as $torrent) {\n                $title = $torrent->title;\n                $regex480 = '/480p/';\n                $regex720 = '/720p/';\n                $regex1080 = '/1080p/';\n                $regex2160 = '/2160p/';\n                $regexUnknown = '/(480p|720p|1080p|2160p)/';\n                // Skip unwanted resolution torrents\n                if (\n                    (preg_match($regex480, $title) === 1 && $this->getInput('no480'))\n                    || (preg_match($regex720, $title) === 1 && $this->getInput('no720'))\n                    || (preg_match($regex1080, $title) === 1 && $this->getInput('no1080'))\n                    || (preg_match($regex2160, $title) === 1 && $this->getInput('no2160'))\n                    || (preg_match($regexUnknown, $title) !== 1 && $this->getInput('noUnknownRes'))\n                ) {\n                    continue;\n                }\n                $this->items[] = $this->getItemFromTorrent($torrent);\n            }\n        }\n        usort($this->items, function ($torrent1, $torrent2) {\n            return $torrent2['timestamp'] <=> $torrent1['timestamp'];\n        });\n    }\n\n    protected function getEztvUri()\n    {\n        $html = getSimpleHTMLDom(self::URI);\n        $urls = $html->find('a.domainLink');\n        foreach ($urls as $url) {\n            $headers = get_headers($url->href);\n            if (substr($headers[0], 9, 3) === '200') {\n                return $url->href;\n            }\n        }\n        throw new Exception('No valid EZTV URI available');\n    }\n\n    protected function getItemFromTorrent($torrent)\n    {\n        $item = [];\n        $item['uri'] = $torrent->episode_url ?? $torrent->torrent_url;\n        $item['author'] = $torrent->imdb_id;\n        $item['timestamp'] = $torrent->date_released_unix;\n        $item['title'] = $torrent->title;\n        $item['enclosures'][] = $torrent->torrent_url;\n\n        $thumbnailUri = 'https:' . $torrent->small_screenshot;\n        $torrentSize = format_bytes((int) $torrent->size_bytes);\n\n        $item['content'] = $torrent->filename . '<br>File size: '\n        . $torrentSize . '<br><a href=\"' . $torrent->magnet_url\n        . '\">magnet link</a><br><a href=\"' . $torrent->torrent_url\n        . '\">torrent link</a><br><img src=\"' . $thumbnailUri . '\" />';\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/EconomistBridge.php",
    "content": "<?php\n\nclass EconomistBridge extends FeedExpander\n{\n    const MAINTAINER = 'bockiii, sqrtminusone';\n    const NAME = 'Economist';\n    const URI = 'https://www.economist.com/';\n    const CACHE_TIMEOUT = 3600; //1hour\n    const DESCRIPTION = 'Returns the latest articles for the selected category';\n\n    const CONFIGURATION = [\n        'cookie' => [\n            'required' => false,\n        ]\n    ];\n\n    const PARAMETERS = [\n        'global' => [\n            'limit' => [\n                'name' => 'Feed Item Limit',\n                'required' => true,\n                'type' => 'number',\n                'defaultValue' => 10,\n                'title' => 'Maximum number of returned feed items. Maximum 30, default 10'\n            ]\n        ],\n        'Topics' => [\n            'topic' => [\n                'name' => 'Topics',\n                'type' => 'list',\n                'title' => 'Select a Topic',\n                'defaultValue' => 'latest',\n                'values' => [\n                    'Latest' => 'latest',\n                    'The world this week' => 'the-world-this-week',\n                    'Letters' => 'letters',\n                    'Leaders' => 'leaders',\n                    'Briefings' => 'briefing',\n                    'Special reports' => 'special-report',\n                    'Britain' => 'britain',\n                    'Europe' => 'europe',\n                    'United States' => 'united-states',\n                    'The Americas' => 'the-americas',\n                    'Middle East and Africa' => 'middle-east-and-africa',\n                    'Asia' => 'asia',\n                    'China' => 'china',\n                    'International' => 'international',\n                    'Business' => 'business',\n                    'Finance and economics' => 'finance-and-economics',\n                    'Science and technology' => 'science-and-technology',\n                    'Books and arts' => 'books-and-arts',\n                    'Obituaries' => 'obituary',\n                    'Graphic detail' => 'graphic-detail',\n                    'Indicators' => 'economic-and-financial-indicators',\n                    'The Economist Reads' => 'the-economist-reads',\n                ]\n            ]\n        ],\n        'Blogs' => [\n            'blog' => [\n                'name' => 'Blogs',\n                'type' => 'list',\n                'title' => 'Select a Blog',\n                'values' => [\n                    'Bagehots notebook' => 'bagehots-notebook',\n                    'Bartleby' => 'bartleby',\n                    'Buttonwoods notebook' => 'buttonwoods-notebook',\n                    'Charlemagnes notebook' => 'charlemagnes-notebook',\n                    'Democracy in America' => 'democracy-in-america',\n                    'Erasmus' => 'erasmus',\n                    'Free exchange' => 'free-exchange',\n                    'Game theory' => 'game-theory',\n                    'Gulliver' => 'gulliver',\n                    'Kaffeeklatsch' => 'kaffeeklatsch',\n                    'Prospero' => 'prospero',\n                    'The Economist Explains' => 'the-economist-explains',\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        // get if topics or blogs were selected and store the selected category\n        switch ($this->queriedContext) {\n            case 'Topics':\n                $category = $this->getInput('topic');\n                break;\n            case 'Blogs':\n                $category = $this->getInput('blog');\n                break;\n            default:\n                $category = 'latest';\n        }\n        // limit the returned articles to 30 at max\n        if ((int)$this->getInput('limit') <= 30) {\n            $limit = (int)$this->getInput('limit');\n        } else {\n            $limit = 30;\n        }\n\n        $url = 'https://www.economist.com/' . $category . '/rss.xml';\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $headers = [];\n        if ($this->getOption('cookie')) {\n            $headers = [\n                'Authority: www.economist.com',\n                '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',\n                'Accept-language: en-US,en;q=0.9',\n                'Cache-control: max-age=0',\n                'Cookie: ' . $this->getOption('cookie'),\n                'Upgrade-insecure-requests: 1',\n                'User-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'\n            ];\n        }\n        try {\n            $dom = getSimpleHTMLDOM($item['uri'], $headers);\n        } catch (Exception $e) {\n            $item['content'] = $e->getMessage();\n            return $item;\n        }\n\n        $article = $dom->find('#new-article-template', 0);\n        if ($article == null) {\n            $article = $dom->find('main', 0);\n        }\n        if ($article) {\n            $elem = $article->find('div', 0);\n            list($content, $audio_url) = $this->processContent($dom, $elem);\n            $item['content'] = $content;\n            if ($audio_url != null) {\n                $item['enclosures'] = [$audio_url];\n            }\n        }\n        return $item;\n    }\n\n    private function processContent($html, $elem)\n    {\n        // Remove extra styles\n        $styles = $elem->find('style');\n        foreach ($styles as $style) {\n            $style->parent->removeChild($style);\n        }\n\n        // Remove the section with remaining articles\n        $more_elem = $elem->find('h2.ds-section-headline.ds-section-headline--rule-emphasised', 0);\n        if ($more_elem != null) {\n            if ($more_elem->parent && $more_elem->parent->parent) {\n                $more_elem->parent->parent->removeChild($more_elem->parent);\n            }\n        }\n\n        // Remove 'capitalization' with <small> tags\n        foreach ($elem->find('small') as $small) {\n            $small->outertext = strtoupper($small->innertext);\n        }\n\n        // Extract audio\n        $audio_url = null;\n        $audio_elem = $elem->find('#audio-player', 0);\n        if ($audio_elem != null) {\n            $audio_url = $audio_elem->src;\n            $audio_elem->parent->parent->removeChild($audio_elem->parent);\n        }\n\n        // No idea how this works on the original site\n        foreach ($elem->find('img') as $img) {\n            $img->removeAttribute('width');\n            $img->removeAttribute('height');\n        }\n\n        // Some hacks for 'interactive' sections to make them a bit\n        // more readable. Here's one example:\n        // https://www.economist.com/interactive/briefing/2022/09/24/war-in-ukraine-has-reshaped-worlds-fuel-markets\n        $svelte = $elem->find('svelte-scroller-outer', 0);\n        if ($svelte != null) {\n            $svelte->parent->removeChild($svelte);\n        }\n        foreach ($elem->find('img') as $strange_img) {\n            if (!str_contains($strange_img->src, 'economist.com')) {\n                $strange_img->src = 'https://economist.com' . $strange_img->src;\n            }\n        }\n        // Trying to fix interactive infographics. This doesn't look\n        // quite as well, but fortunately, such elements are rare\n        // (~95% of infographics are plain images)\n        foreach ($elem->find('div.ds-image') as $ds_img) {\n            $ds_img->style = 'max-width: min(100%, 700px); overflow: hidden; margin: 2rem auto;';\n            $g_artboard = null;\n            foreach ($ds_img->find('div.g-artboard') as $g_artboard_cand) {\n                if (!str_contains($g_artboard_cand->style, 'display: none')) {\n                    $g_artboard = $g_artboard_cand;\n                }\n            }\n            if ($g_artboard != null) {\n                $g_artboard->style = $g_artboard->style . 'position: relative;';\n                $img = $g_artboard->find('img', 0);\n                if ($img != null) {\n                    $img->style = 'top: 0; display: block; width: 100% !important;';\n                    foreach ($g_artboard->find('div') as $div) {\n                        if ($div->style == null) {\n                            $div->style = 'position: absolute;';\n                        } else {\n                            $div->style = $div->style . 'position: absolute';\n                        }\n                    }\n                }\n            }\n        }\n\n        $vertical = $elem->find('div[data-test-id=vertical]', 0);\n        if ($vertical != null) {\n            $vertical->parent->removeChild($vertical);\n        }\n\n        // Section with 'Save', 'Share' and 'Give buttons'\n        foreach ($elem->find('div[data-test-id=sharing-modal]') as $sharing) {\n            $sharing->parent->removeChild($sharing);\n        }\n        // These links become HUGE without <style> tags and aren't\n        // particularly useful anyhow\n        foreach ($elem->find('a.ds-link-with-arrow-icon') as $a) {\n            $a->parent->removeChild($a);\n        }\n        // Sections like \"Leaders on day X\"\n        foreach ($elem->find('div[data-tracking-id=content-well-chapter-list]') as $div) {\n            $div->parent->removeChild($div);\n        }\n        // \"Explore more\" section\n        foreach ($elem->find('h3[id=article-tags]') as $h3) {\n            $div = $h3->parent;\n            $div->parent->removeChild($div);\n        }\n\n        // The Economist puts infographics into iframes, which doesn't\n        // work in any of my readers. So this replaces iframes with\n        // links.\n        foreach ($elem->find('iframe') as $iframe) {\n            $a = $html->createElement('a');\n            $a->href = $iframe->src;\n            $a->innertext = $iframe->src;\n            $iframe->parent->appendChild($a);\n            $iframe->parent->removeChild($iframe);\n        }\n\n        // Using <section> tags does nothing except interfering with\n        // rss-bridge styles, so this replaces them with <div>\n        $res = $elem->innertext;\n        $res = str_replace('<section', '<div', $res);\n        $res = str_replace('</section', '</div', $res);\n        $content = '<div>' . $res . '</div>';\n        return [$content, $audio_url];\n    }\n}\n"
  },
  {
    "path": "bridges/EconomistWorldInBriefBridge.php",
    "content": "<?php\n\nclass EconomistWorldInBriefBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Economist the World in Brief';\n    const URI = 'https://www.economist.com/the-world-in-brief';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Returns stories from the World in Brief section';\n\n    const CONFIGURATION = [\n        'cookie' => [\n            'required' => false,\n        ]\n    ];\n\n    const PARAMETERS = [\n        '' => [\n            'splitGobbets' => [\n                'name' => 'Split the short stories',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n                'title' => 'Whether to split the short stories into separate entries'\n            ],\n            'limit' => [\n                'name' => 'Truncate headers for the short stories',\n                'type' => 'number',\n                'defaultValue' => 100\n            ],\n            'agenda' => [\n                'name' => 'Add agenda for the day',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'agendaPictures' => [\n                'name' => 'Include pictures to the agenda',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'quote' => [\n                'name' => 'Include the quote of the day',\n                'type' => 'checkbox'\n            ],\n            'mergeEverything' => [\n                'name' => 'Merge everything into one entry',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n                'title' => 'Whether to merge all the stories into one entry'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $headers = [];\n        if ($this->getOption('cookie')) {\n            $headers = [\n                'Authority: www.economist.com',\n                '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',\n                'Accept-language: en-US,en;q=0.9',\n                'Cache-control: max-age=0',\n                'Cookie: ' . $this->getOption('cookie'),\n                'Upgrade-insecure-requests: 1',\n                'User-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'\n            ];\n        }\n        $html = getSimpleHTMLDOM(self::URI, $headers);\n        $gobbets = $html->find('p[data-component=\"the-world-in-brief-paragraph\"]');\n        if ($this->getInput('splitGobbets') == 1 && !$this->getInput('mergeEverything')) {\n            $this->splitGobbets($gobbets);\n        } else {\n            $this->mergeGobbets($gobbets);\n        };\n        if ($this->getInput('agenda') == 1) {\n            $articles = $html->find('div[data-test-id=\"chunks\"] > div > div', 0);\n\n            if ($articles != null) {\n                $this->collectArticles($articles);\n            }\n        }\n        if ($this->getInput('quote') == 1) {\n            $quote = $html->find('blockquote[data-test-id=\"inspirational-quote\"]', 0);\n            $this->addQuote($quote);\n        }\n        if ($this->getInput('mergeEverything') == 1) {\n            $this->mergeEverything();\n        }\n    }\n\n    private function splitGobbets($gobbets)\n    {\n        $today = new Datetime();\n        $today->setTime(0, 0, 0, 0);\n        $limit = $this->getInput('limit');\n        foreach ($gobbets as $gobbet) {\n            $title = $gobbet->plaintext;\n            $match = preg_match('/[\\.,]/', $title, $matches, PREG_OFFSET_CAPTURE);\n            if ($match > 0) {\n                $point = $matches[0][1];\n                $title = mb_substr($title, 0, $point);\n            }\n            if ($limit && mb_strlen($title) > $limit) {\n                $title = mb_substr($title, 0, $limit) . '...';\n            }\n            $item = [\n                'uri' => self::URI,\n                'title' => $title,\n                'content' => $gobbet->innertext,\n                'timestamp' => $today->format('U'),\n                'uid' => md5($gobbet->plaintext)\n            ];\n            $this->items[] = $item;\n        }\n    }\n\n    private function mergeGobbets($gobbets)\n    {\n        $today = new Datetime();\n        $today->setTime(0, 0, 0, 0);\n        $contents = '';\n        foreach ($gobbets as $gobbet) {\n            $contents .= \"<p>{$gobbet->innertext}\";\n        }\n        $this->items[] = [\n            'uri' => self::URI,\n            'title' => 'World in brief at ' . $today->format('Y.m.d'),\n            'content' => $contents,\n            'timestamp' => $today->format('U'),\n            'uid' => 'world-in-brief-' . $today->format('U')\n        ];\n    }\n\n    private function collectArticles($articles)\n    {\n        $i = 0;\n        $today = new Datetime();\n        $today->setTime(0, 0, 0, 0);\n        foreach ($articles->children() as $element) {\n            if ($element->tag != 'div') {\n                continue;\n            }\n            if ($element->find('._newsletterContentPromo', 0) != null) {\n                continue;\n            }\n            $image = $element->find('figure', 0);\n            $title = $element->find('h3', 0)->plaintext;\n            $content = $element->find('h3', 0)->parent();\n            $content->find('h3', 0)->outertext = '';\n\n            $res_content = '';\n            if ($image != null && $this->getInput('agendaPictures') == 1) {\n                $img = $image->find('img', 0);\n                $res_content .= '<img src=\"' . $img->src . '\" />';\n            }\n            $res_content .= $content->innertext;\n            $this->items[] = [\n                'uri' => self::URI,\n                'title' => $title,\n                'content' => $res_content,\n                'timestamp' => $today->format('U'),\n                'uid' => 'story-' . $today->format('U') . \"{$i}\",\n            ];\n            $i++;\n        }\n    }\n\n    private function addQuote($quote)\n    {\n        $today = new Datetime();\n        $today->setTime(0, 0, 0, 0);\n        $this->items[] = [\n            'uri' => self::URI,\n            'title' => 'Quote of the day ' . $today->format('Y.m.d'),\n            'content' => $quote->innertext,\n            'timestamp' => $today->format('U'),\n            'uid' => 'quote-' . $today->format('U')\n        ];\n    }\n\n    private function mergeEverything()\n    {\n        $today = new Datetime();\n        $today->setTime(0, 0, 0, 0);\n        $contents = '';\n\n        foreach ($this->items as $item) {\n            $header = null;\n            if (str_contains($item['uid'], 'story-')) {\n                $header = $item['title'];\n            } elseif (str_contains($item['uid'], 'quote-')) {\n                $header = 'Quote of the day';\n            } elseif (str_contains($item['uid'], 'world-in-brief-')) {\n                $header = 'World in brief';\n            }\n            if ($header != null) {\n                $contents .= \"<h2>{$header}</h2>\";\n            }\n            $contents .= $item['content'];\n        }\n\n        $item = [\n            'uri' => self::URI,\n            'title' => 'The Economist World in Brief ' . $today->format('d.m.Y'),\n            'content' => $contents,\n            'timestamp' => $today->format('U'),\n            'uid' => 'world-in-brief-merged' . $today->format('U')\n        ];\n        $this->items = [$item];\n    }\n}\n"
  },
  {
    "path": "bridges/EdfPricesBridge.php",
    "content": "<?php\n\nclass EdfPricesBridge extends BridgeAbstract\n{\n    const NAME = 'EDF tarifs';\n    // pull info from this site for now because EDF do not provide correct opendata\n    const URI = 'https://www.jechange.fr';\n    const DESCRIPTION = 'Fetches the latest infos of EDF prices';\n    const MAINTAINER = 'floviolleau';\n    const PARAMETERS = [\n        [\n            'contract' => [\n                'name' => 'Choisir un contrat',\n                'type' => 'list',\n                // we can add later more option prices\n                'values' => [\n                    'Base' => '/energie/edf/tarifs/tarif-bleu#base',\n                    'HPHC' => '/energie/edf/tarifs/tarif-bleu#hphc',\n                    'EJP' => '/energie/edf/tarifs/tarif-bleu#ejp',\n                    'Tempo' => '/energie/edf/tarifs/tempo'\n                ],\n            ],\n            'power' => [\n                'name' => 'Choisir une puissance',\n                'type' => 'list',\n                'values' => [\n                    '3 kVA' => 3,\n                    '6 kVA' => 6,\n                    '9 kVA' => 9,\n                    '12 kVA' => 12,\n                    '15 kVA' => 15,\n                    '18 kVA' => 18,\n                    '24 kVA' => 24,\n                    '30 kVA' => 30,\n                    '36 kVA' => 36\n                ]\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 7200; // 2h\n\n    private function removeEmojisAndSpecialSpaces(string $text): string\n    {\n        // This regex covers most common emoji ranges in Unicode\n        $regex = '/[\\x{1F600}-\\x{1F64F}' . // Emoticons\n                 '\\x{1F300}-\\x{1F5FF}' . // Misc Symbols and Pictographs\n                 '\\x{1F680}-\\x{1F6FF}' . // Transport and Map\n                 '\\x{1F700}-\\x{1F77F}' . // Alchemical Symbols\n                 '\\x{1F780}-\\x{1F7FF}' . // Geometric Shapes Extended\n                 '\\x{1F800}-\\x{1F8FF}' . // Supplemental Arrows-C\n                 '\\x{1F900}-\\x{1F9FF}' . // Supplemental Symbols and Pictographs\n                 '\\x{1FA00}-\\x{1FA6F}' . // Chess Symbols, Symbols and Pictographs Extended-A\n                 '\\x{1FA70}-\\x{1FAFF}' . // Symbols and Pictographs Extended-B\n                 '\\x{2600}-\\x{26FF}' . // Misc symbols\n                 '\\x{2700}-\\x{27BF}' . // Dingbats\n                 ']+/u';\n\n        return preg_replace($regex, '', str_replace('&nbsp;', '', $text));\n    }\n\n    /**\n     * @param simple_html_dom $html\n     * @param string $contractUri\n     * @return void\n     */\n    private function tempo(simple_html_dom $html, string $contractUri, int $power): void\n    {\n        // colors\n        $ulDom = $html->find('#les-tarifs-du-kwh-tempo-pour-les-differentes-couleurs-et-heures-de-la-journee', 0)->nextSibling();\n        $elementsDom = $ulDom->children;\n\n        if ($elementsDom && count($elementsDom) === 3) {\n            // price per kWh is same for all powers\n            foreach ($elementsDom as $elementDom) {\n                $item = [];\n\n                $matches = [];\n                preg_match_all(\n                    '/Jour (.*) :.*?Heures (.*) : (.*).*?€.*?Heures (.*) : (.*).*?€/um',\n                    $this->removeEmojisAndSpecialSpaces($elementDom->plaintext),\n                    $matches,\n                    PREG_SET_ORDER,\n                    0\n                );\n\n                // for tempo contract we have 2x3 colors\n                if ($matches && count($matches[0]) === 6) {\n                    for ($i = 0; $i < 2; $i++) {\n                        $text = 'Jour ' . $matches[0][1] . ' - Heures ' . $matches[0][2 + 2 * $i] . ' : ' . $matches[0][3 + 2 * $i] . '€';\n                        $item['uri'] = self::URI . $contractUri;\n                        $item['title'] = $text;\n                        $item['author'] = self::MAINTAINER;\n                        $item['content'] = $text;\n                        $item['uid'] = hash('sha256', $item['title']);\n\n                        $this->items[] = $item;\n                    }\n                }\n            }\n        }\n\n        // add subscription power info\n        $tablePrices = $ulDom->nextSibling()->nextSibling();\n        $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 7);\n    }\n\n    /**\n     * @param simple_html_dom $html\n     * @param string $contractUri\n     * @return void\n     */\n    private function base(simple_html_dom $html, string $contractUri, int $power): void\n    {\n        $tablePrices = $html\n                            ->find('#grille-tarifaire-et-prix-du-kwh-du-tarif-reglemente-edf-en-option-base', 0)\n                            ->nextSibling()\n                            ->nextSibling();\n\n        $prices = $tablePrices->find('.table tbody tr');\n\n        // price per kWh is same for all powers\n        if ($prices && count($prices) === 9) {\n            $item = [];\n\n            $text = 'Base : ' . $prices[0]->children(2);\n            $item['uri'] = self::URI . $contractUri;\n            $item['title'] = $text;\n            $item['author'] = self::MAINTAINER;\n            $item['content'] = $text;\n            $item['uid'] = hash('sha256', $item['title']);\n\n            $this->items[] = $item;\n        }\n\n        $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 9);\n    }\n\n    /**\n     * @param simple_html_dom $html\n     * @param string $contractUri\n     * @return void\n     */\n    private function hphc(simple_html_dom $html, string $contractUri, int $power): void\n    {\n        $tablePrices = $html\n                            ->find('#grille-tarifaire-et-prix-du-kwh-du-tarif-reglemente-edf-en-option-heures-pleines-heures-creuses', 0)\n                            ->nextSibling()\n                            ->nextSibling();\n\n        $prices = $tablePrices->find('.table tbody tr');\n\n        // price per kWh is same for all powers\n        if ($prices && count($prices) === 8) {\n            $values = ['HC', 'HP'];\n            foreach ($values as $key => $value) {\n                $i++;\n                $item = [];\n\n                $text = $values[$key] . ' : ' . $prices[0]->children($key + 2);\n                $item['uri'] = self::URI . $contractUri;\n                $item['title'] = $text;\n                $item['author'] = self::MAINTAINER;\n                $item['content'] = $text;\n                $item['uid'] = hash('sha256', $item['title']);\n\n                $this->items[] = $item;\n            }\n        }\n\n        $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 8);\n    }\n\n    /**\n     * @param simple_html_dom $html\n     * @param string $contractUri\n     * @return void\n     */\n    private function ejp(simple_html_dom $html, string $contractUri, int $power): void\n    {\n        $tablePrices = $html\n                            ->find('#ejp', 0)\n                            ->nextSibling()\n                            ->nextSibling()\n                            ->nextSibling()\n                            ->nextSibling()\n                            ->nextSibling();\n\n        $prices = $tablePrices->find('.table tbody tr');\n\n        // price per kWh is same for all powers\n        if ($prices && count($prices) === 5) {\n            $values = ['Non EJP', 'EJP'];\n            foreach ($values as $key => $value) {\n                $i++;\n                $item = [];\n\n                $text = $values[$key] . ' : ' . $prices[0]->children($key + 2);\n                $item['uri'] = self::URI . $contractUri;\n                $item['title'] = $text;\n                $item['author'] = self::MAINTAINER;\n                $item['content'] = $text;\n                $item['uid'] = hash('sha256', $item['title']);\n\n                $this->items[] = $item;\n            }\n        }\n\n        $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 5);\n    }\n\n    private function addSubscriptionPowerInfo(simple_html_dom_node $tablePrices, string $contractUri, int $power, int $numberOfPrices): void\n    {\n        $prices = $tablePrices->find('.table tbody tr');\n\n        // 7 contracts for tempo: 6, 9, 12, 15, 18, 30 and 36 kVA\n        // 9 contracts for base: 3, 6, 9, 12, 15, 18, 24, 30 and 36 kVA\n        // 8 contracts for HPHC: 6, 9, 12, 15, 18, 24, 30 and 36 kVA\n        // 5 contracts for EJP: 9, 12, 15, 18 and 36 kVA\n        if ($prices && count($prices) === $numberOfPrices) {\n            $powerFound = false;\n            foreach ($prices as $price) {\n                $powerText = trim($price->children(0)->innertext);\n                if ($price->children(0)->children(0)) {\n                    $powerText = trim($price->children(0)->children(0)->innertext);\n                }\n                $powerValue = (int)substr($powerText, 0, strpos($powerText, ' kVA'));\n\n                if ($powerValue !== $power) {\n                    continue;\n                }\n\n                $item = [];\n\n                $text = $powerText . ' : ' . $price->children(1) . '/an';\n                $item['uri'] = self::URI . $contractUri;\n                $item['title'] = $text;\n                $item['author'] = self::MAINTAINER;\n                $item['content'] = $text;\n                $item['uid'] = hash('sha256', $item['title']);\n\n                $this->items[] = $item;\n                $powerFound = true;\n                break;\n            }\n\n            if (!$powerFound) {\n                $item = [];\n\n                $text = 'Pas de tarif abonnement pour cette puissance et ce contrat';\n                $item['uri'] = self::URI . $contractUri;\n                $item['title'] = $text;\n                $item['author'] = self::MAINTAINER;\n                $item['content'] = $text;\n                $item['uid'] = hash('sha256', $item['title']);\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function collectData()\n    {\n        $contract = $this->getKey('contract');\n        $contractUri = $this->getInput('contract');\n        $power = $this->getInput('power');\n        $html = getSimpleHTMLDOM(self::URI . $contractUri);\n\n        if ($contract === 'Tempo') {\n            $this->tempo($html, $contractUri, $power);\n        }\n\n        if ($contract === 'Base') {\n            $this->base($html, $contractUri, $power);\n        }\n\n        if ($contract === 'HPHC') {\n            $this->hphc($html, $contractUri, $power);\n        }\n\n        if ($contract === 'EJP') {\n            $this->ejp($html, $contractUri, $power);\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ElektroARGOSBridge.php",
    "content": "<?php\n\n/**\n *\n * this code downloads the HTML page with product news from ARGOS website (https://www.i4wifi.cz), parses it, extracts key information\n *  about each article (title, link, date, description, images), and formats it into a structured form,\n *  likely for further processing, such as creating an RSS feed.\n */\n\nclass ElektroARGOSBridge extends BridgeAbstract\n{\n    const NAME = 'Elektro ARGOS';\n    const URI = 'https://www.argos.cz/';\n    const DESCRIPTION = 'News, events and promotions on ARGOS electro shop - www.argos.cz - Czech Republic';\n    const MAINTAINER = 'pprenghyorg';\n    const CACHE_TIMEOUT = 86400;\n\n    // Only Weekly offer and Promotional letter are supported\n    const PARAMETERS = [\n        'News and articles' => [],\n        'Events' => [],\n        'Topics and Promos' => []\n    ];\n\n    /**\n     * Fetches and processes data based on the selected context.\n     *\n     * This function retrieves the HTML content for the specified context's URI,\n     * resolves relative links within the content, and then delegates the data\n     * extraction to the appropriate method (currently only `collectNews` for the 'Articles' context).\n     */\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI(), self::CACHE_TIMEOUT);\n\n        defaultLinkTo($html, static::URI);\n\n        // Router\n        switch ($this->queriedContext) {\n            case 'News and articles':\n                $this->collectNews($html);\n                break;\n            case 'Events':\n                $this->collectEvents($html);\n                break;\n            case 'Topics and Promos':\n                $this->collectTopic($html);\n                break;\n        }\n    }\n\n    /**\n     * Returns the icon for the bridge.\n     *\n     * @return string The icon URL.\n     */\n    public function getURI()\n    {\n        $uri = static::URI;\n\n        // URI Router\n        switch ($this->queriedContext) {\n            case 'News and articles':\n                $uri .= 'akce/nabidka/';\n                break;\n            case 'Events':\n                $uri .= 'pobocka-praha-hostivar/akce/udalosti/';\n                break;\n            case 'Topics and Promos':\n                $uri .= 'pobocka-praha-hostivar/akce/temata/';\n                break;\n        }\n\n        return $uri;\n    }\n\n    /**\n     * Returns the keyword URL map for the bridge.\n     *\n     * @return string The Name.\n     */\n    public function getKeywordUrlMap()\n    {\n        // Get the keyword URL map from the class constant\n        $keywordUrlMap = static::KEYWORDURLMAP;\n\n        // returns the keyword URL map\n        return $keywordUrlMap;\n    }\n\n    /**\n     * Returns the name for the bridge.\n     *\n     * @return string The Name.\n     */\n    public function getName()\n    {\n        $name = static::NAME;\n\n        $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';\n\n        switch ($this->queriedContext) {\n            case 'News and articles':\n                break;\n            case 'Events':\n                break;\n            case 'Topics and Promos':\n                break;\n        }\n\n        return $name;\n    }\n\n    /**\n     * Parse most used date formats\n     *\n     * Basically strtotime doesn't convert dates correctly due to formats\n     * being hard to interpret. So we use the DateTime object, manually\n     * fixing dates and times (set to 00:00:00.000).\n     *\n     * We don't know the timezone, so just assume +00:00 (or whatever\n     * DateTime chooses)\n     */\n    private function fixDate($date)\n    {\n        $df = $this->parseDateTimeFromString($date);\n\n        return date_format($df, 'U');\n    }\n\n    /**\n     * Extracts the images from the article.\n     *\n     * @param object $article The article object.\n     * @return array An array of image URLs.\n     */\n    private function extractImages($article)\n    {\n        // Notice: We can have zero or more images (though it should mostly be 1)\n        $elements = $article->find('img');\n\n        $images = [];\n\n        foreach ($elements as $img) {\n            $images[] = $img->src;\n        }\n\n        return $images;\n    }\n\n    // region Weekly offer\n\n    /**\n     * Collects uri, timestamp, title, content and images in the product offers from the HTML and transforms to rss.\n     *\n     * @param object $html The HTML object.\n     * @return void\n     */\n    private function collectNews($html)\n    {\n        // Check if page contains articles and split by class\n        $articles = $html->find('.com-news-feature-prerex') or\n            throwServerException('No articles found! Layout might have changed!');\n\n        // Articles loop\n        foreach ($articles as $article) {\n            $item = [];\n\n            // Add URI\n            $item['uri'] = $this->extractNewsUri($article);\n//            echo $item['uri'] . '<BR>';\n            // Add title\n            $item['title'] = $this->extractNewsTitle($article);\n//            echo $item['title'] . '<BR>';\n            $item['enclosures'] = $this->extractImages($article);\n\n            // Add to rss query\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Collects uri, timestamp, title, content and images in the promotional letter from the HTML and transforms to rss.\n     *\n     * @param object $html The HTML object.\n     * @return void\n     */\n    private function collectEvents($html)\n    {\n        // Check if page contains articles and split by class\n        $articles = $html->find('.com-news-common-prerex') or\n            throwServerException('No articles found! Layout might have changed!');\n\n        // Articles loop\n        foreach ($articles as $article) {\n            $item = [];\n\n            // Add URI\n            $item['uri'] = $this->extractEventUri($article);\n            // Add title\n            $item['title'] = $this->extractEventTitle($article);\n            // Add content\n            $item['content'] = $this->extractEventDescription($article);\n            // Parse time\n            $newsDate = $this->extractDate($article);\n            // Remove prefix\n            $newsDate = str_replace('zveřejněno: ', '', $newsDate);\n            // Fix date\n            $item['timestamp'] = $this->fixDate($newsDate);\n            // Add images\n            $item['enclosures'] = $this->extractImages($article);\n\n            // Add to rss query\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Collects uri, timestamp, title, content and images in the promotional letter from the HTML and transforms to rss.\n     *\n     * @param object $html The HTML object.\n     * @return void\n     */\n    private function collectTopic($html)\n    {\n        // Check if page contains articles and split by class\n        $articles = $html->find('.com-news-common-prerex') or\n            throwServerException('No articles found! Layout might have changed!');\n\n        // Articles loop\n        foreach ($articles as $article) {\n            $item = [];\n\n            // Add URI\n            $item['uri'] = $this->extractEventUri($article);\n            // Add title\n            $item['title'] = $this->extractEventTitle($article);\n            // Add content\n            $item['content'] = $this->extractEventDescription($article);\n            // Parse time\n            $newsDate = $this->extractDate($article);\n            // Remove prefix\n            $newsDate = str_replace('zveřejněno: ', '', $newsDate);\n            // Fix date\n            $item['timestamp'] = $this->fixDate($newsDate);\n            // Add images\n            $item['enclosures'] = $this->extractImages($article);\n\n            // Add to rss query\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extracts the URI of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The URI of the news article.\n     */\n    private function extractEventUri($article)\n    {\n        return $article->href;\n    }\n\n\n    /**\n     * Extracts the URI of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The URI of the news article.\n     */\n    private function extractNewsUri($article)\n    {\n        // Return URI of the article\n        $element = $article->find('a', 0) or\n            throwServerException('Anchor not found!');\n\n        return $element->href;\n    }\n\n    /**\n     * Extracts the URI of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The URI of the news article.\n     */\n    private function extractLetterUri($article)\n    {\n        // Return URI of the article\n        $element = $article->find('a.ws-btn', 0);\n\n        // Element empty check\n        if ($element == null) {\n            return '';\n        }\n\n        return $element->href;\n    }\n\n    /**\n     * Extracts the date of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The date of the news article.\n     */\n    private function extractDate($article)\n    {\n        // Check if date is set\n        $element = $article->find('div.com-news-common-prerex__date', 0) or\n            throwServerException('Date not found!');\n\n        return $element->plaintext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription($article)\n    {\n        // Extract description\n        $element = $article->find('ul.ws-product-information__piece-description', 0)->find('li', 0) or\n            throwServerException('Description not found!');\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription1($article)\n    {\n        // Extract description\n        $element = $article->find('div.ws-product-price-validity', 0)->find('div', 0) or\n            throwServerException('Description not found!');\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription2($article)\n    {\n        // Extract description\n        $element = $article->find('div.ws-product-price-validity', 0)->find('div', 1) or\n            throwServerException('Description not found!');\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription3($article)\n    {\n        // Extract description\n        $element = $article->find('div.ws-product-badge-text', 0);\n\n        // Check if element is not null\n        // If it is null, return empty string\n        // If it is not null, return the inner text\n        // This is to avoid errors when the element is not found\n        // and to ensure that the function always returns a string\n        if ($element != null) {\n            return $element->innertext;\n        } else {\n            return '';\n        }\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription4($article)\n    {\n        // Extract description\n        $element = $article->find('div.ws-product-price-type__value', 0);\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription5($article)\n    {\n        // Extract description\n        $element = $article->find('div.ws-product-price-type__label', 0);\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription6($article)\n    {\n        // Extract description\n        $element = $article->find('div.ws-product-price', 0)->find('div.ws-product-price-type', 1);\n\n        // Element empty check\n        if ($element == null) {\n            return '';\n        }\n\n        // Not null, so we can safely access the element\n        $element = $element->find('div.ws-product-price-type__value', 0);\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractEventDescription($article)\n    {\n        // Extract description\n        $element = $article->find('.com-news-common-prerex__text', 0);\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the title of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The title of the news article.\n     */\n    private function extractNewsTitle($article)\n    {\n        // Extract title\n        $element = $article->find('img', 0) or\n            throwServerException('Title not found!');\n\n        return $element->alt;\n    }\n\n    /**\n     * Extracts the title of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The title of the news article.\n     */\n    private function extractEventTitle($article)\n    {\n        // Extract title\n        $element = $article->find('div.com-news-common-prerex__right-box', 0)->find('h3', 0)\n            or throwServerException('Title not found!');\n\n        return $element->plaintext;\n    }\n\n    /**\n     * Extracts the description of the letter article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractLetterDescription($article)\n    {\n        // Extract description\n        $element = $article->find('a', 0);\n\n        return $element;\n    }\n\n    /**\n     * It attempts to recognize the date/time format in a string and create a DateTime object.\n     *\n     * It goes through the list of defined formats and tries to apply them to the input string.\n     * Returns the first successfully parsed DateTime object that matches the entire string.\n     *\n     * @param string $dateString A string potentially containing a date and/or time.\n     * @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null.\n     */\n    private function parseDateTimeFromString(string $dateString): ?DateTime\n    {\n        // List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs!\n        // Order may matter if the formats are ambiguous.\n        // It is recommended to give more specific formats (with time, full year) before more general ones.\n        $possibleFormats = [\n            // Czech formats (day.month.year)\n            'd.m.Y H:i:s',  // 10.04.2025 10:57:47\n            'j.n.Y H:i:s',  // 10.4.2025 10:57:47\n            'd. m. Y H:i:s',  // 10. 04. 2025 10:57:47\n            'j. n. Y H:i:s',  // 10. 4. 2025 10:57:47\n            'd.m.Y H:i',  // 10.04.2025 10:57\n            'j.n.Y H:i',  // 10.4.2025 10:57\n            'd. m. Y H:i',  // 10. 04. 2025 10:57\n            'j. n. Y H:i',  // 10. 4. 2025 10:57\n            'd.m.Y',  // 10.04.2025\n            'j.n.Y',  // 10.4.2025\n            'd. m. Y',  // 10. 04. 2025\n            'j. n. Y',  // 10. 4. 2025\n            // ISO 8601 and international formats (year-month-day)\n            'Y-m-d H:i:s',  // 2025-04-10 10:57:47\n            'Y-m-d H:i',  // 2025-04-10 10:57\n            'Y-m-d',  // 2025-04-10\n            'YmdHis',  // 20250410105747\n            'Ymd',  // 20250410\n            // American formats (month/day/year) - beware of ambiguity!\n            'm/d/Y H:i:s',  // 04/10/2025 10:57:47\n            'n/j/Y H:i:s',  // 4/10/2025 10:57:47\n            'm/d/Y H:i',  // 04/10/2025 10:57\n            'n/j/Y H:i',  // 4/10/2025 10:57\n            'm/d/Y',  // 04/10/2025\n            'n/j/Y',  // 4/10/2025\n            // Standard formats (including time zone)\n            DateTime::ATOM,  // example. 2025-04-10T10:57:47+02:00\n            DateTime::RFC3339,  // example. 2025-04-10T10:57:47+02:00\n            DateTime::RFC3339_EXTENDED,  // example. 2025-04-10T10:57:47.123+02:00\n            DateTime::RFC2822,  // example. Thu, 10 Apr 2025 10:57:47 +0200\n            DateTime::ISO8601,  // example. 2025-04-10T105747+0200\n            'Y-m-d\\TH:i:sP',  // ISO 8601 s 'T' oddělovačem\n            'Y-m-d\\TH:i:s.uP',  // ISO 8601 s mikrosekundami\n            // You can add more formats as needed...\n            // e.g. 'd-M-Y' (10-Apr-2025) - requires English locale\n            // e.g. 'j. F Y' (10. abren 2025) - requires Czech locale\n        ];\n\n        // Set locale for parsing month/day names (if using F, M, l, D)\n        // E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8');\n\n        foreach ($possibleFormats as $format) {\n            // We will try to create a DateTime object from the given format\n            $dateTime = DateTime::createFromFormat($format, $dateString);\n\n            // We check that the parsing was successful AND ALSO\n            // that there were no errors or warnings during the parsing.\n            // This is important to ensure that the format matches the ENTIRE string.\n            if ($dateTime !== false) {\n                $errors = DateTime::getLastErrors();\n                if (!($errors)) {\n                    // Success! We found a valid format for the entire string.\n                    return $dateTime;\n                }\n            }\n        }\n\n        // If no format matches or parsing failed\n        return null;\n    }\n\n    /**\n     * Finds values from an associative array whose keys are substrings of a given text.\n     *\n     * The function iterates through the `$map` associative array. For each key,\n     * it checks if that key exists as a substring within the input `$text`.\n     * If found, the corresponding value from the map is added to the result array.\n     * The search is case-sensitive and treats special characters literally.\n     *\n     * @param string $text The input text string to search within.\n     * @param array $map An associative array (key => value). Keys from this array will be searched for in `$text`.\n     * @return array An array of values whose corresponding keys were found as substrings in `$text`. Returns an empty array if no keys are found.\n     */\n    private function findValuesByKeySubstring(string $text, array $map): array\n    {\n        $foundValues = [];  // Initialize array for found values\n\n        // Iterate through each key => value pair in the map\n        foreach ($map as $key => $value) {\n            // Use strpos(), which finds the position of the first occurrence of a substring.\n            // Returns the position (including 0) or `false` if the substring is not found.\n            // We use `!== false` to correctly handle the case where the key starts at position 0.\n            // Cast key to string for robustness (though array keys are usually strings or ints).\n            // `strpos` treats special characters in the key and text literally.\n\n            //          echo \"Key: $key, Text: $text<BR>\\n\";\n            if (strpos($text, $key) !== false) {\n                // If the key was found in the text, add its corresponding value to the result array\n                $foundValues[] = $value;\n            }\n        }\n\n        // Return the array of found values\n        return $foundValues;\n    }\n\n    /**\n     * Removes Czech diacritics from a given string.\n     *\n     * This function replaces Czech characters with their ASCII equivalents.\n     * For example, 'á' becomes 'a', 'č' becomes 'c', etc.\n     *\n     * @param string $text The input string with Czech diacritics.\n     * @return string The string with Czech diacritics removed.\n     */\n    private function removeCzechDiacritics(string $text): string\n    {\n        $czech = [\n            'á', 'č', 'ď', 'é', 'ě', 'í', 'ň', 'ó', 'ř', 'š', 'ť', 'ú', 'ů', 'ý', 'ž',\n            'Á', 'Č', 'Ď', 'É', 'Ě', 'Í', 'Ň', 'Ó', 'Ř', 'Š', 'Ť', 'Ú', 'Ů', 'Ý', 'Ž'\n        ];\n        $ascii = [\n            'a', 'c', 'd', 'e', 'e', 'i', 'n', 'o', 'r', 's', 't', 'u', 'u', 'y', 'z',\n            'A', 'C', 'D', 'E', 'E', 'I', 'N', 'O', 'R', 'S', 'T', 'U', 'U', 'Y', 'Z'\n        ];\n\n        return str_replace($czech, $ascii, $text);\n    }\n\n    // endregion\n\n    /**\n     * Creates title by clean URI by removing unwanted characters and leaves last part of the URI.\n     *\n     * @param string $text The input string with Czech diacritics.\n     * @return string The string with Czech diacritics removed.\n    */\n    private function formatTitleFromURI(string $uri): string\n    {\n        // get last part of the URI\n        $title = basename($uri);\n\n        // Pattern: /[^\\p{L}\\p{N}]+/u\n        // [^...] - Match any character NOT in the set\n        // \\p{L}  - Any Unicode letter (including 'é', 'ü', 'ñ', etc.)\n        // \\p{N}  - Any Unicode number (0-9 and other numeric characters)\n        // +      - Match one or more occurrences of the preceding pattern consecutively\n        // /u     - Unicode modifier, essential for \\p{} constructs\n        $pattern = '/[^\\p{L}\\p{N}]+/u';\n        $replacement = ' '; // Replace with a single space\n\n        // lets replace\n        $title = preg_replace($pattern, $replacement, $title);\n\n        // first letter to uppercase\n        $title = ucfirst($title);\n\n        return trim((string)$title);\n    }\n}\n"
  },
  {
    "path": "bridges/EliteDangerousGalnetBridge.php",
    "content": "<?php\n\nclass EliteDangerousGalnetBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'Elite: Dangerous Galnet';\n    const URI = 'https://community.elitedangerous.com/galnet/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Returns the latest page of news from Galnet';\n    const PARAMETERS = [\n        [\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'English' => 'en-US',\n                    'French' => 'fr-FR',\n                    'German' => 'de-DE',\n                    'Russian' => 'ru-RU',\n                    'Spanish' => 'es-ES',\n                ],\n                'defaultValue' => 'en-US'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $language = $this->getInput('language');\n        $url = 'https://cms.zaonce.net/';\n        $url = $url . $language . '/jsonapi/node/galnet_article';\n        $url = $url . '?&sort=-published_at&page[offset]=0&page[limit]=12';\n\n        $html = getSimpleHTMLDOM($url);\n        $json = json_decode($html);\n\n        foreach ($json->data as $element) {\n            $item = [];\n\n            $uri = 'https://www.elitedangerous.com/news/galnet/';\n            $uri = $uri . $element->attributes->field_slug;\n            $item['uri'] = $uri;\n\n            $item['title'] = $element->attributes->title;\n\n            $picture = 'https://hosting.zaonce.net/elite-dangerous/galnet/';\n            $picture = $picture . $element->attributes->field_galnet_image . '.png';\n            $picture = '<img src=\"' . $picture . '\"/>';\n\n            $content = $element->attributes->body->processed;\n            $item['content'] = $picture . $content;\n\n            $item['timestamp'] = strtotime($element->attributes->published_at);\n\n            $this->items[] = $item;\n        }\n\n        //Remove duplicates that sometimes show up on the website\n        $this->items = array_unique($this->items, SORT_REGULAR);\n    }\n}\n"
  },
  {
    "path": "bridges/ElloBridge.php",
    "content": "<?php\n\nclass ElloBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'Ello';\n    const URI = 'https://ello.co/';\n    const CACHE_TIMEOUT = 4800; //2hours\n    const DESCRIPTION = 'Returns the newest posts for Ello';\n\n    const PARAMETERS = [\n        'By User' => [\n            'u' => [\n                'name' => 'Username',\n                'required' => true,\n                'exampleValue' => 'zteph',\n                'title' => 'Username'\n            ]\n        ],\n        'Search' => [\n            's' => [\n                'name' => 'Search',\n                'required' => true,\n                'exampleValue' => 'bird',\n                'title' => 'Search'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $header = [\n            'Authorization: Bearer ' . $this->getAPIKey()\n        ];\n\n        if (!empty($this->getInput('u'))) {\n            $postData = getContents(self::URI . 'api/v2/users/~' . urlencode($this->getInput('u')) . '/posts', $header);\n        } else {\n            $postData = getContents(self::URI . 'api/v2/posts?terms=' . urlencode($this->getInput('s')), $header);\n        }\n\n        $postData = json_decode($postData);\n        $count = 0;\n        foreach ($postData->posts as $post) {\n            $item = [];\n            $item['author'] = $this->getUsername($post, $postData);\n            $item['timestamp'] = strtotime($post->created_at);\n            $item['title'] = strip_tags($this->findText($post->summary));\n            $item['content'] = $this->getPostContent($post->body);\n            $item['enclosures'] = $this->getEnclosures($post, $postData);\n            $item['uri'] = self::URI . $item['author'] . '/post/' . $post->token;\n            $content = $post->body;\n\n            $this->items[] = $item;\n            $count += 1;\n        }\n    }\n\n    private function findText($path)\n    {\n        foreach ($path as $summaryElement) {\n            if ($summaryElement->kind == 'text') {\n                return $summaryElement->data;\n            }\n        }\n\n        return '';\n    }\n\n    private function getPostContent($path)\n    {\n        $content = '';\n        foreach ($path as $summaryElement) {\n            if ($summaryElement->kind == 'text') {\n                $content .= $summaryElement->data;\n            } elseif ($summaryElement->kind == 'image') {\n                $alt = '';\n                if (property_exists($summaryElement->data, 'alt')) {\n                    $alt = $summaryElement->data->alt;\n                }\n                $content .= '<img src=\"' . $summaryElement->data->url . '\" alt=\"' . $alt . '\" />';\n            }\n        }\n\n        return $content;\n    }\n\n    private function getEnclosures($post, $postData)\n    {\n        $assets = [];\n        foreach ($post->links->assets as $asset) {\n            foreach ($postData->linked->assets as $assetLink) {\n                if ($asset == $assetLink->id) {\n                    $assets[] = $assetLink->attachment->original->url;\n                    break;\n                }\n            }\n        }\n\n        return $assets;\n    }\n\n    private function getUsername($post, $postData)\n    {\n        foreach ($postData->linked->users as $user) {\n            if ($user->id == $post->links->author->id) {\n                return $user->username;\n            }\n        }\n    }\n\n    private function getAPIKey()\n    {\n        $cacheKey = 'ElloBridge_key';\n        $apiKey = $this->cache->get($cacheKey);\n\n        if (!$apiKey) {\n            $keyInfo = getContents(self::URI . 'api/webapp-token');\n            $apiKey = json_decode($keyInfo)->token->access_token;\n            $ttl = 60 * 60 * 20;\n            $this->cache->set($cacheKey, $apiKey, $ttl);\n        }\n\n        return $apiKey;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return $this->getInput('u') . ' - Ello';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/ElsevierBridge.php",
    "content": "<?php\n\nclass ElsevierBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dvikan';\n    const NAME = 'Elsevier journals recent articles';\n    const URI = 'https://www.journals.elsevier.com/';\n    const CACHE_TIMEOUT = 43200; //12h\n    const DESCRIPTION = 'Returns the recent articles published in Elsevier journals';\n\n    const PARAMETERS = [ [\n        'j' => [\n            'name' => 'Journal name',\n            'required' => true,\n            'exampleValue' => 'academic-pediatrics',\n            'title' => 'Insert html-part of your journal'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        // Not all journals have the /recent-articles page\n        $url = sprintf('https://www.journals.elsevier.com/%s/recent-articles/', $this->getInput('j'));\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('article') as $recentArticle) {\n            $item = [];\n            $item['uri'] = $recentArticle->find('a', 0)->getAttribute('href');\n            $item['title'] = $recentArticle->find('h2', 0)->plaintext;\n            $item['author'] = $recentArticle->find('p > span', 0)->plaintext;\n            $publicationDateString = trim($recentArticle->find('p > span', 1)->plaintext);\n            $publicationDate = DateTimeImmutable::createFromFormat('F d, Y', $publicationDateString);\n            if ($publicationDate) {\n                $item['timestamp'] = $publicationDate->getTimestamp();\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    public function getIcon(): string\n    {\n        return 'https://cdn.elsevier.io/verona/includes/favicons/favicon-32x32.png';\n    }\n}\n"
  },
  {
    "path": "bridges/EngadgetBridge.php",
    "content": "<?php\n\nclass EngadgetBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'Engadget';\n    const URI = 'https://www.engadget.com/';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'Article content for Engadget.';\n\n    public function collectData()\n    {\n        $url = 'https://www.engadget.com/rss.xml';\n        $max = 10;\n        $this->collectExpandableDatas($url, $max);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $itemUrl = trim($item['uri']);\n        if (!$itemUrl) {\n            return $item;\n        }\n        // todo: remove querystring tracking\n        $dom = getSimpleHTMLDOM($itemUrl);\n        // figure contain's the main article image\n        $article = $dom->find('figure', 0);\n        // .article-text has the actual article\n        foreach ($dom->find('.article-text') as $element) {\n            $article = $article . $element;\n        }\n        $item['content'] = $article ?? '';\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/EpicGamesFreeBridge.php",
    "content": "<?php\n\nclass EpicGamesFreeBridge extends BridgeAbstract\n{\n    const NAME = 'Epic Games Free Games';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://store.epicgames.com/';\n    const DESCRIPTION = 'Returns the latest free games from Epic Games';\n    const PARAMETERS = [ [\n        'locale' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'values' => [\n                'English' => 'en-US',\n                'العربية' => 'ar',\n                'Deutsch' => 'de',\n                'Español (Spain)' => 'es-ES',\n                'Español (LA)' => 'es-MX',\n                'Français' => 'fr',\n                'Italiano' => 'it',\n                '日本語' => 'ja',\n                '한국어' => 'ko',\n                'Polski' => 'pl',\n                'Português (Brasil)' => 'pt-BR',\n                'Русский' => 'ru',\n                'ไทย' => 'th',\n                'Türkçe' => 'tr',\n                '简体中文' => 'zh-CN',\n                '繁體中文' => 'zh-Hant',\n            ],\n            'title' => 'Language for game information',\n            'defaultValue' => 'en-US',\n        ],\n        'country' => [\n            'name' => 'Country',\n            'title' => 'Country store to check for deals',\n            'defaultValue' => 'US',\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = 'https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?';\n        $params = [\n            'locale' => $this->getInput('locale'),\n            'country' => $this->getInput('country'),\n            'allowCountries' => $this->getInput('country'),\n        ];\n        $url .= http_build_query($params);\n        $json = Json::decode(getContents($url));\n\n        $data = $json['data']['Catalog']['searchStore']['elements'];\n        foreach ($data as $element) {\n            $promo = $element['promotions']['promotionalOffers'][0]['promotionalOffers'][0] ?? false;\n            if (\n                !$promo ||\n                $promo['discountSetting']['discountType'] !== 'PERCENTAGE' ||\n                $promo['discountSetting']['discountPercentage'] !== 0\n            ) {\n                continue;\n            }\n            $slug = $element['catalogNs']['mappings'][0]['pageSlug'] ?? null;\n            if ($slug !== null) {\n                $uri = parent::getURI() . $this->getInput('locale') . '/p/' . $slug;\n            } else {\n                // slug not found, show the root promos page\n                $uri = parent::getURI() . $this->getInput('locale') . '/free-games';\n            }\n            $item = [\n                'author' => $element['seller']['name'],\n                'content' => $element['description'],\n                'enclosures' => array_map(fn($item) => $item['url'] . '#.image', $element['keyImages']), // Force image usage.\n                'timestamp' => strtotime($promo['startDate']),\n                'title' => $element['title'],\n                'uri' => $uri,\n            ];\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI() . $this->getInput('locale') . '/free-games';\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/EpicgamesBridge.php",
    "content": "<?php\n\nclass EpicgamesBridge extends BridgeAbstract\n{\n    const NAME = 'Epic Games Store News';\n    const MAINTAINER = 'otakuf';\n    const URI = 'https://www.epicgames.com';\n    const DESCRIPTION = 'Returns the latest posts from epicgames.com';\n    const CACHE_TIMEOUT = 3600; // 60min\n\n    const PARAMETERS = [ [\n        'postcount' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => true,\n            'title' => 'Maximum number of items to return',\n            'defaultValue' => 10,\n        ],\n        'language' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'values' => [\n                'English' => 'en',\n                'العربية' => 'ar',\n                'Deutsch' => 'de',\n                'Español (Spain)' => 'es-ES',\n                'Español (LA)' => 'es-MX',\n                'Français' => 'fr',\n                'Italiano' => 'it',\n                '日本語' => 'ja',\n                '한국어' => 'ko',\n                'Polski' => 'pl',\n                'Português (Brasil)' => 'pt-BR',\n                'Русский' => 'ru',\n                'ไทย' => 'th',\n                'Türkçe' => 'tr',\n                '简体中文' => 'zh-CN',\n                '繁體中文' => 'zh-Hant',\n             ],\n            'title' => 'Language of blog posts',\n            'defaultValue' => 'en',\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $api = 'https://store-content.ak.epicgames.com/api/';\n\n        // Get sticky posts first\n        // Example: https://store-content.ak.epicgames.com/api/ru/content/blog/sticky?locale=ru\n        $urlSticky = $api . $this->getInput('language') . '/content/blog/sticky';\n        // Then get posts\n        // Example: https://store-content.ak.epicgames.com/api/ru/content/blog?limit=25\n        $urlBlog = $api . $this->getInput('language') . '/content/blog?limit=' . $this->getInput('postcount');\n\n        $dataSticky = getContents($urlSticky);\n        $dataBlog = getContents($urlBlog);\n\n        // Merge data\n        $decodedData = array_merge(json_decode($dataSticky), json_decode($dataBlog));\n\n        foreach ($decodedData as $key => $value) {\n            $item = [];\n            $item['uri'] = self::URI . $value->url;\n            $item['title'] = $value->title;\n            $item['timestamp'] = $value->date;\n            $item['author'] = 'Epic Games Store';\n            if (!empty($value->author)) {\n                $item['author'] = $value->author;\n            }\n            if (!empty($value->content)) {\n                $item['content'] = defaultLinkTo($value->content, self::URI);\n            }\n            if (!empty($value->image)) {\n                $item['enclosures'][] = $value->image;\n            }\n            $item['uid'] = $value->_id;\n            $item['id'] = $value->_id;\n\n            $this->items[] = $item;\n        }\n\n        // Sort data\n        usort($this->items, function ($item1, $item2) {\n            if ($item2['timestamp'] == $item1['timestamp']) {\n                return 0;\n            }\n            return ($item2['timestamp'] < $item1['timestamp']) ? -1 : 1;\n        });\n\n        // Limit data\n        $this->items = array_slice($this->items, 0, $this->getInput('postcount'));\n    }\n}\n"
  },
  {
    "path": "bridges/ErowallBridge.php",
    "content": "<?php\n\nclass ErowallBridge extends BridgeAbstract\n{\n    const NAME = 'Erowall.com';\n    const URI = 'https://www.erowall.com/';\n    const DESCRIPTION = 'Latest wallpapers from erowall.com';\n    const MAINTAINER = 'kurz.junge';\n\n    const PARAMETERS = [\n        'global' => [\n            'count' => [\n                'type' => 'number',\n                'name' => 'Count',\n                'title' => 'How many wallpapers to fetch',\n                'defaultValue' => 16\n            ]\n        ],\n        'By tag' => [\n            'tag' => [\n                'type' => 'text',\n                'name' => 'tag',\n                'title' => 'Filter results by tag (e.g. playboy)',\n                'required' => true\n            ]\n        ],\n        'Latest' => [],\n        'Most viewed' => [],\n        'Most downloaded' => []\n    ];\n\n    public function collectData()\n    {\n        $requestedCount = $this->getInput('count');\n        $count = 0;\n\n        while ($count < $requestedCount) {\n            # Indexing from 1\n            $videosURL = $this->getPagedURI($count / 16 + 1);\n\n            $website = getSimpleHTMLDOMCached($videosURL);\n            $nodes = $website->find('.wpmini');\n\n            foreach ($nodes as $wpmini) {\n                $n = $wpmini->find('a', 0);\n\n                # The href has format \"/w/1234/\" so we just remove all non-numeric\n                $uid = preg_replace('/[^0-9]/', '', $n->href);\n                $imageURL = self::URI . \"/wallpapers/original/$uid.jpg\";\n\n                $item = [\n                    'title' => \"Wallpaper $uid\",\n                    'uri' => self::URI . $n->href,\n                    'uid' => \"$uid\",\n                    'enclosures' => [ $imageURL ],\n                    'content' => \"<img src=\\\"$imageURL\\\"/>\"\n                ];\n\n                $tags = basename($n->title, ' wallpaper');\n                $item['categories'] = array_map(\n                    'ucwords',\n                    explode(',', $tags)\n                );\n\n                $this->items[] = $item;\n                $count++;\n\n                if ($count >= $requestedCount) {\n                    break;\n                }\n            }\n\n            # In case that current page has less than 16 wallpapers, it is the\n            # last page and we don't iterate further\n            if (count($nodes) < 16) {\n                break;\n            }\n        }\n    }\n\n\n    private function getPagedURI($pgnum)\n    {\n        return $this->getURI() . \"/page/$pgnum\";\n    }\n\n    public function getURI()\n    {\n        $ret = self::URI;\n        switch ($this->queriedContext) {\n            case 'Most viewed':\n                $ret .= 'views/';\n                break;\n            case 'Most downloaded':\n                $ret .= 'down/';\n                break;\n            case 'Latest':\n                $ret .= 'dat/';\n                break;\n            default:\n                $tag = $this->getInput('tag') ?? '';\n                $ret .= 'teg/' . str_replace(' ', '+', $tag);\n        }\n\n        return $ret;\n    }\n\n    public function getName()\n    {\n        $count = $this->getInput('count');\n        $ret = 'Erowall ';\n        switch ($this->queriedContext) {\n            case 'Most viewed':\n            case 'Most downloaded':\n            case 'Latest':\n                $ret .= $count . ' ' . strtolower($this->queriedContext);\n                break;\n            case 'By tag':\n                $tag = $this->getInput('tag');\n                $ret .= \"$count latest \" . $tag;\n                break;\n            default:\n        }\n\n        return $ret . ' wallpapers';\n    }\n}\n"
  },
  {
    "path": "bridges/EsquerdaNetBridge.php",
    "content": "<?php\n\n/**\n * Appears to be protected by cloudflare now\n */\nclass EsquerdaNetBridge extends FeedExpander\n{\n    const MAINTAINER = 'somini';\n    const NAME = 'Esquerda.net';\n    const URI = 'https://www.esquerda.net';\n    const DESCRIPTION = 'Esquerda.net';\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'Feed',\n                'type' => 'list',\n                'defaultValue' => 'Geral',\n                'values' => [\n                    'Geral' => 'geral',\n                    'Dossier' => 'artigos-dossier',\n                    'Vídeo' => 'video',\n                    'Opinião' => 'opinioes',\n                    'Rádio' => 'radio',\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        parent::collectExpandableDatas($this->getURI());\n    }\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $content = $html->find('div#content div.content', 0);\n        ## Fix author\n        $authorHTML = $html->find('.field-name-field-op-author a', 0);\n        if ($authorHTML) {\n            $item['author'] = $authorHTML->innertext;\n            $authorHTML->remove();\n        }\n        ## Remove crap\n        $content->find('.field-name-addtoany', 0)->remove();\n        ## Fix links\n        $content = defaultLinkTo($content, self::URI);\n        ## Fix Images\n        foreach ($content->find('img') as $img) {\n            $altSrc = $img->getAttribute('data-src');\n            if ($altSrc) {\n                $img->setAttribute('src', $altSrc);\n            }\n            $img->width = null;\n            $img->height = null;\n        }\n        $item['content'] = $content;\n        return $item;\n    }\n\n    public function getURI()\n    {\n        $type = $this->getInput('feed');\n        return self::URI . '/rss/' . $type;\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.esquerda.net/sites/default/files/favicon_0.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/EstCeQuonMetEnProdBridge.php",
    "content": "<?php\n\nclass EstCeQuonMetEnProdBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Est-ce qu\\'on met en prod aujourd\\'hui ?';\n    const URI = 'https://www.estcequonmetenprodaujourdhui.info/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Should we put a website in production today? (French)';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $item = [];\n        $item['uri'] = $this->getURI() . '#' . date('Y-m-d');\n        $item['title'] = $this->getName();\n        $item['author'] = 'Nicolas Hoffmann';\n        $item['timestamp'] = strtotime('today midnight');\n        $item['content'] = str_replace(\n            'src=\"/',\n            'src=\"' . self::URI,\n            trim(extractFromDelimiters($html->outertext, '<body role=\"document\">', '<div id=\"share'))\n        );\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/EtsyBridge.php",
    "content": "<?php\n\nclass EtsyBridge extends BridgeAbstract\n{\n    const NAME = 'Etsy search';\n    const URI = 'https://www.etsy.com';\n    const DESCRIPTION = 'Returns feeds for search results';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        [\n            'query' => [\n                'name' => 'Search query',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert your search term here',\n                'exampleValue' => 'lamp'\n            ],\n            'queryextension' => [\n                'name' => 'Query extension',\n                'type' => 'text',\n                'required' => false,\n                'title' => 'Insert additional query parts here\n(anything after ?search=<your search query>)',\n                'exampleValue' => '&explicit=1&locationQuery=2921044'\n            ],\n            'hideimage' => [\n                'name' => 'Hide image in content',\n                'type' => 'checkbox',\n                'title' => 'Activate to hide the image in the content',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $results = $html->find('li.wt-list-unstyled');\n\n        foreach ($results as $result) {\n            // Remove Lazy loading\n            if ($result->find('.wt-skeleton-ui', 0)) {\n                continue;\n            }\n\n            $item = [];\n\n            $item['title'] = $result->find('a', 0)->title;\n            $item['uri'] = $result->find('a', 0)->href;\n            $item['author'] = $result->find('p.wt-text-gray > span', 2)->plaintext ?? '';\n\n            $item['content'] = '<p>'\n            . ($result->find('span.currency-symbol', 0)->plaintext ?? '')\n            . ($result->find('span.currency-value', 0)->plaintext ?? '')\n            . '</p><p>'\n            . $result->find('a', 0)->title\n            . '</p>';\n\n            $image = $result->find('img.wt-display-block', 0)->src;\n\n            if (!$this->getInput('hideimage')) {\n                $item['content'] .= '<img src=\"' . $image . '\">';\n            }\n\n            $item['enclosures'] = [$image];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('query'))) {\n            $uri = self::URI . '/search?q=' . urlencode($this->getInput('query'));\n\n            if (!is_null($this->getInput('queryextension'))) {\n                $uri .= $this->getInput('queryextension');\n            }\n\n            return $uri;\n        }\n\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/EuronewsBridge.php",
    "content": "<?php\n\nclass EuronewsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Euronews';\n    const URI = 'https://www.euronews.com/';\n    const CACHE_TIMEOUT = 600; // 10 minutes\n    const DESCRIPTION = 'Return articles from the \"Just In\" feed of Euronews.';\n\n    const PARAMETERS = [\n        '' => [\n            'lang' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'defaultValue' => 'www.euronews.com',\n                'values' => [\n                    'English' => 'www.euronews.com',\n                    'French' => 'fr.euronews.com',\n                    'German' => 'de.euronews.com',\n                    'Italian' => 'it.euronews.com',\n                    'Spanish' => 'es.euronews.com',\n                    'Portuguese' => 'pt.euronews.com',\n                    'Russian' => 'ru.euronews.com',\n                    'Turkish' => 'tr.euronews.com',\n                    'Greek' => 'gr.euronews.com',\n                    'Hungarian' => 'hu.euronews.com',\n                    'Persian' => 'per.euronews.com',\n                    'Arabic' => 'arabic.euronews.com',\n                    /* These versions don't have timeline.json */\n                    // 'Albanian' => 'euronews.al',\n                    // 'Romanian' => 'euronews.ro',\n                    // 'Georigian' => 'euronewsgeorgia.com',\n                    // 'Bulgarian' => 'euronewsbulgaria.com'\n                    // 'Serbian' => 'euronews.rs'\n                ]\n            ],\n            'limit' => [\n                'name' => 'Limit of items per feed',\n                'required' => true,\n                'type' => 'number',\n                'defaultValue' => 10,\n                'title' => 'Maximum number of returned feed items. Maximum 50, default 10'\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit');\n        $lang = $this->getInput('lang');\n        if ($lang === 'euronews.com') {\n            $lang = 'www.euronews.com';\n        }\n        $root_url = 'https://' . $lang;\n        $url = $root_url . '/api/timeline.json?limit=' . $limit;\n        $json = getContents($url);\n        $data = json_decode($json, true);\n\n        foreach ($data as $datum) {\n            $datum_uri = $root_url . $datum['path'];\n            $url_datum = $this->getItemContent($datum_uri);\n            $categories = [];\n            if (array_key_exists('program', $datum)) {\n                if (array_key_exists('title', $datum['program'])) {\n                    $categories[] = $datum['program']['title'];\n                }\n            }\n            if (array_key_exists('themes', $datum)) {\n                foreach ($datum['themes'] as $theme) {\n                    $categories[] = $theme['title'];\n                }\n            }\n            $item = [\n                'uri' => $datum_uri,\n                'title' => $datum['title'],\n                'uid' => strval($datum['id']),\n                'timestamp' => $datum['publishedAt'],\n                'content' => $url_datum['content'],\n                'author' => $url_datum['author'],\n                'enclosures' => $url_datum['enclosures'],\n                'categories' => array_unique($categories)\n            ];\n            $this->items[] = $item;\n        }\n    }\n\n    private function getItemContent($url)\n    {\n        try {\n            $html = getSimpleHTMLDOMCached($url);\n        } catch (Exception $e) {\n            // Every once in a while it fails with too many redirects\n            return ['author' => null, 'content' => null, 'enclosures' => null];\n        }\n        $data = $html->find('script[type=\"application/ld+json\"]', 0)->innertext;\n        $json = json_decode($data, true);\n        $author = 'Euronews';\n        $content = '';\n        $enclosures = [];\n        if (array_key_exists('@graph', $json)) {\n            foreach ($json['@graph'] as $item) {\n                if ($item['@type'] == 'NewsArticle') {\n                    if (array_key_exists('author', $item)) {\n                        if (is_array($item['author'])) {\n                            $author = implode(', ', array_map(function ($e) {\n                                return $e['name'];\n                            }, $item['author']));\n                        } elseif (array_key_exists('name', $item['author'])) {\n                            $author = $item['author']['name'];\n                        }\n                    }\n                    if (array_key_exists('image', $item)) {\n                        $content .= '<figure>';\n                        $content .= '<img src=\"' . $item['image']['url'] . '\">';\n                        $content .= '<figcaption>' . $item['image']['caption'] . '</figcaption>';\n                        $content .= '</figure><br>';\n                    }\n                    if (array_key_exists('video', $item)) {\n                        $enclosures[] = $item['video']['contentUrl'];\n                    }\n                }\n            }\n        }\n\n        // Normal article\n        $article_content = $html->find('.c-article-content', 0);\n        if ($article_content) {\n            // Usually the .c-article-content is the root of the\n            // content, but once in a blue moon the root is the second\n            // div\n            if (\n                (count($article_content->children()) == 2)\n                && ($article_content->children(1)->tag == 'div')\n            ) {\n                $article_content = $article_content->children(1);\n            }\n            // The content is interspersed with links and stuff, so we\n            // iterate over the children\n            foreach ($article_content->children() as $element) {\n                if ($element->tag == 'p') {\n                    $scribble_live = $element->find('#scribblelive-items', 0);\n                    if (is_null($scribble_live)) {\n                        // A normal paragraph\n                        $content .= '<p>' . $element->innertext . '</p>';\n                    } else {\n                        // LIVE mode\n                        foreach ($scribble_live->children() as $child) {\n                            if ($child->tag == 'div') {\n                                $content .= '<div>' . $child->innertext . '</div>';\n                            }\n                        }\n                    }\n                } elseif (preg_match('/h[1-6]/', $element->tag)) {\n                    // Header\n                    $content .= '<h' . $element->tag[1] . '>' . $element->innertext . '</h' . $element->tag[1] . '>';\n                } elseif ($element->tag == 'div') {\n                    if (preg_match('/.*widget--type-image.*/', $element->class)) {\n                        // Image\n                        $content .= '<figure>';\n                        $content .= '<img src=\"' . $element->find('img', 0)->src . '\">';\n                        $caption = $element->find('figcaption', 0);\n                        if ($caption) {\n                            $content .= '<figcaption>' . $element->plaintext . '</figcaption>';\n                        }\n                        $content .= '</figure><br>';\n                    } elseif (preg_match('/.*widget--type-quotation.*/', $element->class)) {\n                        // Quotation\n                        $quote = $element->find('.widget__quoteText', 0);\n                        $author = $element->find('.widget__author', 0);\n                        $content .= '<figure>';\n                        $content .= '<blockquote>' . $quote->plaintext . '</blockquote>';\n                        if ($author) {\n                            $content .= '<figcaption>' . $author->plaintext . '</figcaption>';\n                        }\n                        $content .= '</figure><br>';\n                    }\n                }\n            }\n        }\n\n        // Video article\n        if (is_null($article_content)) {\n            $image = $html->find('.c-article-media__img', 0);\n            if ($image) {\n                $content .= '<figure>';\n                $content .= '<img src=\"' . $image->src . '\">';\n                $content .= '</figure><br>';\n            }\n\n            $description = $html->find('.m-object__description', 0);\n            if ($description) {\n                // In some editions the description is a link to the\n                // current page\n                $content .= '<div>' . $description->plaintext . '</div>';\n            }\n\n            // Euronews usually hosts videos on dailymotion...\n            $player_div = $html->find('.dmPlayer', 0);\n            if ($player_div) {\n                $video_id = $player_div->getAttribute('data-video-id');\n                $video_url = 'https://www.dailymotion.com/video/' . $video_id;\n                $content .= '<a href=\"' . $video_url . '\">' . $video_url . '</a>';\n            }\n\n            // ...or on YouTube\n            $player_div = $html->find('.js-player-pfp', 0);\n            if ($player_div) {\n                $video_id = $player_div->getAttribute('data-video-id');\n                $content .= handleYoutube($video_id);\n            }\n        }\n\n        return [\n            'author' => $author,\n            'content' => $content,\n            'enclosures' => $enclosures\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/ExecuteProgramBridge.php",
    "content": "<?php\n\nclass ExecuteProgramBridge extends BridgeAbstract\n{\n    const NAME = 'Execute Program Blog';\n    const URI = 'https://www.executeprogram.com/blog';\n    const DESCRIPTION = 'Unofficial feed for the www.executeprogram.com blog';\n    const MAINTAINER = 'dvikan';\n\n    public function collectData()\n    {\n        $data = json_decode(getContents('https://www.executeprogram.com/api/pages/blog'));\n\n        foreach ($data->posts as $post) {\n            $year = $post->date->year;\n            $month = $post->date->month;\n            $day = $post->date->day;\n\n            $item = [];\n            $item['uri'] = sprintf('https://www.executeprogram.com/blog/%s', $post->slug);\n            $item['title'] = $post->title;\n            $dateTime = \\DateTime::createFromFormat('Y-m-d', $year . '-' . $month . '-' . $day);\n            $item['timestamp'] = $dateTime->format('U');\n            $item['content'] = $post->body;\n\n            $this->items[] = $item;\n        }\n\n        usort($this->items, function ($a, $b) {\n            return $a['timestamp'] < $b['timestamp'];\n        });\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.executeprogram.com/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/ExplosmBridge.php",
    "content": "<?php\n\nclass ExplosmBridge extends BridgeAbstract\n{\n    const NAME = 'Explosm: Cyanide & Happiness';\n    const URI = 'https://explosm.net/';\n    const DESCRIPTION = 'A Webcomic by Kris Wilson, Rob DenBleyker, and Dave McElfatrick.';\n    const MAINTAINER = 'sal0max, bockiii';\n    const CACHE_TIMEOUT = 60 * 60 * 2; // 2 hours\n    const PARAMETERS = [[\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'title' => 'The number of recent comics to get.',\n                'defaultValue' => 5\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return self::URI . 'favicon-32x32.png';\n    }\n\n    public function getURI()\n    {\n        return self::URI . 'comics/latest#comic';\n    }\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit');\n        $url = $this->getUri();\n\n        for ($i = 0; $i < $limit; $i++) {\n            $html = getSimpleHTMLDOM($url);\n\n            $element = $html->find('[class*=ComicImage]', 0);\n            if (!$element) {\n                break; // skip, if element was not found\n            }\n            $date    = $element->find('[class^=Author__Right] p', 0)->plaintext;\n            $author  = str_replace('by ', '', $element->find('[class^=Author__Right] p', 1)->plaintext);\n            $image   = $element->find('img', 0)->src;\n            $link    = $html->find('[rel=canonical]', 0)->href;\n\n            $item = [\n                'uid'       => $link,\n                'author'    => $author,\n                'title'     => $date,\n                'uri'       => $link . '#comic',\n                'timestamp' => str_replace('.', '-', $date) . 'T00:00:00Z',\n                'content'   => \"<img src=\\\"$image\\\" />\"\n            ];\n            $this->items[] = $item;\n\n            // get next url\n            $url = self::URI . $html->find('[class*=ComicSelector]>a', 0)->href;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FB2Bridge.php",
    "content": "<?php\n\nclass FB2Bridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'Facebook Bridge | Touch Site';\n    const URI = 'https://www.facebook.com/';\n    const CACHE_TIMEOUT = 1000;\n    const DESCRIPTION = 'Input a page title or a profile log. For a profile log,\n please insert the parameter as follow : myExamplePage/132621766841117';\n\n    const PARAMETERS = [ [\n        'u' => [\n            'name' => 'Username',\n            'required' => true\n        ],\n        'abbrev_name' => [\n            'name' => 'Abbreviate author name in title',\n            'type' => 'checkbox',\n            'defaultValue' => true,\n        ],\n    ]];\n\n    public function getIcon()\n    {\n        return 'https://static.xx.fbcdn.net/rsrc.php/yo/r/iRmz9lCMBD2.ico';\n    }\n\n    public function collectData()\n    {\n        //Utility function for cleaning a Facebook link\n        $unescape_fb_link = function ($matches) {\n            if (is_array($matches) && count($matches) > 1) {\n                $link = $matches[1];\n                if (strpos($link, '/') === 0) {\n                    $link = self::URI . substr($link, 1);\n                }\n                if (strpos($link, 'facebook.com/l.php?u=') !== false) {\n                    $link = urldecode(extractFromDelimiters($link, 'facebook.com/l.php?u=', '&'));\n                }\n                return ' href=\"' . $link . '\"';\n            }\n        };\n\n        //Utility function for converting facebook emoticons\n        $unescape_fb_emote = function ($matches) {\n            static $facebook_emoticons = [\n                'smile' => ':)',\n                'frown' => ':(',\n                'tongue' => ':P',\n                'grin' => ':D',\n                'gasp' => ':O',\n                'wink' => ';)',\n                'pacman' => ':<',\n                'grumpy' => '>_<',\n                'unsure' => ':/',\n                'cry' => ':\\'(',\n                'kiki' => '^_^',\n                'glasses' => '8-)',\n                'sunglasses' => 'B-)',\n                'heart' => '<3',\n                'devil' => ']:D',\n                'angel' => '0:)',\n                'squint' => '-_-',\n                'confused' => 'o_O',\n                'upset' => 'xD',\n                'colonthree' => ':3',\n                'like' => '&#x1F44D;'];\n            $len = count($matches);\n            if ($len > 1) {\n                for ($i = 1; $i < $len; $i++) {\n                    foreach ($facebook_emoticons as $name => $emote) {\n                        if ($matches[$i] === $name) {\n                            return $emote;\n                        }\n                    }\n                }\n            }\n            return $matches[0];\n        };\n\n        if ($this->getInput('u') !== null) {\n            $page = 'https://touch.facebook.com/' . $this->getInput('u');\n            $cookies = $this->getCookies($page);\n            $pageInfo = $this->getPageInfos($page, $cookies);\n\n            if ($pageInfo['userId'] === null) {\n                throwClientException(\n                    <<<EOD\nUnable to get the page id. You should consider getting the ID by hand, then importing it into FB2Bridge\nEOD\n                );\n            } elseif ($pageInfo['userId'] == -1) {\n                throwClientException(\n                    <<<EOD\nThis page is not accessible without being logged in.\nEOD\n                );\n            }\n        }\n\n        //Build the string for the first request\n        $requestString = 'https://touch.facebook.com/page_content_list_view/more/?page_id='\n        . $pageInfo['userId']\n        . '&start_cursor=1&num_to_fetch=105&surface_type=timeline';\n        $fileContent = getContents($requestString);\n        $html = $this->buildContent($fileContent);\n        $author = $pageInfo['username'];\n\n        foreach ($html->find('article') as $content) {\n            $item = [];\n\n            preg_match('/publish_time\\\\\\\":([0-9]+),/', $content->getAttribute('data-store', 0), $match);\n            if (isset($match[1])) {\n                $timestamp = $match[1];\n            } else {\n                $timestamp = 0;\n            }\n\n            $item['uri'] = html_entity_decode('https://touch.facebook.com'\n            . $content->find(\"div[class='_52jc _5qc4 _78cz _24u0 _36xo']\", 0)->find('a', 0)->getAttribute('href'), ENT_QUOTES);\n\n            //Decode images\n            $imagecleaned = preg_replace_callback('/<i [^>]* style=\"[^\"]*url\\(\\'(.*?)\\'\\).*?><\\/i>/m', function ($matches) {\n                return \"<img src='\" . str_replace(['\\\\3a ', '\\\\3d ', '\\\\26 '], [':', '=', '&'], $matches[1]) . \"' />\";\n            }, $content);\n            $content = str_get_html($imagecleaned);\n\n            if ($content->find('header', 0) !== null) {\n                $content->find('header', 0)->innertext = '';\n            }\n\n            if ($content->find('footer', 0) !== null) {\n                $content->find('footer', 0)->innertext = '';\n            }\n\n            // Replace emoticon images by their textual representation (part of the span)\n            foreach ($content->find('span[title*=\"emoticon\"]') as $emoticon) {\n                $emoticon->innertext = $emoticon->find('span[aria-hidden=\"true\"]', 0)->innertext;\n            }\n\n            //Remove html nodes, keep only img, links, basic formatting\n            $content = strip_tags($content, '<a><img><i><u><br><p><h3><h4><section>');\n\n            //Adapt link hrefs: convert relative links into absolute links and bypass external link redirection\n            $content = preg_replace_callback('/ href=\\\"([^\"]+)\\\"/i', $unescape_fb_link, $content);\n\n            //Clean useless html tag properties and fix link closing tags\n            foreach (\n                [\n                'onmouseover',\n                'onclick',\n                'target',\n                'ajaxify',\n                'tabindex',\n                'class',\n                'data-[^=]*',\n                'aria-[^=]*',\n                'role',\n                'rel',\n                'id'] as $property_name\n            ) {\n                $content = preg_replace('/ ' . $property_name . '=\\\"[^\"]*\\\"/i', '', $content);\n            }\n            $content = preg_replace('/<\\/a [^>]+>/i', '</a>', $content);\n\n            //Convert textual representation of emoticons eg\n            // \"<i><u>smile emoticon</u></i>\" back to ASCII emoticons eg \":)\"\n            $content = preg_replace_callback('/<i><u>([^ <>]+) ([^<>]+)<\\/u><\\/i>/i', $unescape_fb_emote, $content);\n\n            //Remove the \"...Plus\" tag\n            $content = preg_replace(\n                '/… (<span>|)<a href=\"https:\\/\\/www\\.facebook\\.com\\/story\\.php\\?story_fbid=.*?<\\/a>/m',\n                '',\n                $content,\n                1\n            );\n\n            //Remove tracking images\n            $content = preg_replace('/<img src=\\'.*?safe_image\\.php.*?\\' \\/>/m', '', $content);\n\n            //Remove the double section tags\n            $content = str_replace(\n                ['<section><section>', '</section></section>'],\n                ['<section>', '</section>'],\n                $content\n            );\n\n            //Move the section tag link upper, if it is down\n            $content = str_get_html($content);\n            $sectionContent = $content->find('section', 0);\n            if ($sectionContent != null) {\n                $sectionLink = $sectionContent->nextSibling();\n                if ($sectionLink != null) {\n                    $fullLink = '<a href=\"' . $sectionLink->getAttribute('href') . '\">' . $sectionContent->innertext . '</a>';\n                    $sectionContent->innertext = $fullLink;\n                }\n            }\n\n            //Move the href tag upper if it is inside the section\n            foreach ($content->find('section > a') as $sectionToFix) {\n                $sectionLink = $sectionToFix->getAttribute('href');\n                $section = $sectionToFix->parent();\n                $section->outertext = '<a href=\"' . $sectionLink . '\">' . $section . '</a>';\n            }\n\n            $item['content'] = html_entity_decode($content, ENT_QUOTES);\n\n            $title = $author;\n            if ($this->getInput('abbrev_name') === true) {\n                if (strlen($title) > 24) {\n                    $title = substr($title, 0, strpos(wordwrap($title, 24), \"\\n\")) . '...';\n                }\n            }\n            $title = $title . ' | ' . strip_tags($content);\n            if (strlen($title) > 64) {\n                $title = substr($title, 0, strpos(wordwrap($title, 64), \"\\n\")) . '...';\n            }\n\n            $item['title'] = html_entity_decode($title, ENT_QUOTES);\n            $item['author'] = html_entity_decode($author, ENT_QUOTES);\n            $item['timestamp'] = html_entity_decode($timestamp, ENT_QUOTES);\n\n            if ($item['timestamp'] != 0) {\n                array_push($this->items, $item);\n            }\n        }\n    }\n\n    //Builds the HTML from the encoded JS that Facebook provides.\n    private function buildContent($pageContent)\n    {\n        // The html ends with:\n        // /div>\",\"replaceifexists\n        $regex = '/\\\\\"html\\\\\":(\\\".+\\/div>\"),\"replace/';\n        preg_match($regex, $pageContent, $result);\n\n        $htmlContent = json_decode($result[1]);\n        $htmlContent = preg_replace('/(?<!style)=\"(.*?)\"/', '=\\'$1\\'', $htmlContent);\n        $htmlContent = html_entity_decode($htmlContent, ENT_QUOTES, 'UTF-8');\n\n        return str_get_html($htmlContent);\n    }\n\n    //Builds the cookie from the page, as Facebook sometimes refuses to give\n    //the page if no cookie is provided.\n    private function getCookies($pageURL)\n    {\n        $ctx = stream_context_create([\n            'http' => [\n                'user_agent' => Configuration::getConfig('http', 'useragent'),\n                'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'\n                ]\n            ]);\n        $a = file_get_contents($pageURL, 0, $ctx);\n\n        //First request to get the cookie\n        $cookies = '';\n        foreach ($http_response_header as $hdr) {\n            if (strpos($hdr, 'Set-Cookie') !== false) {\n                $cLine = explode(':', $hdr)[1];\n                $cLine = explode(';', $cLine)[0];\n                $cookies .= ';' . $cLine;\n            }\n        }\n\n        return substr($cookies, 1);\n    }\n\n    //Get the page ID and username from the Facebook page.\n    private function getPageInfos($page, $cookies)\n    {\n        $context = stream_context_create([\n            'http' => [\n                'user_agent' => Configuration::getConfig('http', 'useragent'),\n                'header' => 'Cookie: ' . $cookies\n                ]\n            ]);\n\n        $pageContent = file_get_contents($page, 0, $context);\n\n        if (strpos($pageContent, 'signup-button') != false) {\n            return -1;\n        }\n\n        //Get the username\n        $usernameRegex = '/data-nt=\\\"FB:TEXT4\\\">(.*?)<\\/div>/m';\n        preg_match($usernameRegex, $pageContent, $usernameMatches);\n        if (count($usernameMatches) > 0) {\n            $username = strip_tags($usernameMatches[1]);\n        } else {\n            $username = $this->getInput('u');\n        }\n\n        //Get the page ID if we don't have a captcha\n        $regex = '/page_id=([0-9]*)&/';\n        preg_match($regex, $pageContent, $matches);\n\n        if (count($matches) > 0) {\n            return ['userId' => $matches[1], 'username' => $username];\n        }\n\n        //Get the page ID if we do have a captcha\n        $regex = '/\"pageID\":\"([0-9]*)\"/';\n        preg_match($regex, $pageContent, $matches);\n\n        $arr = [\n            'userId' => $matches[1] ?? null,\n            'username' => $username,\n        ];\n        return $arr;\n    }\n\n    public function getName()\n    {\n        $username = $this->getInput('u');\n        if (isset($username)) {\n            return $this->getInput('u') . ' | Facebook';\n        } else {\n            return self::NAME;\n        }\n    }\n\n    public function getURI()\n    {\n        $username = $this->getInput('u');\n        if (isset($username)) {\n            return 'https://facebook.com/' . $this->getInput('u') . '/posts';\n        } else {\n            return self::URI;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FDroidRepoBridge.php",
    "content": "<?php\n\nclass FDroidRepoBridge extends BridgeAbstract\n{\n    const NAME = 'F-Droid Repository';\n    const URI = 'https://f-droid.org/';\n    const DESCRIPTION = 'Query any F-Droid Repository for its latest updates.';\n\n    const ITEM_LIMIT = 50;\n\n    const PARAMETERS = [\n        'global' => [\n            'url' => [\n                'name' => 'Repository URL',\n                'title' => 'Usually ends with /repo/',\n                'required' => true,\n                'exampleValue' => 'https://molly.im/fdroid/foss/fdroid/repo'\n            ]\n        ],\n        'Latest Updates' => [\n            'sorting' => [\n                'name' => 'Sort By',\n                'type' => 'list',\n                'values' => [\n                    'Latest added apps' => 'added',\n                    'Latest updated apps' => 'lastUpdated'\n                ]\n            ],\n            'locale' => [\n                'name' => 'Locale',\n                'defaultValue' => 'en-US'\n            ]\n        ],\n        'Follow Package' => [\n            'package' => [\n                'name' => 'Package Identifier',\n                'required' => true,\n                'exampleValue' => 'im.molly.app'\n            ]\n        ]\n    ];\n\n    // Stores repo information\n    private $repo;\n\n    public function collectData()\n    {\n        $this->repo = $this->fetchData();\n        switch ($this->queriedContext) {\n            case 'Latest Updates':\n                $this->getAllUpdates();\n                break;\n            case 'Follow Package':\n                $this->getPackage($this->getInput('package'));\n                break;\n            default:\n                throw new \\Exception('Unimplemented Context (collectData)');\n        }\n    }\n\n    private function fetchData()\n    {\n        $url = $this->getURI();\n        $json = getContents($url . '/index-v1.json');\n        $data = Json::decode($json);\n        return $data;\n    }\n\n    private function getAllUpdates()\n    {\n        $apps = $this->repo['apps'];\n        usort($apps, function ($a, $b) {\n            return $b[$this->getInput('sorting')] <=> $a[$this->getInput('sorting')];\n        });\n        $apps = array_slice($apps, 0, self::ITEM_LIMIT);\n        foreach ($apps as $app) {\n            $latest = reset($this->repo['packages'][$app['packageName']]);\n\n            if (isset($app['localized'])) {\n                // Try provided locale, then en-US, then any\n                $lang = $app['localized'];\n                $lang = $lang[$this->getInput('locale')] ?? $lang['en-US'] ?? reset($lang);\n            } else {\n                $lang = [];\n            }\n\n            $item = [];\n            $item['uri'] = $this->getURI() . '/' . $latest['apkName'];\n            $item['title'] = $lang['name'] ?? $app['packageName'];\n            $item['title'] .= ' ' . $latest['versionName'];\n            $item['timestamp'] = date(DateTime::ISO8601, (int) ($app['lastUpdated'] / 1000));\n            if (isset($app['authorName'])) {\n                $item['author'] = $app['authorName'];\n            }\n            if (isset($app['categories'])) {\n                $item['categories'] = $app['categories'];\n            }\n\n            // Adding Content\n            $icon = $app['icon'] ?? '';\n            if (!empty($icon)) {\n                $icon = $this->getURI() . '/icons-320/' . $icon;\n                $item['enclosures'] = [$icon];\n                $icon = '<img src=\"' . $icon . '\">';\n            }\n            $summary = $lang['summary'] ?? $app['summary'] ?? '';\n            $description = markdownToHtml(trim($lang['description'] ?? $app['description'] ?? 'None'));\n            $whatsNew = markdownToHtml(trim($lang['whatsNew'] ?? 'None'));\n            $website = $this->createAnchor($lang['webSite'] ?? $app['webSite'] ?? $app['authorWebSite'] ?? null);\n            $source = $this->createAnchor($app['sourceCode'] ?? null);\n            $issueTracker = $this->createAnchor($app['issueTracker'] ?? null);\n            $license = $app['license'] ?? 'None';\n            $item['content'] = <<<EOD\n{$icon}\n<p>{$summary}</p>\n<h1>Description</h1>\n{$description}\n<h1>What's New</h1>\n{$whatsNew}\n<h1>Information</h1>\n<p>Website: {$website}</p>\n<p>Source Code: {$source}</p>\n<p>Issue Tracker: {$issueTracker}</p>\n<p>license: {$app['license']}</p>\nEOD;\n            $this->items[] = $item;\n        }\n    }\n\n    private function getPackage($package)\n    {\n        if (!isset($this->repo['packages'][$package])) {\n            throw new \\Exception('Invalid Package Name');\n        }\n        $package = $this->repo['packages'][$package];\n\n        $count = self::ITEM_LIMIT;\n        foreach ($package as $version) {\n            $item = [];\n            $item['uri'] = $this->getURI() . '/' . $version['apkName'];\n            $item['title'] = $version['versionName'];\n            $item['timestamp'] = date(DateTime::ISO8601, (int) ($version['added'] / 1000));\n            $item['uid'] = (string) $version['versionCode'];\n            $size = round($version['size'] / 1048576, 1); // Bytes -> MB\n            $sdk_link = 'https://developer.android.com/studio/releases/platforms';\n            $item['content'] = <<<EOD\n<p>size: {$size}MB</p>\n<p>Minimum SDK: {$version['minSdkVersion']}\n(<a href=\"{$sdk_link}\">SDK to Android Version List</a>)</p>\n<p>hash ({$version['hashType']}): {$version['hash']}</p>\nEOD;\n            $this->items[] = $item;\n            if (--$count <= 0) {\n                break;\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        if (empty($this->queriedContext)) {\n            return parent::getURI();\n        }\n\n        $url = rtrim($this->getInput('url'), '/');\n        if (strstr($url, '?', true)) {\n            return strstr($url, '?', true);\n        } else {\n            return $url;\n        }\n    }\n\n    public function getName()\n    {\n        if (empty($this->queriedContext)) {\n            return parent::getName();\n        }\n\n        $name = $this->repo['repo']['name'];\n        switch ($this->queriedContext) {\n            case 'Latest Updates':\n                return $name;\n            case 'Follow Package':\n                return $this->getInput('package') . ' - ' . $name;\n            default:\n                throw new \\Exception('Unimplemented Context (getName)');\n        }\n    }\n\n    private function createAnchor($url)\n    {\n        if (empty($url)) {\n            return null;\n        }\n        return sprintf('<a href=\"%s\">%s</a>', $url, $url);\n    }\n}\n"
  },
  {
    "path": "bridges/FFXIVLodestoneNewsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass FFXIVLodestoneNewsBridge extends BridgeAbstract\n{\n    const NAME = 'FFXIV Lodestone News';\n    const URI = 'https://eu.finalfantasyxiv.com/lodestone/';\n    const DESCRIPTION = 'Catch up on the latest FFXIV Lodestone articles';\n    const MAINTAINER = 'nairol203';\n    const PARAMETERS = [\n        [\n            'region' => [\n                'type' => 'list',\n                'name' => 'Region',\n                'values' => [\n                    'North America' => 'na',\n                    'Europe' => 'eu',\n                    'France' => 'fr',\n                    'Germany' => 'de',\n                    'Japan' => 'jp',\n                ],\n                'title' => 'Choose region',\n                'defaultValue' => 'eu',\n            ],\n            'category' => [\n                'type' => 'list',\n                'name' => 'Category',\n                'values' => [\n                    'All' => 'feed',\n                    'Topics' => 'topics',\n                    'Notices' => 'notices',\n                    'Maintenance' => 'maintenance',\n                    'Updates' => 'updates',\n                    'Status' => 'status',\n                    'Developers\\' Blog' => 'developers',\n                ],\n                'title' => 'Choose article category',\n                'defaultValue' => 'all',\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://lds-img.finalfantasyxiv.com/pc/global/images/favicon.ico?1720069015';\n    }\n\n    public function collectData()\n    {\n        $json = getContents(\n            \"https://lodestonenews.com/news/{$this->getInput('category')}?locale={$this->getInput('region')}\"\n        );\n\n        $articles = json_decode($json);\n\n        if ($articles === null) {\n            throwServerException('Failed to decode JSON content.');\n        }\n\n        foreach ($articles as $article) {\n            $this->items[] = [\n                'uri' => $article->url,\n                'title' => $article->title,\n                'timestamp' => $article->time,\n                'content' => isset($article->description) ? $article->description : '',\n                'enclosures' => isset($article->image) ? [$article->image] : [],\n                'categories' => [ucfirst(isset($article->category) ? $article->category : $this->getInput('category'))],\n                'uid' => $article->id,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FM4Bridge.php",
    "content": "<?php\n\nclass FM4Bridge extends BridgeAbstract\n{\n    const MAINTAINER = 'joni1993';\n    const NAME = 'FM4';\n    const URI = 'https://fm4.orf.at';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Feed for FM4 articles by tags (authors)';\n    const PARAMETERS = [\n        [\n            'tag' => [\n                'name' => 'Tag (author, category, ...)',\n                'title' => 'Tag to retrieve',\n                'exampleValue' => 'musik'\n            ],\n            'loadcontent' => [\n                'name' => 'Load Full Article Content',\n                'title' => 'Retrieve full content of articles (may take longer)',\n                'type' => 'checkbox'\n            ],\n            'pages' => [\n                'name' => 'Pages',\n                'title' => 'Amount of pages to load',\n                'type' => 'number',\n                'defaultValue' => 1\n            ]\n        ]\n    ];\n\n    private function getPageData($tag, $page)\n    {\n        if ($tag) {\n            $uri = self::URI . '/tags/' . $tag;\n        } else {\n            $uri = self::URI;\n        }\n\n        $uri = $uri . '?page=' . $page;\n\n        $html = getSimpleHTMLDOM($uri);\n\n        $page_items = [];\n\n        foreach ($html->find('div[class*=listItem]') as $article) {\n            $item = [];\n\n            $item['uri'] = $article->find('a', 0)->href;\n            $item['title'] = $article->find('h2', 0)->plaintext;\n            $item['author'] = $article->find('p[class*=keyword]', 0)->plaintext;\n            $item['timestamp'] = strtotime($article->find('p[class*=time]', 0)->plaintext);\n\n            if ($this->getInput('loadcontent')) {\n                $item['content'] = getSimpleHTMLDOM($item['uri'])->find('div[class=storyText]', 0);\n            }\n\n            $page_items[] = $item;\n        }\n        return $page_items;\n    }\n\n    public function collectData()\n    {\n        for ($cur_page = 1; $cur_page <= $this->getInput('pages'); $cur_page++) {\n            $this->items = array_merge($this->items, $this->getPageData($this->getInput('tag'), $cur_page));\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FSecureBlogBridge.php",
    "content": "<?php\n\nclass FSecureBlogBridge extends BridgeAbstract\n{\n    const NAME = 'F-Secure Blog';\n    const URI = 'https://blog.f-secure.com';\n    const DESCRIPTION = 'F-Secure Blog';\n    const MAINTAINER = 'simon816';\n    const PARAMETERS = [\n        '' => [\n            'categories' => [\n                'name' => 'Blog categories',\n                'exampleValue' => 'home-security',\n            ],\n            'language' => [\n                'name' => 'Language',\n                'required' => true,\n                'defaultValue' => 'en',\n            ],\n            'oldest_date' => [\n                'name' => 'Oldest article date',\n                'exampleValue' => '-6 months',\n            ],\n        ]\n    ];\n\n    public function getURI()\n    {\n        $lang = $this->getInput('language') or 'en';\n        if ($lang === 'en') {\n            return self::URI;\n        }\n        return self::URI . \"/$lang\";\n    }\n\n    public function collectData()\n    {\n        $this->items = [];\n        $this->seen = [];\n\n        $this->oldest = strtotime($this->getInput('oldest_date')) ?: 0;\n\n        $categories = $this->getInput('categories');\n        if (!empty($categories)) {\n            foreach (explode(',', $categories) as $cat) {\n                if (!empty($cat)) {\n                    $this->collectCategory($cat);\n                }\n            }\n            return;\n        }\n\n        $html = getSimpleHTMLDOMCached($this->getURI() . '/');\n\n        foreach ($html->find('ul.c-header-menu-desktop__list li a') as $link) {\n            $url = parse_url($link->href);\n            if (($pos = strpos($url['path'], '/category/')) !== false) {\n                $cat = substr($url['path'], $pos + strlen('/category/'), -1);\n                $this->collectCategory($cat);\n            }\n        }\n    }\n\n    private function collectCategory($category)\n    {\n        $url = $this->getURI() . \"/category/$category/\";\n        while ($url) {\n            //Limit total amount of requests\n            if (count($this->items) >= 20) {\n                break;\n            }\n            $url = $this->collectListing($url);\n        }\n    }\n\n    // n.b. this relies on articles to be ordered by date so the cutoff works\n    private function collectListing($url)\n    {\n        $html = getSimpleHTMLDOMCached($url, 60 * 60);\n        $items = $html->find('section.b-blog .l-blog__content__listing div.c-listing-item');\n\n        $catName = trim($html->find('section.b-blog .c-blog-header__title', 0)->plaintext);\n\n        foreach ($items as $item) {\n            $url = $item->getAttribute('data-url');\n            if (!$this->collectArticle($url)) {\n                return null; // Too old, stop collecting\n            }\n        }\n\n        // Point's to 404 for non-english blog\n        // $next = $html->find('link[rel=next]', 0);\n        $next = $html->find('ul.page-numbers a.next', 0);\n        return $next ? $next->href : null;\n    }\n\n    // Returns a boolean whether to continue collecting articles\n    // i.e. date is after oldest cutoff\n    private function collectArticle($url)\n    {\n        if (array_key_exists($url, $this->seen)) {\n            return true;\n        }\n        $html = getSimpleHTMLDOMCached($url);\n\n        $rssItem = [ 'uri' => $url, 'uid' => $url ];\n        $rssItem['title'] = $html->find('meta[property=og:title]', 0)->content;\n        $dt = $html->find('meta[property=article:published_time]', 0)->content;\n        // Exit if too old\n        if (strtotime($dt) < $this->oldest) {\n            return false;\n        }\n        $rssItem['timestamp'] = $dt;\n        $img = $html->find('meta[property=og:image]', 0);\n        $rssItem['enclosures'] = $img ? [$img->content] : [];\n        $rssItem['author'] = trim($html->find('.c-blog-author__text a', 0)->plaintext);\n        $rssItem['categories'] = array_map(function ($link) {\n            return trim($link->plaintext);\n        }, $html->find('.b-single-header__categories .c-category-list a'));\n        $rssItem['content'] = trim($html->find('article', 0)->innertext);\n\n        $this->items[] = $rssItem;\n        $this->seen[$url] = 1;\n        return true;\n    }\n}\n"
  },
  {
    "path": "bridges/FabBridge.php",
    "content": "<?php\n\nclass FabBridge extends BridgeAbstract\n{\n    const NAME = 'Epic Games Fab.com';\n    const URI = 'https://www.fab.com';\n    const DESCRIPTION = 'Limited-Time Free Game Engine Assets';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 86400;\n\n    public function collectData()\n    {\n        $url = static::URI . '/i/blades/free_content_blade';\n\n        $header = [\n            'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0',\n            'Accept: application/json, text/plain, */*',\n            'Accept-Language: en',\n            'Accept-Encoding: gzip, deflate, br, zstd',\n            'Referer: ' . static::URI\n        ];\n\n        $json = getContents($url, $header);\n        $json = json_decode($json);\n\n        foreach ($json->tiles as $item) {\n            $thumbnail = $item->listing->thumbnails[0]->mediaUrl;\n            $itemurl = static::URI . '/listings/' . $item->listing->uid;\n\n            $this->items[] = [\n                'title' => $item->listing->title,\n                'author' => $item->listing->user->sellerName,\n                'uri' => $itemurl,\n                'timestamp' => strtotime($item->listing->lastUpdatedAt),\n                'content' => '<a href=\"' . $itemurl . '\"><img src=\"' . $thumbnail . '\"></a>' . $item->listing->description,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FabriceBellardBridge.php",
    "content": "<?php\n\nclass FabriceBellardBridge extends BridgeAbstract\n{\n    const NAME = 'Fabrice Bellard';\n    const URI = 'https://bellard.org/';\n    const DESCRIPTION = \"Fabrice Bellard's Home Page\";\n    const MAINTAINER = 'somini';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('p') as $obj) {\n            $item = [];\n\n            $html = defaultLinkTo($html, $this->getURI());\n\n            $links = $obj->find('a');\n            if (count($links) > 0) {\n                $link_uri = $links[0]->href;\n            } else {\n                $link_uri = $this->getURI();\n            }\n\n            /* try to make sure the link is valid */\n            if ($link_uri[-1] !== '/' && strpos($link_uri, '/') === false) {\n                $link_uri = $link_uri . '/';\n            }\n\n            $item['title'] = strip_tags($obj->innertext);\n            $item['uri'] = $link_uri;\n            $item['content'] = $obj->innertext;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FacebookBridge.php",
    "content": "<?php\n\nclass FacebookBridge extends BridgeAbstract\n{\n    // const MAINTAINER = 'teromene, logmanoriginal';\n    const NAME = 'Facebook Bridge | Main Site';\n    const URI = 'https://www.facebook.com/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Input a page title or a profile log. For a profile log,\n please insert the parameter as follow : myExamplePage/132621766841117';\n\n    const PARAMETERS = [\n        'User' => [\n            'u' => [\n                'name' => 'Username',\n                'required' => true\n            ],\n            'media_type' => [\n                'name' => 'Media type',\n                'type' => 'list',\n                'required' => false,\n                'values' => [\n                    'All' => 'all',\n                    'Video' => 'video',\n                    'No Video' => 'novideo'\n                ],\n                'defaultValue' => 'all'\n            ],\n            'skip_reviews' => [\n                'name' => 'Skip reviews',\n                'type' => 'checkbox',\n                'required' => false,\n                'defaultValue' => false,\n                'title' => 'Feed includes reviews when unchecked'\n            ]\n        ],\n        'Group' => [\n            'g' => [\n                'name' => 'Group',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'https://www.facebook.com/groups/743149642484225',\n                'title' => 'Insert group name or facebook group URL'\n            ]\n        ],\n        'global' => [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify the number of items to return (default: -1)',\n                'defaultValue' => -1\n            ]\n        ]\n    ];\n\n    private $authorName = '';\n    private $groupName = '';\n\n    public function getIcon()\n    {\n        return 'https://static.xx.fbcdn.net/rsrc.php/yo/r/iRmz9lCMBD2.ico';\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'User':\n                if (!empty($this->authorName)) {\n                    return $this->extraInfos['name'] ?? $this->authorName;\n                }\n                break;\n\n            case 'Group':\n                if (!empty($this->groupName)) {\n                    return $this->groupName;\n                }\n                break;\n        }\n\n        return parent::getName();\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // By profile\n        $regex = '/^(https?:\\/\\/)?(www\\.)?facebook\\.com\\/profile\\.php\\?id\\=([^\\/?&\\n]+)?(.*)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'User';\n            $params['u'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        // By group\n        $regex = '/^(https?:\\/\\/)?(www\\.)?facebook\\.com\\/groups\\/([^\\/?\\n]+)?(.*)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Group';\n            $params['g'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        // By username\n        $regex = '/^(https?:\\/\\/)?(www\\.)?facebook\\.com\\/([^\\/?\\n]+)/';\n\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = '';\n            $params['u'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function getURI()\n    {\n        $uri = self::URI;\n\n        switch ($this->queriedContext) {\n            case 'Group':\n                // Discover groups via  https://www.facebook.com/groups/\n                // Example group:       https://www.facebook.com/groups/sailors.worldwide\n                $uri .= 'groups/' . $this->sanitizeGroup(filter_var($this->getInput('g'), FILTER_SANITIZE_URL));\n                break;\n\n            case 'User':\n                // Example user 1:      https://www.facebook.com/artetv/\n                // Example user 2:      artetv\n                $user = $this->sanitizeUser($this->getInput('u'));\n\n                if (!strpos($user, '/')) {\n                    $uri .= urlencode($user) . '/posts';\n                } else {\n                    $uri .= 'pages/' . $user;\n                }\n\n                break;\n        }\n\n        // Request the mobile version to reduce page size (no javascript)\n        // More information: https://stackoverflow.com/a/11103592\n        return $uri .= '?_fb_noscript=1';\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Group':\n                $this->collectGroupData();\n                break;\n\n            case 'User':\n                $this->collectUserData();\n                break;\n\n            default:\n                throwClientException('Unknown context: \"' . $this->queriedContext . '\"!');\n        }\n\n        $limit = $this->getInput('limit') ?: -1;\n\n        if ($limit > 0 && count($this->items) > $limit) {\n            $this->items = array_slice($this->items, 0, $limit);\n        }\n    }\n\n    #region Group\n\n    private function collectGroupData()\n    {\n        if (getEnv('HTTP_ACCEPT_LANGUAGE')) {\n            $header = ['Accept-Language: ' . getEnv('HTTP_ACCEPT_LANGUAGE')];\n        } else {\n            $header = [];\n        }\n\n        $touchURI = str_replace(\n            'https://www.facebook',\n            'https://touch.facebook',\n            $this->getURI()\n        );\n\n        $html = getSimpleHTMLDOM($touchURI, $header);\n\n        if (!$this->isPublicGroup($html)) {\n            throwClientException('This group is not public! RSS-Bridge only supports public groups!');\n        }\n\n        defaultLinkTo($html, substr(self::URI, 0, strlen(self::URI) - 1));\n\n        $this->groupName = $this->extractGroupName($html);\n\n        $posts = $html->find('div.story_body_container')\n            or throwServerException('Failed finding posts!');\n\n        foreach ($posts as $post) {\n            $item = [];\n\n            $item['uri'] = $this->extractGroupPostURI($post);\n            $item['title'] = $this->extractGroupPostTitle($post);\n            $item['author'] = $this->extractGroupPostAuthor($post);\n            $item['content'] = $this->extractGroupPostContent($post);\n            $item['enclosures'] = $this->extractGroupPostEnclosures($post);\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function sanitizeGroup($group)\n    {\n        if (\n            filter_var(\n                $group,\n                FILTER_VALIDATE_URL,\n                FILTER_FLAG_PATH_REQUIRED\n            )\n        ) {\n            // User provided a URL\n\n            $urlparts = parse_url($group);\n\n            $this->validateHost($urlparts['host']);\n\n            return explode('/', $urlparts['path'])[2];\n        } elseif (strpos($group, '/') !== false) {\n            throwClientException('The group you provided is invalid: ' . $group);\n        } else {\n            return $group;\n        }\n    }\n\n    private function validateHost($provided_host)\n    {\n        // Handle mobile links\n        if (strpos($provided_host, 'm.') === 0) {\n            $provided_host = substr($provided_host, strlen('m.'));\n        }\n        if (strpos($provided_host, 'touch.') === 0) {\n            $provided_host = substr($provided_host, strlen('touch.'));\n        }\n\n        $facebook_host = parse_url(self::URI)['host'];\n\n        if (\n            $provided_host !== $facebook_host\n            && 'www.' . $provided_host !== $facebook_host\n        ) {\n            throwClientException('The host you provided is invalid! Received \"'\n                . $provided_host\n                . '\", expected \"'\n                . $facebook_host\n                . '\"!');\n        }\n    }\n\n    /**\n     * @param $html simple_html_dom\n     * @return bool\n     */\n    private function isPublicGroup($html)\n    {\n        // Facebook touch just presents a login page for non-public groups\n        $title = $html->find('title', 0);\n        return $title->plaintext !== 'Log in to Facebook | Facebook';\n    }\n\n    private function extractGroupName($html)\n    {\n        $ogtitle = $html->find('._de1', 0)\n            or throwServerException('Unable to find group title!');\n\n        return html_entity_decode($ogtitle->plaintext, ENT_QUOTES);\n    }\n\n    private function extractGroupPostURI($post)\n    {\n        $elements = $post->find('a')\n            or throwServerException('Unable to find URI!');\n\n        foreach ($elements as $anchor) {\n            // Find the one that is a permalink\n            if (strpos($anchor->href, 'permalink') !== false) {\n                $arr = explode('?', $anchor->href, 2);\n                return $arr[0];\n            }\n        }\n\n        return null;\n    }\n\n    private function extractGroupPostContent($post)\n    {\n        $content = $post->find('div._5rgt', 0)\n            or throwServerException('Unable to find user content!');\n\n        $context_text = $content->innertext;\n        if ($content->next_sibling() !== null) {\n            $context_text .= $content->next_sibling()->innertext;\n        }\n        return $context_text;\n    }\n\n    private function extractGroupPostAuthor($post)\n    {\n        $element = $post->find('h3 a', 0)\n            or throwServerException('Unable to find author information!');\n\n        return $element->plaintext;\n    }\n\n    private function extractGroupPostEnclosures($post)\n    {\n        $elements = $post->find('span._6qdm');\n        if ($post->find('div._5rgt', 0)->next_sibling() !== null) {\n            array_push($elements, ...$post->find('div._5rgt', 0)->next_sibling()->find('i.img'));\n        }\n\n        $enclosures = [];\n\n        $background_img_regex = '/background-image: ?url\\\\((.+?)\\\\);/';\n\n        foreach ($elements as $enclosure) {\n            if (preg_match($background_img_regex, $enclosure, $matches) > 0) {\n                $bg_img_value = trim(html_entity_decode($matches[1], ENT_QUOTES), \"'\\\"\");\n                $bg_img_url = urldecode(preg_replace('/\\\\\\([0-9a-z]{2}) /', '%$1', $bg_img_value));\n                $enclosures[] = urldecode($bg_img_url);\n            }\n        }\n\n        return empty($enclosures) ? null : $enclosures;\n    }\n\n    private function extractGroupPostTitle($post)\n    {\n        $element = $post->find('h3', 0)\n            or throwServerException('Unable to find title!');\n\n        if (strpos($element->plaintext, 'shared') === false) {\n            $content = strip_tags($this->extractGroupPostContent($post));\n\n            return $this->extractGroupPostAuthor($post)\n            . ' posted: '\n            . substr(\n                $content,\n                0,\n                strpos(wordwrap($content, 64), \"\\n\")\n            )\n            . '...';\n        }\n\n        return $element->plaintext;\n    }\n\n    #endregion (Group)\n\n    #region User\n\n    /**\n     * Checks if $user is a valid username or URI and returns the username\n     */\n    private function sanitizeUser($user)\n    {\n        if (filter_var($user, FILTER_VALIDATE_URL)) {\n            $urlparts = parse_url($user);\n\n            $this->validateHost($urlparts['host']);\n\n            if (\n                !array_key_exists('path', $urlparts)\n                || $urlparts['path'] === '/'\n            ) {\n                throwClientException('The URL you provided doesn\\'t contain the user name!');\n            }\n\n            return explode('/', $urlparts['path'])[1];\n        } else {\n            // First character cannot be a forward slash\n            if (strpos($user, '/') === 0) {\n                throwClientException('Remove leading slash \"/\" from the username!');\n            }\n\n            return $user;\n        }\n    }\n\n    /**\n     * Bypass external link redirection\n     */\n    private function unescapeFacebookLink($content)\n    {\n        return preg_replace_callback('/ href=\\\"([^\"]+)\\\"/i', function ($matches) {\n            if (is_array($matches) && count($matches) > 1) {\n                $link = $matches[1];\n\n                if (strpos($link, 'facebook.com/l.php?u=') !== false) {\n                    $link = urldecode(extractFromDelimiters($link, 'facebook.com/l.php?u=', '&'));\n                }\n\n                return ' href=\"' . $link . '\"';\n            }\n        }, $content);\n    }\n\n    /**\n     * Remove Facebook's tracking code\n     */\n    private function removeTrackingCodes($content)\n    {\n        return preg_replace_callback('/ href=\\\"([^\"]+)\\\"/i', function ($matches) {\n            if (is_array($matches) && count($matches) > 1) {\n                $link = $matches[1];\n\n                if (strpos($link, 'facebook.com') !== false) {\n                    if (strpos($link, '?') !== false) {\n                        $link = substr($link, 0, strpos($link, '?'));\n                    }\n                }\n                return ' href=\"' . $link . '\"';\n            }\n        }, $content);\n    }\n\n    /**\n     * Convert textual representation of emoticons back to ASCII emoticons.\n     * i.e. \"<i><u>smile emoticon</u></i>\" => \":)\"\n     */\n    private function unescapeFacebookEmote($content)\n    {\n        return preg_replace_callback('/<i><u>([^ <>]+) ([^<>]+)<\\/u><\\/i>/i', function ($matches) {\n            static $facebook_emoticons = [\n                    'smile' => ':)',\n                    'frown' => ':(',\n                    'tongue' => ':P',\n                    'grin' => ':D',\n                    'gasp' => ':O',\n                    'wink' => ';)',\n                    'pacman' => ':<',\n                    'grumpy' => '>_<',\n                    'unsure' => ':/',\n                    'cry' => ':\\'(',\n                    'kiki' => '^_^',\n                    'glasses' => '8-)',\n                    'sunglasses' => 'B-)',\n                    'heart' => '<3',\n                    'devil' => ']:D',\n                    'angel' => '0:)',\n                    'squint' => '-_-',\n                    'confused' => 'o_O',\n                    'upset' => 'xD',\n                    'colonthree' => ':3',\n                    'like' => '&#x1F44D;'];\n\n            $len = count($matches);\n\n            if ($len > 1) {\n                for ($i = 1; $i < $len; $i++) {\n                    foreach ($facebook_emoticons as $name => $emote) {\n                        if ($matches[$i] === $name) {\n                            return $emote;\n                        }\n                    }\n                }\n            }\n\n            return $matches[0];\n        }, $content);\n    }\n\n    /**\n     * Returns the captcha message for the given captcha\n     */\n    private function returnCaptchaMessage($captcha)\n    {\n        // Save form for submitting after getting captcha response\n        if (session_status() == PHP_SESSION_NONE) {\n            session_start();\n        }\n\n        $captcha_fields = [];\n\n        foreach ($captcha->find('input, button') as $input) {\n            $captcha_fields[$input->name] = $input->value;\n        }\n\n        $_SESSION['captcha_fields'] = $captcha_fields;\n        $_SESSION['captcha_action'] = $captcha->find('form', 0)->action;\n\n        // Show captcha filling form to the viewer, proxying the captcha image\n        $img = base64_encode(getContents($captcha->find('img', 0)->src));\n\n        header('Content-Type: text/html', true, 500);\n\n        $message = <<<EOD\n<form method=\"post\" action=\"?{$_SERVER['QUERY_STRING']}\">\n<h2>Facebook captcha challenge</h2>\n<p>Unfortunately, rss-bridge cannot fetch the requested page.<br />\nFacebook wants rss-bridge to resolve the following captcha:</p>\n<p><img src=\"data:image/png;base64,{$img}\" /></p>\n<p><b>Response:</b> <input name=\"captcha_response\" placeholder=\"please fill in\" />\n<input type=\"submit\" value=\"Submit!\" /></p>\n</form>\nEOD;\n\n        die($message);\n    }\n\n    /**\n     * Checks if a capture response was received and tries to load the contents\n     * @return mixed null if no capture response was received, simplhtmldom document otherwise\n     */\n    private function handleCaptchaResponse()\n    {\n        if (isset($_POST['captcha_response'])) {\n            if (session_status() == PHP_SESSION_NONE) {\n                session_start();\n            }\n\n            if (isset($_SESSION['captcha_fields'], $_SESSION['captcha_action'])) {\n                $captcha_action = $_SESSION['captcha_action'];\n                $captcha_fields = $_SESSION['captcha_fields'];\n                $captcha_fields['captcha_response'] = preg_replace('/[^a-zA-Z0-9]+/', '', $_POST['captcha_response']);\n\n                $header = [\n                    'Content-type: application/x-www-form-urlencoded',\n                    'Referer: ' . $captcha_action,\n                    'Cookie: noscript=1'\n                ];\n\n                $opts = [\n                    CURLOPT_POST => 1,\n                    CURLOPT_POSTFIELDS => http_build_query($captcha_fields)\n                ];\n\n                $html = getSimpleHTMLDOM($captcha_action, $header, $opts);\n\n                return $html;\n            }\n\n            unset($_SESSION['captcha_fields']);\n            unset($_SESSION['captcha_action']);\n        }\n\n        return null;\n    }\n\n    private function collectUserData()\n    {\n        $html = $this->handleCaptchaResponse();\n\n        // Retrieve page contents\n        if (is_null($html)) {\n            if (getEnv('HTTP_ACCEPT_LANGUAGE')) {\n                $header = ['Accept-Language: ' . getEnv('HTTP_ACCEPT_LANGUAGE')];\n            } else {\n                $header = [];\n            }\n\n            $url = $this->getURI();\n            $html = getSimpleHTMLDOM($url, $header);\n        }\n\n        // Handle captcha form?\n        $captcha = $html->find('div.captcha_interstitial', 0);\n\n        if (!is_null($captcha)) {\n            $this->returnCaptchaMessage($captcha);\n        }\n\n        // No captcha? We can carry on retrieving page contents :)\n        // First, we check whether the page is public or not\n        $loginForm = $html->find('._585r', 0);\n\n        if ($loginForm != null) {\n            throwServerException('You must be logged in to view this page. This is not supported by RSS-Bridge.');\n        }\n\n        $mainColumn = $html->find('#pagelet_timeline_main_column');\n        if (!$mainColumn) {\n            throw new \\Exception(sprintf('Unable to find anything useful in %s', $url));\n        }\n\n        $element = $mainColumn[0]\n            ->children(0)\n            ->children(0)\n            ->next_sibling()\n            ->children(0);\n\n        if (isset($element)) {\n            $author = str_replace(' - Posts | Facebook', '', $html->find('title#pageTitle', 0)->innertext);\n\n            $profilePic = $html->find('meta[property=\"og:image\"]', 0)->content;\n\n            $this->authorName = $author;\n\n            foreach ($element->children() as $cell) {\n                // Manage summary posts\n                if (strpos($cell->class, '_3xaf') !== false) {\n                    $posts = $cell->children();\n                } else {\n                    $posts = [$cell];\n                }\n\n                // Optionally skip reviews\n                if (\n                    $this->getInput('skip_reviews')\n                    && !is_null($cell->find('#review_composer_container', 0))\n                ) {\n                    continue;\n                }\n\n                foreach ($posts as $post) {\n                    // Check media type\n                    switch ($this->getInput('media_type')) {\n                        case 'all':\n                            break;\n                        case 'video':\n                            if (empty($post->find('[aria-label=Video]'))) {\n                                continue 2;\n                            }\n                            break;\n                        case 'novideo':\n                            if (!empty($post->find('[aria-label=Video]'))) {\n                                continue 2;\n                            }\n                            break;\n                        default:\n                            break;\n                    }\n\n                    $item = [];\n\n                    if (count($post->find('abbr')) > 0) {\n                        $content = $post->find('.userContentWrapper', 0);\n\n                        // This array specifies filters applied to all posts in order of appearance\n                        $content_filters = [\n                            '._5mly', // Remove embedded videos (the preview image remains)\n                            '._2ezg', // Remove \"Views ...\"\n                            '.hidden_elem', // Remove hidden elements (they are hidden anyway)\n                            '.timestampContent', // Remove relative timestamp\n                            '._6spk', // Remove redundant separator\n                        ];\n\n                        foreach ($content_filters as $filter) {\n                            foreach ($content->find($filter) as $subject) {\n                                $subject->outertext = '';\n                            }\n                        }\n\n                        // Change origin tag for embedded media from div to paragraph\n                        foreach ($content->find('._59tj') as $subject) {\n                            $subject->outertext = '<p>' . $subject->innertext . '</p>';\n                        }\n\n                        // Change title tag for embedded media from anchor to paragraph\n                        foreach ($content->find('._3n1k a') as $anchor) {\n                            $anchor->outertext = '<p>' . $anchor->innertext . '</p>';\n                        }\n\n                        $content = preg_replace(\n                            '/(?i)><div class=\\\"_3dp([^>]+)>(.+?)div\\ class=\\\"[^u]+userContent\\\"/i',\n                            '',\n                            $content\n                        );\n\n                        $content = preg_replace(\n                            '/(?i)><div class=\\\"_4l5([^>]+)>(.+?)<\\/div>/i',\n                            '',\n                            $content\n                        );\n\n                        // Remove \"SpSonsSoriSsés\"\n                        $content = preg_replace(\n                            '/(?iU)<a [^>]+ href=\"#\" role=\"link\" [^>}]+>.+<\\/a>/iU',\n                            '',\n                            $content\n                        );\n\n                        // Remove html nodes, keep only img, links, basic formatting\n                        $content = strip_tags($content, '<a><img><i><u><br><p>');\n\n                        $content = $this->unescapeFacebookLink($content);\n\n                        // Clean useless html tag properties and fix link closing tags\n                        foreach (\n                            [\n                            'onmouseover',\n                            'onclick',\n                            'target',\n                            'ajaxify',\n                            'tabindex',\n                            'class',\n                            'style',\n                            'data-[^=]*',\n                            'aria-[^=]*',\n                            'role',\n                            'rel',\n                            'id'] as $property_name\n                        ) {\n                            $content = preg_replace('/ ' . $property_name . '=\\\"[^\"]*\\\"/i', '', $content);\n                        }\n\n                        $content = preg_replace('/<\\/a [^>]+>/i', '</a>', $content);\n\n                        $this->unescapeFacebookEmote($content);\n\n                        // Restore links in the post before further parsing\n                        $post = defaultLinkTo($post, self::URI);\n\n                        // Restore links in the content before adding to the item\n                        $content = defaultLinkTo($content, self::URI);\n\n                        $content = $this->removeTrackingCodes($content);\n\n                        // Retrieve date of the post\n                        $date = $post->find('abbr')[0];\n\n                        if (isset($date) && $date->hasAttribute('data-utime')) {\n                            $date = $date->getAttribute('data-utime');\n                        } else {\n                            $date = 0;\n                        }\n\n                        // Build title from content\n                        $title = strip_tags($post->find('.userContent', 0)->innertext);\n                        if (strlen($title) > 64) {\n                            $title = substr($title, 0, strpos(wordwrap($title, 64), \"\\n\")) . '...';\n                        }\n\n                        $uri = $post->find('abbr')[0]->parent()->getAttribute('href');\n\n                        // Extract fbid and patch link\n                        if (strpos($uri, '?') !== false) {\n                            $query = substr($uri, strpos($uri, '?') + 1);\n                            parse_str($query, $query_params);\n                            if (isset($query_params['story_fbid'])) {\n                                $uri = self::URI . $query_params['story_fbid'];\n                            } else {\n                                $uri = substr($uri, 0, strpos($uri, '?'));\n                            }\n                        }\n\n                        //Build and add final item\n                        $item['uri'] = htmlspecialchars_decode($uri, ENT_QUOTES);\n                        $item['content'] = htmlspecialchars_decode($content, ENT_QUOTES);\n                        $item['title'] = htmlspecialchars_decode($title, ENT_QUOTES);\n                        $item['author'] = htmlspecialchars_decode($author, ENT_QUOTES);\n                        $item['timestamp'] = $date;\n\n                        if (strpos($item['content'], '<img') === false) {\n                            $item['enclosures'] = [$profilePic];\n                        }\n\n                        $this->items[] = $item;\n                    }\n                }\n            }\n        }\n    }\n\n    #endregion (User)\n}\n"
  },
  {
    "path": "bridges/FallGuysBridge.php",
    "content": "<?php\n\nclass FallGuysBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'User123698745';\n    const NAME = 'Fall Guys';\n    const BASE_URI = 'https://www.fallguys.com';\n    const URI = self::BASE_URI . '/news';\n    const CACHE_TIMEOUT = 600; // 10min\n    const DESCRIPTION = 'News from the Fall Guys website';\n    const DEFAULT_LOCALE = 'en-US';\n    const PARAMETERS = [\n        [\n            'locale' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'English' => 'en-US',\n                    'لعربية' => 'ar',\n                    'Deutsch' => 'de',\n                    'Español (Spain)' => 'es-ES',\n                    'Español (LA)' => 'es-MX',\n                    'Français' => 'fr',\n                    'Italiano' => 'it',\n                    '日本語' => 'ja',\n                    '한국어' => 'ko',\n                    'Polski' => 'pl',\n                    'Português (Brasil)' => 'pt-BR',\n                    'Русский' => 'ru',\n                    'Türkçe' => 'tr',\n                    '简体中文' => 'zh-CN',\n                ],\n                'defaultValue' => self::DEFAULT_LOCALE,\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $newsData = self::requestJsonData(self::getURI(), false);\n\n        foreach ($newsData->props->pageProps->newsList as $newsItem) {\n            $newsItemUrl = self::getURI() . '/' . $newsItem->slug;\n            $newsItemTitle = $newsItem->header->title;\n\n            $headerDescription = property_exists($newsItem->header, 'description') ? $newsItem->header->description : '';\n            $headerImage = $newsItem->newsLandingConfig->options[0]->image->src->url;\n\n            $contentImages = [$headerImage];\n\n            $content = <<<HTML\n            <p>{$headerDescription}</p>\n            <p><img src=\"{$headerImage}\"></p>\n            HTML;\n\n            try {\n                $newsItemData = self::requestJsonData($newsItemUrl, true);\n            } catch (\\Exception $e) {\n                $this->logger->error(sprintf('Failed to request data for news item \"%s\" (%s)', $newsItemTitle, $newsItemUrl), ['e' => $e]);\n                $newsItemData = null;\n            }\n            if (!$newsItemData) {\n                $this->logger->error(sprintf('Failed to parse json data for news item \"%s\" (%s)', $newsItemTitle, $newsItemUrl));\n            } else {\n                foreach ($newsItemData->props->pageProps->pageData->content->items as $contentItem) {\n                    if (property_exists($contentItem, 'articleCopy')) {\n                        if (property_exists($contentItem->articleCopy, 'title')) {\n                            $title = $contentItem->articleCopy->title;\n\n                            $content .= <<<HTML\n                            <h2>{$title}</h2>\n                            HTML;\n                        }\n\n                        $text = $contentItem->articleCopy->copy;\n\n                        $content .= <<<HTML\n                        <p>{$text}</p>\n                        HTML;\n                    } elseif (property_exists($contentItem, 'articleImage')) {\n                        $image = $contentItem->articleImage->imageSrc;\n\n                        if ($image != $headerImage) {\n                            $contentImages[] = $image;\n\n                            $content .= <<<HTML\n                            <p><img src=\"{$image}\"></p>\n                            HTML;\n                        }\n                    } elseif (property_exists($contentItem, 'embeddedVideo')) {\n                        $mediaOptions = $contentItem->embeddedVideo->mediaOptions;\n                        $mainContentOptions = $contentItem->embeddedVideo->mainContentOptions;\n\n                        if (count($mediaOptions) == count($mainContentOptions)) {\n                            for ($i = 0; $i < count($mediaOptions); $i++) {\n                                if (property_exists($mediaOptions[$i], 'youtubeVideo')) {\n                                    $videoID = $mediaOptions[$i]->youtubeVideo->contentId;\n                                    $videoHtml = handleYoutube($videoID);\n\n                                    $content .= '<p>' . $videoHtml;\n\n                                    $image = $mainContentOptions[$i]->image->src ?? '';\n                                    if ($image != $headerImage) {\n                                        $contentImages[] = $image;\n\n                                        $content .= '<img src=\"{$image}\"><br>';\n                                    }\n\n                                    $content .= '</p>';\n                                }\n                            }\n                        }\n                    } else {\n                        $this->logger->warning(sprintf('Unsupported content item in news item \"%s\" (%s)', $newsItemTitle, $newsItemUrl));\n                    }\n                }\n            }\n\n            $item = [\n                'uid' => $newsItem->id,\n                'uri' => $newsItemUrl,\n                'title' => $newsItemTitle,\n                'timestamp' => $newsItem->activeDate,\n                'content' => $content,\n                'enclosures' => $contentImages,\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $locale = $this->getInput('locale') ?? self::DEFAULT_LOCALE;\n        return self::BASE_URI . '/' . $locale . '/news';\n    }\n\n    public function getIcon()\n    {\n        return self::BASE_URI . '/favicon.ico';\n    }\n\n    private function requestJsonData(string $url, bool $useCache)\n    {\n        $html = $useCache ? getSimpleHTMLDOMCached($url) : getSimpleHTMLDOM($url);\n        $jsonElement = $html->find('#__NEXT_DATA__', 0);\n        $json = $jsonElement ? $jsonElement->innertext : null;\n        return json_decode($json);\n    }\n}\n"
  },
  {
    "path": "bridges/FanaticalBridge.php",
    "content": "<?php\n\nclass FanaticalBridge extends BridgeAbstract\n{\n    const NAME = 'Fanatical';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://www.fanatical.com/en/';\n    const DESCRIPTION = 'Returns bundles from Fanatical.';\n    const PARAMETERS = [[\n        'type' => [\n            'name' => 'Bundle type',\n            'type' => 'list',\n            'defaultValue' => 'all',\n            'values' => [\n                'All' => 'all',\n                'Books' => 'book-',\n                'ELearning' => 'elearning-',\n                'Games' => '',\n                'Software' => 'software-',\n            ]\n        ]\n    ]];\n\n\n    const IMGURL = 'https://fanatical.imgix.net/product/original/';\n    public function collectData()\n    {\n        $api = 'https://www.fanatical.com/api/all/en';\n        $json = json_decode(getContents($api), true)['pickandmix'];\n        $type = $this->getInput('type');\n\n        foreach ($json as $element) {\n            if ($type != 'all') {\n                if ($element['type'] != $type . 'bundle') {\n                    continue;\n                }\n            }\n\n            $item = [\n                'categories' => [$element['type']],\n                'content' => '<ul>',\n                'enclosures' => [self::IMGURL . $element['cover_image']],\n                'timestamp' => $element['valid_from'],\n                'title' => $element['name'],\n                'uri' => parent::getURI() . 'pick-and-mix/' . $element['slug'],\n            ];\n\n            $slugs = [];\n            foreach ($element['products'] as $product) {\n                $slug = $product['slug'];\n                if (in_array($slug, $slugs)) {\n                    continue;\n                }\n                $slugs[] = $slug;\n                $uri = parent::getURI() . 'game/' . $slug;\n                $item['content'] .= '<li><a href=\"' . $uri . '\">' . $product['name'] . '</a></li>';\n                $item['enclosures'][] = self::IMGURL . $product['cover'];\n            }\n            foreach ($element['tiers'] as $tier) {\n                $count = $tier['quantity'];\n                $price = round($tier['price']['USD'] / 100, 2);\n                $per = round($price / $count, 2);\n                $item['categories'][] = \"$count at $per for $price total\";\n            }\n\n            $item['content'] .= '</ul>';\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        $name .= $this->getKey('type') ? ' - ' . $this->getKey('type') : '';\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n        $type = $this->getKey('type');\n        if ($type) {\n            $uri .= 'bundle/';\n            if ($type != 'All') {\n                $uri .= strtolower($type);\n            }\n        }\n        return $uri;\n    }\n\n    public function getIcon()\n    {\n        return 'https://cdn.fanatical.com/production/icons/fanatical-icon-android-chrome-192x192.png';\n    }\n}\n"
  },
  {
    "path": "bridges/FarsideNitterBridge.php",
    "content": "<?php\n\nclass FarsideNitterBridge extends FeedExpander\n{\n    const NAME = 'Farside Nitter';\n    const DESCRIPTION = \"Returns an user's recent tweets\";\n    const URI = 'https://farside.link/nitter/';\n    const HOST = 'https://twitter.com/';\n    const MAX_RETRIES = 3;\n    const PARAMETERS = [\n        [\n            'username' => [\n                'name' => 'username',\n                'required' => true,\n                'exampleValue' => 'NASA'\n            ],\n            'noreply' => [\n                'name' => 'Without replies',\n                'type' => 'checkbox',\n                'title' => 'Only return initial tweets'\n            ],\n            'noretweet' => [\n                'name' => 'Without retweets',\n                'required' => false,\n                'type' => 'checkbox',\n                'title' => 'Hide retweets'\n            ],\n            'linkbacktotwitter' => [\n                'name' => 'Link back to twitter',\n                'required' => false,\n                'type' => 'checkbox',\n                'title' => 'Rewrite links back to twitter.com'\n            ]\n        ],\n    ];\n\n    public function detectParameters($url)\n    {\n        if (preg_match('/^(https?:\\/\\/)?(www\\.)?(nitter\\.net|twitter\\.com)\\/([^\\/?\\n]+)/', $url, $matches) > 0) {\n            return [\n                'username' => $matches[4],\n                'noreply' => true,\n                'noretweet' => true,\n                'linkbacktotwitter' => true\n            ];\n        }\n        return null;\n    }\n\n    public function collectData()\n    {\n        $this->getRSS();\n    }\n\n    private function getRSS($attempt = 0)\n    {\n        try {\n            $this->collectExpandableDatas(self::URI . $this->getInput('username') . '/rss');\n        } catch (\\Exception $e) {\n            if ($attempt >= self::MAX_RETRIES) {\n                throw $e;\n            } else {\n                $this->getRSS($attempt++);\n            }\n        }\n    }\n\n    protected function parseItem(array $item)\n    {\n        if ($this->getInput('noreply') && substr($item['title'], 0, 5) == 'R to ') {\n            return;\n        }\n        if ($this->getInput('noretweet') && substr($item['title'], 0, 6) == 'RT by ') {\n            return;\n        }\n        $item['title'] = truncate($item['title']);\n        if (preg_match('/(\\/status\\/.+)/', $item['uri'], $matches) > 0) {\n            if ($this->getInput('linkbacktotwitter')) {\n                $item['uri'] = self::HOST . $this->getInput('username') . $matches[1];\n            } else {\n                $item['uri'] = self::URI . $this->getInput('username') . $matches[1];\n            }\n        }\n        return $item;\n    }\n\n    public function getName()\n    {\n        if (preg_match('/(.+) \\//', parent::getName(), $matches) > 0) {\n            return $matches[1];\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('linkbacktotwitter')) {\n            return self::HOST . $this->getInput('username');\n        } else {\n            return self::URI . $this->getInput('username');\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FeedExpanderExampleBridge.php",
    "content": "<?php\n\nclass FeedExpanderExampleBridge extends FeedExpander\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'FeedExpander Example';\n    const URI = 'http://github.com/RSS-Bridge/rss-bridge/';\n    const DESCRIPTION = 'Example bridge to test FeedExpander';\n\n    const PARAMETERS = [\n        'Feed' => [\n            'version' => [\n                'name' => 'Version',\n                'type' => 'list',\n                'title' => 'Select your feed format/version',\n                'defaultValue' => 'RSS 2.0',\n                'values' => [\n                    'RSS 0.91' => 'rss_0_9_1',\n                    'RSS 1.0' => 'rss_1_0',\n                    'RSS 2.0' => 'rss_2_0',\n                    'ATOM 1.0' => 'atom_1_0'\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        switch ($this->getInput('version')) {\n            case 'rss_0_9_1':\n                parent::collectExpandableDatas('http://static.userland.com/gems/backend/sampleRss.xml');\n                break;\n            case 'rss_1_0':\n                parent::collectExpandableDatas('http://feeds.nature.com/nature/rss/current?format=xml');\n                break;\n            case 'rss_2_0':\n                parent::collectExpandableDatas('http://feeds.rssboard.org/rssboard?format=xml');\n                break;\n            case 'atom_1_0':\n                parent::collectExpandableDatas('http://segfault.linuxmint.com/feed/atom/');\n                break;\n            default:\n                throwClientException('Unknown version ' . $this->getInput('version') . '!');\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FeedExpanderTestBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass FeedExpanderTestBridge extends FeedExpander\n{\n    const MAINTAINER = 'No maintainer';\n    const NAME = 'Unnamed';\n    const URI = 'https://esdf.com/';\n    const DESCRIPTION = 'No description provided';\n    const PARAMETERS = [];\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $url = 'http://static.userland.com/gems/backend/sampleRss.xml'; // rss 0.91\n        $url = 'http://feeds.nature.com/nature/rss/current?format=xml'; // rss 1.0\n        $url = 'https://dvikan.no/feed.xml'; // rss 2.0\n        $url = 'https://nedlasting.geonorge.no/geonorge/Tjenestefeed.xml'; // atom\n\n        $this->collectExpandableDatas($url);\n    }\n}\n"
  },
  {
    "path": "bridges/FeedMergeBridge.php",
    "content": "<?php\n\nclass FeedMergeBridge extends FeedExpander\n{\n    const MAINTAINER = 'dvikan';\n    const NAME = 'FeedMerge';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge';\n    const DESCRIPTION = <<<'TEXT'\n        This bridge merges two or more feeds into a single feed. <br>\n        Max 10 latest items are fetched from each individual feed. <br>\n        Items with identical url or title are considered duplicates (and are removed). <br>\n    TEXT;\n\n    const PARAMETERS = [\n        [\n            'feed_name' => [\n                'name' => 'Feed name',\n                'type' => 'text',\n                'exampleValue' => 'FeedMerge',\n            ],\n            'feed_1' => [\n                'name' => 'Feed url',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'https://lorem-rss.herokuapp.com/feed?unit=day'\n            ],\n            'feed_2' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_3' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_4' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_5' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_6' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_7' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_8' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_9' => ['name' => 'Feed url', 'type' => 'text'],\n            'feed_10' => ['name' => 'Feed url', 'type' => 'text'],\n            'limit' => self::LIMIT,\n        ]\n    ];\n\n    /**\n     * TODO: Consider a strategy which produces a shorter feed url\n     */\n    public function collectData()\n    {\n        $limit = (int)($this->getInput('limit') ?: 99);\n        $feeds = [\n            $this->getInput('feed_1'),\n            $this->getInput('feed_2'),\n            $this->getInput('feed_3'),\n            $this->getInput('feed_4'),\n            $this->getInput('feed_5'),\n            $this->getInput('feed_6'),\n            $this->getInput('feed_7'),\n            $this->getInput('feed_8'),\n            $this->getInput('feed_9'),\n            $this->getInput('feed_10'),\n        ];\n\n        // Remove empty values\n        $feeds = array_filter($feeds);\n\n        foreach ($feeds as $feed) {\n            if (count($feeds) > 1) {\n                // Allow one or more feeds to fail\n                try {\n                    $this->collectExpandableDatas($feed, 10);\n                } catch (HttpException $e) {\n                    $this->logger->warning(sprintf('Exception in FeedMergeBridge: %s', create_sane_exception_message($e)));\n                    // This feed item might be spammy. Considering dropping it.\n                    $this->items[] = [\n                        'title' => 'RSS-Bridge: ' . $e->getMessage(),\n                        // Give current time so it sorts to the top\n                        'timestamp' => time(),\n                    ];\n                    continue;\n                } catch (\\Exception $e) {\n                    if (str_starts_with($e->getMessage(), 'Failed to parse xml')) {\n                        // Allow this particular exception from FeedExpander\n                        $this->logger->warning(sprintf('Exception in FeedMergeBridge: %s', create_sane_exception_message($e)));\n                        continue;\n                    }\n                    throw $e;\n                }\n            } else {\n                $this->collectExpandableDatas($feed, 10);\n            }\n        }\n\n        // If $this->items is empty we should consider throw exception here\n\n        // Sort by timestamp, uri, title in descending order\n        usort($this->items, function ($a, $b) {\n            $t1 = $a['timestamp'] ?? $a['uri'] ?? $a['title'];\n            $t2 = $b['timestamp'] ?? $b['uri'] ?? $b['title'];\n            return $t2 <=> $t1;\n        });\n\n        // Remove duplicates by url\n        $items = [];\n        foreach ($this->items as $item) {\n            $uri = $item['uri'] ?? null;\n            if ($uri) {\n                // Insert or override the existing duplicate\n                $items[$uri] = $item;\n            } else {\n                // The item doesn't have a uri!\n                $items[] = $item;\n            }\n        }\n        $this->items = array_values($items);\n\n        // Remove duplicates by title\n        $items = [];\n        foreach ($this->items as $item) {\n            $title = $item['title'] ?? null;\n            if ($title) {\n                // Insert or override the existing duplicate\n                $items[$title] = $item;\n            } else {\n                // The item doesn't have a title!\n                $items[] = $item;\n            }\n        }\n        $this->items = array_values($items);\n\n        $this->items = array_slice($this->items, 0, $limit);\n    }\n\n    public function getIcon()\n    {\n        return 'https://cdn.jsdelivr.net/npm/famfamfam-silk@1.0.0/dist/png/folder_feed.png';\n    }\n\n    public function getName()\n    {\n        return $this->getInput('feed_name') ?: 'FeedMerge';\n    }\n}\n"
  },
  {
    "path": "bridges/FeedReducerBridge.php",
    "content": "<?php\n\nclass FeedReducerBridge extends FeedExpander\n{\n    const MAINTAINER = 'mdemoss';\n    const NAME = 'Feed Reducer';\n    const URI = 'http://github.com/RSS-Bridge/rss-bridge/';\n    const DESCRIPTION = 'Choose a percentage of a feed you want to see.';\n    const PARAMETERS = [ [\n        'url' => [\n            'name' => 'Feed URI',\n            'exampleValue' => 'https://lorem-rss.herokuapp.com/feed?length=42',\n            'required' => true\n        ],\n        'percentage' => [\n            'name' => 'percentage',\n            'type' => 'number',\n            'exampleValue' => 50,\n            'required' => true\n        ]\n    ]];\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n        if (preg_match('#^http(s?)://#i', $url)) {\n            $this->collectExpandableDatas($url);\n        } else {\n            throw new Exception('URI must begin with http(s)://');\n        }\n    }\n\n    public function getItems()\n    {\n        $filteredItems = [];\n        $intPercentage = (int)preg_replace('/[^0-9]/', '', $this->getInput('percentage'));\n\n        foreach ($this->items as $item) {\n            // The URL is included in the hash:\n            //  - so you can change the output by adding a local-part to the URL\n            //  - so items with the same URI in different feeds won't be correlated\n\n            // $pseudoRandomInteger will be a 16 bit unsigned int mod 100.\n            // This won't be uniformly distributed 1-100, but should be close enough.\n\n            $data = $item['uri'] . '::' . $this->getInput('url');\n            $hash = hash('sha256', $data, true);\n            // S = unsigned 16-bit int\n            $pseudoRandomInteger = unpack('S', $hash)[1] % 100;\n\n            if ($pseudoRandomInteger < $intPercentage) {\n                $filteredItems[] = $item;\n            }\n        }\n\n        return $filteredItems;\n    }\n\n    public function getName()\n    {\n        $trimmedPercentage = preg_replace('/[^0-9]/', '', $this->getInput('percentage') ?? '');\n        return parent::getName() . ' [' . $trimmedPercentage . '%]';\n    }\n}\n"
  },
  {
    "path": "bridges/FiaBridge.php",
    "content": "<?php\n\nclass FiaBridge extends BridgeAbstract\n{\n    const NAME = 'Federation Internationale de l\\'Automobile site feed';\n    const URI = 'https://fia.com';\n    const DESCRIPTION = 'Get the latest F1 documents from the fia site';\n    const PARAMETERS = [];\n    const CACHE_TIMEOUT = 900;\n\n    public function collectData()\n    {\n        $url = 'https://www.fia.com/documents/championships/fia-formula-one-world-championship-14/';\n        $html = getSimpleHTMLDOM($url);\n        $items = $html->find('li.document-row');\n        foreach ($items as $item) {\n            /** @var simple_html_dom $item */\n            // Do something with each list item\n            $title = trim($item->find('div.title', 0)->plaintext);\n            $href = $item->find('a', 0)->href;\n            $url = 'https://www.fia.com' . $href;\n\n            $date = $item->find('span.date-display-single', 0)->plaintext;\n\n            $item = [];\n            $item['uri'] = $url;\n            $item['title'] = $title;\n            $item['timestamp'] = (string) DateTime::createFromFormat('d.m.y H:i', $date)->getTimestamp();\n            ;\n            $item['author'] = 'Fia';\n            $item['content'] = \"Document on date $date: $title <br /><a href='$url'>$url</a>\";\n            $item['categories'] = 'Document';\n            $item['uid'] = $title . $date;\n\n            $count = count($this->items);\n            if ($count > 20) {\n                break;\n            } else {\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FicbookBridge.php",
    "content": "<?php\n\nclass FicbookBridge extends BridgeAbstract\n{\n    const NAME = 'Ficbook';\n    const URI = 'https://ficbook.net/';\n    const DESCRIPTION = 'No description provided';\n    const MAINTAINER = 'logmanoriginal';\n\n    const PARAMETERS = [\n        'Site News' => [],\n        'Fiction Updates' => [\n            'fiction_id' => [\n                'name' => 'Fanfiction ID',\n                'type' => 'text',\n                'pattern' => '[0-9]+',\n                'required' => true,\n                'title' => 'Insert fanfiction ID',\n                'exampleValue' => '5783919',\n            ],\n            'include_contents' => [\n                'name' => 'Include contents',\n                'type' => 'checkbox',\n                'title' => 'Activate to include contents in the feed',\n            ],\n        ],\n        'Fiction Comments' => [\n            'fiction_id' => [\n                'name' => 'Fanfiction ID',\n                'type' => 'text',\n                'pattern' => '[0-9]+',\n                'required' => true,\n                'title' => 'Insert fanfiction ID',\n                'exampleValue' => '5783919',\n            ],\n        ],\n    ];\n\n    protected $titleName;\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Site News':\n                // For some reason this is not HTTPS\n                return 'http://ficbook.net/sitenews';\n\n            case 'Fiction Updates':\n                return self::URI\n                . 'readfic/'\n                . urlencode($this->getInput('fiction_id'));\n\n            case 'Fiction Comments':\n                return self::URI\n                . 'readfic/'\n                . urlencode($this->getInput('fiction_id'))\n                . '/comments#content';\n\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Site News':\n                return $this->queriedContext . ' | ' . self::NAME;\n\n            case 'Fiction Updates':\n                return $this->titleName . ' | ' . self::NAME;\n\n            case 'Fiction Comments':\n                return $this->titleName . ' | Comments | ' . self::NAME;\n\n            default:\n                return self::NAME;\n        }\n    }\n\n    public function collectData()\n    {\n        $header = ['Accept-Language: en-US'];\n\n        $html = getSimpleHTMLDOM($this->getURI(), $header);\n\n        $html = defaultLinkTo($html, self::URI);\n\n        if ($this->queriedContext == 'Fiction Updates' or $this->queriedContext == 'Fiction Comments') {\n            $this->titleName = $html->find('.fanfic-main-info > h1', 0)->innertext;\n        }\n\n        switch ($this->queriedContext) {\n            case 'Site News':\n                return $this->collectSiteNews($html);\n            case 'Fiction Updates':\n                return $this->collectUpdatesData($html);\n            case 'Fiction Comments':\n                return $this->collectCommentsData($html);\n        }\n    }\n\n    private function collectSiteNews($html)\n    {\n        foreach ($html->find('.news_view') as $news) {\n            $this->items[] = [\n                'title' => $news->find('h1.title', 0)->plaintext,\n                'timestamp' => strtotime($this->fixDate($news->find('span[title]', 0)->title)),\n                'content' => $news->find('.news_text', 0),\n            ];\n        }\n    }\n\n    private function collectCommentsData($html)\n    {\n        foreach ($html->find('article.comment-container') as $article) {\n            $this->items[] = [\n                'uri' => $article->find('.comment_link_to_fic > a', 0)->href,\n                'title' => $article->find('.comment_author', 0)->plaintext,\n                'author' => $article->find('.comment_author', 0)->plaintext,\n                'timestamp' => strtotime($this->fixDate($article->find('time[datetime]', 0)->datetime)),\n                'content' => $article->find('.comment_message', 0),\n                'enclosures' => [$article->find('img', 0)->src],\n            ];\n        }\n    }\n\n    private function collectUpdatesData($html)\n    {\n        foreach ($html->find('ul.list-of-fanfic-parts > li') as $chapter) {\n            $item = [\n                'uri' => $chapter->find('a', 0)->href,\n                'title' => $chapter->find('a', 0)->plaintext,\n                'timestamp' => strtotime($this->fixDate($chapter->find('span[title]', 0)->title)),\n            ];\n\n            if ($this->getInput('include_contents')) {\n                $content = getSimpleHTMLDOMCached($item['uri'], 86400, [], [], true, true, DEFAULT_TARGET_CHARSET, false);\n                $item['content'] = str_replace(\"\\n\", '<br>', $content->find('#content', 0)->innertext);\n            }\n\n            $this->items[] = $item;\n\n            // Sort by time, descending\n            usort($this->items, function ($a, $b) {\n                return $b['timestamp'] - $a['timestamp'];\n            });\n        }\n    }\n\n    private function fixDate($date)\n    {\n        // FIXME: This list was generated using Google tranlator. Someone who\n        // actually knows russian should check this list! Please keep in mind\n        // that month names must match exactly the names returned by Ficbook.\n        $ru_month = [\n            'января',\n            'февраля',\n            'марта',\n            'апреля',\n            'мая',\n            'июня',\n            'июля',\n            'августа',\n            'сентября',\n            'октября',\n            'ноября',\n            'декабря',\n        ];\n\n        $en_month = [\n            'January',\n            'February',\n            'March',\n            'April',\n            'May',\n            'June',\n            'July',\n            'August',\n            'September',\n            'October',\n            'November',\n            'December',\n        ];\n\n        $fixed_date = str_replace($ru_month, $en_month, $date);\n        $fixed_date = str_replace(' г.', '', $fixed_date);\n\n        if ($fixed_date === $date) {\n            return null;\n        }\n\n        return $fixed_date;\n    }\n}\n"
  },
  {
    "path": "bridges/FiderBridge.php",
    "content": "<?php\n\nclass FiderBridge extends BridgeAbstract\n{\n    const NAME = 'Fider';\n    const URI = 'https://fider.io/';\n    const DESCRIPTION = 'Bridge for any Fider instance';\n    const MAINTAINER = 'Oliver Nutter';\n    const PARAMETERS = [\n        'global' => [\n            'instance' => [\n                'name' => 'Instance URL',\n                'required' => true,\n                'example' => 'https://feedback.fider.io',\n            ],\n        ],\n        'Post' => [\n            'num' => [\n                'name' => 'Post Number',\n                'type' => 'number',\n                'required' => true,\n            ],\n            'limit' => [\n                'name' => 'Number of comments to return',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify number of comments to return',\n            ],\n        ],\n    ];\n\n    private $instance;\n    private $posturi;\n    private $title;\n\n    public function getName()\n    {\n        return $this->title ?? parent::getName();\n    }\n\n    public function getURI()\n    {\n        return $this->posturi ?? parent::getURI();\n    }\n\n    protected function setTitle($title)\n    {\n        $html = getSimpleHTMLDOMCached($this->instance);\n        $name = $html->find('title', 0)->innertext;\n\n        $this->title = \"$title - $name\";\n    }\n\n    protected function getItem($post, $response = false, $first = false)\n    {\n        $item = [];\n        $item['uri'] = $this->getURI();\n        $item['timestamp'] = $response ? $post->respondedAt : $post->createdAt;\n        $item['author'] = $post->user->name;\n\n        $datetime = new DateTime($item['timestamp']);\n        if ($response) {\n            $item['uid'] = 'response';\n            $item['content'] = $post->text;\n            $item['title'] = \"{$item['author']} marked as $post->status {$datetime->format('M d, Y')}\";\n        } elseif ($first) {\n            $item['uid'] = 'post';\n            $item['content'] = $post->description;\n            $item['title'] = $post->title;\n        } else {\n            $item['uid'] = 'comment';\n            $item['content'] = $post->content;\n            $item['title'] = \"{$item['author']} commented {$datetime->format('M d, Y')}\";\n        }\n\n        $item['uid'] .= $item['author'] . $item['timestamp'];\n\n        // parse markdown with implicit line breaks\n        $item['content'] = markdownToHtml($item['content'], ['breaksEnabled' => true]);\n\n        if (property_exists($post, 'editedAt')) {\n            $item['title'] .= ' (edited)';\n        }\n\n        if ($first) {\n            $item['categories'] = $post->tags;\n        }\n\n        return $item;\n    }\n\n    public function collectData()\n    {\n        // collect first post\n        $this->instance = rtrim($this->getInput('instance'), '/');\n\n        $num = $this->getInput('num');\n        $this->posturi = \"$this->instance/posts/$num\";\n\n        $post_api_uri = \"$this->instance/api/v1/posts/$num\";\n        $post = json_decode(getContents($post_api_uri));\n\n        $this->setTitle($post->title);\n\n        $item = $this->getItem($post, false, true);\n        $this->items[] = $item;\n\n        // collect response to first post\n        if (property_exists($post, 'response')) {\n            $response = $post->response;\n            $response->status = $post->status;\n            $this->items[] = $this->getItem($response, true);\n        }\n\n        // collect comments\n        $comment_api_uri = \"$post_api_uri/comments\";\n        $comments = json_decode(getContents($comment_api_uri));\n\n        foreach ($comments as $post) {\n            $item = $this->getItem($post);\n            $this->items[] = $item;\n        }\n\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] <=> $a['timestamp'];\n        });\n\n        if ($this->getInput('limit') ?? 0 > 0) {\n            $this->items = array_slice($this->items, 0, $this->getInput('limit'));\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FilterBridge.php",
    "content": "<?php\n\nclass FilterBridge extends FeedExpander\n{\n    const MAINTAINER = 'Frenzie, ORelio';\n    const NAME = 'Filter';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Filters a feed of your choice';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge';\n\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Feed URL',\n            'type'  => 'text',\n            'exampleValue' => 'https://lorem-rss.herokuapp.com/feed?unit=day',\n            'required' => true,\n        ],\n        'name' => [\n            'name'          => 'Feed name (optional)',\n            'type'          => 'text',\n            'exampleValue'  => 'My feed',\n            'required'      => false,\n        ],\n        'filter' => [\n            'name' => 'Filter (regular expression!!!)',\n            'required' => false,\n        ],\n        'filter_type' => [\n            'name' => 'Filter type',\n            'type' => 'list',\n            'required' => false,\n            'values' => [\n                'Keep matching items' => 'permit',\n                'Hide matching items' => 'block',\n            ],\n            'defaultValue' => 'permit',\n        ],\n        'case_insensitive' => [\n            'name' => 'Case-insensitive filter',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n        'fix_encoding' => [\n            'name' => 'Attempt Latin1/UTF-8 fixes when evaluating filter',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n        'target_author' => [\n            'name' => 'Apply filter on author',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n        'target_content' => [\n            'name' => 'Apply filter on content',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n        'target_title' => [\n            'name' => 'Apply filter on title',\n            'type' => 'checkbox',\n            'required' => false,\n            'defaultValue' => 'checked'\n        ],\n        'target_uri' => [\n            'name' => 'Apply filter on URI/URL',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n        'title_from_content' => [\n            'name' => 'Generate title from content (overwrite existing title)',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n        'length_limit' => [\n            'name' => 'Max length analyzed by filter (-1: no limit)',\n            'type' => 'number',\n            'required' => false,\n            'defaultValue' => -1,\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n        if (!Url::validate($url)) {\n            throw new \\Exception('The url parameter must either refer to http or https protocol.');\n        }\n        $this->collectExpandableDatas($this->getURI());\n    }\n\n    protected function parseItem(array $item)\n    {\n        // Generate title from first 50 characters of content?\n        if ($this->getInput('title_from_content') && array_key_exists('content', $item)) {\n            $content = str_get_html($item['content']);\n            $plaintext = $content->plaintext;\n            if (mb_strlen($plaintext) < 51) {\n                $item['title'] = $plaintext;\n            } else {\n                $pos = strpos($item['content'], ' ', 50);\n                $item['title'] = substr($plaintext, 0, $pos);\n                if (strlen($plaintext) >= $pos) {\n                    $item['title'] .= '...';\n                }\n            }\n        }\n\n        $filter = $this->getInput('filter');\n        if (! str_contains($filter, '#')) {\n            $delimiter = '#';\n        } elseif (! str_contains($filter, '/')) {\n            $delimiter = '/';\n        } else {\n            throw new \\Exception('Cannot use both / and # inside filter');\n        }\n\n        $regex = $delimiter . $filter . $delimiter;\n        if ($this->getInput('case_insensitive')) {\n            $regex .= 'i';\n        }\n\n        // Retrieve fields to check\n        $filter_fields = [];\n        if ($this->getInput('target_author')) {\n            $filter_fields[] = $item['author'] ?? null;\n        }\n        if ($this->getInput('target_content')) {\n            $filter_fields[] = $item['content'] ?? null;\n        }\n        if ($this->getInput('target_title')) {\n            $filter_fields[] = $item['title'] ?? null;\n        }\n        if ($this->getInput('target_uri')) {\n            // todo: maybe consider 'http' and 'https' equivalent? Also maybe optionally .www subdomain?\n            $filter_fields[] = $item['uri'] ?? null;\n        }\n\n        // Apply filter on item\n        $keep_item = false;\n        $length_limit = intval($this->getInput('length_limit'));\n        foreach ($filter_fields as $field) {\n            if ($length_limit > 0) {\n                $field = substr($field, 0, $length_limit);\n            }\n            $result = preg_match($regex, $field);\n            if ($result === false) {\n                // todo: maybe notify user about the error here?\n            }\n            $keep_item |= boolval($result);\n            if ($this->getInput('fix_encoding')) {\n                $keep_item |= boolval(preg_match($regex, utf8_decode($field)));\n                $keep_item |= boolval(preg_match($regex, utf8_encode($field)));\n            }\n        }\n\n        // Reverse result? (keep everything but matching items)\n        if ($this->getInput('filter_type') === 'block') {\n            $keep_item = !$keep_item;\n        }\n\n        return $keep_item ? $item : null;\n    }\n\n    public function getURI()\n    {\n        $url = $this->getInput('url');\n        if ($url) {\n            return $url;\n        }\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        $name = $this->getInput('name');\n        if ($name) {\n            return $name;\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/FinanzflussBridge.php",
    "content": "<?php\n\nclass FinanzflussBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Tone866';\n    const NAME = 'finanzfluss';\n    const URI = 'https://www.finanzfluss.de/blog';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Feed for finanzfluss';\n    const LIMIT = 10;\n\n    public function collectData()\n    {\n        $baseurl = 'https://www.finanzfluss.de';\n        $dom = getSimpleHTMLDOM('https://www.finanzfluss.de/blog');\n        foreach ($dom->find('.preview-card') as $li) {\n            $a = $li->find('a', 0);\n            $title = $a->find('.title', 0);\n            $url = $baseurl . $a->href;\n\n            //get article\n            $domarticle = getSimpleHTMLDOM($url);\n            $content = $domarticle->find('div.content', 0);\n\n            //get header-image\n            $headerimage = $domarticle->find('div.article-header-image', 0);\n            $headerimageimg = $headerimage->find('img[src]', 0);\n\n            //remove unwanted stuff\n            foreach ($content->find('div.newsletter-signup') as $element) {\n                $element->remove();\n            }\n\n            //get author\n            $author = $domarticle->find('div.author-name', 0);\n\n            $this->items[] = [\n                'title' => $title->plaintext,\n                'uri' => $url,\n                'content' => $headerimage . '<br />' . $content,\n                'author' => $author->plaintext\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FindACrewBridge.php",
    "content": "<?php\n\nclass FindACrewBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'couraudt';\n    const NAME = 'Find A Crew';\n    const URI = 'https://www.findacrew.net';\n    const DESCRIPTION = 'Returns the newest sailing offers.';\n    const PARAMETERS = [\n        [\n            'type' => [\n                'name' => 'Type of search',\n                'title' => 'Choose between finding a boat or a crew',\n                'type' => 'list',\n                'values' => [\n                    'Find a boat' => 'boat',\n                    'Find a crew' => 'crew'\n                ]\n            ],\n            'long' => [\n                'name' => 'Longitude of the searched location',\n                'title' => 'Center the search at that longitude (e.g: -42.02)'\n            ],\n            'lat' => [\n                'name' => 'Latitude of the searched location',\n                'title' => 'Center the search at that latitude (e.g: 12.42)'\n            ],\n            'distance' => [\n                'name' => 'Limit boundary of search in KM',\n                'title' => 'Boundary of the search in kilometers when using longitude and latitude'\n            ],\n            'limit' => self::LIMIT,\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n\n        if ($this->getInput('type') == 'boat') {\n            $data = ['SrhLstBtAction' => 'Create'];\n        } else {\n            $data = ['SrhLstCwAction' => 'Create'];\n        }\n\n        if ($this->getInput('long') && $this->getInput('lat')) {\n            $data['real_LocSrh_Lng'] = $this->getInput('long');\n            $data['real_LocSrh_Lat'] = $this->getInput('lat');\n            if ($this->getInput('distance')) {\n                $data['LocDis'] = (int)$this->getInput('distance') * 1000;\n            }\n        }\n\n        $header = [\n            'Content-Type: application/x-www-form-urlencoded'\n        ];\n\n        $opts = [\n            CURLOPT_CUSTOMREQUEST => 'POST',\n            CURLOPT_POSTFIELDS => http_build_query($data) . \"\\n\"\n        ];\n\n        $html = getSimpleHTMLDOM($url, $header, $opts);\n\n        $annonces = $html->find('.css_SrhRst');\n        $limit = $this->getInput('limit') ?? 10;\n        foreach (array_slice($annonces, 0, $limit) as $annonce) {\n            $item = [];\n\n            $link = parent::getURI() . $annonce->find('.lstsum-btn-con a', 0)->href;\n            $htmlDetail = getSimpleHTMLDOMCached($link . '?mdl=2'); // add ?mdl=2 for xhr content not full html page\n\n            $img = parent::getURI() . $htmlDetail->find('img.img-responsive', 0)->getAttribute('src');\n            $item['title'] = $htmlDetail->find('div.label-account', 0)->plaintext;\n            $item['uri'] = $link;\n            $content = $htmlDetail->find('.panel-body div.clearfix.row > div', 1)->innertext;\n            $content .= $htmlDetail->find('.panel-body > div', 1)->innertext;\n            $content = defaultLinkTo($content, parent::getURI());\n            $item['content'] = $content;\n            $item['enclosures'] = [$img];\n            $item['categories'] = [$annonce->find('.css_AccLocCur', 0)->plaintext];\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n        // Those params must be in the URL\n        $uri .= '/en/' . $this->getInput('type') . '/search?srhtyp=srhrst&mdl=2';\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/FirefoxAddonsBridge.php",
    "content": "<?php\n\nclass FirefoxAddonsBridge extends BridgeAbstract\n{\n    const NAME = 'Firefox Add-ons';\n    const URI = 'https://addons.mozilla.org/';\n    const DESCRIPTION = 'Returns version history for a Firefox Add-on.';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n            'id' => [\n                'name' => 'Add-on ID',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'save-to-the-wayback-machine',\n            ]\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600;\n\n    private $feedName = '';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $this->feedName = $html->find('h1[class=\"AddonTitle\"] > a', 0)->innertext;\n        $author = $html->find('span.AddonTitle-author > a', 0)->plaintext;\n\n        foreach ($html->find('li.AddonVersionCard') as $li) {\n            $item = [];\n\n            $item['title'] = $li->find('h2.AddonVersionCard-version', 0)->plaintext;\n            $item['uid'] = $item['title'];\n            $item['uri'] = $this->getURI();\n            $item['author'] = $author;\n\n            $releaseDateRegex = '/Released ([\\w, ]+) - ([\\w. ]+)/';\n            if (preg_match($releaseDateRegex, $li->find('div.AddonVersionCard-fileInfo', 0)->plaintext, $match)) {\n                $item['timestamp'] = $match[1];\n                $size = $match[2];\n            }\n\n            $compatibility = $li->find('div.AddonVersionCard-compatibility', 0)->plaintext;\n            $license = $li->find('p.AddonVersionCard-license', 0)->innertext;\n\n            if ($li->find('a.InstallButtonWrapper-download-link', 0)) {\n                $downloadlink = $li->find('a.InstallButtonWrapper-download-link', 0)->href;\n            } elseif ($li->find('a.Button.Button--action.AMInstallButton-button.Button--puffy', 0)) {\n                $downloadlink = $li->find('a.Button.Button--action.AMInstallButton-button.Button--puffy', 0)->href;\n            }\n\n            $releaseNotes = $this->removeLinkRedirects($li->find('div.AddonVersionCard-releaseNotes', 0));\n\n            $xpiFileRegex = '/([A-Za-z0-9_.-]+)\\.xpi$/';\n            if (preg_match($xpiFileRegex, $downloadlink, $match)) {\n                $xpiFilename = $match[0];\n            }\n\n            $item['content'] = <<<EOD\n<p><strong>Release Notes</strong></p>\n<p>{$releaseNotes}</p>\n<p><strong>Compatibility</strong></p>\n<p>{$compatibility}</p>\n<p><strong>License</strong></p>\n<p>{$license}</p>\n<p><strong>Download</strong></p>\n<p><a href=\"{$downloadlink}\">{$xpiFilename}</a> ($size)</p>\nEOD;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('id'))) {\n            return self::URI . 'en-US/firefox/addon/' . $this->getInput('id') . '/versions/';\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName . ' - Firefox Add-on';\n        }\n\n        return parent::getName();\n    }\n\n    /**\n     * Removes 'https://prod.outgoing.prod.webservices.mozgcp.net/v1/' from external links\n     */\n    private function removeLinkRedirects($html)\n    {\n        $outgoingRegex = '/https:\\/\\/prod.outgoing\\.prod\\.webservices\\.mozgcp\\.net\\/v1\\/(?:[A-z0-9]+)\\//';\n        foreach ($html->find('a') as $a) {\n            $a->href = urldecode(preg_replace($outgoingRegex, '', $a->href));\n        }\n\n        return $html->innertext;\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Example: https://addons.mozilla.org/en-US/firefox/addon/ublock-origin\n        $pattern = '/addons\\.mozilla\\.org\\/(?:[\\w-]+\\/)?firefox\\/addon\\/([\\w-]+)/';\n        if (preg_match($pattern, $url, $matches)) {\n            $params['id'] = $matches[1];\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/FirefoxReleaseNotesBridge.php",
    "content": "<?php\n\nclass FirefoxReleaseNotesBridge extends BridgeAbstract\n{\n    const NAME = 'Firefox Release Notes';\n    const URI = 'https://www.mozilla.org/en-US/firefox/';\n    const DESCRIPTION = 'Retrieve the latest Firefox release notes.';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'platform' => [\n                'name' => 'Platform',\n                'type' => 'list',\n                'values' => [\n                    'Desktop' => '',\n                    'Beta' => 'beta',\n                    'Nightly' => 'nightly',\n                    'Android' => 'android',\n                    'iOS' => 'ios',\n                ]\n            ]\n        ]\n    ];\n\n    public function getName()\n    {\n        $platform = $this->getKey('platform');\n        return sprintf('Firefox %s Release Notes', $platform ?? '');\n    }\n\n    public function collectData()\n    {\n        $platform = $this->getKey('platform');\n        $url = self::URI . $this->getInput('platform') . '/notes/';\n        $dom = getSimpleHTMLDOM($url);\n\n        $version = $dom->find('.c-release-version', 0)->innertext;\n\n        $this->items[] = [\n            'content' => $dom->find('.c-release-notes', 0)->innertext,\n            'timestamp' => $dom->find('.c-release-date', 0)->innertext,\n            'title' => sprintf('Firefox %s %s Release Note', $platform, $version),\n            'uri' => $url,\n            'uid' => $platform . $version,\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/FlaschenpostBridge.php",
    "content": "<?php\n\nclass FlaschenpostBridge extends BridgeAbstract\n{\n    const NAME = 'Flaschenpost';\n    const URI = 'https://www.flaschenpost.de/';\n    const DESCRIPTION = 'Aktuelle Angebote auf Flaschenpost.de';\n    const MAINTAINER = 'sal0max';\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const PARAMETERS = [\n        [\n            'zip-code' => [\n                'name' => 'Postleitzahl',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '80333',\n                // https://stackoverflow.com/a/7926743/421140\n                'pattern' => '^(?!01000|99999)(0[1-9]\\d{3}|[1-9]\\d{4})$',\n            ],\n            'water' => [\n                'name' => 'Wasser',\n                'type' => 'checkbox',\n            ],\n            'beer' => [\n                'name' => 'Bier',\n                'type' => 'checkbox',\n            ],\n            'lemonade' => [\n                'name' => 'Limonade',\n                'type' => 'checkbox',\n            ],\n            'juice' => [\n                'name' => 'Saft & Schorle',\n                'type' => 'checkbox',\n            ],\n            'wine' => [\n                'name' => 'Wein & Mehr',\n                'type' => 'checkbox',\n            ],\n            'liquor' => [\n                'name' => 'Spirituosen',\n                'type' => 'checkbox',\n            ],\n            'food' => [\n                'name' => 'Lebensmittel',\n                'type' => 'checkbox',\n            ],\n            'household' => [\n                'name' => 'Haushalt',\n                'type' => 'checkbox',\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $categories = [];\n        if ($this->getInput('water')) {\n            array_push(\n                $categories,\n                'wasser/spritzig',\n                'wasser/medium',\n                'wasser/still',\n                'wasser/aromatisiert',\n                'wasser/heilwasser',\n                'wasser/bio-wasser',\n                'wasser/gourmet'\n            );\n        }\n        if ($this->getInput('beer')) {\n            array_push(\n                $categories,\n                'bier/alkoholfrei',\n                'bier/biermischgetraenke',\n                'bier/craft-beer',\n                'bier/export-lager-maerzen',\n                'bier/helles',\n                'bier/internationale-biere',\n                'bier/koelsch',\n                'bier/land-kellerbier',\n                'bier/malzbier',\n                'bier/pils',\n                'bier/radler',\n                'bier/spezialitaeten',\n                'bier/weizen-weissbier'\n            );\n        }\n        if ($this->getInput('lemonade')) {\n            array_push(\n                $categories,\n                'limonade/cola',\n                'limonade/orangenlimonade',\n                'limonade/zitronenlimonade',\n                'limonade/cola-mix',\n                'limonade/teegetraenke',\n                'limonade/fassbrause',\n                'limonade/mate',\n                'limonade/bio',\n                'limonade/zum-mixen',\n                'limonade/sonstige-limos'\n            );\n        }\n        if ($this->getInput('juice')) {\n            array_push(\n                $categories,\n                'saft-und-schorle/apfelsaft',\n                'saft-und-schorle/apfelschorle',\n                'saft-und-schorle/orangensaft',\n                'saft-und-schorle/multivitaminsaft',\n                'saft-und-schorle/maracujasaft',\n                'saft-und-schorle/traubensaft',\n                'saft-und-schorle/johannisbeersaft',\n                'saft-und-schorle/rhabarbersaft',\n                'saft-und-schorle/rhabarberschorle',\n                'saft-und-schorle/kirschsaft',\n                'saft-und-schorle/sonstige-saefte',\n                'saft-und-schorle/sonstige-schorlen'\n            );\n        }\n        if ($this->getInput('wine')) {\n            array_push(\n                $categories,\n                'wein-und-mehr/weisswein',\n                'wein-und-mehr/rotwein',\n                'wein-und-mehr/rose',\n                'wein-und-mehr/bio-wein',\n                'wein-und-mehr/sonstige-weine',\n                'wein-und-mehr/sekt-mehr',\n                'wein-und-mehr/probierpakete',\n                'wein-und-mehr/gluehwein'\n            );\n        }\n        if ($this->getInput('liquor')) {\n            array_push(\n                $categories,\n                'spirituosen/wodka',\n                'spirituosen/gin',\n                'spirituosen/whisky',\n                'spirituosen/rum',\n                'spirituosen/weitere-spirituosen',\n                'spirituosen/kraeuterlikoer',\n                'spirituosen/weitere-likoere',\n                'spirituosen/aperitif'\n            );\n        }\n        if ($this->getInput('food')) {\n            array_push(\n                $categories,\n                'lebensmittel/veggie-vegan',\n                'lebensmittel/kaffee-tee',\n                'lebensmittel/milch-alternativen',\n                'lebensmittel/tiefkuehltruhe',\n                'lebensmittel/nuesse-trockenobst',\n                'lebensmittel/suesses-salziges',\n                'lebensmittel/nudeln-reis-getreide',\n                'lebensmittel/fertiges-konserven',\n                'lebensmittel/sossen-oele-gewuerze'\n            );\n        }\n        if ($this->getInput('household')) {\n            array_push(\n                $categories,\n                'haushalt/hygieneartikel',\n                'haushalt/gesundheit-verhuetung',\n                'haushalt/kueche',\n                'haushalt/haushaltsartikel',\n                'haushalt/spuelen-reinigen',\n                'haushalt/waschen'\n            );\n        }\n\n        foreach ($categories as $category) {\n            try {\n                $url = sprintf('https://www.flaschenpost.de/%s?plz=%s', $category, $this->getInput('zip-code'));\n                // Gives redirect on unknown zip-code\n                $html = getSimpleHTMLDOM($url, [], [CURLOPT_FOLLOWLOCATION => false]);\n            } catch (\\Exception $e) {\n                // skip\n                continue;\n            }\n\n            // extract the JavaScript block which contains all the data we need\n            $regex = '/(\\{childElements:\\[.*\\})\\];/';\n            preg_match($regex, $html, $matches);\n            $js = $matches[1];\n\n            // convert JavaScript to JSON\n            $js = $this->jsToJson($js);\n\n            // get all products\n            $json_decode = json_decode($js, false, 512, JSON_THROW_ON_ERROR);\n            $products = $this->recursiveFind((array) $json_decode, 'products');\n            foreach ($products as $product) {\n                // there can be multiple variants, like 0.5l and 0.33l bottles\n                foreach ($product->product->articles as $article) {\n                    $this->addArticle($article, $product->product);\n                }\n            }\n        }\n    }\n\n    public function getName(): string\n    {\n        $categories = [];\n        if ($this->getInput('water')) {\n            $categories[] = 'Wasser';\n        }\n        if ($this->getInput('beer')) {\n            $categories[] = 'Bier';\n        }\n        if ($this->getInput('lemonade')) {\n            $categories[] = 'Limonade';\n        }\n        if ($this->getInput('juice')) {\n            $categories[] = 'Saft & Schorle';\n        }\n        if ($this->getInput('wine')) {\n            $categories[] = 'Wein & Mehr';\n        }\n        if ($this->getInput('liquor')) {\n            $categories[] = 'Spirituosen';\n        }\n        if ($this->getInput('food')) {\n            $categories[] = 'Lebensmittel';\n        }\n        if ($this->getInput('household')) {\n            $categories[] = 'Haushalt';\n        }\n        if (empty($categories)) {\n            return $this::NAME;\n        } else {\n            return $this::NAME . ' – ' . implode(', ', $categories);\n        }\n    }\n\n    private function jsToJson(string $js): string\n    {\n        // remove all html\n        $js = strip_tags($js);\n        // escape double quotes\n        $js = str_replace('\"', '\\\\\"', $js);\n        // add double quotes to all keys\n        $js = preg_replace('/(?<=[,{])(\\w+)(?=:)/', '\"$1\"', $js);\n        // replace all single quotes with double quotes at all values\n        $js = str_replace('\\'', '\"', $js);\n        // sometimes, there are more than one JSON blocks; we're interested in the first one\n        $js = $this->splitJsonObjects($js)[0];\n        return $js;\n    }\n\n    private function addArticle($article, $product)\n    {\n        $regularPrice = $article->trackingDefaultPrice;\n        $discountPrice = $article->crossedPrice;\n        $discount = round((($regularPrice - $discountPrice) / $regularPrice) * 100.0);\n        $regularPriceString = $article->defaultPrice;\n        $discountPriceString = $article->price;\n\n        // only discounted products\n        if ($regularPrice != $discountPrice) {\n            $name = str_replace('\"', '\\'', $product->name);\n            $imageUrl = 'https://image.flaschenpost.de/cdn-cgi/image/width=120,height=120,q=50/articles/small/'\n                . $article->articleId . '.png';\n            $pricePerUnit = str_replace(['(', ')'], '', $article->pricePerUnit);\n            $deposit = $article->deposit ? \"Pfand: $article->deposit\" : 'Pfandfrei';\n            $alcohol = $product->alcoholInfo ? str_replace(['enthält', 'Vol.-', 'Alkohol'], '', $product->alcoholInfo)\n                . ' Alkohol' : '';\n            $description = <<<EOD\n<div style=\"padding: 20px; display: flex;\">\n<img src=\"{$imageUrl}\" alt=\"{$name}\" style=\"float: left; margin-right: 35px;\"/>\n<p style=\"display: inline-block; align-self: center; line-height: 1.5rem;\">\n$pricePerUnit\n<br>\n{$article->shortDescription}\n<br>\n$deposit\n<br>\n$alcohol\n</p>\n</div>\nEOD;\n\n            $item['title'] = \"$name: $discountPriceString statt $regularPriceString (-$discount\\u{2009}%)\";\n            $item['content'] = $description;\n\n            // use current date (@midnight) as timestamp\n            $item['timestamp'] = (new \\DateTime())\n                ->setTimezone(new \\DateTimeZone('Europe/Berlin'))\n                ->setTime(0, 0)\n                ->getTimestamp();\n\n            $item['uri'] = urljoin(\n                'https://www.flaschenpost.de/',\n                \"{$product->brandWebShopUrl}/{$product->webShopUrl}\"\n            );\n\n            // use \"name-<timestamp>\" as uid; that way, there's a new entry each day, when a product stays discounted\n            $item['uid'] = $name . '-' . $item['timestamp'];\n\n            // only add if unique\n            $exists = false;\n            foreach ($this->items as $i) {\n                if ($i['uri'] === $item['uri']) {\n                    $exists = true;\n                    break;\n                }\n            }\n            if (!$exists) {\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://image.flaschenpost.de/CI/fp-favicon.png';\n    }\n\n    // https://stackoverflow.com/a/3975706/421140\n    private function recursiveFind(array $haystack, $needle)\n    {\n        $iterator = new \\RecursiveArrayIterator($haystack);\n        $recursive = new \\RecursiveIteratorIterator(\n            $iterator,\n            \\RecursiveIteratorIterator::SELF_FIRST\n        );\n        foreach ($recursive as $key => $value) {\n            if ($key === $needle) {\n                return $value;\n            }\n        }\n        return null;\n    }\n\n    /**\n     * http://ryanuber.com/07-31-2012/split-and-decode-json-php.html\n     *\n     * json_split_objects - Return an array of many JSON objects\n     *\n     * In some applications (such as PHPUnit, or salt), JSON output is presented as multiple\n     * objects, which you cannot simply pass in to json_decode(). This function will split\n     * the JSON objects apart and return them as an array of strings, one object per indice.\n     *\n     * @param string $json The JSON data to parse\n     *\n     * @return array (of strings)\n     */\n    private function splitJsonObjects(string $json): array\n    {\n        $q = false;\n        $len = strlen($json);\n        for ($l = $c = $i = 0; $i < $len; $i++) {\n            $json[$i] == '\"' && ($i > 0 ? $json[$i - 1] : '') != '\\\\' && $q = !$q;\n            if (!$q && in_array($json[$i], [' ', \"\\r\", \"\\n\", \"\\t\"])) {\n                continue;\n            }\n            in_array($json[$i], ['{', '[']) && !$q && $l++;\n            in_array($json[$i], ['}', ']']) && !$q && $l--;\n            (isset($objects[$c]) && $objects[$c] .= $json[$i]) || $objects[$c] = $json[$i];\n            $c += ($l == 0);\n        }\n        return $objects ?? [];\n    }\n}\n"
  },
  {
    "path": "bridges/FlashbackBridge.php",
    "content": "<?php\n\nclass FlashbackBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'fatuus';\n    const NAME = 'Flashback forum';\n    const URI = 'https://www.flashback.org';\n    const DESCRIPTION = 'Returns post from forum';\n    const CACHE_TIMEOUT = 10800; // 3h\n\n    const PARAMETERS = [\n    'Category' => [\n    'c' => [\n                'name' => 'Category number',\n                'type' => 'number',\n                'exampleValue' => '249',\n                'required' => true\n                ]\n    ],\n    'Tag' => [\n    'a' => [\n                'name' => 'Tag',\n                'type' => 'text',\n                'exampleValue' => 'stockholm',\n                'required' => true\n                ]\n    ],\n    'Thread' => [\n    't' => [\n                'name' => 'Thread number',\n                'type' => 'number',\n                'exampleValue' => '1420554',\n                'required' => true\n                ]\n    ],\n    /*'User' => array(\n    'u' => array(\n                'name' => 'User number',\n                'type' => 'text',\n                'exampleValue' => 'not working, need login',\n                'required' => true\n                )\n    ),*/\n    'Search string' => [\n    's' => [\n                'name' => 'Words',\n                'type' => 'text',\n                'exampleValue' => 'sök',\n                'required' => true\n                ],\n    'type' => [\n                'name' => 'Type of search',\n                'type' => 'list',\n                'defaultValue' => 'Posts',\n                'values' => [\n                    'Posts' => 'posts',\n                    'Subjects' => 'subjects'\n                ]\n    ]\n    ]\n    ];\n\n    public function getName()\n    {\n        if ($this->getInput('c')) {\n            $category = $this->getInput('c');\n            return 'Category ' . $category . ' - Flashback';\n        } elseif ($this->getInput('a')) {\n            $tag = $this->getInput('a');\n            return 'Tag: ' . $tag . ' - Flashback';\n        } elseif ($this->getInput('t')) {\n            $thread = $this->getInput('t');\n            return 'Thread ' . $thread . ' - Flashback';\n        } elseif ($this->getInput('u')) {\n            $user = $this->getInput('u');\n            return 'User ' . $user . ' - Flashback';\n        } elseif ($this->getInput('s')) {\n            $search = $this->getInput('s');\n            return 'Search: ' . $search . ' - Flashback';\n        }\n\n        return self::NAME;\n    }\n\n    public function collectData()\n    {\n        if ($this->getInput('c')) {\n            $page = self::URI . '/f' . $this->getInput('c');\n        } elseif ($this->getInput('a')) {\n            $page = self::URI . '/find_threads_by_tag.php?tag=' . $this->getInput('a');\n        } elseif ($this->getInput('t')) {\n            $page = self::URI . '/t' . $this->getInput('t');\n            $page = $page . 's'; // last-page\n        } elseif ($this->getInput('u')) {\n            $page = self::URI . '/find_posts_by_user.php?userid=' . $this->getInput('u');\n        } elseif ($this->getInput('s')) {\n            if ($this->getInput('type') == 'posts') {\n                $page = self::URI . '/sok/?query=' . $this->getInput('s') . '&search_post=1&sp=1&so=pd';\n            } else {\n                $page = self::URI . '/sok/?query=' . $this->getInput('s') . '&search_post=0&sp=1&so=pd';\n            }\n        }\n\n        $html = getSimpleHTMLDOM($page);\n\n        if ($this->getInput('c') || $this->getInput('a')) {\n            $category = $this->getInput('c');\n            $array = $html->find('table#threadslist tbody tr');\n            foreach ($array as $key => $element) {\n                $item = [];\n                $item['uri'] = self::URI . $element->find('td.td_title a', 0)->href;\n                $item['title'] = trim(utf8_encode($element->find('td.td_title a', 0)->innertext));\n                $item['author'] = trim(utf8_encode(\n                    $element->find('td.td_title span.thread-poster span', 0)->innertext\n                ));\n                $timestamp = $element->find('td.td_last_post div', 0);\n                if (isset($timestamp->plaintext)) {\n                    $item['timestamp'] = strtotime(str_replace(\n                        ['Ig&aring;r', 'Idag'],\n                        ['yesterday', 'today'],\n                        trim($timestamp->plaintext)\n                    ));\n                }\n                $item['content'] = $item['title'] . '<br />' . trim(preg_replace(\n                    '/\\t+/',\n                    '',\n                    $element->find('td.td_replies', 0)->innertext\n                ));\n                $item['uid'] = preg_split('/(\\/)/', $element->find('td.td_title a', 0)->href)[1];\n                $this->items[] = $item;\n            }\n        } elseif ($this->getInput('t')) {\n            $tags = $html->find('div.hidden-xs a.tag');\n            $array = $html->find('div.post');\n\n            foreach ($array as $key => $element) {\n                $item = [];\n                $item['uri_post'] = self::URI . $element->find('div.post-heading a', 2)->href;\n                $item['uri'] = self::URI . '/' . preg_split('/(\\/s)/', $item['uri_post'])[1] . '#' .\n                    preg_split('/(\\/s)/', $item['uri_post'])[1];\n                $item['uri_thread'] = $page;\n                $item['author'] = utf8_encode($element->find('div.post-user ul li', 0)->innertext);\n                $item['author_link'] = self::URI . $element->find('div.post-user ul li a', 0)->href;\n                $item['post_nr'] = $element->find('div.post-heading a strong', 0)->innertext;\n                $item['timestamp'] = strtotime(\n                    str_replace(\n                        ['Ig&aring;r', 'Idag'],\n                        ['yesterday', 'today'],\n                        current(explode(\"\\t\", str_replace(\"\\t\\t\", \"\\t\", trim(\n                            $element->find('div.post-heading', 0)->plaintext\n                        ))))\n                    )\n                );\n                if ($element->find('div.smallfont strong', 0)) {\n                    $item['title'] = trim(utf8_encode($element->find('div.smallfont strong', 0)->innertext));\n                }\n                if (empty($item['title'])) {\n                    $item['title'] = date('D j M y H:i', $item['timestamp']);\n                }\n                $item['content'] = trim(preg_replace('/\\t+/', '', $element->find('div.post_message', 0)));\n                $item['uid'] = preg_split('/(\\#|\\/)/', $element->find('div.post-heading a', 2)->href)[1];\n                foreach ($tags as $tag_key => $tag) {\n                    $item['categories'][] = trim(utf8_encode($tag->innertext));\n                }\n                $this->items[] = $item;\n            }\n            // } elseif ( $this->getInput('u') ) {\n        } elseif ($this->getInput('s')) {\n            $array = $html->find('div.post');\n            foreach ($array as $key => $element) {\n                $item = [];\n                $item['uri'] = self::URI . $element->find('div.post-body a', 0)->href;\n                $item['uri_thread'] = $page . $element->find('div.post-heading a', 0)->href . 's';\n                $item['author'] = $element->find('div.post-body a', 1)->innertext;\n                $item['author_link'] = self::URI . $element->find('div.post-body a', 1)->href;\n                $time = preg_split('/(\\>)/', $element->find('div.post-heading', 0)->innertext);\n                $item['timestamp'] = strtotime(trim(end($time)));\n                $item['title'] = trim(utf8_encode($element->find('div.post-body strong', 0)->innertext));\n                if (empty($item['title'])) {\n                    $item['title'] = date('D j M y H:i', $item['timestamp']);\n                }\n\n                $item['datetime'] = (trim(end($time)));\n                $item['categories'][] = trim(utf8_encode($element->find('div.post-heading a', 0)->innertext));\n                $item['content'] = trim(preg_replace('/\\t+/', '', $element->find('div.post_message', 0)));\n                $item['uid'] = preg_split('/(\\#|\\/)/', $element->find('div.post-body a', 0)->href)[1];\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FlickrBridge.php",
    "content": "<?php\n\n/* This is a mashup of FlickrExploreBridge by sebsauvage and FlickrTagBridge\n * by erwang, providing the functionality of both in one.\n */\nclass FlickrBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Flickr';\n    const URI = 'https://www.flickr.com/';\n    const CACHE_TIMEOUT = 21600; // 6 hours\n    const DESCRIPTION = 'Returns images from Flickr';\n\n    const PARAMETERS = [\n        'Explore' => [],\n        'By keyword' => [\n            'q' => [\n                'name' => 'Keyword',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert keyword',\n                'exampleValue' => 'bird'\n            ],\n            'media' => [\n                'name' => 'Media',\n                'type' => 'list',\n                'values' => [\n                    'All (Photos & videos)' => 'all',\n                    'Photos' => 'photos',\n                    'Videos' => 'videos',\n                ],\n                'defaultValue' => 'all',\n            ],\n            'sort' => [\n                'name' => 'Sort By',\n                'type' => 'list',\n                'values' => [\n                    'Relevance' => 'relevance',\n                    'Date uploaded' => 'date-posted-desc',\n                    'Date taken' => 'date-taken-desc',\n                    'Interesting' => 'interestingness-desc',\n                ],\n                'defaultValue' => 'relevance',\n            ]\n        ],\n        'By username' => [\n            'u' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert username (as shown in the address bar)',\n                'exampleValue' => 'flickr'\n            ],\n            'content' => [\n                'name' => 'Content',\n                'type' => 'list',\n                'values' => [\n                    'Uploads' => 'uploads',\n                    'Favorites' => 'faves',\n                ],\n                'defaultValue' => 'uploads',\n            ],\n            'media' => [\n                'name' => 'Media',\n                'type' => 'list',\n                'values' => [\n                    'All (Photos & videos)' => 'all',\n                    'Photos' => 'photos',\n                    'Videos' => 'videos',\n                ],\n                'defaultValue' => 'all',\n            ],\n            'sort' => [\n                'name' => 'Sort By',\n                'type' => 'list',\n                'values' => [\n                    'Relevance' => 'relevance',\n                    'Date uploaded' => 'date-posted-desc',\n                    'Date taken' => 'date-taken-desc',\n                    'Interesting' => 'interestingness-desc',\n                ],\n                'defaultValue' => 'date-posted-desc',\n            ]\n        ]\n    ];\n\n    private $username = '';\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Explore':\n                $filter = 'photo-lite-models';\n                $html = getSimpleHTMLDOM($this->getURI());\n                break;\n\n            case 'By keyword':\n                $filter = 'photo-lite-models';\n                $html = getSimpleHTMLDOM($this->getURI());\n                break;\n\n            case 'By username':\n                //$filter = 'photo-models';\n                $filter = 'photo-lite-models';\n                $html = getSimpleHTMLDOM($this->getURI());\n\n                $this->username = $this->getInput('u');\n\n                if ($html->find('span.search-pill-name', 0)) {\n                    $this->username = $html->find('span.search-pill-name', 0)->plaintext;\n                }\n                break;\n\n            default:\n                throwClientException('Invalid context: ' . $this->queriedContext);\n        }\n\n        $model_json = $this->extractJsonModel($html);\n        $photo_models = $this->getPhotoModels($model_json, $filter);\n\n        foreach ($photo_models as $model) {\n            $item = [];\n\n            /* Author name depends on scope. On a keyword search the\n            * author is part of the picture data. On a username search\n            * the author is part of the owner data.\n            */\n            if (array_key_exists('username', $model)) {\n                $item['author'] = urldecode($model['username']);\n            } elseif (array_key_exists('owner', reset($model_json)[0])) {\n                $item['author'] = urldecode(reset($model_json)[0]['owner']['username']);\n            }\n\n            $item['title'] = urldecode((array_key_exists('title', $model) ? $model['title'] : 'Untitled'));\n            $item['uri'] = self::URI . 'photo.gne?id=' . $model['id'];\n\n            $description = (array_key_exists('description', $model) ? $model['description'] : '');\n\n            $item['content'] = '<a href=\"'\n            . $item['uri']\n            . '\"><img src=\"'\n            . $this->extractContentImage($model)\n            . '\" style=\"max-width: 640px; max-height: 480px;\"/></a><br><p>'\n            . urldecode($description)\n            . '</p>';\n\n            $item['enclosures'] = $this->extractEnclosures($model);\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Explore':\n                return self::URI . 'explore';\n                break;\n            case 'By keyword':\n                return self::URI . 'search/?q=' . urlencode($this->getInput('q'))\n                    . '&sort=' . $this->getInput('sort') . '&media=' . $this->getInput('media');\n                break;\n            case 'By username':\n                $uri = self::URI . 'search/?user_id=' . urlencode($this->getInput('u'))\n                    . '&sort=date-posted-desc&media=' . $this->getInput('media');\n\n                if ($this->getInput('content') === 'faves') {\n                    return $uri . '&faves=1';\n                }\n\n                return $uri;\n                break;\n\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Explore':\n                return 'Explore - ' . self::NAME;\n                break;\n            case 'By keyword':\n                return $this->getInput('q') . ' - keyword - ' . self::NAME;\n                break;\n            case 'By username':\n                if ($this->getInput('content') === 'faves') {\n                    return $this->username . ' - favorites - ' . self::NAME;\n                }\n\n                return $this->username . ' - ' . self::NAME;\n                break;\n\n            default:\n                return parent::getName();\n        }\n\n        return parent::getName();\n    }\n\n    private function extractJsonModel($html)\n    {\n        // Find SCRIPT containing JSON data\n        $model = $html->find('.modelExport', 0);\n        $model_text = $model->innertext;\n\n        // Find start and end of JSON data\n        $start = strpos($model_text, 'modelExport:') + strlen('modelExport:');\n        $end = strpos($model_text, 'auth:') - strlen('auth:');\n\n        // Extract JSON data, remove trailing comma\n        $model_text = trim(substr($model_text, $start, $end - $start));\n        $model_text = substr($model_text, 0, strlen($model_text) - 1);\n\n        return json_decode($model_text, true);\n    }\n\n    private function getPhotoModels($json, $filter)\n    {\n        // The JSON model contains a \"legend\" array, where each element contains\n        // the path to an element in the \"main\" object\n        $photo_models = [];\n\n        foreach ($json['legend'] as $legend) {\n            $photo_model = $json['main'];\n\n            foreach ($legend as $element) { // Traverse tree\n                $photo_model = $photo_model[$element];\n            }\n\n            // We are only interested in content\n            if ($photo_model['_flickrModelRegistry'] === $filter) {\n                $photo_models[] = $photo_model;\n            }\n        }\n\n        return $photo_models;\n    }\n\n    private function extractEnclosures($model)\n    {\n        $areas = [];\n\n        foreach ($model['sizes']['data'] as $size) {\n            $size = $size['data'];\n            $areas[$size['width'] * $size['height']] = $size['url'];\n        }\n\n        return [$this->fixURL(max($areas))];\n    }\n\n    private function extractContentImage($model)\n    {\n        $areas = [];\n        $limit = 320 * 240;\n        $sizes = $model['sizes']['data'];\n        foreach ($sizes as $sizeData) {\n            $sizeData = $sizeData['data'];\n            $area = $sizeData['width'] * $sizeData['height'];\n            if ($area >= $limit) {\n                $areas[$area] = $sizeData['url'];\n            }\n        }\n        if ($areas) {\n            $minKey = min(array_keys($areas));\n            $url = $areas[$minKey];\n        } else {\n            $array_key_first = array_key_first($sizes);\n            $url = $sizes[$array_key_first]['data']['url'];\n        }\n        return $this->fixURL($url);\n    }\n\n    private function fixURL($url)\n    {\n        // For some reason the image URLs don't include the protocol (https)\n        if (strpos($url, '//') === 0) {\n            $url = 'https:' . $url;\n        }\n\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/FliegermagazinBridge.php",
    "content": "<?php\n\nclass FliegermagazinBridge extends XPathAbstract\n{\n    const NAME = 'fliegermagazin';\n    const URI = 'https://www.fliegermagazin.de/news-fuer-piloten/';\n    const DESCRIPTION = 'News für Piloten';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://www.fliegermagazin.de/news-fuer-piloten/';\n    const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"shortcut icon\"]/@href';\n    const XPATH_EXPRESSION_ITEM = '//article[@data-type=\"post\"]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/h3/a/text()';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/h3/a/text()';\n    const XPATH_EXPRESSION_ITEM_URI = './/h3/a/@href';\n    const XPATH_EXPRESSION_ITEM_AUTHOR = './/p[@class=\"author-field\"]';\n    // Timestamp kann nur durch Laden des Artikels herausgefunden werden\n    //const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/span/i';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@src';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n}\n\n"
  },
  {
    "path": "bridges/FolhaDeSaoPauloBridge.php",
    "content": "<?php\n\nclass FolhaDeSaoPauloBridge extends FeedExpander\n{\n    const MAINTAINER = 'somini';\n    const NAME = 'Folha de São Paulo';\n    const URI = 'https://www1.folha.uol.com.br';\n    const DESCRIPTION = 'Returns the newest posts from Folha de São Paulo (full text)';\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'Feed sub-URL',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Select the sub-feed (see https://www1.folha.uol.com.br/feed/)',\n                'exampleValue' => 'emcimadahora/rss091.xml',\n            ],\n            'amount' => [\n                'name' => 'Amount of items to fetch',\n                'type' => 'number',\n                'defaultValue' => 15,\n            ],\n            'deep_crawl' => [\n                'name' => 'Deep Crawl',\n                'description' => 'Crawl each item \"deeply\", that is, return the article contents',\n                'type' => 'checkbox',\n                'defaultValue' => true,\n            ],\n        ]\n    ];\n\n    protected function parseItem(array $item)\n    {\n        if ($this->getInput('deep_crawl')) {\n            $articleHTMLContent = getSimpleHTMLDOMCached($item['uri']);\n            if ($articleHTMLContent) {\n                foreach ($articleHTMLContent->find('div.c-news__body .is-hidden') as $toRemove) {\n                    $toRemove->innertext = '';\n                }\n                $item_content = $articleHTMLContent->find('div.c-news__body', 0);\n                if ($item_content) {\n                    $text = $item_content->innertext;\n                    $text = strip_tags($text, '<p><b><a><blockquote><figure><figcaption><img><strong><em><ul><li>');\n                    $item['content'] = $text;\n                    $item['uri'] = explode('*', $item['uri'])[1];\n                }\n            }\n        } else {\n            $item['uri'] = explode('*', $item['uri'])[1];\n        }\n\n        return $item;\n    }\n\n    public function collectData()\n    {\n        $feed_input = $this->getInput('feed');\n        if (substr($feed_input, 0, strlen(self::URI)) === self::URI) {\n            $feed_url = $feed_input;\n        } else {\n            /* TODO: prepend `/` if missing */\n            $feed_url = self::URI . '/' . $this->getInput('feed');\n        }\n        $limit = $this->getInput('amount');\n        $this->collectExpandableDatas($feed_url, $limit);\n    }\n}\n"
  },
  {
    "path": "bridges/ForGifsBridge.php",
    "content": "<?php\n\nclass ForGifsBridge extends FeedExpander\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'forgifs';\n    const URI = 'https://forgifs.com';\n    const DESCRIPTION = 'Returns the forgifs feed with actual gifs instead of images';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://forgifs.com/gallery/srss/7');\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = str_get_html($item['content']);\n        $img = $dom->find('img', 0);\n        $poster = $img->src;\n\n        // The actual gif is the same path but its id must be decremented by one.\n        // Example:\n        // http://forgifs.com/gallery/d/279419-2/Reporter-videobombed-shoulder-checks.gif\n        // http://forgifs.com/gallery/d/279418-2/Reporter-videobombed-shoulder-checks.gif\n        // Notice how this changes ----------^\n        // Now let's extract that number and do some math\n        // Notice: Technically we could also load the content page but that would\n        // require unnecessary traffic. As long as it works...\n        $num = substr($img->src, 29, 6);\n        $num -= 1;\n        $img->src = substr_replace($img->src, $num, 29, strlen($num));\n        $img->width = 'auto';\n        $img->height = 'auto';\n\n        $item['content'] = (string) $dom;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ForensicArchitectureBridge.php",
    "content": "<?php\n\nclass ForensicArchitectureBridge extends BridgeAbstract\n{\n    const NAME = 'Forensic Architecture';\n    const URI = 'https://forensic-architecture.org/';\n    const DESCRIPTION = 'Generates content feeds from forensic-architecture.org';\n    const MAINTAINER = 'tillcash';\n\n    public function collectData()\n    {\n        $url = 'https://forensic-architecture.org/api/fa/v1/investigations';\n        $jsonData = json_decode(getContents($url));\n\n        foreach ($jsonData->investigations as $investigation) {\n            $this->items[] = [\n                'content' => $investigation->abstract,\n                'timestamp' => $investigation->publication_date,\n                'title' => $investigation->title,\n                'uid' => $investigation->id,\n                'uri' => self::URI . 'investigation/' . $investigation->slug,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/Formula1Bridge.php",
    "content": "<?php\n\nclass Formula1Bridge extends BridgeAbstract\n{\n    const NAME = 'Formula1';\n    const URI = 'https://formula1.com/';\n    const DESCRIPTION = 'Returns latest official Formula 1 news';\n    const MAINTAINER = 'axor-mst';\n\n    const API_KEY = 'xZ7AOODSjiQadLsIYWefQrpCSQVDbHGC';\n    const API_URL = 'https://api.formula1.com/v1/editorial/articles?limit=%u';\n\n    const ARTICLE_AUTHOR = 'Formula 1';\n    const ARTICLE_URL = 'https://formula1.com/en/latest/article/%s.%s';\n\n    const LIMIT_MIN = 1;\n    const LIMIT_DEFAULT = 10;\n    const LIMIT_MAX = 100;\n\n    const PARAMETERS = [\n        [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Number of articles to return',\n                'exampleValue' => self::LIMIT_DEFAULT,\n                'defaultValue' => self::LIMIT_DEFAULT\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit') ?: self::LIMIT_DEFAULT;\n        $limit = min(self::LIMIT_MAX, max(self::LIMIT_MIN, $limit));\n        $url = sprintf(self::API_URL, $limit);\n\n        $json = json_decode(getContents($url, [\n            'Accept: application/json',\n            'apikey: ' . self::API_KEY,\n            'locale: en'\n        ]));\n        if (property_exists($json, 'error')) {\n            throwServerException($json->message);\n        }\n        $list = $json->items;\n\n        foreach ($list as $article) {\n            if (property_exists($article->thumbnail, 'caption')) {\n                $caption = $article->thumbnail->caption;\n            } else {\n                $caption = $article->thumbnail->image->title;\n            }\n\n            $item = [];\n            $item['uri'] = sprintf(self::ARTICLE_URL, $article->slug, $article->id);\n            $item['title'] = $article->title;\n            $item['timestamp'] = $article->updatedAt;\n            $item['author'] = self::ARTICLE_AUTHOR;\n            $item['enclosures'] = [$article->thumbnail->image->url];\n            $item['uid'] = $article->id;\n            $item['content'] = sprintf(\n                '<p>%s</p><a href=\"%s\" target=\"_blank\"><img src=\"%s\" alt=\"%s\" title=\"%s\"></a>',\n                $article->metaDescription ?? $article->title,\n                $item['uri'],\n                $item['enclosures'][0],\n                $caption,\n                $caption\n            );\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/FourchanBridge.php",
    "content": "<?php\n\nclass FourchanBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = '4chan';\n    const URI = 'https://boards.4chan.org/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns posts from the specified thread';\n\n    const PARAMETERS = [ [\n        'c' => [\n            'name' => 'Thread category',\n            'required' => true,\n            'exampleValue' => 'po',\n        ],\n        't' => [\n            'name' => 'Thread number',\n            'type' => 'number',\n            'exampleValue' => '597271',\n            'required' => true\n        ]\n    ]];\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('c')) && !is_null($this->getInput('t'))) {\n            return static::URI . $this->getInput('c') . '/thread/' . $this->getInput('t');\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('div.postContainer') as $element) {\n            $item = [];\n            $item['id'] = $element->find('.post', 0)->getAttribute('id');\n            $item['uri'] = $this->getURI() . '#' . $item['id'];\n            $item['timestamp'] = $element->find('span.dateTime', 0)->getAttribute('data-utc');\n            $item['author'] = $element->find('span.name', 0)->plaintext;\n\n            $file = $element->find('.file', 0);\n\n            if (!empty($file)) {\n                $var = $element->find('.file a', 0);\n                $item['image'] = $var->href ?? '';\n                $item['imageThumb'] = $element->find('.file img', 0)->src;\n                if (!isset($item['imageThumb']) and strpos($item['image'], '.swf') !== false) {\n                    $item['imageThumb'] = 'http://i.imgur.com/eO0cxf9.jpg';\n                }\n            }\n\n            if (!empty($element->find('span.subject', 0)->innertext)) {\n                $item['subject'] = $element->find('span.subject', 0)->innertext;\n            }\n\n            $item['title'] = 'reply ' . $item['id'] . ' | ' . $item['author'];\n            if (isset($item['subject'])) {\n                $item['title'] = $item['subject'] . ' - ' . $item['title'];\n            }\n\n            $content = $element->find('.postMessage', 0)->innertext;\n            $content = str_replace('href=\"#p', 'href=\"' . $this->getURI() . '#p', $content);\n            $item['content'] = '<span id=\"' . $item['id'] . '\">' . $content . '</span>';\n\n            if (isset($item['image'])) {\n                $item['content'] = '<a href=\"'\n                . $item['image']\n                . '\"><img alt=\"'\n                . $item['id']\n                . '\" src=\"'\n                . $item['imageThumb']\n                . '\" /></a><br>'\n                . $item['content'];\n            }\n            $this->items[] = $item;\n        }\n        $this->items = array_reverse($this->items);\n    }\n}\n"
  },
  {
    "path": "bridges/FreeCodeCampBridge.php",
    "content": "<?php\n\nclass FreeCodeCampBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'FreeCodecamp';\n    const URI = 'https://www.freecodecamp.org';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'RSS feed for FreeCodeCamp';\n    // Freecodecamp removed their old full content rss feed and replaced it with one liner content.\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://www.freecodecamp.org/news/rss/', 15);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOM($item['uri']);\n\n        // figure contain's the main article image\n        $article = $dom->find('figure', 0);\n\n        // the actual article\n        foreach ($dom->find('.post-full-content') as $element) {\n            $article = $article . $element;\n        }\n        $item['content'] = $article;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/FreeTelechargerBridge.php",
    "content": "<?php\n\nclass FreeTelechargerBridge extends BridgeAbstract\n{\n    const NAME = 'Free-Telecharger';\n    const URI = 'https://www.free-telecharger.fun/';\n    const ALTERNATEURI = 'https://www.free-telecharger.com/';\n    const DESCRIPTION = 'Suivi de série sur Free-Telecharger';\n    const MAINTAINER  = 'sysadminstory';\n    const PARAMETERS = [\n            'Suivi de publication de série' => [\n                    'url' => [\n                            'name' => 'URL de la série',\n                            'type' => 'text',\n                            'required' => true,\n                            'title' => 'URL d\\'une série sans le https://www.free-telecharger.fun/',\n                            'pattern' => 'series.*\\.html',\n                            'exampleValue' => 'series-vf-hd/151432-wolf-saison-1-complete-web-dl-720p.html'\n                    ],\n            ]\n    ];\n    const CACHE_TIMEOUT = 3600;\n    private string $showTitle = '';\n    private string $showTechDetails = '';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::ALTERNATEURI . $this->getInput('url'));\n\n        // Find all block content of the page\n        $blocks = $html->find('div[class=block1]');\n\n        // Global Infos block\n        $infosBlock = $blocks[0];\n        // Links block\n        $linksBlock = $blocks[2];\n\n        // Extract Global Show infos\n        $this->showTitle = trim($infosBlock->find('div[class=titre1]', 0)->find('font', 0)->plaintext);\n        $this->showTechDetails = trim($infosBlock->find('div[align=center]', 0)->find('b', 0)->plaintext);\n\n\n\n        // Get Episodes names and links\n        $episodes = $linksBlock->find('div[id=link]', 0)->find('font[color=#e93100]');\n        $links = $linksBlock->find('div[id=link]', 0)->find('a');\n\n        foreach ($episodes as $index => $episode) {\n            $item = []; // Create an empty item\n            $item['title'] = $this->showTitle . ' ' . $this->showTechDetails . '  - ' . ltrim(trim($episode->plaintext), '-');\n            $item['uri'] = $links[$index]->href;\n            $item['content'] = '<a href=\"' . $item['uri'] . '\">' . $item['title'] . '</a>';\n            $item['uid'] = hash('md5', $item['uri']);\n\n            $this->items[] = $item; // Add this item to the list\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Suivi de publication de série':\n                return $this->showTitle . ' ' . $this->showTechDetails . ' - ' . self::NAME;\n                break;\n            default:\n                return self::NAME;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Suivi de publication de série':\n                return self::URI . $this->getInput('url');\n                break;\n            default:\n                return self::URI;\n        }\n    }\n\n    public function detectParameters($url)\n    {\n        // Example: https://www.free-telecharger.art/series-vf-hd/151432-wolf-saison-1-complete-web-dl-720p.html\n\n        $params = [];\n        $regex = '/^https:\\/\\/www.*\\.free-telecharger\\.art\\/(series.*\\.html)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Suivi de publication de série';\n            $params['url'] = urldecode($matches[1]);\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/FunkBridge.php",
    "content": "<?php\n\nclass FunkBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'µKöff';\n    const NAME = 'Funk';\n    const URI = 'https://www.funk.net/';\n    const DESCRIPTION = 'Videos per channel of German public video-on-demand service Funk';\n\n    const PARAMETERS = [\n        'Channel' => [\n            'channel' => [\n                'name' => 'Slug',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'game-two-856'\n            ],\n            'max' => [\n                'name' => 'Maximum',\n                'type' => 'number',\n                'defaultValue' => '-1'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Channel':\n                $url = static::URI . 'data/videos/byChannelAlias/' . $this->getInput('channel') . '/';\n                if (!empty($this->getInput('max')) && $this->getInput('max') >= 0) {\n                    $url .= '?size=' . $this->getInput('max');\n                }\n\n                $jsonString = getContents($url);\n                $json = json_decode($jsonString, true);\n\n                foreach ($json['list'] as $element) {\n                    $this->items[] = $this->collectArticle($element);\n                }\n                break;\n            default:\n                throwServerException('Unknown context!');\n        }\n    }\n\n    private function collectArticle($element)\n    {\n        $item = [];\n        $item['uri'] = static::URI . 'channel/' . $element['channelAlias'] . '/' . $element['alias'];\n        $item['title'] = $element['title'];\n        $item['timestamp'] = $element['publicationDate'];\n        $item['author'] = str_replace('-' . $element['channelId'], '', $element['channelAlias']);\n        $item['content'] = $element['shortDescription'];\n        $item['enclosures'] = [\n            'https://www.funk.net/api/v4.0/thumbnails/' . $element['imageLandscape']\n        ];\n        $item['uid'] = $element['entityId'];\n        return $item;\n    }\n\n    public function detectParameters($url)\n    {\n        $regex = '/^https?:\\/\\/(?:www\\.)?funk\\.net\\/channel\\/([^\\/]+).*$/';\n        if (preg_match($regex, $url, $urlMatches) > 0) {\n            return [\n                'context' => 'Channel',\n                'channel' => $urlMatches[1]\n            ];\n        } else {\n            return null;\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.funk.net/img/favicons/favicon-192x192.png';\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Channel':\n                if (!empty($this->getInput('channel'))) {\n                    return $this->getInput('channel');\n                }\n                break;\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/FurAffinityBridge.php",
    "content": "<?php\n\nclass FurAffinityBridge extends BridgeAbstract\n{\n    const NAME = 'FurAffinity';\n    const URI = 'https://www.furaffinity.net';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns posts from various sections of FurAffinity';\n    const MAINTAINER = 'Roliga, mruac';\n    const CONFIGURATION = [\n        'aCookie' => [\n            'required' => false,\n            'defaultValue' => 'ca6e4566-9d81-4263-9444-653b142e35f8'\n\n        ],\n        'bCookie' => [\n            'required' => false,\n            'defaultValue' => '4ce65691-b50f-4742-a990-bf28d6de16ee'\n        ]\n    ];\n    const PARAMETERS = [\n        'Search' => [\n            'q' => [\n                'name' => 'Query',\n                'required' => true,\n                'exampleValue' => 'dog',\n            ],\n            'rating-general' => [\n                'name' => 'General',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'rating-mature' => [\n                'name' => 'Mature',\n                'type' => 'checkbox',\n            ],\n            'rating-adult' => [\n                'name' => 'Adult',\n                'type' => 'checkbox',\n            ],\n            'range' => [\n                'name' => 'Time range',\n                'type' => 'list',\n                'values' => [\n                    'A Day' => 'day',\n                    '3 Days' => '3days',\n                    'A Week' => 'week',\n                    'A Month' => 'month',\n                    'All time' => 'all'\n                ],\n                'defaultValue' => 'all'\n            ],\n            'type-art' => [\n                'name' => 'Art',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'type-flash' => [\n                'name' => 'Flash',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'type-photo' => [\n                'name' => 'Photography',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'type-music' => [\n                'name' => 'Music',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'type-story' => [\n                'name' => 'Story',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'type-poetry' => [\n                'name' => 'Poetry',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'mode' => [\n                'name' => 'Match mode',\n                'type' => 'list',\n                'values' => [\n                    'All of the words' => 'all',\n                    'Any of the words' => 'any',\n                    'Extended' => 'extended'\n                ],\n                'defaultValue' => 'extended'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10,\n                'title' => 'Limit number of submissions to return. -1 for unlimited.'\n            ],\n            'full' => [\n                'name' => 'Full view',\n                'title' => 'Include description, tags, date and larger image in article. Uses more bandwidth.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'cache' => [\n                'name' => 'Cache submission pages',\n                'title' => 'Reduces requests to FA when Full view is enabled. Changes to submission details may be delayed.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ],\n        'Browse' => [\n            'cat' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'Visual Art' => [\n                        'All' => 1,\n                        'Artwork (Digital)' => 2,\n                        'Artwork (Traditional)' => 3,\n                        'Cellshading' => 4,\n                        'Crafting' => 5,\n                        'Designs' => 6,\n                        'Flash' => 7,\n                        'Fursuiting' => 8,\n                        'Icons' => 9,\n                        'Mosaics' => 10,\n                        'Photography' => 11,\n                        'Sculpting' => 12\n                    ],\n                    'Readable Art' => [\n                        'Story' => 13,\n                        'Poetry' => 14,\n                        'Prose' => 15\n                    ],\n                    'Audio Art' => [\n                        'Music' => 16,\n                        'Podcasts' => 17\n                    ],\n                    'Downloadable' => [\n                        'Skins' => 18,\n                        'Handhelds' => 19,\n                        'Resources' => 20\n                    ],\n                    'Other Stuff' => [\n                        'Adoptables' => 21,\n                        'Auctions' => 22,\n                        'Contests' => 23,\n                        'Current Events' => 24,\n                        'Desktops' => 25,\n                        'Stockart' => 26,\n                        'Screenshots' => 27,\n                        'Scraps' => 28,\n                        'Wallpaper' => 29,\n                        'YCH / Sale' => 30,\n                        'Other' => 31\n                    ]\n                ],\n                'defaultValue' => 1\n            ],\n            'atype' => [\n                'name' => 'Type',\n                'type' => 'list',\n                'values' => [\n                    'General Things' => [\n                        'All' => 1,\n                        'Abstract' => 2,\n                        'Animal related (non-anthro)' => 3,\n                        'Anime' => 4,\n                        'Comics' => 5,\n                        'Doodle' => 6,\n                        'Fanart' => 7,\n                        'Fantasy' => 8,\n                        'Human' => 9,\n                        'Portraits' => 10,\n                        'Scenery' => 11,\n                        'Still Life' => 12,\n                        'Tutorials' => 13,\n                        'Miscellaneous' => 14\n                    ],\n                    'Fetish / Furry specialty' => [\n                        'Baby fur' => 101,\n                        'Bondage' => 102,\n                        'Digimon' => 103,\n                        'Fat Furs' => 104,\n                        'Fetish Other' => 105,\n                        'Fursuit' => 106,\n                        'Gore / Macabre Art' => 119,\n                        'Hyper' => 107,\n                        'Inflation' => 108,\n                        'Macro / Micro' => 109,\n                        'Muscle' => 110,\n                        'My Little Pony / Brony' => 111,\n                        'Paw' => 112,\n                        'Pokemon' => 113,\n                        'Pregnancy' => 114,\n                        'Sonic' => 115,\n                        'Transformation' => 116,\n                        'Vore' => 117,\n                        'Water Sports' => 118,\n                        'General Furry Art' => 100\n                    ],\n                    'Music' => [\n                        'Techno' => 201,\n                        'Trance' => 202,\n                        'House' => 203,\n                        '90s' => 204,\n                        '80s' => 205,\n                        '70s' => 206,\n                        '60s' => 207,\n                        'Pre-60s' => 208,\n                        'Classical' => 209,\n                        'Game Music' => 210,\n                        'Rock' => 211,\n                        'Pop' => 212,\n                        'Rap' => 213,\n                        'Industrial' => 214,\n                        'Other Music' => 200\n                    ]\n                ],\n                'defaultValue' => 1\n            ],\n            'species' => [\n                'name' => 'Species',\n                'type' => 'list',\n                'values' => [\n                    'Unspecified / Any' => 1,\n                    'Amphibian' => [\n                        'Frog' => 1001,\n                        'Newt' => 1002,\n                        'Salamander' => 1003,\n                        'Amphibian (Other)' => 1000\n                    ],\n                    'Aquatic' => [\n                        'Cephalopod' => 2001,\n                        'Dolphin' => 2002,\n                        'Fish' => 2005,\n                        'Porpoise' => 2004,\n                        'Seal' => 6068,\n                        'Shark' => 2006,\n                        'Whale' => 2003,\n                        'Aquatic (Other)' => 2000\n                    ],\n                    'Avian' => [\n                        'Corvid' => 3001,\n                        'Crow' => 3002,\n                        'Duck' => 3003,\n                        'Eagle' => 3004,\n                        'Falcon' => 3005,\n                        'Goose' => 3006,\n                        'Gryphon' => 3007,\n                        'Hawk' => 3008,\n                        'Owl' => 3009,\n                        'Phoenix' => 3010,\n                        'Swan' => 3011,\n                        'Avian (Other)' => 3000\n                    ],\n                    'Bears &amp; Ursines' => [\n                        'Bear' => 6002\n                    ],\n                    'Camelids' => [\n                        'Camel' => 6074,\n                        'Llama' => 6036\n                    ],\n                    'Canines &amp; Lupines' => [\n                        'Coyote' => 6008,\n                        'Doberman' => 6009,\n                        'Dog' => 6010,\n                        'Dingo' => 6011,\n                        'German Shepherd' => 6012,\n                        'Jackal' => 6013,\n                        'Husky' => 6014,\n                        'Wolf' => 6016,\n                        'Canine (Other)' => 6017\n                    ],\n                    'Cervines' => [\n                        'Cervine (Other)' => 6018\n                    ],\n                    'Cows &amp; Bovines' => [\n                        'Antelope' => 6004,\n                        'Cows' => 6003,\n                        'Gazelle' => 6005,\n                        'Goat' => 6006,\n                        'Bovines (General)' => 6007\n                    ],\n                    'Dragons' => [\n                        'Eastern Dragon' => 4001,\n                        'Hydra' => 4002,\n                        'Serpent' => 4003,\n                        'Western Dragon' => 4004,\n                        'Wyvern' => 4005,\n                        'Dragon (Other)' => 4000\n                    ],\n                    'Equestrians' => [\n                        'Donkey' => 6019,\n                        'Horse' => 6034,\n                        'Pony' => 6073,\n                        'Zebra' => 6071\n                    ],\n                    'Exotic &amp; Mythicals' => [\n                        'Argonian' => 5002,\n                        'Chakat' => 5003,\n                        'Chocobo' => 5004,\n                        'Citra' => 5005,\n                        'Crux' => 5006,\n                        'Daemon' => 5007,\n                        'Digimon' => 5008,\n                        'Dracat' => 5009,\n                        'Draenei' => 5010,\n                        'Elf' => 5011,\n                        'Gargoyle' => 5012,\n                        'Iksar' => 5013,\n                        'Kaiju/Monster' => 5015,\n                        'Langurhali' => 5014,\n                        'Moogle' => 5017,\n                        'Naga' => 5016,\n                        'Orc' => 5018,\n                        'Pokemon' => 5019,\n                        'Satyr' => 5020,\n                        'Sergal' => 5021,\n                        'Tanuki' => 5022,\n                        'Unicorn' => 5023,\n                        'Xenomorph' => 5024,\n                        'Alien (Other)' => 5001,\n                        'Exotic (Other)' => 5000\n                    ],\n                    'Felines' => [\n                        'Domestic Cat' => 6020,\n                        'Cheetah' => 6021,\n                        'Cougar' => 6022,\n                        'Jaguar' => 6023,\n                        'Leopard' => 6024,\n                        'Lion' => 6025,\n                        'Lynx' => 6026,\n                        'Ocelot' => 6027,\n                        'Panther' => 6028,\n                        'Tiger' => 6029,\n                        'Feline (Other)' => 6030\n                    ],\n                    'Insects' => [\n                        'Arachnid' => 8000,\n                        'Mantid' => 8004,\n                        'Scorpion' => 8005,\n                        'Insect (Other)' => 8003\n                    ],\n                    'Mammals (Other)' => [\n                        'Bat' => 6001,\n                        'Giraffe' => 6031,\n                        'Hedgehog' => 6032,\n                        'Hippopotamus' => 6033,\n                        'Hyena' => 6035,\n                        'Panda' => 6052,\n                        'Pig/Swine' => 6053,\n                        'Rabbit/Hare' => 6059,\n                        'Raccoon' => 6060,\n                        'Red Panda' => 6062,\n                        'Meerkat' => 6043,\n                        'Mongoose' => 6044,\n                        'Rhinoceros' => 6063,\n                        'Mammals (Other)' => 6000\n                    ],\n                    'Marsupials' => [\n                        'Opossum' => 6037,\n                        'Kangaroo' => 6038,\n                        'Koala' => 6039,\n                        'Quoll' => 6040,\n                        'Wallaby' => 6041,\n                        'Marsupial (Other)' => 6042\n                    ],\n                    'Mustelids' => [\n                        'Badger' => 6045,\n                        'Ferret' => 6046,\n                        'Mink' => 6048,\n                        'Otter' => 6047,\n                        'Skunk' => 6069,\n                        'Weasel' => 6049,\n                        'Mustelid (Other)' => 6051\n                    ],\n                    'Primates' => [\n                        'Gorilla' => 6054,\n                        'Human' => 6055,\n                        'Lemur' => 6056,\n                        'Monkey' => 6057,\n                        'Primate (Other)' => 6058\n                    ],\n                    'Reptillian' => [\n                        'Alligator &amp; Crocodile' => 7001,\n                        'Gecko' => 7003,\n                        'Iguana' => 7004,\n                        'Lizard' => 7005,\n                        'Snakes &amp; Serpents' => 7006,\n                        'Turtle' => 7007,\n                        'Reptilian (Other)' => 7000\n                    ],\n                    'Rodents' => [\n                        'Beaver' => 6064,\n                        'Mouse' => 6065,\n                        'Rat' => 6061,\n                        'Squirrel' => 6070,\n                        'Rodent (Other)' => 6067\n                    ],\n                    'Vulpines' => [\n                        'Fennec' => 6072,\n                        'Fox' => 6075,\n                        'Vulpine (Other)' => 6015\n                    ],\n                    'Other' => [\n                        'Dinosaur' => 8001,\n                        'Wolverine' => 6050\n                    ]\n                ],\n                'defaultValue' => 1\n            ],\n            'gender' => [\n                'name' => 'Gender',\n                'type' => 'list',\n                'values' => [\n                    'Any' => 0,\n                    'Male' => 2,\n                    'Female' => 3,\n                    'Herm' => 4,\n                    'Transgender' => 5,\n                    'Multiple characters' => 6,\n                    'Other / Not Specified' => 7\n                ],\n                'defaultValue' => 0\n            ],\n            'rating_general' => [\n                'name' => 'General',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'rating_mature' => [\n                'name' => 'Mature',\n                'type' => 'checkbox',\n            ],\n            'rating_adult' => [\n                'name' => 'Adult',\n                'type' => 'checkbox',\n            ],\n            'limit-browse' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10,\n                'title' => 'Limit number of submissions to return. -1 for unlimited.'\n            ],\n            'full' => [\n                'name' => 'Full view',\n                'title' => 'Include description, tags, date and larger image in article. Uses more bandwidth.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'cache' => [\n                'name' => 'Cache submission pages',\n                'title' => 'Reduces requests to FA when Full view is enabled. Changes to submission details may be delayed.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n\n        ],\n        'Journals' => [\n            'username-journals' => [\n                'name' => 'Username',\n                'required' => true,\n                'exampleValue' => 'dhw',\n                'title' => 'Lowercase username as seen in URLs'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'defaultValue' => -1,\n                'title' => 'Limit number of journals to return. -1 for unlimited.'\n            ]\n\n        ],\n        'Single Journal' => [\n            'journal-id' => [\n                'name' => 'Journal ID',\n                'required' => true,\n                'exampleValue' => '10008853',\n                'type' => 'number',\n                'title' => 'Number seen in journal URL'\n            ]\n        ],\n        'Gallery' => [\n            'username-gallery' => [\n                'name' => 'Username',\n                'required' => true,\n                'exampleValue' => 'dhw',\n                'title' => 'Lowercase username as seen in URLs'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10,\n                'title' => 'Limit number of submissions to return. -1 for unlimited.'\n            ],\n            'full' => [\n                'name' => 'Full view',\n                'title' => 'Include description, tags, date and larger image in article. Uses more bandwidth.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'cache' => [\n                'name' => 'Cache submission pages',\n                'title' => 'Reduces requests to FA when Full view is enabled. Changes to submission details may be delayed.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ],\n        'Scraps' => [\n            'username-scraps' => [\n                'name' => 'Username',\n                'required' => true,\n                'exampleValue' => 'dhw',\n                'title' => 'Lowercase username as seen in URLs'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10,\n                'title' => 'Limit number of submissions to return. -1 for unlimited.'\n            ],\n            'full' => [\n                'name' => 'Full view',\n                'title' => 'Include description, tags, date and larger image in article. Uses more bandwidth.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'cache' => [\n                'name' => 'Cache submission pages',\n                'title' => 'Reduces requests to FA when Full view is enabled. Changes to submission details may be delayed.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ],\n        'Favorites' => [\n            'username-favorites' => [\n                'name' => 'Username',\n                'required' => true,\n                'exampleValue' => 'dhw',\n                'title' => 'Lowercase username as seen in URLs'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10,\n                'title' => 'Limit number of submissions to return. -1 for unlimited.'\n            ],\n            'full' => [\n                'name' => 'Full view',\n                'title' => 'Include description, tags, date and larger image in article. Uses more bandwidth.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'cache' => [\n                'name' => 'Cache submission pages',\n                'title' => 'Reduces requests to FA when Full view is enabled. Changes to submission details may be delayed.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ],\n        'Gallery Folder' => [\n            'username-folder' => [\n                'name' => 'Username',\n                'required' => true,\n                'exampleValue' => 'kopk',\n                'title' => 'Lowercase username as seen in URLs'\n            ],\n            'folder-id' => [\n                'name' => 'Folder ID',\n                'required' => true,\n                'exampleValue' => '1031990',\n                'type' => 'number',\n                'title' => 'Number seen in folder URL'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10,\n                'title' => 'Limit number of submissions to return. -1 for unlimited.'\n            ],\n            'full' => [\n                'name' => 'Full view',\n                'title' => 'Include description, tags, date and larger image in article. Uses more bandwidth.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'cache' => [\n                'name' => 'Cache submission pages',\n                'title' => 'Reduces requests to FA when Full view is enabled. Changes to submission details may be delayed.',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ]\n    ];\n\n    /*\n     * This was aquired by creating a new user on FA then\n     * extracting the cookie from the browsers dev console.\n     */\n    private $FA_AUTH_COOKIE;\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Single journal\n        $regex = '/^(https?:\\/\\/)?(www\\.)?furaffinity.net\\/journal\\/(\\d+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Single Journal';\n            $params['journal-id'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        // Journals\n        $regex = '/^(https?:\\/\\/)?(www\\.)?furaffinity.net\\/journals\\/([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Journals';\n            $params['username-journals'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        // Gallery folder\n        $regex = '/^(https?:\\/\\/)?(www\\.)?furaffinity.net\\/gallery\\/([^\\/&?\\n]+)\\/folder\\/(\\d+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Gallery Folder';\n            $params['username-folder'] = urldecode($matches[3]);\n            $params['folder-id'] = urldecode($matches[4]);\n            $params['full'] = 'on';\n            return $params;\n        }\n\n        // Gallery (must be after gallery folder)\n        $regex = '/^(https?:\\/\\/)?(www\\.)?furaffinity.net\\/(gallery|scraps|favorites)\\/([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Gallery';\n            $params['username-' . $matches[3]] = urldecode($matches[4]);\n            $params['full'] = 'on';\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Search':\n                return 'Search For '\n                . $this->getInput('q');\n            case 'Browse':\n                return 'Browse';\n            case 'Journals':\n                return $this->getInput('username-journals');\n            case 'Single Journal':\n                return 'Journal '\n                . $this->getInput('journal-id');\n            case 'Gallery':\n                return $this->getInput('username-gallery');\n            case 'Scraps':\n                return $this->getInput('username-scraps');\n            case 'Favorites':\n                return $this->getInput('username-favorites');\n            case 'Gallery Folder':\n                return $this->getInput('username-folder')\n                . '\\'s Folder '\n                . $this->getInput('folder-id');\n            default:\n                $name = parent::getName();\n                if ($this->getOption('aCookie') !== null) {\n                    $username = $this->loadCacheValue('username');\n                    if ($username) {\n                        $name = $username . '\\'s ' . parent::getName();\n                    }\n                }\n                return $name;\n        }\n    }\n\n    public function getDescription()\n    {\n        switch ($this->queriedContext) {\n            case 'Search':\n                return 'FurAffinity Search For '\n                . $this->getInput('q');\n            case 'Browse':\n                return 'FurAffinity Browse';\n            case 'Journals':\n                return 'FurAffinity Journals By '\n                . $this->getInput('username-journals');\n            case 'Single Journal':\n                return 'FurAffinity Journal '\n                . $this->getInput('journal-id');\n            case 'Gallery':\n                return 'FurAffinity Gallery By '\n                . $this->getInput('username-gallery');\n            case 'Scraps':\n                return 'FurAffinity Scraps By '\n                . $this->getInput('username-scraps');\n            case 'Favorites':\n                return 'FurAffinity Favorites By '\n                . $this->getInput('username-favorites');\n            case 'Gallery Folder':\n                return 'FurAffinity Gallery Folder '\n                . $this->getInput('folder-id')\n                . ' By '\n                . $this->getInput('username-folder');\n            default:\n                return parent::getDescription();\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Search':\n                return self::URI\n                . '/search';\n            case 'Browse':\n                return self::URI\n                . '/browse';\n            case 'Journals':\n                return self::URI\n                . '/journals/'\n                . $this->getInput('username-journals');\n            case 'Single Journal':\n                return self::URI\n                . '/journal/'\n                . $this->getInput('journal-id');\n            case 'Gallery':\n                return self::URI\n                . '/gallery/'\n                . $this->getInput('username-gallery');\n            case 'Scraps':\n                return self::URI\n                . '/scraps/'\n                . $this->getInput('username-scraps');\n            case 'Favorites':\n                return self::URI\n                . '/favorites/'\n                . $this->getInput('username-favorites');\n            case 'Gallery Folder':\n                return self::URI\n                . '/gallery/'\n                . $this->getInput('username-folder')\n                . '/folder/'\n                . $this->getInput('folder-id');\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function collectData()\n    {\n        $this->FA_AUTH_COOKIE = 'b=' . $this->getOption('bCookie') . '; a=' . $this->getOption('aCookie');\n        switch ($this->queriedContext) {\n            case 'Search':\n                $data = [\n                'q' => $this->getInput('q'),\n                'perpage' => 72,\n                'rating-general' => ($this->getInput('rating-general') === true ? 'on' : 0),\n                'rating-mature' => ($this->getInput('rating-mature') === true ? 'on' : 0),\n                'rating-adult' => ($this->getInput('rating-adult') === true ? 'on' : 0),\n                'range' => $this->getInput('range'),\n                'type-art' => ($this->getInput('type-art') === true ? 'on' : 0),\n                'type-flash' => ($this->getInput('type-flash') === true ? 'on' : 0),\n                'type-photo' => ($this->getInput('type-photo') === true ? 'on' : 0),\n                'type-music' => ($this->getInput('type-music') === true ? 'on' : 0),\n                'type-story' => ($this->getInput('type-story') === true ? 'on' : 0),\n                'type-poetry' => ($this->getInput('type-poetry') === true ? 'on' : 0),\n                'mode' => $this->getInput('mode')\n                ];\n                $html = $this->postFASimpleHTMLDOM($data);\n                $limit = (is_int($this->getInput('limit')) ? $this->getInput('limit') : 10);\n                $this->itemsFromSubmissionList($html, $limit);\n                break;\n            case 'Browse':\n                $data = [\n                'cat' => $this->getInput('cat'),\n                'atype' => $this->getInput('atype'),\n                'species' => $this->getInput('species'),\n                'gender' => $this->getInput('gender'),\n                'perpage' => 72,\n                'rating_general' => ($this->getInput('rating_general') === true ? 'on' : 0),\n                'rating_mature' => ($this->getInput('rating_mature') === true ? 'on' : 0),\n                'rating_adult' => ($this->getInput('rating_adult') === true ? 'on' : 0)\n                ];\n                $html = $this->postFASimpleHTMLDOM($data);\n                $limit = (is_int($this->getInput('limit-browse')) ? $this->getInput('limit-browse') : 10);\n                $this->itemsFromSubmissionList($html, $limit);\n                break;\n            case 'Journals':\n                $html = $this->getFASimpleHTMLDOM($this->getURI());\n                $limit = (is_int($this->getInput('limit')) ? $this->getInput('limit') : -1);\n                $this->itemsFromJournalList($html, $limit);\n                break;\n            case 'Single Journal':\n                $html = $this->getFASimpleHTMLDOM($this->getURI());\n                $this->itemsFromJournal($html);\n                break;\n            case 'Gallery':\n            case 'Scraps':\n            case 'Favorites':\n            case 'Gallery Folder':\n                $html = $this->getFASimpleHTMLDOM($this->getURI());\n                $limit = (is_int($this->getInput('limit')) ? $this->getInput('limit') : 10);\n                $this->itemsFromSubmissionList($html, $limit);\n                break;\n        }\n    }\n\n    private function postFASimpleHTMLDOM($data)\n    {\n        $opts = [\n                CURLOPT_CUSTOMREQUEST => 'POST',\n                CURLOPT_POSTFIELDS => http_build_query($data)\n            ];\n        $header = [\n                'Host: ' . parse_url(self::URI, PHP_URL_HOST),\n                'Content-Type: application/x-www-form-urlencoded',\n                'Cookie: ' . $this->FA_AUTH_COOKIE\n            ];\n\n        $html = getSimpleHTMLDOM($this->getURI(), $header, $opts);\n        $html = defaultLinkTo($html, $this->getURI());\n        $this->saveLoggedInUser($html);\n        return $html;\n    }\n\n    private function getFASimpleHTMLDOM($url, $cache = false)\n    {\n        $header = [\n                'Cookie: ' . $this->FA_AUTH_COOKIE\n            ];\n\n        if ($cache) {\n            $html = getSimpleHTMLDOMCached($url, 86400, $header); // 24 hours\n        } else {\n            $html = getSimpleHTMLDOM($url, $header);\n        }\n        $this->saveLoggedInUser($html);\n        $html = defaultLinkTo($html, $url);\n\n        return $html;\n    }\n\n    private function saveLoggedInUser($html)\n    {\n        $current_user = $html->find('#my-username', 0);\n        if ($current_user !== null) {\n            preg_match('/^(?:My FA \\( |~)(.*?)(?: \\)|)$/', trim($current_user->plaintext), $matches);\n            $current_user = $current_user ? $matches[1] : null;\n            if ($current_user !== null) {\n                $this->saveCacheValue('username', $current_user);\n            }\n        }\n    }\n\n    private function itemsFromJournalList($html, $limit)\n    {\n        foreach ($html->find('table[id^=jid:]') as $journal) {\n            # allows limit = -1 to mean 'unlimited'\n            if ($limit-- === 0) {\n                break;\n            }\n\n            $item = [];\n\n            $this->setReferrerPolicy($journal);\n\n            $item['uri'] = $journal->find('a', 0)->href;\n            $item['title'] = html_entity_decode($journal->find('a', 0)->plaintext);\n            $item['author'] = $this->getInput('username-journals');\n            $item['timestamp'] = strtotime(\n                $journal->find('span.popup_date', 0)->plaintext\n            );\n            $item['content'] = $journal\n                ->find('.alt1 table div.no_overflow', 0)\n                ->innertext;\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function itemsFromJournal($html)\n    {\n        $this->setReferrerPolicy($html);\n        $item = [];\n\n        $item['uri'] = $this->getURI();\n\n        $title = $html->find('.journal-title-box .no_overflow', 0)->plaintext;\n        $title = html_entity_decode($title);\n        $title = trim($title, \" \\t\\n\\r\\0\\x0B\" . chr(0xC2) . chr(0xA0));\n        $item['title'] = $title;\n\n        $item['author'] = $html->find('.journal-title-box a', 0)->plaintext;\n        $item['timestamp'] = strtotime(\n            $html->find('.journal-title-box span.popup_date', 0)->plaintext\n        );\n        $item['content'] = $html->find('.journal-body', 0)->innertext;\n\n        $this->items[] = $item;\n    }\n\n    private function itemsFromSubmissionList($html, $limit)\n    {\n        $cache = ($this->getInput('cache') === true);\n\n        foreach ($html->find('section.gallery figure') as $figure) {\n            # allows limit = -1 to mean 'unlimited'\n            if ($limit-- === 0) {\n                break;\n            }\n\n            $item = [\n                'categories' => [],\n            ];\n\n            $submissionURL = $figure->find('b u a', 0)->href;\n            $imgURL = $figure->find('b u a img', 0)->src;\n\n            $item['uri'] = $submissionURL;\n            $item['title'] = html_entity_decode(\n                $figure->find('figcaption p a[href*=/view/]', 0)->title\n            );\n            $item['author'] = $figure->find('figcaption p a[href*=/user/]', 0)->title;\n\n            $item['content'] = \"<a href=\\\"$submissionURL\\\"> <img src=\\\"{$imgURL}\\\" referrerpolicy=\\\"no-referrer\\\"/></a>\";\n\n            if ($this->getInput('full') === true) {\n                $submissionHTML = $this->getFASimpleHTMLDOM($submissionURL, $cache);\n                if (!$this->isHiddenSubmission($submissionHTML)) {\n                    $popupDate = $submissionHTML->find('section .popup_date', 0);\n                    if ($popupDate) {\n                        $item['timestamp'] = strtotime($popupDate->title);\n                    }\n\n                    $var = $submissionHTML->find('.actions a[href^=https://d.facdn]', 0);\n                    if ($var) {\n                        $item['enclosures'] = [$var->href];\n                    }\n\n                    foreach ($submissionHTML->find('.tags-row .tags a') as $keyword) {\n                        $item['categories'][] = $keyword->plaintext;\n                    }\n                    $item['categories'] = array_filter($item['categories']);\n\n                    $previewSrc = $submissionHTML->find('#submissionImg', 0);\n                    if ($previewSrc) {\n                        $imgURL = 'https:' . $previewSrc->{'data-preview-src'};\n                    } else {\n                        $imgURL = $submissionHTML->find('[property=\"og:image\"]', 0)->{'content'};\n                    }\n\n                    $description = $submissionHTML->find('div.submission-description', 0);\n                    if ($description) {\n                        $this->setReferrerPolicy($description);\n                        $description = trim($description->innertext);\n                    } else {\n                        $description = '';\n                    }\n\n                    $item['content'] = \"<a href=\\\"$submissionURL\\\"> <img src=\\\"{$imgURL}\\\" referrerpolicy=\\\"no-referrer\\\"/></a><p>{$description}</p>\";\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function setReferrerPolicy(&$html)\n    {\n        foreach ($html->find('img') as $img) {\n            /*\n             * Note: Without the no-referrer policy their CDN sometimes denies requests.\n             * We can't control this for enclosures sadly.\n             * At least tt-rss adds the referrerpolicy on its own.\n             * Alternatively we could not use https for images, but that's not ideal.\n             */\n            $img->referrerpolicy = 'no-referrer';\n        }\n    }\n\n    private function isHiddenSubmission($html)\n    {\n        //Disabled accounts prevents their userpage, gallery, favorites and journals from being viewed.\n        //Submissions can require maturity limit or logged-in account.\n        $system_message = $html->find('.section-body.alignleft', 0);\n        $system_message = $system_message ? $system_message->plaintext : '';\n\n        return str_contains($system_message, 'System Message');\n    }\n}\n"
  },
  {
    "path": "bridges/FurAffinityUserBridge.php",
    "content": "<?php\n\nclass FurAffinityUserBridge extends BridgeAbstract\n{\n    const NAME = 'FurAffinity User Gallery';\n    const URI = 'https://www.furaffinity.net';\n    const MAINTAINER = 'CyberJacob';\n    const DESCRIPTION = 'See https://rss-bridge.github.io/rss-bridge/Bridge_Specific/Furaffinityuser.html for explanation';\n    const PARAMETERS = [\n        [\n            'searchUsername' => [\n                'name' => 'Search Username',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Username to fetch the gallery for',\n                'exampleValue' => 'armundy',\n            ],\n            'aCookie' => [\n                'name' => 'Login cookie \\'a\\'',\n                'type' => 'text',\n                'required' => true\n            ],\n            'bCookie' => [\n                'name' => 'Login cookie \\'b\\'',\n                'type' => 'text',\n                'required' => true\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $opt = [CURLOPT_COOKIE => 'b=' . $this->getInput('bCookie') . '; a=' . $this->getInput('aCookie')];\n\n        $url = self::URI . '/gallery/' . $this->getInput('searchUsername');\n\n        $html = getSimpleHTMLDOM($url, [], $opt);\n\n        $submissions = $html->find('section[id=gallery-gallery]', 0)->find('figure');\n        foreach ($submissions as $submission) {\n            $item = [];\n            $item['title'] = $submission->find('figcaption', 0)->find('a', 0)->plaintext;\n\n            $thumbnail = $submission->find('a', 0);\n            $thumbnail->href = self::URI . $thumbnail->href;\n\n            $item['content'] = $submission->find('a', 0);\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        return self::NAME . ' for ' . $this->getInput('searchUsername');\n    }\n\n    public function getURI()\n    {\n        return self::URI . '/user/' . $this->getInput('searchUsername');\n    }\n}\n"
  },
  {
    "path": "bridges/FuturaSciencesBridge.php",
    "content": "<?php\n\nclass FuturaSciencesBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Futura-Sciences';\n    const URI = 'https://www.futura-sciences.com/';\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed',\n            'type' => 'list',\n            'values' => [\n                'Les flux multi-magazines' => [\n                    'Les dernières actualités de Futura-Sciences' => 'actualites',\n                    'Les dernières définitions de Futura-Sciences' => 'definitions',\n                    'Les dernières photos de Futura-Sciences' => 'photos',\n                    'Les dernières questions - réponses de Futura-Sciences' => 'questions-reponses',\n                    'Les derniers dossiers de Futura-Sciences' => 'dossiers'\n                ],\n                'Les flux Services' => [\n                    'Les cartes virtuelles de Futura-Sciences' => 'services/cartes-virtuelles',\n                    'Les fonds d\\'écran de Futura-Sciences' => 'services/fonds-ecran'\n                ],\n                'Les flux Santé' => [\n                    'Les dernières actualités de Futura-Santé' => 'sante/actualites',\n                    'Les dernières définitions de Futura-Santé' => 'sante/definitions',\n                    'Les dernières questions-réponses de Futura-Santé' => 'sante/question-reponses',\n                    'Les derniers dossiers de Futura-Santé' => 'sante/dossiers'\n                ],\n                'Les flux High-Tech' => [\n                    'Les dernières actualités de Futura-High-Tech' => 'high-tech/actualites',\n                    'Les dernières astuces de Futura-High-Tech' => 'high-tech/question-reponses',\n                    'Les dernières définitions de Futura-High-Tech' => 'high-tech/definitions',\n                    'Les derniers dossiers de Futura-High-Tech' => 'high-tech/dossiers'\n                ],\n                'Les flux Espace' => [\n                    'Les dernières actualités de Futura-Espace' => 'espace/actualites',\n                    'Les dernières définitions de Futura-Espace' => 'espace/definitions',\n                    'Les dernières questions-réponses de Futura-Espace' => 'espace/question-reponses',\n                    'Les derniers dossiers de Futura-Espace' => 'espace/dossiers'\n                ],\n                'Les flux Environnement' => [\n                    'Les dernières actualités de Futura-Environnement' => 'environnement/actualites',\n                    'Les dernières définitions de Futura-Environnement' => 'environnement/definitions',\n                    'Les dernières questions-réponses de Futura-Environnement' => 'environnement/question-reponses',\n                    'Les derniers dossiers de Futura-Environnement' => 'environnement/dossiers'\n                ],\n                'Les flux Maison' => [\n                    'Les dernières actualités de Futura-Maison' => 'maison/actualites',\n                    'Les dernières astuces de Futura-Maison' => 'maison/question-reponses',\n                    'Les dernières définitions de Futura-Maison' => 'maison/definitions',\n                    'Les derniers dossiers de Futura-Maison' => 'maison/dossiers'\n                ],\n                'Les flux Nature' => [\n                    'Les dernières actualités de Futura-Nature' => 'nature/actualites',\n                    'Les dernières définitions de Futura-Nature' => 'nature/definitions',\n                    'Les dernières questions-réponses de Futura-Nature' => 'nature/question-reponses',\n                    'Les derniers dossiers de Futura-Nature' => 'nature/dossiers'\n                ],\n                'Les flux Terre' => [\n                    'Les dernières actualités de Futura-Terre' => 'terre/actualites',\n                    'Les dernières définitions de Futura-Terre' => 'terre/definitions',\n                    'Les dernières questions-réponses de Futura-Terre' => 'terre/question-reponses',\n                    'Les derniers dossiers de Futura-Terre' => 'terre/dossiers'\n                ],\n                'Les flux Matière' => [\n                    'Les dernières actualités de Futura-Matière' => 'matiere/actualites',\n                    'Les dernières définitions de Futura-Matière' => 'matiere/definitions',\n                    'Les dernières questions-réponses de Futura-Matière' => 'matiere/question-reponses',\n                    'Les derniers dossiers de Futura-Matière' => 'matiere/dossiers'\n                ],\n                'Les flux Mathématiques' => [\n                    'Les dernières actualités de Futura-Mathématiques' => 'mathematiques/actualites',\n                    'Les derniers dossiers de Futura-Mathématiques' => 'mathematiques/dossiers'\n                ]\n            ]\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = self::URI . 'rss/' . $this->getInput('feed') . '.xml';\n        $this->collectExpandableDatas($url, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['uri'] = str_replace('#xtor%3DRSS-8', '', $item['uri']);\n        $dom = getSimpleHTMLDOMCached($item['uri']);\n        $item['content'] = $this->extractArticleContent($dom);\n        $author = $this->extractAuthor($dom);\n        if (!empty($author)) {\n            $item['author'] = $author;\n        }\n        return $item;\n    }\n\n    private function extractArticleContent($article)\n    {\n        $contents = $article->find('div.article-text', 0);\n\n        foreach ($contents->find('img') as $img) {\n            if (!empty($img->getAttribute('data-src'))) {\n                $img->src = $img->getAttribute('data-src');\n            }\n        }\n\n        foreach ($contents->find('a.tooltip-link') as $a) {\n            $a->outertext = $a->plaintext;\n        }\n\n        foreach (\n            [\n            'clear',\n            'sharebar2',\n            'diaporamafullscreen',\n            'module.social-button',\n            'module.social-share',\n            'ficheprevnext',\n            'addthis_toolbox',\n            'noprint',\n            'hubbottom',\n            'hubbottom2'\n            ] as $div_class_remove\n        ) {\n            foreach ($contents->find('div.' . $div_class_remove) as $div) {\n                $keep_div = false;\n                foreach (\n                    [\n                    'didyouknow'\n                    ] as $div_class_dont_remove\n                ) {\n                    if (strpos($div->getAttribute('class'), $div_class_dont_remove) !== false) {\n                        $keep_div = true;\n                    }\n                }\n                if (!$keep_div) {\n                    $div->outertext = '';\n                }\n            }\n        }\n\n        $contents = $contents->innertext;\n\n        $contents = stripWithDelimiters($contents, '<hr ', '/>');\n        $contents = stripWithDelimiters($contents, '<p class=\"content-date', '</p>');\n        $contents = stripWithDelimiters($contents, '<h1 class=\"content-title', '</h1>');\n        $contents = stripWithDelimiters($contents, 'fs:definition=\"', '\"');\n        $contents = stripWithDelimiters($contents, 'fs:xt:clicktype=\"', '\"');\n        $contents = stripWithDelimiters($contents, 'fs:xt:clickname=\"', '\"');\n        $contents = StripWithDelimiters($contents, '<section class=\"module-toretain module-propal-nl', '</section>');\n        $contents = stripWithDelimiters($contents, '<script ', '</script>');\n        $contents = stripWithDelimiters($contents, '<script>', '</script>');\n\n        return trim($contents);\n    }\n\n    // Extracts the author from an article or element\n    private function extractAuthor($article)\n    {\n        $article_author = $article->find('h3.epsilon', 0);\n        if ($article_author) {\n            return trim(str_replace(', Futura-Sciences', '', $article_author->plaintext));\n        }\n        return '';\n    }\n}\n"
  },
  {
    "path": "bridges/GBAtempBridge.php",
    "content": "<?php\n\nclass GBAtempBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'GBAtemp';\n    const URI = 'https://gbatemp.net/';\n    const DESCRIPTION = 'GBAtemp is a user friendly underground video game community.';\n\n    const PARAMETERS = [ [\n        'type' => [\n            'name' => 'Type',\n            'type' => 'list',\n            'values' => [\n                'News' => 'N',\n                'Reviews' => 'R',\n                'Tutorials' => 'T',\n                'Forum' => 'F'\n            ]\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        switch ($this->getInput('type')) {\n            case 'N':\n                foreach ($html->find('li.news_item.full') as $newsItem) {\n                    $url = urljoin(self::URI, $newsItem->find('a', 0)->href);\n                    $img = $this->findItemImage($newsItem, 'a.news_image');\n                    $time = $this->findItemDate($newsItem);\n                    $author = $newsItem->find('a.username', 0)->plaintext;\n                    $title = $this->decodeHtmlEntities($newsItem->find('h2.news_title', 0)->plaintext);\n                    $content = $this->fetchPostContent($url, self::URI);\n                    $this->items[] = $this->buildItem($url, $title, $author, $time, $img, $content);\n                    unset($newsItem); // Some items are heavy, freeing the item proactively helps saving memory\n                }\n                break;\n            case 'R':\n                foreach ($html->find('li.portal_review') as $reviewItem) {\n                    $url = urljoin(self::URI, $reviewItem->find('a.review_boxart', 0)->href);\n                    $img = $this->findItemImage($reviewItem, 'a.review_boxart');\n                    $title = $this->decodeHtmlEntities($reviewItem->find('div.review_title', 0)->find('h2', 0)->plaintext);\n                    $content = getSimpleHTMLDOMCached($url);\n                    $author = $content->find('span.author--name', 0)->plaintext;\n                    $time = $this->findItemDate($content);\n                    $intro = '<p><b>' . ($content->find('div#review_introduction', 0)->plaintext) . '</b></p>';\n                    $review = $content->find('div#review_main', 0)->innertext;\n                    $content = $this->cleanupPostContent($intro . $review, self::URI);\n                    $this->items[] = $this->buildItem($url, $title, $author, $time, $img, $content);\n                    unset($reviewItem); // Free up memory\n                }\n                break;\n            case 'T':\n                foreach ($html->find('li.portal-tutorial') as $tutorialItem) {\n                    $url = urljoin(self::URI, $tutorialItem->find('a', 1)->href);\n                    $title = $this->decodeHtmlEntities($tutorialItem->find('a', 1)->plaintext);\n                    $time = $this->findItemDate($tutorialItem);\n                    $author = $tutorialItem->find('a.username', 0)->plaintext;\n                    $content = $this->fetchPostContent($url, self::URI);\n                    $this->items[] = $this->buildItem($url, $title, $author, $time, null, $content);\n                    unset($tutorialItem); // Free up memory\n                }\n                break;\n            case 'F':\n                foreach ($html->find('li.rc_item') as $postItem) {\n                    $url = urljoin(self::URI, $postItem->find('a', 1)->href);\n                    $title = $this->decodeHtmlEntities($postItem->find('a', 1)->plaintext);\n                    $time = $this->findItemDate($postItem);\n                    $author = $postItem->find('a.username', 0)->plaintext;\n                    $content = $this->fetchPostContent($url, self::URI);\n                    $this->items[] = $this->buildItem($url, $title, $author, $time, null, $content);\n                    unset($postItem); // Free up memory\n                }\n                break;\n        }\n    }\n\n    private function fetchPostContent($uri, $site_url)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n        if (!$html) {\n            return 'Could not request GBAtemp: ' . $uri;\n        }\n        $var = $html->find('#review_main', 0);\n        if (!$var) {\n            $var = $html->find('div.message-userContent article.message-body', 0);\n        }\n        return $this->cleanupPostContent($var->innertext, $site_url);\n    }\n\n    private function buildItem($uri, $title, $author, $timestamp, $thumbnail, $content)\n    {\n        $item = [];\n        $item['uri'] = $uri;\n        $item['title'] = $title;\n        $item['author'] = $author;\n        $item['timestamp'] = $timestamp;\n        $item['content'] = $content;\n        if (!empty($thumbnail)) {\n            $item['enclosures'] = [$thumbnail];\n        }\n        return $item;\n    }\n\n    private function decodeHtmlEntities($text)\n    {\n        $text = html_entity_decode($text);\n        $convmap = [0x0, 0x2FFFF, 0, 0xFFFF];\n        return trim(mb_decode_numericentity($text, $convmap, 'UTF-8'));\n    }\n\n    private function cleanupPostContent($content, $site_url)\n    {\n        $content = defaultLinkTo($content, self::URI);\n        $content = stripWithDelimiters($content, '<script', '</script>');\n        $content = stripWithDelimiters($content, '<svg', '</svg>');\n        $content = stripRecursiveHTMLSection($content, 'div', '<div class=\"reactionsBar');\n        return $this->decodeHtmlEntities($content);\n    }\n\n    private function findItemDate($item)\n    {\n        $time = 0;\n        $dateField = $item->find('time', 0);\n        if (is_object($dateField)) {\n            $time = strtotime($dateField->datetime);\n        }\n        return $time;\n    }\n\n    private function findItemImage($item, $selector)\n    {\n        $img = extractFromDelimiters($item->find($selector, 0)->style, 'url(', ')');\n        $paramPos = strpos($img, '?');\n        if ($paramPos !== false) {\n            $img = substr($img, 0, $paramPos);\n        }\n        if (!str_ends_with($img, '.png') && !str_ends_with($img, '.jpg')) {\n            $img = $img . '#.image';\n        }\n        return urljoin(self::URI, $img);\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('type'))) {\n            return 'GBAtemp ' . $this->getKey('type');\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/GGDealsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass GGDealsBridge extends BridgeAbstract\n{\n    const DESCRIPTION = 'Returns the price history for a game from gg.deals.';\n    const MAINTAINER = 'phantop';\n    const NAME = 'GG.deals';\n    const URI = 'https://gg.deals/';\n\n    const PARAMETERS = [[\n        'slug' => [\n            'name' => 'Game slug',\n            'type' => 'text',\n            'required' => true,\n            'title' => 'Game slug from the gg.deals URL',\n            'exampleValue' => 'a-hat-in-time-ultimate-edition-nintendo-switch'\n        ],\n        'region' => [\n            'name' => 'Region',\n            'type' => 'list',\n            'title' => 'Select the region for pricing',\n            'defaultValue' => 'us',\n            'values' => [\n                'Australia' => 'au',\n                'Belgium' => 'be',\n                'Brazil' => 'br',\n                'Canada' => 'ca',\n                'Denmark' => 'dk',\n                'Europe' => 'eu',\n                'Finland' => 'fi',\n                'France' => 'fr',\n                'Germany' => 'de',\n                'Ireland' => 'ie',\n                'Italy' => 'it',\n                'Netherlands' => 'nl',\n                'Norway' => 'no',\n                'Poland' => 'pl',\n                'Spain' => 'es',\n                'Sweden' => 'se',\n                'Switzerland' => 'ch',\n                'United Kingdom' => 'gb',\n                'United States' => 'us',\n            ],\n        ],\n        'keyshops' => [\n            'name' => 'Include keyshops',\n            'type' => 'checkbox',\n            'title' => 'Check to include prices from keyshops',\n            'defaultValue' => 'checked'\n        ],\n        'lowest' => [\n            'name' => 'Only return lowest prices',\n            'type' => 'checkbox',\n            'title' => 'Check to only show a price if it\\'s the new lowest',\n            'defaultValue' => 'checked'\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI());\n        $html = defaultLinkTo($html, self::URI);\n        $types = ['official-stores'];\n        if ($this->getInput('keyshops')) {\n            $types[] = 'keyshops';\n        }\n\n        foreach ($types as $type) {\n            $low = false;\n            foreach ($html->find(\"#$type .game-item\") as $deal) {\n                $item = [\n                    'author' => $deal->getAttribute('data-shop-name'),\n                    'categories' => [ $deal->find('.tag-drm svg, time', 0)->getAttribute('title'),\n                                      $deal->find('.label.historical', 0)->plaintext,\n                                      $deal->find('.label.best', 0)->plaintext,\n                                      $deal->find('.code', 0)->plaintext,\n                                      $type ],\n                    'timestamp' => $deal->find('time', 0)->getAttribute('datetime'),\n                    'title' => $deal->find('.price-inner, .price-text', 0)->plaintext,\n                    'uri' => $deal->find('.full-link', 0)->href,\n                ];\n                // Unsure how referral links change—exclude from guid\n                $item['uid'] = implode('', array_diff_key($item, ['url' => '']));\n\n\n                // First entry for type is always the lowest\n                if (!$low || $item['title'] = $low) {\n                    $low = $item['title'];\n                    $item['title'] .= \" ($type low)\";\n                    $item['categories'][] = 'Low Price';\n                }\n\n                $this->items[] = $item;\n\n                // First entry for type is always the lowest\n                if ($this->getInput('lowest')) {\n                    break;\n                }\n            }\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if ($this->getInput('slug')) {\n            $html = getSimpleHTMLDOMCached($this->getURI());\n            $name .= ' - ' . end($html->find('[itemscope] span'))->innertext;\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n        if ($this->getInput('slug')) {\n            $uri .= $this->getInput('region') . '/game/' . $this->getInput('slug');\n        }\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/GOGBridge.php",
    "content": "<?php\n\nclass GOGBridge extends BridgeAbstract\n{\n    const NAME = 'GOGBridge';\n    const MAINTAINER = 'teromene';\n    const URI = 'https://gog.com';\n    const DESCRIPTION = 'Returns the latest releases from GOG.com';\n\n    public function collectData()\n    {\n        $values = getContents('https://catalog.gog.com/v1/catalog?limit=48&order=desc%3AstoreReleaseDate');\n        $decodedValues = json_decode($values);\n\n        $limit = 0;\n        foreach ($decodedValues->products as $game) {\n            $item = [];\n            $item['author'] = implode(', ', $game->developers) . ' / ' . implode(', ', $game->publishers);\n            $item['title'] = $game->title;\n            $item['id'] = $game->id;\n            $item['uri'] = $game->storeLink;\n            $item['content'] = $this->buildGameContentPage($game);\n\n            foreach ($game->screenshots as $image) {\n                $item['enclosures'][] = $image . '.jpg';\n            }\n\n            $this->items[] = $item;\n            $limit += 1;\n\n            if ($limit == 10) {\n                break;\n            }\n        }\n    }\n\n    private function buildGameContentPage($game)\n    {\n        $gameDescriptionText = getContents('https://api.gog.com/products/' . $game->id . '?expand=description');\n\n        $gameDescriptionValue = json_decode($gameDescriptionText);\n\n        $content = 'Genres: ';\n        $content .= implode(', ', array_column($game->genres, 'name'));\n\n        $content .= '<br />Supported Platforms: ';\n        $content .= implode(', ', $game->operatingSystems);\n\n        $content .= '<br />' . $gameDescriptionValue->description->full;\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/GQMagazineBridge.php",
    "content": "<?php\n\n/**\n * An extension of the previous SexactuBridge to cover the whole GQMagazine.\n * This one taks a page (as an example sexe/news or journaliste/maia-mazaurette) which is to be configured,\n * reads all the articles visible on that page, and make a stream out of it.\n * @author nicolas-delsaux\n *\n */\nclass GQMagazineBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Riduidel';\n\n    const NAME = 'GQMagazine';\n\n    // URI is no more valid, since we can address the whole gq galaxy\n    const URI = 'https://www.gqmagazine.fr';\n\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'GQMagazine section extractor bridge. This bridge allows you get only a specific section.';\n\n    const DEFAULT_DOMAIN = 'www.gqmagazine.fr';\n\n    const PARAMETERS = [ [\n        'domain' => [\n            'name' => 'Domain to use',\n            'required' => true,\n            'defaultValue' => self::DEFAULT_DOMAIN\n        ],\n        'page' => [\n            'name' => 'Initial page to load',\n            'required' => true,\n            'exampleValue' => 'sexe/news'\n        ],\n        'limit' => self::LIMIT,\n    ]];\n\n    const REPLACED_ATTRIBUTES = [\n        'href' => 'href',\n        'src' => 'src',\n        'data-original' => 'src'\n    ];\n\n    const POSSIBLE_TITLES = [\n        'h2',\n        'h3'\n    ];\n\n    private function getDomain()\n    {\n        $domain = $this->getInput('domain');\n        if (empty($domain)) {\n            $domain = self::DEFAULT_DOMAIN;\n        }\n        if (strpos($domain, '://') === false) {\n            $domain = 'https://' . $domain;\n        }\n        return $domain;\n    }\n\n    public function getURI()\n    {\n        return $this->getDomain() . '/' . $this->getInput('page');\n    }\n\n    private function findTitleOf($link)\n    {\n        foreach (self::POSSIBLE_TITLES as $tag) {\n            $title = $link->parent()->find($tag, 0);\n            if ($title !== null) {\n                if ($title->plaintext !== null) {\n                    return $title->plaintext;\n                }\n            }\n        }\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        // Since GQ don't want simple class scrapping, let's do it the hard way and ... discover content !\n        $main = $html->find('main', 0);\n        $limit = $this->getInput('limit') ?? 10;\n        foreach ($main->find('a') as $link) {\n            if (count($this->items) >= $limit) {\n                break;\n            }\n\n            $uri = $link->href;\n            $date = $link->parent()->find('time', 0);\n\n            $item = [];\n            $author = $link->parent()->find('span[itemprop=name]', 0);\n            if ($author !== null) {\n                $item['author'] = $author->plaintext;\n                $item['title'] = $this->findTitleOf($link);\n                switch (substr($uri, 0, 1)) {\n                    case 'h': // absolute uri\n                        $item['uri'] = $uri;\n                        break;\n                    case '/': // domain relative uri\n                        $item['uri'] = $this->getDomain() . $uri;\n                        break;\n                    default:\n                        $item['uri'] = $this->getDomain() . '/' . $uri;\n                }\n                $article = $this->loadFullArticle($item['uri']);\n                if ($article) {\n                    $item['content'] = $this->replaceUriInHtmlElement($article);\n                } else {\n                    $item['content'] = \"<strong>Article body couldn't be loaded</strong>. It must be a bug!\";\n                }\n                $short_date = $date->datetime;\n                $item['timestamp'] = strtotime($short_date);\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    /**\n     * Loads the full article and returns the contents\n     * @param $uri The article URI\n     * @return The article content\n     */\n    private function loadFullArticle($uri)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n        return $html->find('article', 0);\n    }\n\n    /**\n     * Replaces all relative URIs with absolute ones\n     * @param $element A simplehtmldom element\n     * @return The $element->innertext with all URIs replaced\n     */\n    private function replaceUriInHtmlElement($element)\n    {\n        $returned = $element->innertext;\n        foreach (self::REPLACED_ATTRIBUTES as $initial => $final) {\n            $returned = str_replace($initial . '=\"/', $final . '=\"' . self::URI . '/', $returned);\n        }\n        return $returned;\n    }\n}\n"
  },
  {
    "path": "bridges/GULPProjekteBridge.php",
    "content": "<?php\n\nuse Facebook\\WebDriver\\Exception\\NoSuchElementException;\nuse Facebook\\WebDriver\\Remote\\RemoteWebElement;\nuse Facebook\\WebDriver\\WebDriverBy;\nuse Facebook\\WebDriver\\WebDriverExpectedCondition;\n\nclass GULPProjekteBridge extends WebDriverAbstract\n{\n    const NAME = 'GULP Projekte';\n    const URI = 'https://www.gulp.de/gulp2/g/projekte';\n    const DESCRIPTION = 'Projektsuche';\n    const MAINTAINER = 'hleskien';\n\n    const MAXITEMS = 60;\n\n    /**\n     * Adds accept language german to the Chrome Options.\n     *\n     * @return Facebook\\WebDriver\\Chrome\\ChromeOptions\n     */\n    protected function getBrowserOptions()\n    {\n        $chromeOptions = parent::getBrowserOptions();\n        $chromeOptions->addArguments(['--accept-lang=de']);\n        return $chromeOptions;\n    }\n\n    /**\n     * @throws Facebook\\WebDriver\\Exception\\NoSuchElementException\n     * @throws Facebook\\WebDriver\\Exception\\TimeoutException\n     */\n    protected function clickAwayCookieBanner()\n    {\n        $this->getDriver()->wait()->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::id('onetrust-reject-all-handler')));\n        $buttonRejectCookies = $this->getDriver()->findElement(WebDriverBy::id('onetrust-reject-all-handler'));\n        $buttonRejectCookies->click();\n        $this->getDriver()->wait()->until(WebDriverExpectedCondition::invisibilityOfElementLocated(WebDriverBy::id('onetrust-reject-all-handler')));\n    }\n\n    /**\n     * @throws Facebook\\WebDriver\\Exception\\NoSuchElementException\n     * @throws Facebook\\WebDriver\\Exception\\TimeoutException\n     */\n    protected function clickNextPage()\n    {\n        $nextPage = $this->getDriver()->findElement(WebDriverBy::xpath('//app-linkable-paginator//li[@id=\"next-page\"]/a'));\n        $href = $nextPage->getAttribute('href');\n        $nextPage->click();\n        $this->getDriver()->wait()->until(WebDriverExpectedCondition::not(\n            WebDriverExpectedCondition::presenceOfElementLocated(\n                WebDriverBy::xpath('//app-linkable-paginator//li[@id=\"next-page\"]/a[@href=\"' . $href . '\"]')\n            )\n        ));\n    }\n\n    /**\n     * Returns the uri of the 'Projektanbieter' logo or false if there is\n     * no logo present in the item.\n     *\n     * @return string | false\n     */\n    protected function getLogo(RemoteWebElement $item)\n    {\n        try {\n            $logo = $item->findElement(WebDriverBy::tagName('img'))->getAttribute('src');\n            if (str_starts_with($logo, 'http')) {\n                // different domain\n                return $logo;\n            } else {\n                // relative path\n                $remove = substr(self::URI, strrpos(self::URI, '/') + 1);\n                return substr(self::URI, 0, -strlen($remove)) . $logo;\n            }\n        } catch (NoSuchElementException $e) {\n            return false;\n        }\n    }\n\n    /**\n     * Converts a string like \"vor einigen Minuten\" into a reasonable timestamp.\n     * Long and complicated, but we don't want to be more specific than\n     * the information we have available.\n     *\n     * @throws Exception If the DateInterval can't be parsed.\n     */\n    protected function getTimestamp(string $timeAgo): int\n    {\n        $dateTime = new DateTime();\n        $dateArray = explode(' ', $dateTime->format('Y m d H i s'));\n        $quantityStr = explode(' ', $timeAgo)[1];\n        // convert possible word into a number\n        if (in_array($quantityStr, ['einem', 'einer', 'einigen'])) {\n            $quantity = 1;\n        } else {\n            $quantity = intval($quantityStr);\n        }\n        // subtract time ago + inferior units for lower precision\n        if (str_contains($timeAgo, 'Sekunde')) {\n            $interval = new DateInterval('PT' . $quantity . 'S');\n        } elseif (str_contains($timeAgo, 'Minute')) {\n            $interval = new DateInterval('PT' . $quantity . 'M' . $dateArray[5] . 'S');\n        } elseif (str_contains($timeAgo, 'Stunde')) {\n            $interval = new DateInterval('PT' . $quantity . 'H' . $dateArray[4] . 'M' . $dateArray[5] . 'S');\n        } elseif (str_contains($timeAgo, 'Tag')) {\n            $interval = new DateInterval('P' . $quantity . 'DT' . $dateArray[3] . 'H' . $dateArray[4] . 'M' . $dateArray[5] . 'S');\n        } else {\n            throw new UnexpectedValueException($timeAgo);\n        }\n        $dateTime = $dateTime->sub($interval);\n        return $dateTime->getTimestamp();\n    }\n\n    /**\n     * The main loop which clicks through search result pages and puts\n     * the content into the $items array.\n     *\n     * @throws Facebook\\WebDriver\\Exception\\NoSuchElementException\n     * @throws Facebook\\WebDriver\\Exception\\TimeoutException\n     */\n    public function collectData()\n    {\n        parent::collectData();\n\n        try {\n            $this->clickAwayCookieBanner();\n            $this->setIcon($this->getDriver()->findElement(WebDriverBy::xpath('//link[@rel=\"shortcut icon\"]'))->getAttribute('href'));\n\n            while (true) {\n                $items = $this->getDriver()->findElements(WebDriverBy::tagName('app-project-view'));\n                foreach ($items as $item) {\n                    $feedItem = [];\n\n                    $heading = $item->findElement(WebDriverBy::xpath('.//app-heading-tag/h1/a'));\n                    $feedItem['title'] = $heading->getText();\n                    $feedItem['uri'] = 'https://www.gulp.de' . $heading->getAttribute('href');\n                    $info = $item->findElement(WebDriverBy::tagName('app-icon-info-list'));\n                    if ($logo = $this->getLogo($item)) {\n                        $feedItem['enclosures'] = [$logo];\n                    }\n                    if (str_contains($info->getText(), 'Projektanbieter:')) {\n                        $feedItem['author'] = $info->findElement(WebDriverBy::xpath('.//li/span[2]/span'))->getText();\n                    } else {\n                        // mostly \"Direkt vom Auftraggeber\" or \"GULP Agentur\"\n                        $feedItem['author'] = $item->findElement(WebDriverBy::tagName('b'))->getText();\n                    }\n                    $feedItem['content'] = $item->findElement(WebDriverBy::xpath('.//p[@class=\"description\"]'))->getText();\n                    $timeAgo = $item->findElement(WebDriverBy::xpath('.//small[contains(@class, \"time-ago\")]'))->getText();\n                    $feedItem['timestamp'] = $this->getTimestamp($timeAgo);\n\n                    $this->items[] = $feedItem;\n                }\n\n                if (count($this->items) < self::MAXITEMS) {\n                    $this->clickNextPage();\n                } else {\n                    break;\n                }\n            }\n        } finally {\n            $this->cleanUp();\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GabBridge.php",
    "content": "<?php\n\nfinal class GabBridge extends BridgeAbstract\n{\n    const NAME = 'Gab';\n    const URI = 'https://gab.com/';\n    const DESCRIPTION = 'Gab is an American alt-tech microblogging and social networking service';\n    const MAINTAINER = 'dvikan';\n    const PARAMETERS = [\n        [\n            'username' => [\n                'name' => 'username',\n                'type' => 'text',\n                'defaultValue' => 'realdonaldtrump',\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $username = $this->getUsername();\n        $response = json_decode(getContents(sprintf('https://gab.com/api/v1/account_by_username/%s', $username)));\n        $id = $response->id;\n        $gabs = json_decode(getContents(sprintf('https://gab.com/api/v1/accounts/%s/statuses?exclude_replies=true', $id)));\n        foreach ($gabs as $gab) {\n            if ($gab->reblog) {\n                continue;\n            }\n            $this->items[] = [\n                'title' => $gab->content ?: 'Untitled',\n                'author' => $username,\n                'uri' => $gab->url ?? sprintf('https://gab.com/%s', $username),\n                'content' => $gab->content,\n                'timestamp' => (new \\DateTime($gab->created_at))->getTimestamp(),\n            ];\n        }\n    }\n\n    public function getName()\n    {\n        return 'Gab - ' . $this->getUsername();\n    }\n\n    public function getURI()\n    {\n        return 'https://gab.com/' . $this->getUsername();\n    }\n\n    private function getUsername(): string\n    {\n        $username = ltrim($this->getInput('username') ?? '', '@');\n        if (preg_match('#https?://gab\\.com/(\\w+)#', $username, $m)) {\n            return $m[1];\n        }\n        return $username;\n    }\n}\n"
  },
  {
    "path": "bridges/GameBananaBridge.php",
    "content": "<?php\n\nclass GameBananaBridge extends BridgeAbstract\n{\n    const NAME = 'GameBanana';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://gamebanana.com/';\n    const DESCRIPTION = 'Returns mods from GameBanana.';\n    const PARAMETERS = [\n        'Game' => [\n            'gid' => [\n                'name' => 'Game ID',\n                'required' => true,\n                // Example: latest mods from Zelda: Tears of the Kingdom\n                'exampleValue' => '7617',\n            ],\n            'updates' => [\n                'name' => 'Get updates',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Enable game updates in feed'\n            ],\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://images.gamebanana.com/static/img/favicon/favicon.ico';\n    }\n\n    private $title;\n\n    public function collectData()\n    {\n        $url = 'https://api.gamebanana.com/Core/List/New?itemtype=Mod&page=1&gameid=' . $this->getInput('gid');\n        if ($this->getInput('updates')) {\n            $url .= '&include_updated=1';\n        }\n        $api_response = getContents($url);\n        $json_list = json_decode($api_response, true); // Get first page mod list\n\n        $url = 'https://api.gamebanana.com/Core/Item/Data?itemtype[]=Game&fields[]=name&itemid[]=' . $this->getInput('gid');\n        $fields = 'name,Owner().name,text,screenshots,Files().aFiles(),date,Url().sProfileUrl(),udate,Updates().aLatestUpdates(),Category().name,RootCategory().name';\n        foreach ($json_list as $element) { // Build api request to minimize API calls\n            $mid = $element[1];\n            $url .= '&itemtype[]=Mod&fields[]=' . $fields . '&itemid[]=' . $mid;\n        }\n        $api_response = getContents($url);\n        $json_list = json_decode($api_response, true);\n\n        $this->title = $json_list[0][0];\n        array_shift($json_list); // Take title from API request and remove from json\n\n        foreach ($json_list as $element) {\n            // Trashed mod IDs are still picked up and return null; skip\n            if ($element[0] == null) {\n                continue;\n            }\n\n            $item = [];\n            $item['uri'] = $element[6];\n            $item['comments'] = $item['uri'] . '#PostsListModule';\n            $item['title'] = $element[0];\n            $item['author'] = $element[1];\n            $item['categories'][] = $element[9];\n            $item['categories'][] = $element[10];\n\n            $item['timestamp'] = $element[5];\n            if ($this->getInput('updates')) {\n                $item['timestamp'] = $element[7];\n            }\n\n            $item['enclosures'] = [];\n            foreach ($element[4] as $file) { // Place mod downloads in enclosures\n                array_push($item['enclosures'], 'https://files.gamebanana.com/mods/' . $file['_sFile']);\n            }\n\n            // Get screenshots from element[3]\n            $img_list = json_decode($element[3], true);\n            $item['content'] = '';\n            foreach ($img_list as $img_element) {\n                $item['content'] .= '<img src=\"https://images.gamebanana.com/img/ss/mods/' . $img_element['_sFile'] . '\"/>';\n            }\n\n            // Get updates from element[8], if applicable\n            if ($this->getInput('updates') && count($element[8]) > 0) {\n                $update = $element[8][0];\n                $item['content'] .= '<br><strong>Update:</strong> ' . $update['_sTitle'];\n                if ($update['_sText'] != '') {\n                    $item['content'] .= '<br>' . $update['_sText'];\n                }\n                foreach ($update['_aChangeLog'] as $change) {\n                    if ($change['cat'] == '') {\n                        $change['cat'] = 'Change';\n                    }\n                    $item['content'] .= '<br><em>' . $change['cat'] . '</em>: ' . $change['text'];\n                }\n                $item['content'] .= '<br><hr>';\n            }\n            $item['content'] .= '<br>' . $element[2];\n\n            $item['uid'] = $item['uri'] . $item['title'] . $item['timestamp'];\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if (isset($this->title)) {\n            $name .= \" - $this->title\";\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI() . 'games/' . $this->getInput('gid');\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/GatesNotesBridge.php",
    "content": "<?php\n\nclass GatesNotesBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'Gates Notes';\n    const URI = 'https://www.gatesnotes.com';\n    const DESCRIPTION = 'Returns the newest articles.';\n    const CACHE_TIMEOUT = 21600; // 6h\n\n    public function collectData()\n    {\n        $params = [\n            'validYearsString' => 'all',\n            'setNumber' => '0',\n            'sortByVideo' => 'all',\n            'sortByTopic' => 'all'\n        ];\n        $api_endpoint = '/api/TGNWebAPI/Get_Filtered_Article_Set?';\n        $apiUrl = self::URI . $api_endpoint . http_build_query($params);\n\n        $rawContent = getContents($apiUrl);\n        $cleanedContent = trim($rawContent, '\"');\n        $cleanedContent = str_replace([\n            '<string xmlns=\"http://schemas.microsoft.com/2003/10/Serialization/\">',\n            '</string>'\n        ], '', $cleanedContent);\n        $cleanedContent = str_replace('\\r\\n', \"\\n\", $cleanedContent);\n        $cleanedContent = stripslashes($cleanedContent);\n\n        $json = Json::decode($cleanedContent, false);\n        if (is_string($json)) {\n            throw new \\Exception('wtf? ' . $json);\n        }\n\n        foreach ($json as $article) {\n            $item = [];\n\n            $articleUri = self::URI . '/' . $article->{'_system_'}->name;\n\n            $item['uri'] = $articleUri;\n            $item['title'] = $article->headline;\n            $item['content'] = self::getItemContent($articleUri);\n            $item['timestamp'] = strtotime($article->date);\n\n            $this->items[] = $item;\n        }\n    }\n\n    protected function getItemContent($articleUri)\n    {\n        // We need to change the headers as the normal desktop website\n        // use canvas-based image carousels for some pictures\n        $headers = [\n            'User-Agent: Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',\n        ];\n        $article_html = getSimpleHTMLDOMCached($articleUri, 86400, $headers);\n\n        $content = '';\n        if (!$article_html) {\n            $content .= '<p><em>Could not request ' . $this->getName() . ': ' . $articleUri . '</em></p>';\n            return $content;\n        }\n        $article_html = defaultLinkTo($article_html, $this->getURI());\n\n        $top_description = '<p>' . $article_html->find('div.article_top_description', 0)->innertext . '</p>';\n        $heroImage = $article_html->find('img.article_top_DMT_Image', 0);\n        if ($heroImage) {\n            $hero_image = '<img src=' . $heroImage->getAttribute('data-src') . '>';\n        }\n        $article_body = $article_html->find('div.TGN_Article_ReadTimeSection', 0);\n\n        // Remove the menu bar on some articles (PDF download etc.)\n        foreach ($article_body->find('.TGN_MenuHolder') as $found) {\n            $found->remove();\n        }\n\n        // For the carousels pictures, we still to remove the lazy-loading and force the real picture\n        foreach ($article_body->find('canvas') as $found) {\n            $found->remove();\n        }\n        foreach ($article_body->find('.TGN_PE_C_Img') as $found) {\n            $found->setAttribute('src', $found->getAttribute('data-src'));\n        }\n\n        // Convert iframe of Youtube videos to link\n        foreach ($article_body->find('iframe') as $found) {\n            $iframeUrl = $found->getAttribute('src');\n\n            if ($iframeUrl) {\n                $text = 'Embedded Youtube video, click here to watch on Youtube.com';\n                $found->outertext = '<p><a href=\"' . $iframeUrl . '\">' . $text . '</a></p>';\n            }\n        }\n\n        // Remove <link> CSS ressources\n        foreach ($article_body->find('link') as $found) {\n            $linkedRessourceUrl = $found->getAttribute('href');\n\n            if (str_ends_with($linkedRessourceUrl, '.css')) {\n                $found->outertext = '';\n            }\n        }\n        $article_body = sanitize($article_body->innertext);\n\n        $content = $top_description . ($hero_image ?? '') . $article_body;\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/GelbooruBridge.php",
    "content": "<?php\n\nclass GelbooruBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Gelbooru';\n    const URI = 'https://gelbooru.com/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    const PARAMETERS = [\n        'global' => [\n            'api_key' => [\n                'name' => 'API Key',\n                'exampleValue' => 0,\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Your Gelbooru API key'\n            ],\n            'user_id' => [\n                'name' => 'User ID',\n                'exampleValue' => 0,\n                'type' => 'number',\n                'required' => true,\n                'title' => 'Your Gelbooru user ID'\n            ],\n            'p' => [\n                'name' => 'page',\n                'defaultValue' => 0,\n                'type' => 'number'\n            ],\n            't' => [\n                'name' => 'tags',\n                'exampleValue' => 'solo',\n                'title' => 'Tags to search for'\n            ],\n            'l' => [\n                'name' => 'limit',\n                'exampleValue' => 100,\n                'title' => 'How many posts to retrieve (hard limit of 1000)'\n            ]\n        ],\n        0 => []\n    ];\n\n    protected function getFullURI()\n    {\n        return $this->getURI()\n        . 'index.php?&page=dapi&s=post&q=index&json=1&pid=' . $this->getInput('p')\n        . '&limit=' . $this->getInput('l')\n        . '&tags=' . urlencode($this->getInput('t') ?? '')\n        . '&api_key=' . urlencode($this->getInput('api_key'))\n        . '&user_id=' . urlencode($this->getInput('user_id'));\n    }\n\n    /*\n    This function is superfluous for GelbooruBridge, but useful\n    for Bridges that inherit from it\n    */\n    protected function buildThumbnailURI($element)\n    {\n        return $this->getURI() . 'thumbnails/' . $element->directory\n        . '/thumbnail_' . $element->md5 . '.jpg';\n    }\n\n    protected function getItemFromElement($element)\n    {\n        $item = [];\n        $item['uri'] = $this->getURI() . 'index.php?page=post&s=view&id='\n        . $element->id;\n        $item['postid'] = $element->id;\n        $item['author'] = $element->owner;\n        $item['timestamp'] = date('d F Y H:i:s', $element->change);\n        $item['tags'] = $element->tags;\n        $item['title'] = $this->getName() . ' | ' . $item['postid'];\n\n        if (isset($element->preview_url)) {\n            $thumbnailUri = $element->preview_url;\n        } else {\n            $thumbnailUri = $this->buildThumbnailURI($element);\n        }\n\n        $item['content'] = '<a href=\"' . $item['uri'] . '\"><img src=\"'\n        . $thumbnailUri . '\" /></a><br><br><b>Dimensions:</b> '\n        . strval($element->width) . ' x ' . strval($element->height) . '<br><br><b>Tags:</b> '\n        . $item['tags'];\n        if (isset($element->source)) {\n            $item['content'] .= '<br><br><b>Source: </b><a href=\"' . $element->source . '\">' . $element->source . '</a>';\n        }\n\n        return $item;\n    }\n\n    public function collectData()\n    {\n        $url = $this->getFullURI();\n        $content = getContents($url);\n\n        if ($content === '') {\n            return;\n        }\n\n        $posts = Json::decode($content, false);\n        if (isset($posts->post)) {\n            $posts = $posts->post;\n        }\n\n        foreach ($posts as $post) {\n            $this->items[] = $this->getItemFromElement($post);\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GenshinImpactBridge.php",
    "content": "<?php\n\nclass GenshinImpactBridge extends BridgeAbstract\n{\n    const NAME = 'Genshin Impact';\n    const URI = 'https://genshin.hoyoverse.com/en/news';\n    const CACHE_TIMEOUT = 18000; // 5h\n    const DESCRIPTION = 'Latest news from the Genshin Impact website';\n    const MAINTAINER = 'Miicat_47';\n\n    const API_URL = 'https://api-os-takumi-static.hoyoverse.com/content_v2_user/app/a1b1f9d3315447cc/getContentList?iAppId=%u&iChanId=%u&iPageSize=%u&iPage=1&sLangKey=%s';\n    // const API_URL = 'https://sg-public-api-static.hoyoverse.com/content_v2_user/app/a1b1f9d3315447cc/getContentList?iAppId=%u&iChanId=%u&iPageSize=%u&iPage=1&sLangKey=%s';\n    const API_APP_ID = 32;\n\n    const ARTICLE_URL = 'https://genshin.hoyoverse.com/%s/news/detail/%u';\n    const FAVICON_URL = 'https://genshin.hoyoverse.com/favicon.ico';\n\n    const CATEGORY_DEFAULT = 395;\n    const LANGUAGE_DEFAULT = 'en-us';\n    const LIMIT_MIN = 1;\n    const LIMIT_DEFAULT = 5;\n    const LIMIT_MAX = 100;\n\n    const PARAMETERS = [\n        [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'Latest (all)' => 395,\n                    'Info' => 396,\n                    'Updates' => 397,\n                    'Events' => 398\n                ],\n                'defaultValue' => self::CATEGORY_DEFAULT\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'defaultValue' => self::LIMIT_DEFAULT\n            ],\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'Chinese' => 'zh-tw',\n                    'English' => 'en-us',\n                    'French' => 'fr-fr',\n                    'German' => 'de-de',\n                    'Indonesian' => 'id-id',\n                    'Japanese' => 'ja-jp',\n                    'Korean' => 'ko-kr',\n                    'Portuguese' => 'pt-pt',\n                    'Russian' => 'ru-ru',\n                    'Spanish' => 'es-es',\n                    'Thai' => 'th-th',\n                    'Vietnamese' => 'vi-vn'\n                ],\n                'defaultValue' => self::LANGUAGE_DEFAULT\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $category = $this->getInput('category') ?: self::CATEGORY_DEFAULT;\n        $limit = $this->getInput('limit') ?: self::LIMIT_DEFAULT;\n        $limit = min(self::LIMIT_MAX, max(self::LIMIT_MIN, $limit));\n        $language = $this->getInput('language') ?: self::LANGUAGE_DEFAULT;\n\n        $url = sprintf(self::API_URL, self::API_APP_ID, $category, $limit, $language);\n        $api_response = getContents($url);\n        $json_list = Json::decode($api_response);\n\n        foreach ($json_list['data']['list'] as $json_item) {\n            $article_html = str_get_html($json_item['sContent']);\n\n            // Check if article contains a embed YouTube video\n            $exp_youtube = '#https://[w\\.]+youtube\\.com/embed/([\\w]+)#m';\n            if (preg_match($exp_youtube, $article_html, $matches)) {\n                // Replace the YouTube embed with a YouTube link\n                $yt_embed = $article_html->find('div[class=\"ttr-video-frame\"]', 0);\n                $yt_embed->outertext = handleYoutube($yt_embed);\n            }\n            $item = [];\n            $item['title'] = $json_item['sTitle'];\n            $item['timestamp'] = $json_item['dtStartTime'];\n            $item['content'] = $article_html;\n            $item['uri'] = sprintf(self::ARTICLE_URL, $language, $json_item['iInfoId']);\n            $item['id'] = $json_item['iInfoId'];\n\n            // Picture\n            $json_ext = Json::decode($json_item['sExt']);\n            $item['enclosures'] = [$json_ext['banner'][0]['url']];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getIcon()\n    {\n        return self::FAVICON_URL;\n    }\n}\n"
  },
  {
    "path": "bridges/GettrBridge.php",
    "content": "<?php\n\nclass GettrBridge extends BridgeAbstract\n{\n    const NAME = 'Gettr.com';\n    const URI = 'https://gettr.com';\n    const DESCRIPTION = 'Fetches the latest posts from a GETTR user';\n    const MAINTAINER = 'dvikan';\n    const CACHE_TIMEOUT = 60 * 15; // 15m\n    const PARAMETERS = [\n        [\n            'user' => [\n                'name' => 'User',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'joerogan',\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'title' => 'Maximum number of items to return (maximum 20)',\n                'defaultValue' => 5,\n                'required' => true,\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $user = $this->getInput('user');\n        $api = sprintf(\n            'https://api.gettr.com/u/user/%s/posts?offset=0&max=%s&dir=fwd&incl=posts&fp=f_uo',\n            $user,\n            min($this->getInput('limit'), 20)\n        );\n        try {\n            $json = getContents($api);\n        } catch (HttpException $e) {\n            if ($e->getCode() === 400 && str_contains($e->response->getBody(), 'E_USER_NOTFOUND')) {\n                throw new \\Exception('User not found: ' . $user);\n            }\n            throw $e;\n        }\n        $data = json_decode($json, false);\n\n        foreach ($data->result->aux->post as $post) {\n            $this->items[] = [\n                'title' => mb_substr($post->txt ?? $post->uid . '@gettr.com', 0, 100),\n                'uri' => sprintf('https://gettr.com/post/%s', $post->_id),\n                'author' => $post->uid,\n                // Convert from ms to s\n                'timestamp' => substr($post->cdate, 0, strlen($post->cdate) - 3),\n                'uid' => $post->_id,\n                // Hashtags found within post text\n                'categories' => $post->htgs ?? [],\n                'content' => $this->createContent($post),\n            ];\n        }\n    }\n\n    /**\n     * Collect text, image and video, if they exist\n     */\n    private function createContent(\\stdClass $post): string\n    {\n        $content = '';\n\n        // Text\n        if (isset($post->txt)) {\n            $isRepost = $this->getInput('user') !== $post->uid;\n            if ($isRepost) {\n                $content .= 'Reposted by ' . $this->getInput('user') . '@gettr.com<br><br>';\n            }\n            $content .= \"$post->txt <br><br>\";\n        }\n\n        // Preview image\n        if (isset($post->previmg)) {\n            $content .= <<<HTML\n<a href=\"$post->prevsrc\" target=\"_blank\">\n    <img\n        src='$post->previmg'\n        alt='Unable to load image'\n        loading='lazy'\n    >\n</a>\n<br><br>\nHTML;\n        }\n\n        // Images\n        foreach ($post->imgs ?? [] as $imageUrl) {\n            $content .= <<<HTML\n<img\n    src='https://media.gettr.com/$imageUrl'\n    alt='Unable to load image'\n    target='_blank'\n>\n<br><br>\nHTML;\n        }\n\n        // Video\n        if (isset($post->ovid)) {\n            $mainImage = $post->main;\n\n            $content .= <<<HTML\n<video\n    style=\"max-width: 100%\"\n    controls\n    preload=\"none\"\n    poster=\"https://media.gettr.com/$mainImage\"\n>\n  <source src=\"https://media.gettr.com/$post->ovid\" type=\"video/mp4\">\n  Your browser does not support the video element. Kindly update it to latest version.\n</video >\nHTML;\n            // This is typically a m3u8 which I don't know how to present in a browser\n            $streamingUrl = $post->vid;\n        }\n        $this->processMetadata($post);\n\n        return $content;\n    }\n\n    public function getIcon()\n    {\n        return 'https://gettr.com/favicon.ico';\n    }\n\n    /**\n     * @param stdClass $post\n     */\n    private function processMetadata(stdClass $post): void\n    {\n        // Unused metadata, maybe used later\n        $textLanguage = $post->txt_lang ?? 'en';\n        $replies = $post->cm ?? 0;\n        $likes = $post->lkbpst ?? 0;\n        $reposts = $post->shbpst ?? 0;\n        // I think a visibility of \"p\" means that it's public\n        $visibility = $post->vis ?? 'p';\n    }\n}\n"
  },
  {
    "path": "bridges/GiphyBridge.php",
    "content": "<?php\n\nclass GiphyBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dvikan';\n    const NAME = 'Giphy';\n    const URI = 'https://giphy.com/';\n    const CACHE_TIMEOUT = 60 * 60 * 8; // 8h\n    const DESCRIPTION = 'Bridge for giphy.com';\n\n    const PARAMETERS = [ [\n        's' => [\n            'name' => 'search tag',\n            'exampleValue' => 'bird',\n            'required' => true\n        ],\n        'noGif' => [\n            'name' => 'Without gifs',\n            'type' => 'checkbox',\n            'title' => 'Exclude gifs from the results'\n        ],\n        'noStick' => [\n            'name' => 'Without stickers',\n            'type' => 'checkbox',\n            'title' => 'Exclude stickers from the results'\n        ],\n        'n' => [\n            'name' => 'max number of returned items (max 50)',\n            'type' => 'number',\n            'exampleValue' => 3,\n        ]\n    ]];\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('s'))) {\n            return $this->getInput('s') . ' - ' . parent::getName();\n        }\n\n        return parent::getName();\n    }\n\n    protected function getGiphyItems($entries)\n    {\n        foreach ($entries as $entry) {\n            $createdAt = new \\DateTime($entry->import_datetime);\n\n            $this->items[] = [\n                'id'        => $entry->id,\n                'uri'       => $entry->url,\n                'author'    => $entry->username,\n                'timestamp' => $createdAt->format('U'),\n                'title'     => $entry->title,\n                'content'   => <<<HTML\n<a href=\"{$entry->url}\">\n<img\n\tloading=\"lazy\"\n\tsrc=\"{$entry->images->downsized->url}\"\n\twidth=\"{$entry->images->downsized->width}\"\n\theight=\"{$entry->images->downsized->height}\" />\n</a>\nHTML\n            ];\n        }\n    }\n\n    public function collectData()\n    {\n        /**\n         * This uses Giphy's own undocumented public prod api key,\n         * which should not have any rate limiting.\n         * There is a documented public beta api key (dc6zaTOxFJmzC),\n         * but it has severe rate limiting.\n         *\n         * https://giphy.api-docs.io/1.0/welcome/access-and-api-keys\n         * https://developers.giphy.com/branch/master/docs/api/endpoint/#search\n         */\n        $apiKey = 'Gc7131jiJuvI7IdN0HZ1D7nh0ow5BU6g';\n        $bundle = 'low_bandwidth';\n        $limit = min($this->getInput('n') ?: 10, 50);\n        $endpoints = [];\n        if (empty($this->getInput('noGif'))) {\n            $endpoints[] = 'gifs';\n        }\n        if (empty($this->getInput('noStick'))) {\n            $endpoints[] = 'stickers';\n        }\n\n        foreach ($endpoints as $endpoint) {\n            $uri = sprintf(\n                'https://api.giphy.com/v1/%s/search?q=%s&limit=%s&bundle=%s&api_key=%s',\n                $endpoint,\n                rawurlencode($this->getInput('s')),\n                $limit,\n                $bundle,\n                $apiKey\n            );\n\n            $result = json_decode(getContents($uri));\n\n            $this->getGiphyItems($result->data);\n        }\n\n        usort($this->items, function ($a, $b) {\n            return $a['timestamp'] < $b['timestamp'];\n        });\n    }\n}\n"
  },
  {
    "path": "bridges/GitHubGistBridge.php",
    "content": "<?php\n\nclass GitHubGistBridge extends BridgeAbstract\n{\n    const NAME = 'GitHubGist comment';\n    const URI = 'https://gist.github.com';\n    const DESCRIPTION = 'Generates feeds for Gist comments';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [[\n        'id' => [\n            'name' => 'Gist',\n            'type' => 'text',\n            'required' => true,\n            'title' => 'Insert Gist ID or URI',\n            'exampleValue' => '2646763'\n        ]\n    ]];\n\n    private $filename;\n\n    public function getURI()\n    {\n        $id = $this->getInput('id') ?: '';\n\n        $urlpath = parse_url($id, PHP_URL_PATH);\n\n        if ($urlpath) {\n            $components = explode('/', $urlpath);\n            $id = end($components);\n        }\n\n        return static::URI . '/' . $id;\n    }\n\n    public function getName()\n    {\n        return $this->filename ? $this->filename . ' - ' . static::NAME : static::NAME;\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(\n            $this->getURI(),\n            null,\n            null,\n            true,\n            true,\n            DEFAULT_TARGET_CHARSET,\n            false, // Do NOT remove line breaks\n            DEFAULT_BR_TEXT,\n            DEFAULT_SPAN_TEXT\n        );\n\n        $html = defaultLinkTo($html, $this->getURI());\n\n        $fileinfo = $html->find('[class~=\"file-info\"]', 0)\n            or throwServerException('Could not find file info!');\n\n        $this->filename = $fileinfo->plaintext;\n\n        $comments = $html->find('div[class~=\"TimelineItem\"]');\n\n        if (is_null($comments)) { // no comments yet\n            return;\n        }\n\n        foreach ($comments as $comment) {\n            $uri = $comment->find('a[href*=#gistcomment]', 0)\n                or throwServerException('Could not find comment anchor!');\n\n            $title = $comment->find('h3', 0);\n\n            $datetime = $comment->find('[datetime]', 0)\n                or throwServerException('Could not find comment datetime!');\n\n            $author = $comment->find('a.author', 0)\n                or throwServerException('Could not find author name!');\n\n            $message = $comment->find('[class~=\"comment-body\"]', 0)\n                or throwServerException('Could not find comment body!');\n\n            $item = [];\n\n            $item['uri'] = $uri->href;\n            $item['title'] = str_replace('commented', 'commented on', $title->plaintext ?? '');\n            $item['timestamp'] = strtotime($datetime->datetime);\n            $item['author'] = '<a href=\"' . $author->href . '\">' . $author->plaintext . '</a>';\n            $item['content'] = $this->fixContent($message);\n            // $item['enclosures'] = array();\n            // $item['categories'] = array();\n\n            $this->items[] = $item;\n        }\n    }\n\n    /** Removes all unnecessary tags and adds formatting */\n    private function fixContent($content)\n    {\n        // Restore code (inside <pre />) highlighting\n        foreach ($content->find('pre') as $pre) {\n            $pre->style = <<<EOD\npadding: 16px;\noverflow: auto;\nfont-size: 85%;\nline-height: 1.45;\nbackground-color: #f6f8fa;\nborder-radius: 3px;\nword-wrap: normal;\nbox-sizing: border-box;\nmargin-bottom: 16px;\nEOD;\n\n            $code = $pre->find('code', 0);\n\n            if ($code) {\n                $code->style = <<<EOD\nwhite-space: pre;\nword-break: normal;\nEOD;\n            }\n        }\n\n        // find <code /> not inside <pre /> (`inline-code`)\n        foreach ($content->find('code') as $code) {\n            if ($code->parent()->tag === 'pre') {\n                continue;\n            }\n\n            $code->style = <<<EOD\nbackground-color: rgba(27,31,35,0.05);\npadding: 0.2em 0.4em;\nborder-radius: 3px;\nEOD;\n        }\n\n        // restore text spacing\n        foreach ($content->find('p') as $p) {\n            $p->style = 'margin-bottom: 16px;';\n        }\n\n        // Remove unnecessary tags\n        $content = strip_tags(\n            $content->innertext,\n            '<p><a><img><ol><ul><li><table><tr><th><td><string><pre><code><br><hr><h>'\n        );\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/GiteaBridge.php",
    "content": "<?php\n\n/**\n * Gitea is a community managed lightweight code hosting solution.\n * https://docs.gitea.io/en-us/\n */\n\nclass GiteaBridge extends BridgeAbstract\n{\n    const NAME = 'Gitea';\n    const URI = 'https://gitea.io';\n    const DESCRIPTION = 'Returns the latest issues, commits or releases';\n    const MAINTAINER = 'gileri';\n    const CACHE_TIMEOUT = 300; // 5 minutes\n\n    const PARAMETERS = [\n        'global' => [\n            'host' => [\n                'name' => 'Host',\n                'exampleValue' => 'https://gitea.com',\n                'required' => true,\n                'title' => 'Host name with its protocol, without trailing slash',\n            ],\n            'user' => [\n                'name' => 'Username',\n                'exampleValue' => 'gitea',\n                'required' => true,\n                'title' => 'User name as it appears in the URL',\n            ],\n            'project' => [\n                'name' => 'Project name',\n                'exampleValue' => 'helm-chart',\n                'required' => true,\n                'title' => 'Project name as it appears in the URL',\n            ],\n        ],\n        'Commits' => [\n            'branch' => [\n                'name' => 'Branch name',\n                'defaultValue' => 'master',\n                'required' => true,\n                'title' => 'Branch name as it appears in the URL',\n            ],\n        ],\n        'Issues' => [\n            'include_description' => [\n                'name' => 'Include issue description',\n                'type' => 'checkbox',\n                'title' => 'Activate to include the issue description',\n            ],\n        ],\n        'Single issue' => [\n            'issue' => [\n                'name' => 'Issue number',\n                'type' => 'number',\n                'exampleValue' => 100,\n                'required' => true,\n                'title' => 'Issue number from the issues list',\n            ],\n        ],\n        'Single pull request' => [\n            'pull_request' => [\n                'name' => 'Pull request number',\n                'type' => 'number',\n                'exampleValue' => 100,\n                'required' => true,\n                'title' => 'Pull request number from the issues list',\n            ],\n        ],\n        'Pull requests' => [\n            'include_description' => [\n                'name' => 'Include pull request description',\n                'type' => 'checkbox',\n                'title' => 'Activate to include the pull request description',\n            ],\n        ],\n        'Releases' => [],\n        'Tags' => [],\n    ];\n\n    private $title = '';\n\n    public function getIcon()\n    {\n        return 'https://gitea.io/images/gitea.png';\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Commits':\n            case 'Issues':\n            case 'Pull requests':\n            case 'Releases':\n            case 'Tags':\n                return $this->title . ' ' . $this->queriedContext;\n            case 'Single issue':\n                return 'Issue ' . $this->getInput('issue') . ': ' . $this->title;\n            case 'Single pull request':\n                return 'Pull request ' . $this->getInput('pull_request') . ': ' . $this->title;\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Commits':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/commits/' . $this->getInput('branch');\n\n            case 'Issues':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/issues/';\n\n            case 'Single issue':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/issues/' . $this->getInput('issue');\n\n            case 'Releases':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/releases/';\n\n            case 'Tags':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/tags/';\n\n            case 'Pull requests':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/pulls/';\n\n            case 'Single pull request':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/pulls/' . $this->getInput('pull_request');\n\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $html = defaultLinkTo($html, $this->getURI());\n\n        $this->title = $html->find('[property=\"og:title\"]', 0)->content;\n\n        switch ($this->queriedContext) {\n            case 'Commits':\n                $this->collectCommitsData($html);\n                break;\n            case 'Issues':\n                $this->collectIssuesData($html);\n                break;\n            case 'Pull requests':\n                $this->collectPullRequestsData($html);\n                break;\n            case 'Single issue':\n                $this->collectSingleIssueOrPrData($html);\n                break;\n            case 'Single pull request':\n                $this->collectSingleIssueOrPrData($html);\n                break;\n            case 'Releases':\n                $this->collectReleasesData($html);\n                break;\n            case 'Tags':\n                $this->collectTagsData($html);\n                break;\n        }\n    }\n\n    protected function collectReleasesData($html)\n    {\n        $releases = $html->find('#release-list > li')\n            or throwServerException('Unable to find releases');\n\n        foreach ($releases as $release) {\n            $this->items[] = [\n                'author' => $release->find('.author', 0)->plaintext,\n                'uri' => $release->find('a', 0)->href,\n                'title' => 'Release ' . $release->find('h4', 0)->plaintext,\n                'timestamp' => $release->find('.time-since', 0)->title,\n            ];\n        }\n    }\n\n    protected function collectTagsData($html)\n    {\n        $tags = $html->find('table#tags-table > tbody > tr')\n            or throwServerException('Unable to find tags');\n\n        foreach ($tags as $tag) {\n            $this->items[] = [\n                'uri' => $tag->find('a', 0)->href,\n                'title' => 'Tag ' . $tag->find('.release-tag-name > a', 0)->plaintext,\n            ];\n        }\n    }\n\n    protected function collectCommitsData($html)\n    {\n        $commits = $html->find('#commits-table tbody tr')\n            or throwServerException('Unable to find commits');\n\n        foreach ($commits as $commit) {\n            $this->items[] = [\n                'uri' => $commit->find('a.sha', 0)->href,\n                'title' => $commit->find('.message span', 0)->plaintext,\n                'author' => $commit->find('.author', 0)->plaintext,\n                'timestamp' => $commit->find('.time-since', 0)->title,\n                'uid' => $commit->find('.sha', 0)->plaintext,\n            ];\n        }\n    }\n\n    protected function collectIssuesData($html)\n    {\n        $issues = $html->find('.issue.list li')\n            or throwServerException('Unable to find issues');\n\n        foreach ($issues as $issue) {\n            $uri = $issue->find('a', 0)->href;\n\n            $item = [\n                'uri' => $uri,\n                'title' => trim($issue->find('a.index', 0)->plaintext) . ' | ' . $issue->find('a.title', 0)->plaintext,\n                'author' => $issue->find('.desc a', 1)->plaintext,\n                'timestamp' => $issue->find('.time-since', 0)->title,\n            ];\n\n            if ($this->getInput('include_description')) {\n                $issue_html = getSimpleHTMLDOMCached($uri, 3600);\n\n                $issue_html = defaultLinkTo($issue_html, $uri);\n\n                $item['content'] = $issue_html->find('.comment .markup', 0);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    protected function collectSingleIssueOrPrData($html)\n    {\n        $comments = $html->find('.comment')\n            or throwServerException('Unable to find comments');\n\n        foreach ($comments as $comment) {\n            if (\n                strpos($comment->getAttribute('class'), 'form') !== false\n                || strpos($comment->getAttribute('class'), 'merge') !== false\n            ) {\n                // Ignore comment form and merge information\n                continue;\n            }\n            $commentLink = $comment->find('a[href*=\"#issue\"]', 0);\n            $item = [\n                'author' => $comment->find('a.author', 0)->plaintext,\n                'content' => $comment->find('.render-content', 0),\n            ];\n            if ($commentLink !== null) {\n                // Regular comment\n                $item['uri'] = $commentLink->href;\n                $item['title'] = str_replace($commentLink->plaintext, '', $comment->find('span', 0)->plaintext);\n                $item['timestamp'] = $comment->find('.time-since', 0)->title;\n            } else {\n                // Change request comment\n                $item['uri'] = $this->getURI() . '#' . $comment->getAttribute('id');\n                $item['title'] = $comment->find('.comment-header .text', 0)->plaintext;\n            }\n            $this->items[] = $item;\n        }\n\n        $this->items = array_reverse($this->items);\n    }\n\n    protected function collectPullRequestsData($html)\n    {\n        $issues = $html->find('.issue.list li')\n            or throwServerException('Unable to find pull requests');\n\n        foreach ($issues as $issue) {\n            $uri = $issue->find('a', 0)->href;\n\n            $item = [\n                'uri' => $uri,\n                'title' => trim($issue->find('a.index', 0)->plaintext) . ' | ' . $issue->find('a.title', 0)->plaintext,\n                'author' => $issue->find('.desc a', 1)->plaintext,\n                'timestamp' => $issue->find('.time-since', 0)->title,\n            ];\n\n            if ($this->getInput('include_description')) {\n                $issue_html = getSimpleHTMLDOMCached($uri, 3600);\n\n                $issue_html = defaultLinkTo($issue_html, $uri);\n\n                $item['content'] = $issue_html->find('.comment .markup', 0);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GithubIssueBridge.php",
    "content": "<?php\n\nclass GithubIssueBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Pierre Mazière';\n    const NAME = 'Github Issue';\n    const URI = 'https://github.com/';\n    const CACHE_TIMEOUT = 600; // 10m\n    const DESCRIPTION = 'Returns the issues or comments of an issue of a github project';\n\n    const PARAMETERS = [\n        'global' => [\n            'u' => [\n                'name' => 'User name',\n                'exampleValue' => 'RSS-Bridge',\n                'required' => true\n            ],\n            'p' => [\n                'name' => 'Project name',\n                'exampleValue' => 'rss-bridge',\n                'required' => true\n            ]\n        ],\n        'Project Issues' => [\n            'c' => [\n                'name' => 'Show Issues Comments',\n                'type' => 'checkbox'\n            ],\n            'q' => [\n                'name' => 'Search Query',\n                'defaultValue' => 'is:issue is:open sort:updated-desc',\n                'required' => true\n            ]\n        ],\n        'Issue comments' => [\n            'i' => [\n                'name' => 'Issue number',\n                'type' => 'number',\n                'exampleValue' => '2099',\n                'required' => true\n            ]\n        ]\n    ];\n\n    // Allows generalization with GithubPullRequestBridge\n    const BRIDGE_OPTIONS = [0 => 'Project Issues', 1 => 'Issue comments'];\n    const URL_PATH = 'issues';\n    const SEARCH_QUERY_PATH = 'issues';\n\n    public function getName()\n    {\n        $name = $this->getInput('u') . '/' . $this->getInput('p');\n        switch ($this->queriedContext) {\n            case static::BRIDGE_OPTIONS[0]: // Project Issues\n                $prefix = static::NAME . 's for ';\n                if ($this->getInput('c')) {\n                    $prefix = static::NAME . 's comments for ';\n                }\n                $name = $prefix . $name;\n                break;\n            case static::BRIDGE_OPTIONS[1]: // Issue comments\n                $name = static::NAME . ' ' . $name . ' #' . $this->getInput('i');\n                break;\n            default:\n                return parent::getName();\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        if (null !== $this->getInput('u') && null !== $this->getInput('p')) {\n            $uri = static::URI . $this->getInput('u') . '/'\n                 . $this->getInput('p') . '/';\n            if ($this->queriedContext === static::BRIDGE_OPTIONS[1]) {\n                $uri .= static::URL_PATH . '/' . $this->getInput('i');\n            } else {\n                $uri .= static::SEARCH_QUERY_PATH . '?q=' . urlencode($this->getInput('q'));\n            }\n            return $uri;\n        }\n\n        return parent::getURI();\n    }\n\n    private function buildGitHubIssueCommentUri($issue_number, $comment_id)\n    {\n        // https://github.com/<user>/<project>/issues/<issue-number>#<id>\n        return static::URI\n        . $this->getInput('u')\n        . '/'\n        . $this->getInput('p')\n        . '/' . static::URL_PATH . '/'\n        . $issue_number\n        . '#'\n        . $comment_id;\n    }\n\n    private function extractIssueEvent($issueNbr, $title, $comment)\n    {\n        $uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id);\n\n        $author = $comment->find('.author, .avatar', 0);\n        if ($author) {\n            $author = trim($author->href, '/');\n        } else {\n            $author = '';\n        }\n\n        $title .= ' / '\n            . trim(str_replace(\n                ['octicon','-'],\n                [''],\n                $comment->find('.octicon', 0)->getAttribute('class')\n            ));\n\n        $time = $comment->find('relative-time', 0);\n        if ($time === null) {\n            return;\n        }\n\n        foreach ($comment->find('.Details-content--hidden, .btn') as $el) {\n            $el->innertext = '';\n        }\n        $content = $comment->plaintext;\n\n        $item = [];\n        $item['author'] = $author;\n        $item['uri'] = $uri;\n        $item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8');\n        $item['timestamp'] = strtotime($time->getAttribute('datetime'));\n        $item['content'] = $content;\n        return $item;\n    }\n\n    private function extractIssueComment($issueNbr, $title, $comment)\n    {\n        $uri = $this->buildGitHubIssueCommentUri($issueNbr, $comment->id);\n\n        $authorDom = $comment->find('.author', 0);\n        $author = $authorDom->plaintext ?? null;\n\n        $header = $comment->find('.timeline-comment-header > h3', 0);\n        $title .= ' / ' . ($header ? $header->plaintext : 'Activity');\n\n        $time = $comment->find('relative-time', 0);\n        if ($time === null) {\n            return;\n        }\n\n        $content = $comment->find('.comment-body', 0)->innertext;\n\n        $item = [];\n        $item['author'] = $author;\n        $item['uri'] = $uri;\n        $item['title'] = html_entity_decode($title, ENT_QUOTES, 'UTF-8');\n        $item['timestamp'] = strtotime($time->getAttribute('datetime'));\n        $item['content'] = $content;\n        return $item;\n    }\n\n    private function extractIssueComments($issue)\n    {\n        $items = [];\n\n        $titleElem = $issue->find('.gh-header-title', 0);\n        $title = $titleElem !== null ? $titleElem->plaintext : '';\n\n        $numberElem = $issue->find('.gh-header-number', 0);\n        if ($numberElem !== null) {\n            $issueNbr = trim(\n                substr($numberElem->plaintext, 1)\n            );\n        } else {\n            $issueNbr = '';\n        }\n\n        $comments = $issue->find(\n            '.comment, .TimelineItem-badge'\n        );\n\n        foreach ($comments as $comment) {\n            if ($comment->hasClass('comment')) {\n                $comment = $comment->parent;\n                $item = $this->extractIssueComment($issueNbr, $title, $comment);\n                if ($item !== null) {\n                    $items[] = $item;\n                }\n                continue;\n            } else {\n                $comment = $comment->parent;\n                $item = $this->extractIssueEvent($issueNbr, $title, $comment);\n                if ($item !== null) {\n                    $items[] = $item;\n                }\n            }\n        }\n        return $items;\n    }\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n\n        switch ($this->queriedContext) {\n            case static::BRIDGE_OPTIONS[1]: // Issue comments\n                $this->items = $this->extractIssueComments($html);\n                break;\n            case static::BRIDGE_OPTIONS[0]: // Project Issues\n                // PRs\n                $issues = $html->find('.js-active-navigation-container .js-navigation-item');\n                if (!$issues) {\n                    // Issues\n                    $issues = $html->find('.IssueRow-module__row--XmR1f');\n                }\n\n                foreach ($issues as $issue) {\n                    preg_match('/\\/([0-9]+)$/', $issue->find('a', 0)->href, $match);\n                    $issueNbr = $match[1];\n\n                    $item = [];\n                    $item['content'] = '';\n\n                    if ($this->getInput('c')) {\n                        $uri = static::URI . $this->getInput('u')\n                         . '/' . $this->getInput('p') . '/' . static::URL_PATH . '/' . $issueNbr;\n\n                        $issue = getSimpleHTMLDOMCached($uri, static::CACHE_TIMEOUT);\n                        if ($issue) {\n                            $this->items = array_merge(\n                                $this->items,\n                                $this->extractIssueComments($issue)\n                            );\n                            continue;\n                        }\n                        $item['content'] = 'Can not extract comments from ' . $uri;\n                    }\n\n                    $item['author'] = $issue->find('a', 1)->plaintext;\n\n                    $time = $issue->find('relative-time', 0);\n                    $datetime = $time->getAttribute('datetime');\n                    if ($datetime) {\n                        $item['timestamp'] = strtotime($datetime);\n                    }\n\n                    $item['title'] = '';\n\n                    # Works for PRs\n                    $title = $issue->find('a.Link--primary', 0);\n                    if ($title) {\n                        $item['title'] = html_entity_decode($title->plaintext, ENT_QUOTES, 'UTF-8');\n                    }\n\n                    $title2 = $issue->find('h3 a', 0);\n                    if ($title2) {\n                        $item['title'] = html_entity_decode($title2->plaintext, ENT_QUOTES, 'UTF-8');\n                    }\n                    //$comment_count = 0;\n                    //if ($span = $issue->find('a[aria-label*=\"comment\"] span', 0)) {\n                    //    $comment_count = $span->plaintext;\n                    //}\n\n                    //$item['content'] .= \"\\n\" . 'Comments: ' . $comment_count;\n                    $item['uri'] = self::URI\n                             . trim($issue->find('a', 0)->getAttribute('href'), '/');\n                    $this->items[] = $item;\n                }\n                break;\n        }\n\n        array_walk($this->items, function (&$item) {\n            $item['content'] = preg_replace('/\\s+/', ' ', $item['content']);\n            $item['content'] = str_replace(\n                'href=\"/',\n                'href=\"' . static::URI,\n                $item['content']\n            );\n            $item['content'] = str_replace(\n                'href=\"#',\n                'href=\"' . substr($item['uri'], 0, strpos($item['uri'], '#') + 1),\n                $item['content']\n            );\n            $item['title'] = preg_replace('/\\s+/', ' ', $item['title']);\n        });\n    }\n\n    public function detectParameters($url)\n    {\n        if (\n            filter_var($url, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED) === false\n            || strpos($url, self::URI) !== 0\n        ) {\n            return null;\n        }\n\n        $url_components = parse_url($url);\n        $path_segments = array_values(array_filter(explode('/', $url_components['path'])));\n\n        switch (count($path_segments)) {\n            case 2: // Project issues\n                [$user, $project] = $path_segments;\n                $show_comments = 'off';\n                $context = 'Project Issues';\n                break;\n            case 3: // Project issues with issue comments\n                if ($path_segments[2] !== static::URL_PATH) {\n                    return null;\n                }\n                [$user, $project] = $path_segments;\n                $show_comments = 'on';\n                $context = 'Project Issues';\n                break;\n            case 4: // Issue comments\n                [$user, $project, /* issues */, $issue] = $path_segments;\n                $context = 'Issue comments';\n                break;\n            default:\n                return null;\n        }\n\n        return [\n            'context' => $context,\n            'u' => $user,\n            'p' => $project,\n            'c' => $show_comments ?? null,\n            'i' => $issue ?? null,\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/GithubPackagesBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass GithubPackagesBridge extends BridgeAbstract\n{\n    const NAME = 'GitHub Packages Bridge';\n    const MAINTAINER = 'rsd76';\n    const URI = 'https://github.com/';\n    const DESCRIPTION = 'List GitHub packages or versions for organization, user or project';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [\n        [\n            'organization' => [\n                'type' => 'text',\n                'name' => 'GitHub organization/user',\n                'exampleValue' => 'RSS-Bridge',\n                'required' => true,\n                'title' => 'Name of the orgarnization/user: https://github.com/<org>/...'\n\n            ],\n            'repository' => [\n                'type' => 'text',\n                'name' => 'GitHub repository',\n                'exampleValue' => 'rss-bridge',\n                'required' => false,\n                'title' => 'Name of the repository: https://github.com/<org>/<repository>'\n            ],\n            'packagename' => [\n                'type' => 'text',\n                'name' => 'Package name',\n                'exampleValue' => 'rss-bridge',\n                'required' => false,\n                'title' => 'Name of the package. Example \"rss-bridge\" or \"curl-container/curl\".'\n            ],\n            'packagetype' => [\n                'type' => 'list',\n                'name' => 'Package Type',\n                'values' => [\n                    'All' => 'all',\n                    'Container' => 'container',\n                    'Maven' => 'maven',\n                    'npm' => 'npm',\n                    'NuGet' => 'nuget',\n                    'RubyGems' => 'rubygems'\n                ],\n                'defaultValue' => 'container',\n                'title' => 'Type of package. Do not select \"All\" if a package name is provided.'\n            ]\n        ]\n    ];\n\n    private function getPackageUri()\n    {\n        if (!empty($this->getInput('organization')) && !empty($this->getInput('repository')) && !empty($this->getInput('packagename'))) {\n            if ($this->getInput('packagetype') === 'all') {\n                throwClientException('Do not provide package type as \"all\" when specifying a package name.');\n            }\n        } elseif (!empty($this->getInput('organization')) && empty($this->getInput('repository')) && !empty($this->getInput('packagename'))) {\n            throwClientException('Provide a repository when providing a package name or do not provide the package name.');\n        } elseif (empty($this->getInput('organization'))) {\n            throwClientException('Provide at least an organization.');\n        }\n        return $this->getUri();\n    }\n\n    public function collectData()\n    {\n        /*\n          Use helper function defaultLinkTo to replace all relative URLs to absolute URLs.\n\n          When only the organization / user is provided, the bridge will list all packages (or filtered to a specific type)\n          for the organization. If the repository is also provided, the bridge will only list the packages of the\n          specified type (or all). In this case the packages are listed in a <div> with class set to \"flex-auto\".\n          An <a>-link within this <div> and with the class set to: \"text-bold f4 Link--primary\" contains the link.\n          Another <relative-time> with class set to: \"no-wrap\" in the <div> contains the creation date of the package.\n          The strtotime function converts the UTC time string to local time.\n\n          When also the packagename is provided, the bridge will show the versions for the package.\n          The package versions are listed in a <div> with the class set to \"col-10 d-flex flex-auto flex-column\".\n          Within the div the <a>-link to the package versions has the following class set: \"Label mr-1 mb-2 text-normal\".\n          For RubyGems the class seems to be set to: \"Link--primary text-bold f4\". If for classes no <a>-link is found,\n          a third class is searched: \"Label Label--success mr-1 mb-2\". The package label \"latest\" is filtered out unless it is\n          the only label. The link text is generally the Label of the package. Sometimes nice labels, like 2026-01-02,\n          but sometimes just a SHA value.\n          There is a time value in the form of \"Published (about) <#> <hours|days|month> ago in a <small> html entry.\n          This <small> has the class set to: \"class=color-fg-muted\". The strtotime functions sets this to a timestamp.\n        */\n\n        $dom = getSimpleHTMLDOM($this->getPackageUri());\n\n        $dom = defaultLinkTo($dom, self::URI);\n\n        if (empty($this->getInput('packagename'))) {\n            $divs = $dom->find('div[class=flex-auto]');\n            foreach ($divs as $div) {\n                $a = ($div->find('a[class=text-bold f4 Link--primary]'))[0];\n                $published = ($div->find('relative-time[class=no-wrap]'))[0];\n                $this->items[] = [\n                    'title' => $a->plaintext,\n                    'uri' => $a->href,\n                    'uid' => $a->href,\n                    'timestamp' => strtotime($published->datetime)\n                ];\n            }\n        } else {\n            $divs = $dom->find('div[class=col-10 d-flex flex-auto flex-column]');\n            foreach ($divs as $div) {\n                $a = ($div->find('a[class=Label mr-1 mb-2 text-normal]'))[0];\n                if (!$a) {\n                    $a = ($div->find('a[class=Link--primary text-bold f4]'))[0];\n                }\n                if (!$a) {\n                    $a = ($div->find('a[class=Label Label--success mr-1 mb-2]'))[0];\n                }\n                $published = ($div->find('small[class=color-fg-muted]'))[0];\n                if (!$published) {\n                    $published = ($div->find('div[class=f6 color-fg-muted]'))[0];\n                }\n                if (preg_match('/[0-9]+ (hour|hours|day|days|week|weeks|month|months|year|years) ago/', $published->plaintext, $ago)) {\n                    $this->items[] = [\n                        'title' => $a->plaintext,\n                        'uri' => $a->href,\n                        'uid' => $a->href,\n                        'timestamp' => strtotime($ago[0])\n                    ];\n                } else {\n                    $this->items[] = [\n                        'title' => $a->plaintext,\n                        'uri' => $a->href,\n                        'uid' => $a->href\n                    ];\n                }\n            }\n        }\n    }\n\n    public function getName()\n    {\n        $packagetype = $this->getInput('packagetype');\n        if ($this->getInput('organization')) {\n            $org = $this->getInput('organization');\n            if ($this->getInput('repository')) {\n                $repo = $this->getInput('repository');\n                if ($this->getInput('packagename')) {\n                    $packagename = $this->getInput('packagename');\n                    return $org . '/' . $repo . ' - ' . $packagename . ' - GitHub ' . $packagetype . ' Package versions';\n                }\n                if ($packagetype === 'all') {\n                    return $org . '/' . $repo . ' - GitHub Packages';\n                }\n                return $org . '/' . $repo . ' - GitHub ' . $packagetype . ' Packages';\n            }\n            if ($packagetype === 'all') {\n                return $org . ' - GitHub Packages';\n            }\n            return $org . ' - GitHub ' . $packagetype . ' Packages';\n        }\n        return parent::getName();\n    }\n\n    public function getUri()\n    {\n        $packagetype = $this->getInput('packagetype');\n        if ($this->getInput('organization')) {\n            $org = urlencode($this->getInput('organization'));\n            if ($this->getInput('repository')) {\n                $repo = urlencode($this->getInput('repository'));\n                if ($this->getInput('packagename')) {\n                    $packagename = urlencode($this->getInput('packagename'));\n                    if ($packagetype !== 'all') {\n                        return self::URI . $org . '/' . $repo . '/pkgs/' . $packagetype . '/' . $packagename . '/versions?filters[version_type]=tagged';\n                    }\n                    return self::URI . 'orgs/' . $org . '/packages?repo_name=' . $repo . '&ecosystem=' . $packagetype;\n                }\n                return self::URI . 'orgs/' . $org . '/packages?repo_name=' . $repo . '&ecosystem=' . $packagetype;\n            }\n            return self::URI . 'orgs/' . $org . '/packages?ecosystem=' . $packagetype;\n        }\n        return self::URI;\n    }\n}\n"
  },
  {
    "path": "bridges/GithubPullRequestBridge.php",
    "content": "<?php\n\nclass GitHubPullRequestBridge extends GithubIssueBridge\n{\n    const NAME = 'GitHub Pull Request';\n    const DESCRIPTION = 'Returns the pull request or comments of a pull request of a GitHub project';\n\n    const PARAMETERS = [\n        'global' => [\n            'u' => [\n                'name' => 'User name',\n                'exampleValue' => 'RSS-Bridge',\n                'required' => true\n            ],\n            'p' => [\n                'name' => 'Project name',\n                'exampleValue' => 'rss-bridge',\n                'required' => true\n            ]\n        ],\n        'Project Pull Requests' => [\n            'c' => [\n                'name' => 'Show Pull Request Comments',\n                'type' => 'checkbox'\n            ],\n            'q' => [\n                'name' => 'Search Query',\n                'defaultValue' => 'is:pr is:open sort:created-desc',\n                'required' => true\n            ]\n        ],\n        'Pull Request comments' => [\n            'i' => [\n                'name' => 'Pull Request number',\n                'type' => 'number',\n                'exampleValue' => '2100',\n                'required' => true\n            ]\n        ]\n    ];\n\n    const BRIDGE_OPTIONS = [0 => 'Project Pull Requests', 1 => 'Pull Request comments'];\n    const URL_PATH = 'pull';\n    const SEARCH_QUERY_PATH = 'pulls';\n}\n"
  },
  {
    "path": "bridges/GithubReleaseBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass GitHubReleaseBridge extends BridgeAbstract\n{\n    const NAME = 'GitHub Releases';\n    const URI = 'https://github.com';\n    const DESCRIPTION = 'Returns releases for a GitHub repository (excludes tag-only entries)';\n    const MAINTAINER = 'kiliankoe';\n    const CACHE_TIMEOUT = 3600;\n\n    const CONFIGURATION = [\n        'token' => [\n            'required' => false,\n        ],\n    ];\n\n    const PARAMETERS = [[\n        'owner' => [\n            'name' => 'Owner',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'RSS-Bridge',\n            'title' => 'GitHub user or organization'\n        ],\n        'repo' => [\n            'name' => 'Repository',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'rss-bridge',\n            'title' => 'GitHub repository name'\n        ],\n        'pre_release' => [\n            'name' => 'Include pre-releases',\n            'type' => 'checkbox',\n            'title' => 'Include pre-releases in the feed'\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $owner = $this->getInput('owner');\n        $repo = $this->getInput('repo');\n        $url = sprintf('https://api.github.com/repos/%s/%s/releases', urlencode($owner), urlencode($repo));\n\n        $headers = [\n            'Accept: application/vnd.github+json',\n            'User-Agent: rss-bridge',\n        ];\n        $token = $this->getOption('token');\n        if ($token) {\n            $headers[] = 'Authorization: token ' . $token;\n        }\n\n        $json = getContents($url, $headers);\n        $releases = json_decode($json, true);\n\n        if (!is_array($releases)) {\n            throwServerException('Unable to parse JSON response from GitHub API');\n        }\n\n        $includePrereleases = $this->getInput('pre_release');\n\n        foreach ($releases as $release) {\n            if ($release['draft']) {\n                continue;\n            }\n\n            if ($release['prerelease'] && !$includePrereleases) {\n                continue;\n            }\n\n            $title = $release['name'];\n            if (empty($title)) {\n                $title = $release['tag_name'];\n            }\n\n            $content = '';\n            if (!empty($release['body'])) {\n                $content = markdownToHtml($release['body']);\n            }\n\n            $enclosures = [];\n            if (!empty($release['assets'])) {\n                foreach ($release['assets'] as $asset) {\n                    if (!empty($asset['browser_download_url'])) {\n                        $enclosures[] = $asset['browser_download_url'];\n                    }\n                }\n            }\n\n            $this->items[] = [\n                'title' => $title,\n                'uri' => $release['html_url'],\n                'content' => $content,\n                'timestamp' => $release['published_at'],\n                'author' => $release['author']['login'] ?? '',\n                'uid' => $release['tag_name'],\n                'enclosures' => $enclosures,\n            ];\n        }\n    }\n\n    public function getName()\n    {\n        $owner = $this->getInput('owner');\n        $repo = $this->getInput('repo');\n        if ($owner && $repo) {\n            return 'Release notes from ' . $owner . '/' . $repo;\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        $owner = $this->getInput('owner');\n        $repo = $this->getInput('repo');\n        if ($owner && $repo) {\n            return self::URI . '/' . $owner . '/' . $repo . '/releases';\n        }\n        return parent::getURI();\n    }\n\n    public function detectParameters($url)\n    {\n        if (filter_var($url, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED) === false) {\n            return null;\n        }\n\n        $parsed = parse_url($url);\n        $host = $parsed['host'] ?? '';\n        if ($host !== 'github.com' && $host !== 'www.github.com') {\n            return null;\n        }\n\n        $path = $parsed['path'] ?? '';\n        // Match /owner/repo/releases, /owner/repo/releases.atom, or /owner/repo/tags\n        if (preg_match('#^/([^/]+)/([^/]+)/(releases(?:\\.atom)?|tags)$#', $path, $matches)) {\n            return [\n                'owner' => $matches[1],\n                'repo' => $matches[2],\n            ];\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/GithubSearchBridge.php",
    "content": "<?php\n\nclass GithubSearchBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting, User123698745';\n    const NAME = 'Github Repositories Search';\n    const BASE_URI = 'https://github.com';\n    const URI = self::BASE_URI . '/search';\n    const CACHE_TIMEOUT = 600; // 10min\n    const DESCRIPTION = 'Returns a specified repositories search (sorted by recently updated)';\n    const PARAMETERS = [ [\n        's' => [\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'rss-bridge',\n            'name' => 'Search query'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::getURI());\n\n        $resultElement = $html->find('[data-testid=\"results-list\"]', 0);\n\n        foreach ($resultElement->children as $element) {\n            $titleElement = $element->find('.search-title', 0);\n            $descriptionElement = $element->find('div > .search-match', 0);\n            $topicElements = $element->find('a[href^=\"/topic\"]');\n            $languageElement = $element->find('li [aria-label$=\"language\"]', 0);\n            $dateElement = $element->find('li [title*=\" \"]', 0);\n\n            $item = [];\n            $item['uri'] = self::BASE_URI . $titleElement->find('a', 0)->href;\n            $item['title'] = trim($titleElement->plaintext);\n            $item['timestamp'] = strtotime($dateElement->attr['title']);\n\n            $categories = [];\n\n            // Description\n            $content = '<p>';\n            if (isset($descriptionElement)) {\n                $content .= trim($descriptionElement->plaintext);\n            } else {\n                $content .= 'No description';\n            }\n            $content .= '</p>';\n\n            // Topics\n            if (count($topicElements) > 0) {\n                $content .= '<p>';\n                $content .= 'Topics: ';\n                foreach ($topicElements as $topicElement) {\n                    $topicLink = self::BASE_URI . $topicElement->href;\n                    $topicTitle = trim($topicElement->plaintext);\n                    $content .= '<a href=\"' . $topicLink . '\">' . $topicTitle . '</a> ';\n                    $categories[] = $topicTitle;\n                }\n                $content .= '</p>';\n            }\n\n            // Programming language\n            if (isset($languageElement)) {\n                $content .= '<p>';\n                $content .= 'Language: ';\n                $content .= trim($languageElement->plaintext);\n                $content .= '</p>';\n            }\n\n            $item['content'] = $content;\n            $item['categories'] = $categories;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $searchValue = $this->getInput('s');\n        if (isset($searchValue)) {\n            $params = [\n                'q' => $searchValue,\n                'type' => 'repositories',\n                's' => 'updated',\n                'o' => 'desc',\n            ];\n            return self::URI . '?' . http_build_query($params);\n        }\n        return self::URI;\n    }\n}\n"
  },
  {
    "path": "bridges/GithubTrendingBridge.php",
    "content": "<?php\n\nclass GithubTrendingBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'liamka';\n    const NAME = 'Github Trending';\n    const URI = 'https://github.com/trending';\n    const URI_ITEM = 'https://github.com';\n    const CACHE_TIMEOUT = 43200; // 12hr\n    const DESCRIPTION = 'See what the GitHub community is most excited repos.';\n    const PARAMETERS = [\n    // If you are changing context and/or parameter names, change them also in getName().\n        'By language' => [\n            'language' => [\n                'name' => 'Select language',\n                'type' => 'list',\n                'values' => [\n                    'All languages' => '',\n                    'HTML' => 'html',\n                    'PHP' => 'php',\n                    'Python' => 'python',\n                    'Shell' => 'shell',\n                    'Unknown languages' => 'unknown',\n                    '1C Enterprise' => '1c-enterprise',\n                    '4D' => '4d',\n                    'ABAP' => 'abap',\n                    'ABNF' => 'abnf',\n                    'ActionScript' => 'actionscript',\n                    'Ada' => 'ada',\n                    'Adobe Font Metrics' => 'adobe-font-metrics',\n                    'Agda' => 'agda',\n                    'AGS Script' => 'ags-script',\n                    'Alloy' => 'alloy',\n                    'Alpine Abuild' => 'alpine-abuild',\n                    'Altium Designer' => 'altium-designer',\n                    'AMPL' => 'ampl',\n                    'AngelScript' => 'angelscript',\n                    'Ant Build System' => 'ant-build-system',\n                    'ANTLR' => 'antlr',\n                    'ApacheConf' => 'apacheconf',\n                    'Apex' => 'apex',\n                    'API Blueprint' => 'api-blueprint',\n                    'APL' => 'apl',\n                    'Apollo Guidance Computer' => 'apollo-guidance-computer',\n                    'AppleScript' => 'applescript',\n                    'Arc' => 'arc',\n                    'AsciiDoc' => 'asciidoc',\n                    'ASN.1' => 'asn.1',\n                    'ASP' => 'asp',\n                    'AspectJ' => 'aspectj',\n                    'Assembly' => 'assembly',\n                    'Asymptote' => 'asymptote',\n                    'ATS' => 'ats',\n                    'Augeas' => 'augeas',\n                    'AutoHotkey' => 'autohotkey',\n                    'AutoIt' => 'autoit',\n                    'Awk' => 'awk',\n                    'Ballerina' => 'ballerina',\n                    'Batchfile' => 'batchfile',\n                    'Befunge' => 'befunge',\n                    'BibTeX' => 'bibtex',\n                    'Bison' => 'bison',\n                    'BitBake' => 'bitbake',\n                    'Blade' => 'blade',\n                    'BlitzBasic' => 'blitzbasic',\n                    'BlitzMax' => 'blitzmax',\n                    'Bluespec' => 'bluespec',\n                    'Boo' => 'boo',\n                    'Brainfuck' => 'brainfuck',\n                    'Brightscript' => 'brightscript',\n                    'Zeek' => 'zeek',\n                    'C' => 'c',\n                    'C#' => 'c%23',     // already URL encoded\n                    'C++' => 'c++',\n                    'C-ObjDump' => 'c-objdump',\n                    'C2hs Haskell' => 'c2hs-haskell',\n                    'Cabal Config' => 'cabal-config',\n                    'Cap\\'n Proto' => 'cap\\'n-proto',\n                    'CartoCSS' => 'cartocss',\n                    'Ceylon' => 'ceylon',\n                    'Chapel' => 'chapel',\n                    'Charity' => 'charity',\n                    'ChucK' => 'chuck',\n                    'Cirru' => 'cirru',\n                    'Clarion' => 'clarion',\n                    'Clean' => 'clean',\n                    'Click' => 'click',\n                    'CLIPS' => 'clips',\n                    'Clojure' => 'clojure',\n                    'Closure Templates' => 'closure-templates',\n                    'Cloud Firestore Security Rules' => 'cloud-firestore-security-rules',\n                    'CMake' => 'cmake',\n                    'COBOL' => 'cobol',\n                    'CodeQL' => 'codeql',\n                    'CoffeeScript' => 'coffeescript',\n                    'ColdFusion' => 'coldfusion',\n                    'ColdFusion CFC' => 'coldfusion-cfc',\n                    'COLLADA' => 'collada',\n                    'Common Lisp' => 'common-lisp',\n                    'Common Workflow Language' => 'common-workflow-language',\n                    'Component Pascal' => 'component-pascal',\n                    'CoNLL-U' => 'conll-u',\n                    'Cool' => 'cool',\n                    'Coq' => 'coq',\n                    'Cpp-ObjDump' => 'cpp-objdump',\n                    'Creole' => 'creole',\n                    'Crystal' => 'crystal',\n                    'CSON' => 'cson',\n                    'Csound' => 'csound',\n                    'Csound Document' => 'csound-document',\n                    'Csound Score' => 'csound-score',\n                    'CSS' => 'css',\n                    'CSV' => 'csv',\n                    'Cuda' => 'cuda',\n                    'cURL Config' => 'curl-config',\n                    'CWeb' => 'cweb',\n                    'Cycript' => 'cycript',\n                    'Cython' => 'cython',\n                    'D' => 'd',\n                    'D-ObjDump' => 'd-objdump',\n                    'Darcs Patch' => 'darcs-patch',\n                    'Dart' => 'dart',\n                    'DataWeave' => 'dataweave',\n                    'desktop' => 'desktop',\n                    'Dhall' => 'dhall',\n                    'Diff' => 'diff',\n                    'DIGITAL Command Language' => 'digital-command-language',\n                    'dircolors' => 'dircolors',\n                    'DirectX 3D File' => 'directx-3d-file',\n                    'DM' => 'dm',\n                    'DNS Zone' => 'dns-zone',\n                    'Dockerfile' => 'dockerfile',\n                    'Dogescript' => 'dogescript',\n                    'DTrace' => 'dtrace',\n                    'Dylan' => 'dylan',\n                    'E' => 'e',\n                    'Eagle' => 'eagle',\n                    'Easybuild' => 'easybuild',\n                    'EBNF' => 'ebnf',\n                    'eC' => 'ec',\n                    'Ecere Projects' => 'ecere-projects',\n                    'ECL' => 'ecl',\n                    'ECLiPSe' => 'eclipse',\n                    'EditorConfig' => 'editorconfig',\n                    'Edje Data Collection' => 'edje-data-collection',\n                    'edn' => 'edn',\n                    'Eiffel' => 'eiffel',\n                    'EJS' => 'ejs',\n                    'Elixir' => 'elixir',\n                    'Elm' => 'elm',\n                    'Emacs Lisp' => 'emacs-lisp',\n                    'EmberScript' => 'emberscript',\n                    'EML' => 'eml',\n                    'EQ' => 'eq',\n                    'Erlang' => 'erlang',\n                    'F#' => 'f%23',     // already URL encoded\n                    'F*' => 'f*',\n                    'Factor' => 'factor',\n                    'Fancy' => 'fancy',\n                    'Fantom' => 'fantom',\n                    'Faust' => 'faust',\n                    'FIGlet Font' => 'figlet-font',\n                    'Filebench WML' => 'filebench-wml',\n                    'Filterscript' => 'filterscript',\n                    'fish' => 'fish',\n                    'FLUX' => 'flux',\n                    'Formatted' => 'formatted',\n                    'Forth' => 'forth',\n                    'Fortran' => 'fortran',\n                    'FreeMarker' => 'freemarker',\n                    'Frege' => 'frege',\n                    'G-code' => 'g-code',\n                    'Game Maker Language' => 'game-maker-language',\n                    'GAML' => 'gaml',\n                    'GAMS' => 'gams',\n                    'GAP' => 'gap',\n                    'GCC Machine Description' => 'gcc-machine-description',\n                    'GDB' => 'gdb',\n                    'GDScript' => 'gdscript',\n                    'Genie' => 'genie',\n                    'Genshi' => 'genshi',\n                    'Gentoo Ebuild' => 'gentoo-ebuild',\n                    'Gentoo Eclass' => 'gentoo-eclass',\n                    'Gerber Image' => 'gerber-image',\n                    'Gettext Catalog' => 'gettext-catalog',\n                    'Gherkin' => 'gherkin',\n                    'Git Attributes' => 'git-attributes',\n                    'Git Config' => 'git-config',\n                    'GLSL' => 'glsl',\n                    'Glyph' => 'glyph',\n                    'Glyph Bitmap Distribution Format' => 'glyph-bitmap-distribution-format',\n                    'GN' => 'gn',\n                    'Gnuplot' => 'gnuplot',\n                    'Go' => 'go',\n                    'Golo' => 'golo',\n                    'Gosu' => 'gosu',\n                    'Grace' => 'grace',\n                    'Gradle' => 'gradle',\n                    'Grammatical Framework' => 'grammatical-framework',\n                    'Graph Modeling Language' => 'graph-modeling-language',\n                    'GraphQL' => 'graphql',\n                    'Graphviz (DOT)' => 'graphviz-(dot)',\n                    'Groovy' => 'groovy',\n                    'Groovy Server Pages' => 'groovy-server-pages',\n                    'Hack' => 'hack',\n                    'Haml' => 'haml',\n                    'Handlebars' => 'handlebars',\n                    'HAProxy' => 'haproxy',\n                    'Harbour' => 'harbour',\n                    'Haskell' => 'haskell',\n                    'Haxe' => 'haxe',\n                    'HCL' => 'hcl',\n                    'HiveQL' => 'hiveql',\n                    'HLSL' => 'hlsl',\n                    'HolyC' => 'holyc',\n                    'HTML+Django' => 'html+django',\n                    'HTML+ECR' => 'html+ecr',\n                    'HTML+EEX' => 'html+eex',\n                    'HTML+ERB' => 'html+erb',\n                    'HTML+PHP' => 'html+php',\n                    'HTML+Razor' => 'html+razor',\n                    'HTTP' => 'http',\n                    'HXML' => 'hxml',\n                    'Hy' => 'hy',\n                    'HyPhy' => 'hyphy',\n                    'IDL' => 'idl',\n                    'Idris' => 'idris',\n                    'Ignore List' => 'ignore-list',\n                    'IGOR Pro' => 'igor-pro',\n                    'Inform 7' => 'inform-7',\n                    'INI' => 'ini',\n                    'Inno Setup' => 'inno-setup',\n                    'Io' => 'io',\n                    'Ioke' => 'ioke',\n                    'IRC log' => 'irc-log',\n                    'Isabelle' => 'isabelle',\n                    'Isabelle ROOT' => 'isabelle-root',\n                    'J' => 'j',\n                    'Jasmin' => 'jasmin',\n                    'Java' => 'java',\n                    'Java Properties' => 'java-properties',\n                    'Java Server Pages' => 'java-server-pages',\n                    'JavaScript' => 'javascript',\n                    'JavaScript+ERB' => 'javascript+erb',\n                    'JFlex' => 'jflex',\n                    'Jison' => 'jison',\n                    'Jison Lex' => 'jison-lex',\n                    'Jolie' => 'jolie',\n                    'JSON' => 'json',\n                    'JSON with Comments' => 'json-with-comments',\n                    'JSON5' => 'json5',\n                    'JSONiq' => 'jsoniq',\n                    'JSONLD' => 'jsonld',\n                    'Jsonnet' => 'jsonnet',\n                    'JSX' => 'jsx',\n                    'Julia' => 'julia',\n                    'Jupyter Notebook' => 'jupyter-notebook',\n                    'KiCad Layout' => 'kicad-layout',\n                    'KiCad Legacy Layout' => 'kicad-legacy-layout',\n                    'KiCad Schematic' => 'kicad-schematic',\n                    'Kit' => 'kit',\n                    'Kotlin' => 'kotlin',\n                    'KRL' => 'krl',\n                    'LabVIEW' => 'labview',\n                    'Lasso' => 'lasso',\n                    'Latte' => 'latte',\n                    'Lean' => 'lean',\n                    'Less' => 'less',\n                    'Lex' => 'lex',\n                    'LFE' => 'lfe',\n                    'LilyPond' => 'lilypond',\n                    'Limbo' => 'limbo',\n                    'Linker Script' => 'linker-script',\n                    'Linux Kernel Module' => 'linux-kernel-module',\n                    'Liquid' => 'liquid',\n                    'Literate Agda' => 'literate-agda',\n                    'Literate CoffeeScript' => 'literate-coffeescript',\n                    'Literate Haskell' => 'literate-haskell',\n                    'LiveScript' => 'livescript',\n                    'LLVM' => 'llvm',\n                    'Logos' => 'logos',\n                    'Logtalk' => 'logtalk',\n                    'LOLCODE' => 'lolcode',\n                    'LookML' => 'lookml',\n                    'LoomScript' => 'loomscript',\n                    'LSL' => 'lsl',\n                    'LTspice Symbol' => 'ltspice-symbol',\n                    'Lua' => 'lua',\n                    'M' => 'm',\n                    'M4' => 'm4',\n                    'M4Sugar' => 'm4sugar',\n                    'Makefile' => 'makefile',\n                    'Mako' => 'mako',\n                    'Markdown' => 'markdown',\n                    'Marko' => 'marko',\n                    'Mask' => 'mask',\n                    'Mathematica' => 'mathematica',\n                    'MATLAB' => 'matlab',\n                    'Maven POM' => 'maven-pom',\n                    'Max' => 'max',\n                    'MAXScript' => 'maxscript',\n                    'mcfunction' => 'mcfunction',\n                    'MediaWiki' => 'mediawiki',\n                    'Mercury' => 'mercury',\n                    'Meson' => 'meson',\n                    'Metal' => 'metal',\n                    'Microsoft Developer Studio Project' => 'microsoft-developer-studio-project',\n                    'MiniD' => 'minid',\n                    'Mirah' => 'mirah',\n                    'mIRC Script' => 'mirc-script',\n                    'MLIR' => 'mlir',\n                    'Modelica' => 'modelica',\n                    'Modula-2' => 'modula-2',\n                    'Modula-3' => 'modula-3',\n                    'Module Management System' => 'module-management-system',\n                    'Monkey' => 'monkey',\n                    'Moocode' => 'moocode',\n                    'MoonScript' => 'moonscript',\n                    'Motorola 68K Assembly' => 'motorola-68k-assembly',\n                    'MQL4' => 'mql4',\n                    'MQL5' => 'mql5',\n                    'MTML' => 'mtml',\n                    'MUF' => 'muf',\n                    'mupad' => 'mupad',\n                    'Muse' => 'muse',\n                    'Myghty' => 'myghty',\n                    'nanorc' => 'nanorc',\n                    'NASL' => 'nasl',\n                    'NCL' => 'ncl',\n                    'Nearley' => 'nearley',\n                    'Nemerle' => 'nemerle',\n                    'nesC' => 'nesc',\n                    'NetLinx' => 'netlinx',\n                    'NetLinx+ERB' => 'netlinx+erb',\n                    'NetLogo' => 'netlogo',\n                    'NewLisp' => 'newlisp',\n                    'Nextflow' => 'nextflow',\n                    'Nginx' => 'nginx',\n                    'Nim' => 'nim',\n                    'Ninja' => 'ninja',\n                    'Nit' => 'nit',\n                    'Nix' => 'nix',\n                    'NL' => 'nl',\n                    'NPM Config' => 'npm-config',\n                    'NSIS' => 'nsis',\n                    'Nu' => 'nu',\n                    'NumPy' => 'numpy',\n                    'ObjDump' => 'objdump',\n                    'Object Data Instance Notation' => 'object-data-instance-notation',\n                    'Objective-C' => 'objective-c',\n                    'Objective-C++' => 'objective-c++',\n                    'Objective-J' => 'objective-j',\n                    'ObjectScript' => 'objectscript',\n                    'OCaml' => 'ocaml',\n                    'Odin' => 'odin',\n                    'Omgrofl' => 'omgrofl',\n                    'ooc' => 'ooc',\n                    'Opa' => 'opa',\n                    'Opal' => 'opal',\n                    'Open Policy Agent' => 'open-policy-agent',\n                    'OpenCL' => 'opencl',\n                    'OpenEdge ABL' => 'openedge-abl',\n                    'OpenQASM' => 'openqasm',\n                    'OpenRC runscript' => 'openrc-runscript',\n                    'OpenSCAD' => 'openscad',\n                    'OpenStep Property List' => 'openstep-property-list',\n                    'OpenType Feature File' => 'opentype-feature-file',\n                    'Org' => 'org',\n                    'Ox' => 'ox',\n                    'Oxygene' => 'oxygene',\n                    'Oz' => 'oz',\n                    'P4' => 'p4',\n                    'Pan' => 'pan',\n                    'Papyrus' => 'papyrus',\n                    'Parrot' => 'parrot',\n                    'Parrot Assembly' => 'parrot-assembly',\n                    'Parrot Internal Representation' => 'parrot-internal-representation',\n                    'Pascal' => 'pascal',\n                    'Pawn' => 'pawn',\n                    'Pep8' => 'pep8',\n                    'Perl' => 'perl',\n                    'Pic' => 'pic',\n                    'Pickle' => 'pickle',\n                    'PicoLisp' => 'picolisp',\n                    'PigLatin' => 'piglatin',\n                    'Pike' => 'pike',\n                    'PLpgSQL' => 'plpgsql',\n                    'PLSQL' => 'plsql',\n                    'Pod' => 'pod',\n                    'Pod 6' => 'pod-6',\n                    'PogoScript' => 'pogoscript',\n                    'Pony' => 'pony',\n                    'PostCSS' => 'postcss',\n                    'PostScript' => 'postscript',\n                    'POV-Ray SDL' => 'pov-ray-sdl',\n                    'PowerBuilder' => 'powerbuilder',\n                    'PowerShell' => 'powershell',\n                    'Prisma' => 'prisma',\n                    'Processing' => 'processing',\n                    'Proguard' => 'proguard',\n                    'Prolog' => 'prolog',\n                    'Propeller Spin' => 'propeller-spin',\n                    'Protocol Buffer' => 'protocol-buffer',\n                    'Public Key' => 'public-key',\n                    'Pug' => 'pug',\n                    'Puppet' => 'puppet',\n                    'Pure Data' => 'pure-data',\n                    'PureBasic' => 'purebasic',\n                    'PureScript' => 'purescript',\n                    'Python console' => 'python-console',\n                    'Python traceback' => 'python-traceback',\n                    'q' => 'q',\n                    'QMake' => 'qmake',\n                    'QML' => 'qml',\n                    'Quake' => 'quake',\n                    'R' => 'r',\n                    'Racket' => 'racket',\n                    'Ragel' => 'ragel',\n                    'Raku' => 'raku',\n                    'RAML' => 'raml',\n                    'Rascal' => 'rascal',\n                    'Raw token data' => 'raw-token-data',\n                    'RDoc' => 'rdoc',\n                    'Readline Config' => 'readline-config',\n                    'REALbasic' => 'realbasic',\n                    'Reason' => 'reason',\n                    'Rebol' => 'rebol',\n                    'Red' => 'red',\n                    'Redcode' => 'redcode',\n                    'Regular Expression' => 'regular-expression',\n                    'Ren\\'Py' => 'ren\\'py',\n                    'RenderScript' => 'renderscript',\n                    'reStructuredText' => 'restructuredtext',\n                    'REXX' => 'rexx',\n                    'RHTML' => 'rhtml',\n                    'Rich Text Format' => 'rich-text-format',\n                    'Ring' => 'ring',\n                    'Riot' => 'riot',\n                    'RMarkdown' => 'rmarkdown',\n                    'RobotFramework' => 'robotframework',\n                    'Roff' => 'roff',\n                    'Roff Manpage' => 'roff-manpage',\n                    'Rouge' => 'rouge',\n                    'RPC' => 'rpc',\n                    'RPM Spec' => 'rpm-spec',\n                    'Ruby' => 'ruby',\n                    'RUNOFF' => 'runoff',\n                    'Rust' => 'rust',\n                    'Sage' => 'sage',\n                    'SaltStack' => 'saltstack',\n                    'SAS' => 'sas',\n                    'Sass' => 'sass',\n                    'Scala' => 'scala',\n                    'Scaml' => 'scaml',\n                    'Scheme' => 'scheme',\n                    'Scilab' => 'scilab',\n                    'SCSS' => 'scss',\n                    'sed' => 'sed',\n                    'Self' => 'self',\n                    'ShaderLab' => 'shaderlab',\n                    'ShellSession' => 'shellsession',\n                    'Shen' => 'shen',\n                    'Slash' => 'slash',\n                    'Slice' => 'slice',\n                    'Slim' => 'slim',\n                    'Smali' => 'smali',\n                    'Smalltalk' => 'smalltalk',\n                    'Smarty' => 'smarty',\n                    'SmPL' => 'smpl',\n                    'SMT' => 'smt',\n                    'Solidity' => 'solidity',\n                    'SourcePawn' => 'sourcepawn',\n                    'SPARQL' => 'sparql',\n                    'Spline Font Database' => 'spline-font-database',\n                    'SQF' => 'sqf',\n                    'SQL' => 'sql',\n                    'SQLPL' => 'sqlpl',\n                    'Squirrel' => 'squirrel',\n                    'SRecode Template' => 'srecode-template',\n                    'SSH Config' => 'ssh-config',\n                    'Stan' => 'stan',\n                    'Standard ML' => 'standard-ml',\n                    'Starlark' => 'starlark',\n                    'Stata' => 'stata',\n                    'STON' => 'ston',\n                    'Stylus' => 'stylus',\n                    'SubRip Text' => 'subrip-text',\n                    'SugarSS' => 'sugarss',\n                    'SuperCollider' => 'supercollider',\n                    'Svelte' => 'svelte',\n                    'SVG' => 'svg',\n                    'Swift' => 'swift',\n                    'SWIG' => 'swig',\n                    'SystemVerilog' => 'systemverilog',\n                    'Tcl' => 'tcl',\n                    'Tcsh' => 'tcsh',\n                    'Tea' => 'tea',\n                    'Terra' => 'terra',\n                    'TeX' => 'tex',\n                    'Texinfo' => 'texinfo',\n                    'Text' => 'text',\n                    'Textile' => 'textile',\n                    'Thrift' => 'thrift',\n                    'TI Program' => 'ti-program',\n                    'TLA' => 'tla',\n                    'TOML' => 'toml',\n                    'TSQL' => 'tsql',\n                    'TSX' => 'tsx',\n                    'Turing' => 'turing',\n                    'Turtle' => 'turtle',\n                    'Twig' => 'twig',\n                    'TXL' => 'txl',\n                    'Type Language' => 'type-language',\n                    'TypeScript' => 'typescript',\n                    'Unified Parallel C' => 'unified-parallel-c',\n                    'Unity3D Asset' => 'unity3d-asset',\n                    'Unix Assembly' => 'unix-assembly',\n                    'Uno' => 'uno',\n                    'UnrealScript' => 'unrealscript',\n                    'UrWeb' => 'urweb',\n                    'V' => 'v',\n                    'Vala' => 'vala',\n                    'VBA' => 'vba',\n                    'VBScript' => 'vbscript',\n                    'VCL' => 'vcl',\n                    'Verilog' => 'verilog',\n                    'VHDL' => 'vhdl',\n                    'Vim script' => 'vim-script',\n                    'Vim Snippet' => 'vim-snippet',\n                    'Visual Basic .NET' => 'visual-basic-.net',\n                    'Visual Basic .NET' => 'visual-basic-.net',\n                    'Volt' => 'volt',\n                    'Vue' => 'vue',\n                    'Wavefront Material' => 'wavefront-material',\n                    'Wavefront Object' => 'wavefront-object',\n                    'wdl' => 'wdl',\n                    'Web Ontology Language' => 'web-ontology-language',\n                    'WebAssembly' => 'webassembly',\n                    'WebIDL' => 'webidl',\n                    'WebVTT' => 'webvtt',\n                    'Wget Config' => 'wget-config',\n                    'Windows Registry Entries' => 'windows-registry-entries',\n                    'wisp' => 'wisp',\n                    'Wollok' => 'wollok',\n                    'World of Warcraft Addon Data' => 'world-of-warcraft-addon-data',\n                    'X BitMap' => 'x-bitmap',\n                    'X Font Directory Index' => 'x-font-directory-index',\n                    'X PixMap' => 'x-pixmap',\n                    'X10' => 'x10',\n                    'xBase' => 'xbase',\n                    'XC' => 'xc',\n                    'XCompose' => 'xcompose',\n                    'XML' => 'xml',\n                    'XML Property List' => 'xml-property-list',\n                    'Xojo' => 'xojo',\n                    'XPages' => 'xpages',\n                    'XProc' => 'xproc',\n                    'XQuery' => 'xquery',\n                    'XS' => 'xs',\n                    'XSLT' => 'xslt',\n                    'Xtend' => 'xtend',\n                    'Yacc' => 'yacc',\n                    'YAML' => 'yaml',\n                    'YANG' => 'yang',\n                    'YARA' => 'yara',\n                    'YASnippet' => 'yasnippet',\n                    'ZAP' => 'zap',\n                    'Zeek' => 'zeek',\n                    'ZenScript' => 'zenscript',\n                    'Zephir' => 'zephir',\n                    'Zig' => 'zig',\n                    'ZIL' => 'zil',\n                    'Zimpl' => 'zimpl',\n                ],\n                'defaultValue' => 'All languages'\n            ]\n        ],\n\n        'global' => [\n            'date_range' => [\n                'name' => 'Date range',\n                'type' => 'list',\n                'values' => [\n                    'Today' => 'today',\n                    'Weekly' => 'weekly',\n                    'Monthly' => 'monthly',\n                ],\n                'defaultValue' => 'today'\n            ],\n            'spokenLanguage' => [\n                'name' => 'Spoken Language Code',\n                'type' => 'text',\n                'exampleValue' => 'en',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->constructUrl();\n        $html = getSimpleHTMLDOM($url);\n\n        $this->items = [];\n        foreach ($html->find('.Box-row') as $element) {\n            $item = [];\n\n            // URI\n            $item['uri'] = self::URI_ITEM . $element->find('h2 a', 0)->href;\n\n            // Title\n            $item['title'] = str_replace('  ', '', trim(strip_tags($element->find('h2 a', 0)->plaintext)));\n\n            // Description\n            $description = $element->find('p', 0);\n            if ($description != null) {\n                $item['content'] = trim(strip_tags($description->innertext));\n            }\n\n            // Time\n            $item['timestamp'] = time();\n\n            // TODO: Proxy?\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('language'))) {\n            return self::NAME . ': ' . $this->getKey('language');\n        }\n\n        return parent::getName();\n    }\n\n    private function constructUrl()\n    {\n        $url = self::URI;\n        $language = $this->getInput('language');\n        $dateRange = $this->getInput('date_range');\n        $spokenLanguage = $this->getInput('spokenLanguage');\n\n        if (!empty($language)) {\n            $url .= '/' . $language;\n        }\n\n        $queryParams = [];\n\n        if (!empty($dateRange)) {\n            $queryParams['since'] = $dateRange;\n        }\n\n        if (!empty($spokenLanguage)) {\n            $queryParams['spoken_language_code'] = trim($spokenLanguage);\n        }\n\n        if (!empty($queryParams)) {\n            $url .= '?' . http_build_query($queryParams);\n        }\n\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/GitlabIssueBridge.php",
    "content": "<?php\n\nclass GitlabIssueBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Mynacol';\n    const NAME = 'Gitlab Issue/Merge Request/Epic';\n    const URI = 'https://gitlab.com/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns  comments of an issue/MR/Epic of a gitlab project';\n\n    const PARAMETERS = [\n        'global' => [\n            'h' => [\n                'name' => 'Gitlab instance host name',\n                'exampleValue' => 'gitlab.com',\n                'defaultValue' => 'gitlab.com',\n                'required' => true\n            ],\n            'u' => [\n                'name' => 'User/Organization name',\n                'exampleValue' => 'gitlab-org',\n                'required' => true\n            ],\n            'p' => [\n                'name' => 'Project name',\n                'exampleValue' => 'gitlab-foss',\n                'required' => true\n            ]\n\n        ],\n        'Issue comments' => [\n            'i' => [\n                'name' => 'Issue number',\n                'type' => 'number',\n                'exampleValue' => '1',\n                'required' => true\n            ]\n        ],\n        'Merge Request comments' => [\n            'i' => [\n                'name' => 'Merge Request number',\n                'type' => 'number',\n                'exampleValue' => '1',\n                'required' => true\n            ]\n        ],\n        'Epic comments' => [\n            'i' => [\n                'name' => 'Epic number',\n                'type' => 'number',\n                'exampleValue' => '1',\n                'required' => true\n            ]\n        ]\n    ];\n\n    public function getName()\n    {\n        $name = $this->getInput('h') . '/' . $this->getInput('u') . '/' . $this->getInput('p');\n        switch ($this->queriedContext) {\n            case 'Issue comments':\n                $name .= ' Issue #' . $this->getInput('i');\n                break;\n            case 'Merge Request comments':\n                $name .= ' MR !' . $this->getInput('i');\n                break;\n            case 'Epic comments':\n                $name .= ' Epic &' . $this->getInput('i');\n                break;\n            default:\n                return parent::getName();\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $host = $this->getInput('h') ?? 'gitlab.com';\n        $uri = 'https://' . $host . '/' . $this->getInput('u') . '/'\n             . $this->getInput('p') . '/';\n        switch ($this->queriedContext) {\n            case 'Issue comments':\n                $uri .= '-/issues';\n                break;\n            case 'Merge Request comments':\n                $uri .= '-/merge_requests';\n                break;\n            case 'Epic comments':\n                $uri = 'https://' . $host . '/groups/' . $this->getInput('u') . '/-/epics';\n                break;\n            default:\n                return $uri;\n        }\n        $uri .= '/' . $this->getInput('i');\n        return $uri;\n    }\n\n    public function getIcon()\n    {\n        return 'https://' . $this->getInput('h') . '/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Issue comments':\n                $this->items[] = $this->parseIssueDescription();\n                break;\n            case 'Merge Request comments':\n                $this->items[] = $this->parseMergeRequestDescription();\n                break;\n            default:\n                break;\n        }\n\n        /* parse issue/MR comments */\n        $comments_uri = $this->getURI() . '/discussions.json';\n        $comments = getContents($comments_uri);\n        $comments = json_decode($comments, false);\n\n        foreach ($comments as $value) {\n            foreach ($value->notes as $comment) {\n                $item = [];\n                if ($comment->noteable_note_url !== null) {\n                    $item['uri'] = $comment->noteable_note_url;\n                    $item['uid'] = $item['uri'];\n                }\n\n                // TODO fix invalid timestamps (fdroid bot)\n                $item['timestamp'] = $comment->created_at ?? $comment->updated_at ?? $comment->last_edited_at;\n                $author = $comment->author ?? $comment->last_edited_by ?? null;\n                if ($author !== null) {\n                    $item['author'] = $author->name . ' @' . $author->username;\n                }\n\n                $content = '';\n                if ($comment->system) {\n                    $content = $comment->note_html;\n                    if ($comment->type === 'StateNote') {\n                        $content .= ' the issue';\n                    } elseif ($comment->type === null) {\n                        // e.g. \"added 900 commits\\n800 from master\\n175h4d - commit message\\n...\"\n                        $content = str_get_html($comment->note_html)->find('p', 0);\n                    }\n                } else {\n                    // no switch-case to do strict comparison\n                    if ($comment->type === null || $comment->type === 'DiscussionNote') {\n                        $content = 'commented';\n                    } elseif ($comment->type === 'DiffNote') {\n                        $content = 'commented on a thread';\n                    } else {\n                        $content = $comment->note_html;\n                    }\n                }\n\n                if ($author !== null) {\n                    $item['title'] = $author->name . ' ';\n                }\n                $item['title'] .= $content;\n\n                $content = $this->fixImgSrc($comment->note_html);\n                $item['content'] = defaultLinkTo($content, 'https://' . $this->getInput('h') . '/');\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function parseIssueDescription()\n    {\n        $description_uri = $this->getURI() . '.json';\n        $description = getContents($description_uri);\n        $description = json_decode($description, false);\n        $description_html = getSimpleHtmlDomCached($this->getURI());\n\n        $item = [];\n        $item['uri'] = $this->getURI();\n        $item['uid'] = $item['uri'];\n\n        $item['timestamp'] = $description->created_at ?? $description->updated_at;\n\n        $author = $this->parseAuthor($description_html);\n        if ($author) {\n            $item['author'] = $author;\n        }\n\n        $item['title'] = $description->title;\n        $item['content'] = markdownToHtml($description->description);\n\n        return $item;\n    }\n\n    private function parseMergeRequestDescription()\n    {\n        $description_uri = $this->getURI() . '/cached_widget.json';\n        $description = getContents($description_uri);\n        $description = json_decode($description, false);\n        $description_html = getSimpleHtmlDomCached($this->getURI());\n\n        $item = [];\n        $item['uri'] = $this->getURI();\n        $item['uid'] = $item['uri'];\n\n        $item['timestamp'] = $description_html->find('.merge-request-details time', 0)->datetime;\n\n        $author = $this->parseAuthor($description_html);\n        if ($author) {\n            $item['author'] = $author;\n        }\n\n        $item['title'] = 'Merge Request ' . $description->title;\n        $item['content'] = markdownToHtml($description->description);\n\n        return $item;\n    }\n\n    private function fixImgSrc($html)\n    {\n        if (is_string($html)) {\n            $html = str_get_html($html);\n        }\n\n        foreach ($html->find('img') as $img) {\n            $img->src = $img->getAttribute('data-src');\n        }\n        return $html;\n    }\n\n    private function parseAuthor($description_html)\n    {\n        $description_html = $this->fixImgSrc($description_html);\n\n        $authors = $description_html->find('.issuable-meta a.author-link, .merge-request a.author-link');\n        $editors = $description_html->find('.edited-text a.author-link');\n\n        if ($authors === [] && $editors === []) {\n            return null;\n        }\n\n        $authors = array_map(fn($author): string => $author->plaintext, $authors);\n        $editors = array_map(fn($author): string => $author->plaintext, $editors);\n\n        $author_str = implode(' ', $authors);\n        if ($editors) {\n            $author_str .= ', ' . implode(' ', $editors);\n        }\n        return defaultLinkTo($author_str, 'https://' . $this->getInput('h') . '/');\n    }\n}\n"
  },
  {
    "path": "bridges/GizmodoBridge.php",
    "content": "<?php\n\nclass GizmodoBridge extends FeedExpander\n{\n    const MAINTAINER = 'polopollo';\n    const NAME = 'Gizmodo';\n    const URI = 'https://gizmodo.com';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns the newest posts from Gizmodo.';\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n\n        $html = defaultLinkTo($html, $this->getURI());\n        $this->stripTags($html);\n        $this->handleFigureTags($html);\n        $this->handleIframeTags($html);\n\n        // Get header image\n        $image = $html->find('meta[property=\"og:image\"]', 0)->content;\n\n        $item['content'] = $html->find('div.js_post-content', 0)->innertext ?? '';\n\n        // Get categories\n        $categories = explode(',', $html->find('meta[name=\"keywords\"]', 0)->content);\n        $item['categories'] = array_map('trim', $categories);\n\n        $item['enclosures'][] = $html->find('meta[property=\"og:image\"]', 0)->content;\n\n        return $item;\n    }\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::URI . '/rss', 20);\n    }\n\n    private function stripTags($html)\n    {\n        foreach ($html->find('aside') as $aside) {\n            $aside->outertext = '';\n        }\n\n        foreach ($html->find('div.ad-unit') as $div) {\n            $div->outertext = '';\n        }\n\n        foreach ($html->find('script') as $script) {\n            $script->outertext = '';\n        }\n    }\n\n    private function handleFigureTags($html)\n    {\n        foreach ($html->find('figure') as $index => $figure) {\n            if (isset($figure->attr['data-id'])) {\n                $id = $figure->attr['data-id'];\n                $format = $figure->attr['data-format'];\n            } else {\n                $img = $figure->find('img', 0);\n                $id = $img->attr['data-chomp-id'];\n                $format = $img->attr['data-format'];\n                $figure->find('div.img-permalink-sub-wrapper', 0)->style = '';\n            }\n\n            $imageUrl = 'https://i.kinja-img.com/gawker-media/image/upload/' . $id . '.' . $format;\n\n            $figure->find('span', 0)->outertext = <<<EOD\n<img src=\"{$imageUrl}\">\nEOD;\n        }\n    }\n\n    private function handleIframeTags($html)\n    {\n        foreach ($html->find('iframe') as $iframe) {\n            $iframe->src = urljoin($this->getURI(), $iframe->src);\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GlassdoorBridge.php",
    "content": "<?php\n\nclass GlassdoorBridge extends BridgeAbstract\n{\n    // Contexts\n    const CONTEXT_BLOG   = 'Blogs';\n    const CONTEXT_REVIEW = 'Company Reviews';\n    const CONTEXT_GLOBAL = 'global';\n\n    // Global context parameters\n    const PARAM_LIMIT = 'limit';\n\n    // Blog context parameters\n    const PARAM_BLOG_TYPE = 'blog_type';\n    const PARAM_BLOG_FULL = 'full_article';\n\n    const BLOG_TYPE_HOME             = 'Home';\n    const BLOG_TYPE_COMPANIES_HIRING = 'Companies Hiring';\n    const BLOG_TYPE_CAREER_ADVICE    = 'Career Advice';\n    const BLOG_TYPE_INTERVIEWS       = 'Interviews';\n\n    // Review context parameters\n    const PARAM_REVIEW_COMPANY = 'company';\n\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Glassdoor';\n    const URI = 'https://www.glassdoor.com/';\n    const DESCRIPTION = 'Returns feeds for blog posts and company reviews';\n    const CACHE_TIMEOUT = 86400; // 24 hours\n\n    const PARAMETERS = [\n        self::CONTEXT_BLOG => [\n            self::PARAM_BLOG_TYPE => [\n                'name' => 'Blog type',\n                'type' => 'list',\n                'title' => 'Select the blog you want to follow',\n                'values' => [\n                    self::BLOG_TYPE_HOME                => 'blog/',\n                    self::BLOG_TYPE_COMPANIES_HIRING    => 'blog/companies-hiring/',\n                    self::BLOG_TYPE_CAREER_ADVICE       => 'blog/career-advice/',\n                    self::BLOG_TYPE_INTERVIEWS          => 'blog/interviews/',\n                ]\n            ],\n            self::PARAM_BLOG_FULL => [\n                'name' => 'Full article',\n                'type' => 'checkbox',\n                'title' => 'Enable to return the full article for each post'\n            ],\n        ],\n        self::CONTEXT_REVIEW => [\n            self::PARAM_REVIEW_COMPANY => [\n                'name' => 'Company URL',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Paste the company review page URL here!',\n                'exampleValue' => 'https://www.glassdoor.com/Reviews/GitHub-Reviews-E671945.htm'\n            ]\n        ],\n        self::CONTEXT_GLOBAL => [\n            self::PARAM_LIMIT => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'defaultValue' => -1,\n                'title' => 'Specifies the maximum number of items to return (default: All)'\n            ]\n        ]\n    ];\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_BLOG:\n                return self::URI . $this->getInput(self::PARAM_BLOG_TYPE);\n            case self::CONTEXT_REVIEW:\n                return $this->filterCompanyURI($this->getInput(self::PARAM_REVIEW_COMPANY));\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n        $html = defaultLinkTo($html, $url);\n        $limit = $this->getInput(self::PARAM_LIMIT);\n\n        switch ($this->queriedContext) {\n            case self::CONTEXT_BLOG:\n                $this->collectBlogData($html, $limit);\n                break;\n            case self::CONTEXT_REVIEW:\n                $this->collectReviewData($html, $limit);\n                break;\n        }\n    }\n\n    private function collectBlogData($html, $limit)\n    {\n        $posts = $html->find('div.post')\n            or throwServerException('Unable to find blog posts!');\n\n        foreach ($posts as $post) {\n            $item = [];\n\n            $item['uri'] = $post->find('a', 0)->href;\n            $item['title'] = $post->find('h3', 0)->plaintext;\n            $item['content'] = $post->find('p', 0)->plaintext;\n            $item['author'] = $post->find('p', -2)->plaintext;\n            $item['timestamp'] = strtotime($post->find('p', -1)->plaintext);\n\n            // TODO: fetch entire blog post content\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                return;\n            }\n        }\n    }\n\n    private function collectReviewData($html, $limit)\n    {\n        $reviews = $html->find('#ReviewsFeed li[id^=\"empReview]')\n            or throwServerException('Unable to find reviews!');\n\n        foreach ($reviews as $review) {\n            $item = [];\n\n            $item['uri'] = $review->find('a.reviewLink', 0)->href;\n\n            // Not all reviews have a title\n            $item['title'] = $review->find('h2', 0)->plaintext ?? 'Glassdoor review';\n\n            [$date, $author] = explode('-', $review->find('span.authorInfo', 0)->plaintext);\n\n            $item['author'] = trim($author);\n\n            $createdAt = DateTimeImmutable::createFromFormat('F m, Y', trim($date));\n            if ($createdAt) {\n                $item['timestamp'] = $createdAt->getTimestamp();\n            }\n\n            $item['content'] = $review->find('.px-std', 2)->text();\n\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                return;\n            }\n        }\n    }\n\n    private function filterCompanyURI($uri)\n    {\n        /* Make sure the URI is a valid review page. Unfortunately there is no\n         * simple way to determine if the URI is valid, because of automagic\n         * redirection and strange naming conventions.\n         */\n        if (\n            !filter_var(\n                $uri,\n                FILTER_VALIDATE_URL,\n                FILTER_FLAG_PATH_REQUIRED\n            )\n        ) {\n            throwClientException('The specified URL is invalid!');\n        }\n\n        $uri = filter_var($uri, FILTER_SANITIZE_URL);\n        $path = parse_url($uri, PHP_URL_PATH);\n        $parts = explode('/', $path);\n\n        $allowed_strings = [\n            'de-DE' => 'Bewertungen',\n            'en-AU' => 'Reviews',\n            'nl-BE' => 'Reviews',\n            'fr-BE' => 'Avis',\n            'en-CA' => 'Reviews',\n            'fr-CA' => 'Avis',\n            'fr-FR' => 'Avis',\n            'en-IN' => 'Reviews',\n            'en-IE' => 'Reviews',\n            'nl-NL' => 'Reviews',\n            'de-AT' => 'Bewertungen',\n            'de-CH' => 'Bewertungen',\n            'fr-CH' => 'Avis',\n            'en-GB' => 'Reviews',\n            'en'    => 'Reviews'\n        ];\n\n        if (!in_array($parts[1], $allowed_strings)) {\n            throwClientException('Please specify a URL pointing to the companies review page!');\n        }\n\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/GlowficBridge.php",
    "content": "<?php\n\nclass GlowficBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'l1n';\n    const NAME = 'Glowfic';\n    const URI = 'https://www.glowfic.com';\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Returns the latest replies on a glowfic post.';\n    const PARAMETERS = [\n        'global' => [],\n        'Thread' => [\n            'post_id' => [\n                'name' => 'Post ID',\n                'title' => 'https://www.glowfic.com/posts/POST ID',\n                'required' => true,\n                'exampleValue' => '2756',\n                'type' => 'number'\n            ],\n            'start_page' => [\n                'name' => 'Start Page',\n                'title' => 'To start from an offset page',\n                'type' => 'number'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getAPIURI();\n        $metadata = get_headers($url . '/replies', true);\n        $metadata['Last-Page'] = ceil($metadata['Total'] / $metadata['Per-Page']);\n        if (\n            !is_null($this->getInput('start_page')) &&\n            $this->getInput('start_page') < 1 && $metadata['Last-Page'] - $this->getInput('start_page') > 0\n        ) {\n            $first_page = $metadata['Last-Page'] - $this->getInput('start_page');\n        } elseif (!is_null($this->getInput('start_page')) && $this->getInput('start_page') <= $metadata['Last-Page']) {\n            $first_page = $this->getInput('start_page');\n        } else {\n            $first_page = 1;\n        }\n        for ($page_offset = $first_page; $page_offset <= $metadata['Last-Page']; $page_offset++) {\n            $jsonContents = getContents($url . '/replies?page=' . $page_offset);\n            $replies = json_decode($jsonContents);\n            foreach ($replies as $reply) {\n                $item = [];\n\n                $item['content'] = $reply->{'content'};\n                $item['uri'] = $this->getURI() . '?page=' . $page_offset . '#reply-' . $reply->{'id'};\n                if ($reply->{'icon'}) {\n                    $item['enclosures'] = [$reply->{'icon'}->{'url'}];\n                }\n                $item['author'] = $reply->{'character'}->{'screenname'} . ' (' . $reply->{'character'}->{'name'} . ')';\n                $item['timestamp'] = date('r', strtotime($reply->{'created_at'}));\n                $item['title'] = 'Tag by ' . $reply->{'user'}->{'username'} . ' updated at ' . $reply->{'updated_at'};\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function getAPIURI()\n    {\n        $url = parent::getURI() . '/api/v1/posts/' . $this->getInput('post_id');\n        return $url;\n    }\n\n    public function getURI()\n    {\n        $url = parent::getURI() . '/posts/' . $this->getInput('post_id');\n        return $url;\n    }\n\n    private function getPost()\n    {\n        $url = $this->getAPIURI();\n        $jsonPost = getContents($url);\n        $post = json_decode($jsonPost);\n\n        return $post;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('post_id'))) {\n            $post = $this->getPost();\n            return $post->{'subject'} . ' - ' . parent::getName();\n        }\n        return parent::getName();\n    }\n\n    public function getDescription()\n    {\n        if (!is_null($this->getInput('post_id'))) {\n            $post = $this->getPost();\n            return $post->{'content'};\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/GoAccessBridge.php",
    "content": "<?php\n\nclass GoAccessBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Simounet';\n    const NAME = 'GoAccess';\n    const URI_BASE = 'https://goaccess.io';\n    const URI = self::URI_BASE . '/release-notes';\n    const CACHE_TIMEOUT = 21600; //6h\n    const DESCRIPTION = 'GoAccess releases.';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $container = $html->find('.container.content', 0);\n        foreach ($container->find('div') as $element) {\n            $titleEl = $element->find('h2', 0);\n            $dateEl = $titleEl->find('small', 0);\n            $date = trim($dateEl->plaintext);\n            $title = is_object($titleEl) ? str_replace($date, '', $titleEl->plaintext) : '';\n            $linkEl = $titleEl->find('a', 0);\n            $link = is_object($linkEl) ? $linkEl->href : '';\n            $postUrl = self::URI . $link;\n\n            $contentEl = $element->find('.dl-horizontal', 0);\n            $content = '<dl>' . $contentEl->xmltext() . '</dl>';\n\n            $item = [];\n            $item['uri'] = $postUrl;\n            $item['timestamp'] = strtotime($date);\n            $item['title'] = $title;\n            $item['content'] = $content;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GoComicsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass GoComicsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'TReKiE';\n    //const MAINTAINER = 'sky';\n    const NAME = 'GoComics Unofficial RSS';\n    const URI = 'https://www.gocomics.com/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'The Unofficial GoComics RSS';\n    const PARAMETERS = [ [\n        'comicname' => [\n            'name' => 'comicname',\n            'type' => 'text',\n            'exampleValue' => 'heartofthecity',\n            'required' => true\n        ],\n        'date-in-title' => [\n            'name' => 'Add date and full name to each day\\'s title',\n            'type' => 'checkbox',\n            'title' => 'Adds the date and the full name into the title of each day\\'s comic',\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'title' => 'The number of recent comics to get',\n            'defaultValue' => 2\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $link = $this->getURI();\n        $header = [\n            'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'\n        ];\n        $landingpage = getSimpleHTMLDOM($link, $header);\n        $element = $landingpage->find('div[data-post-url]', 0);\n        if ($element) {\n            $link = $element->getAttribute('data-post-url');\n        } else { // fallback for comics without data-post-url (assumes daily comic)\n            $nextcomiclink = $landingpage->find('a[class*=\"ComicNavigation_controls__button_previous__\"]', 0)->href;\n            preg_match('/(\\d{4}\\/\\d{2}\\/\\d{2})/', $nextcomiclink, $nclmatches);\n            if (!empty($nclmatches[1])) {\n                $nextdate = new DateTime($nclmatches[1]);\n                $nextdate = $nextdate->modify('+1 day')->format('Y/m/d');\n                $link = $link . '/' . $nextdate;\n            } else {\n                throw new \\Exception('Could not find the first comic URL. Please create a new GitHub issue.');\n            }\n        }\n\n        for ($i = 0; $i < $this->getInput('limit'); $i++) {\n            $html = getSimpleHTMLDOMCached($link, 86400, $header);\n\n            $imagelink = $html->find('meta[property=\"og:image\"]', 0)->content;\n\n            $title = $html->find('meta[property=\"og:title\"]', 0)->content;\n            preg_match('/by (.*?) for/', $title, $authormatches);\n            $author = $authormatches[1] ?? 'GoComics';\n\n            $item = [];\n            $item['id'] = $imagelink;\n            $item['uri'] = $link;\n            $item['author'] = $author;\n            $item['title'] = 'GoComics ' . $this->getInput('comicname');\n            if ($this->getInput('date-in-title') === true) {\n                $item['title'] = $title;\n            }\n\n            $parts = explode('/', $link);\n            $date = DateTime::createFromFormat('Y/m/d', implode('/', array_slice($parts, -3)));\n            if ($date) {\n                $item['timestamp'] = $date->setTime(0, 0, 0)->getTimestamp();\n            }\n\n            $item['content'] = '<img src=\"' . $imagelink . '\" />';\n\n            $this->items[] = $item;\n\n            $button_previous = $html->find('a[class*=\"__controls__button_previous\"]', 0);\n            if (! $button_previous) {\n                break;\n            }\n            $link = rtrim(self::URI, '/') . $button_previous->href;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('comicname'))) {\n            return self::URI . urlencode($this->getInput('comicname'));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('comicname'))) {\n            return $this->getInput('comicname') . ' - GoComics';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/GogsBridge.php",
    "content": "<?php\n\nclass GogsBridge extends BridgeAbstract\n{\n    const NAME = 'Gogs';\n    const URI = 'https://gogs.io';\n    const DESCRIPTION = 'Returns the latest issues, commits or releases';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 300; // 5 minutes\n\n    const PARAMETERS = [\n        'global' => [\n            'host' => [\n                'name' => 'Host',\n                'exampleValue' => 'https://notabug.org',\n                'required' => true,\n                'title' => 'Host name with its protocol, without trailing slash',\n            ],\n            'user' => [\n                'name' => 'Username',\n                'exampleValue' => 'PDModdingCommunity',\n                'required' => true,\n                'title' => 'User name as it appears in the URL',\n            ],\n            'project' => [\n                'name' => 'Project name',\n                'exampleValue' => 'PD-Loader',\n                'required' => true,\n                'title' => 'Project name as it appears in the URL',\n            ],\n        ],\n        'Commits' => [\n            'branch' => [\n                'name' => 'Branch name',\n                'defaultValue' => 'master',\n                'required' => true,\n                'title' => 'Branch name as it appears in the URL',\n            ],\n        ],\n        'Issues' => [\n            'include_description' => [\n                'name' => 'Include issue description',\n                'type' => 'checkbox',\n                'title' => 'Activate to include the issue description',\n            ],\n        ],\n        'Single issue' => [\n            'issue' => [\n                'name' => 'Issue number',\n                'type' => 'number',\n                'exampleValue' => 100,\n                'required' => true,\n                'title' => 'Issue number from the issues list',\n            ],\n        ],\n        'Releases' => [],\n    ];\n\n    private $title = '';\n\n    /**\n     * Note: detectParamters doesn't make sense for this bridge because there is\n     * no \"single\" host for this service. Anyone can host it.\n     */\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Commits':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/commits/' . $this->getInput('branch');\n\n            case 'Issues':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/issues/';\n\n            case 'Single issue':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/issues/' . $this->getInput('issue');\n\n            case 'Releases':\n                return $this->getInput('host')\n                . '/' . $this->getInput('user')\n                . '/' . $this->getInput('project')\n                . '/releases/';\n\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Commits':\n            case 'Issues':\n            case 'Releases':\n                return $this->title . ' ' . $this->queriedContext;\n            case 'Single issue':\n                return $this->title . ' Issue ' . $this->getInput('issue');\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://gogs.io/img/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $html = defaultLinkTo($html, $this->getURI());\n\n        $this->title = $html->find('[property=\"og:title\"]', 0)->content;\n\n        switch ($this->queriedContext) {\n            case 'Commits':\n                $this->collectCommitsData($html);\n                break;\n            case 'Issues':\n                $this->collectIssuesData($html);\n                break;\n            case 'Single issue':\n                $this->collectSingleIssueData($html);\n                break;\n            case 'Releases':\n                $this->collectReleasesData($html);\n                break;\n        }\n    }\n\n    protected function collectCommitsData($html)\n    {\n        $commits = $html->find('#commits-table tbody tr')\n            or throwServerException('Unable to find commits');\n\n        foreach ($commits as $commit) {\n            $this->items[] = [\n                'uri' => $commit->find('a.sha', 0)->href,\n                'title' => $commit->find('.message span', 0)->plaintext,\n                'author' => $commit->find('.author', 0)->plaintext,\n                'timestamp' => $commit->find('.time-since', 0)->title,\n                'uid' => $commit->find('.sha', 0)->plaintext,\n            ];\n        }\n    }\n\n    protected function collectIssuesData($html)\n    {\n        $issues = $html->find('.issue.list li')\n            or throwServerException('Unable to find issues');\n\n        foreach ($issues as $issue) {\n            $uri = $issue->find('a', 0)->href;\n\n            $item = [\n                'uri' => $uri,\n                'title' => $issue->find('.label', 0)->plaintext . ' | ' . $issue->find('a.title', 0)->plaintext,\n                'author' => $issue->find('.desc a', 0)->plaintext,\n                'timestamp' => $issue->find('.time-since', 0)->title,\n                'uid' => $issue->find('.label', 0)->plaintext,\n            ];\n\n            if ($this->getInput('include_description')) {\n                $issue_html = getSimpleHTMLDOMCached($uri, 3600);\n\n                $issue_html = defaultLinkTo($issue_html, $uri);\n\n                $item['content'] = $issue_html->find('.comment .markdown', 0);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    protected function collectSingleIssueData($html)\n    {\n        $comments = $html->find('.comments .comment')\n            or throwServerException('Unable to find comments');\n\n        foreach ($comments as $comment) {\n            $this->items[] = [\n                'uri' => $comment->find('a[href*=\"#issue\"]', 0)->href,\n                'title' => $comment->find('span', 0)->plaintext,\n                'author' => $comment->find('.content a', 0)->plaintext,\n                'timestamp' => $comment->find('.time-since', 0)->title,\n                'content' => $comment->find('.markdown', 0),\n            ];\n        }\n\n        $this->items = array_reverse($this->items);\n    }\n\n    protected function collectReleasesData($html)\n    {\n        $releases = $html->find('#release-list li')\n            or throwServerException('Unable to find releases');\n\n        foreach ($releases as $release) {\n            $this->items[] = [\n                'uri' => $release->find('a', 0)->href,\n                'title' => 'Release ' . $release->find('h4', 0)->plaintext,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GolemBridge.php",
    "content": "<?php\n\nclass GolemBridge extends FeedExpander\n{\n    const MAINTAINER = 'Mynacol';\n    const NAME = 'Golem';\n    const URI = 'https://www.golem.de/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'Alle News'\n                => 'https://rss.golem.de/rss.php?feed=ATOM1.0',\n                'Audio/Video'\n                => 'https://rss.golem.de/rss.php?ms=audio-video&feed=ATOM1.0',\n                'Auto'\n                => 'https://rss.golem.de/rss.php?ms=auto&feed=ATOM1.0',\n                'Foto'\n                => 'https://rss.golem.de/rss.php?ms=foto&feed=ATOM1.0',\n                'Games'\n                => 'https://rss.golem.de/rss.php?ms=games&feed=ATOM1.0',\n                'Handy'\n                => 'https://rss.golem.de/rss.php?ms=handy&feed=ATOM1.0',\n                'Internet'\n                => 'https://rss.golem.de/rss.php?ms=internet&feed=ATOM1.0',\n                'Mobil'\n                => 'https://rss.golem.de/rss.php?ms=mobil&feed=ATOM1.0',\n                'Open Source'\n                => 'https://rss.golem.de/rss.php?ms=open-source&feed=ATOM1.0',\n                'Politik/Recht'\n                => 'https://rss.golem.de/rss.php?ms=politik-recht&feed=ATOM1.0',\n                'Security'\n                => 'https://rss.golem.de/rss.php?ms=security&feed=ATOM1.0',\n                'Desktop-Applikationen'\n                => 'https://rss.golem.de/rss.php?ms=desktop-applikationen&feed=ATOM1.0',\n                'Software-Entwicklung'\n                => 'https://rss.golem.de/rss.php?ms=softwareentwicklung&feed=ATOM1.0',\n                'Wirtschaft'\n                => 'https://rss.golem.de/rss.php?ms=wirtschaft&feed=ATOM1.0',\n                'Wissenschaft'\n                => 'https://rss.golem.de/rss.php?ms=wissenschaft&feed=ATOM1.0'\n            ]\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 5\n        ]\n    ]];\n    const LIMIT = 5;\n    const HEADERS = ['Cookie: golem_consent20=simple|250101;'];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(\n            $this->getInput('category'),\n            $this->getInput('limit') ?: static::LIMIT\n        );\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['content'] ??= '';\n        $uri = $item['uri'];\n\n        $urls = [];\n\n        while ($uri) {\n            if (isset($urls[$uri])) {\n                // Prevent loop in navigation links\n                break;\n            }\n            $urls[$uri] = true;\n\n            $articlePage = getSimpleHTMLDOMCached($uri, static::CACHE_TIMEOUT, static::HEADERS);\n            $articlePage = defaultLinkTo($articlePage, $uri);\n\n            // URI without RSS feed reference\n            $item['uri'] = $articlePage->find('head meta[name=\"twitter:url\"]', 0)->content;\n\n            // extract categories\n            if (!array_key_exists('categories', $item)) {\n                $categories = $articlePage->find('div.go-tag-list__tags a.go-tag');\n                foreach ($categories as $category) {\n                    $trimmedcategories[] = trim(html_entity_decode($category->plaintext));\n                }\n                if (isset($trimmedcategories)) {\n                    $item['categories'] = array_unique($trimmedcategories);\n                }\n            }\n\n            // next page\n            $nextUri = $articlePage->find('li.go-pagination__item--next a', 0);\n            if ($nextUri) {\n                $uri = $nextUri->href;\n            } else {\n                $uri = null;\n            }\n\n            // Only extract the content (and remove content) after all pre-processing is done\n            $item['content'] .= $this->extractContent($articlePage, $item['content']);\n        }\n\n        return $item;\n    }\n\n    private function extractContent($page, $prevcontent)\n    {\n        $item = '';\n\n        $article = $page->find('article', 0);\n\n        // extract embeds from script tags (unfortunately no JSON)\n        $embedSrcs = [];\n        foreach ($page->find('script') as $script) {\n            // Ungreedy match to get precisely the snippet of one embed\n            if (preg_match_all('/type:\\s*\\\"Embed(.*)urlPrivacy:/U', $script, $embeds)) {\n                foreach ($embeds[1] as $embed) {\n                    if (preg_match('/src:\\s*\\\"([^\\\"]+)\\\"/', $embed, $src)) {\n                        $embedSrcs[] = $src[1];\n                    }\n                }\n            }\n        }\n        // inject the embed into the HTML placeholder\n        $placeholders = $article->find('.go-embed-container');\n        foreach (range(0, count($placeholders) - 1) as $i) {\n            if (array_key_exists($i, $embedSrcs)) {\n                $src = $embedSrcs[$i];\n                if (preg_match('/youtube(-nocookie)?\\.com/', $src, $match)) {\n                    $placeholders[$i]->innertext = handleYoutube($src);\n                }\n            }\n        }\n\n        //built golem videos\n        foreach ($article->find('.gvideofig') as &$embedcontent) {\n            if (preg_match('/gvideo_(.*)/', $embedcontent->id, $videoid)) {\n                $embedcontent->innertext .= <<<EOT\n                    <video class=\"rmp-object-fit-contain rmp-video\" x-webkit-airplay=\"allow\" controlslist=\"nodownload\" tabindex=\"-1\"\n                    preload=\"metadata\" src=\"https://video.golem.de/download/$videoid[1]\"></video>                                                                      \n                EOT;\n            }\n        }\n\n        // delete known bad elements and unwanted gallery images\n        foreach (\n            $article->find('div[id*=\"adtile\"], #job-market, #seminars, iframe, .go-article-header__title, .go-article-header__kicker, .go-label--sponsored,\n                        .gbox_affiliate, div.toc, .go-button-bar, .go-alink-list, .go-teaser-block, .go-vh, .go-paywall, .go-index, .go-pagination__list,\n                        .go-gallery .[data-active=\"false\"], .go-article-header__series') as $bad\n        ) {\n            $bad->remove();\n        }\n        // reload html, as remove() is buggy\n        $article = str_get_html($article->outertext);\n\n        // Add multipage headers, but only if they are different to the article header\n        $firstHeader = $page->find('.table-jtoc td', 0);\n        if (isset($firstHeader)) {\n            $firstHeader = html_entity_decode($firstHeader->title);\n        }\n        $multipageHeader = $article->find('header.paged-cluster-header h1', 0);\n        if (isset($multipageHeader) && $multipageHeader->plaintext !== $firstHeader) {\n            $item .= $multipageHeader;\n        }\n\n        $header = $article->find('header', 0);\n        if (isset($header)) {\n            foreach ($header->find('p, figure') as $element) {\n                $item .= $element;\n            }\n        }\n\n        foreach (\n            $article->find('div.go-article-header__intro, p, h1, h2, h3, pre, ul, ol, .go-media img[src*=\".\"], .go-media figcaption,\n                    table, iframe, video') as $element\n        ) {\n            if (!str_contains($prevcontent, $element)) {\n                $item .= $element;\n            }\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/GoodreadsBridge.php",
    "content": "<?php\n\nclass GoodreadsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'captn3m0';\n    const NAME = 'Goodreads';\n    const URI = 'https://www.goodreads.com/';\n    const CACHE_TIMEOUT = 0; // 30min\n    const DESCRIPTION = 'Various RSS feeds from Goodreads';\n\n    const CONTEXT_AUTHOR_BOOKS = 'Books by Author';\n\n    // Using a specific context because I plan to expand this soon\n    const PARAMETERS = [\n        'Books by Author' => [\n            'author_url' => [\n                'name' => 'Link to author\\'s page on Goodreads',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Should look somewhat like goodreads.com/author/show/',\n                'pattern' => '^(https:\\/\\/)?(www.)?goodreads\\.com\\/author\\/show\\/\\d+\\..*$',\n                'exampleValue' => 'https://www.goodreads.com/author/show/38550.Brandon_Sanderson'\n            ],\n            'published_only' => [\n                'name' => 'Show published books only',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'If left unchecked, this will return unpublished books as well',\n                'defaultValue' => 'checked',\n            ],\n        ],\n    ];\n\n    private function collectAuthorBooks($url)\n    {\n        $regex = '/goodreads\\.com\\/author\\/show\\/(\\d+)/';\n\n        preg_match($regex, $url, $matches);\n\n        $authorId = $matches[1];\n\n        $authorListUrl = \"https://www.goodreads.com/author/list/$authorId?sort=original_publication_year\";\n\n        $html = getSimpleHTMLDOMCached($authorListUrl, self::CACHE_TIMEOUT);\n\n        foreach ($html->find('tr[itemtype=\"http://schema.org/Book\"]') as $row) {\n            $dateSpan = $row->find('.uitext', 0)->plaintext;\n            $date = null;\n\n            // If book is not yet published, ignore for now\n            if (preg_match('/published\\s+(\\d{4})/', $dateSpan, $matches) === 1) {\n                // Goodreads doesn't give us exact publication date here, only a year\n                // We are skipping future dates anyway, so this is def published\n                // but we can't pick a dynamic date either to keep clients from getting\n                // confused. So we pick a guaranteed date of 1st-Jan instead.\n                $date = $matches[1] . '-01-01';\n            } elseif ($this->getInput('published_only') !== 'checked') {\n                // We can return unpublished books as well\n                $date = date('Y-01-01');\n            } else {\n                continue;\n            }\n\n            $row = defaultLinkTo($row, $this->getURI());\n\n            $item['title'] = $row->find('.bookTitle', 0)->plaintext;\n            $item['uri'] = $row->find('.bookTitle', 0)->getAttribute('href');\n            $item['author'] = $row->find('.authorName', 0)->plaintext;\n            $item['content'] = '<a href=\"'\n            . $row->find('.bookTitle', 0)->getAttribute('href')\n            . '\"><img src=\"'\n            . $row->find('.bookCover', 0)->getAttribute('src')\n            . '\"></a>';\n            $item['timestamp'] = $date;\n            $item['enclosures'] = [\n            $row->find('.bookCover', 0)->getAttribute('src')\n            ];\n\n            $this->items[] = $item; // Add item to the list\n        }\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_AUTHOR_BOOKS:\n                $this->collectAuthorBooks($this->getInput('author_url'));\n                break;\n\n            default:\n                throw new Exception('Invalid context', 1);\n            break;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GoogleGroupsBridge.php",
    "content": "<?php\n\nclass GoogleGroupsBridge extends XPathAbstract\n{\n    const NAME = 'Google Groups';\n    const DESCRIPTION = 'Returns the latest posts on a Google Group';\n    const URI = 'https://groups.google.com';\n    const PARAMETERS = [ [\n        'group' => [\n            'name' => 'Group id',\n            'title' => 'The string that follows /g/ in the URL',\n            'exampleValue' => 'governance',\n            'required' => true\n        ],\n        'account' => [\n            'name' => 'Account id',\n            'title' => 'Some Google groups have an additional id following /a/ in the URL',\n            'exampleValue' => 'mozilla.org',\n            'required' => false\n        ]\n    ]];\n    const CACHE_TIMEOUT = 3600;\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://groups.google.com/a/mozilla.org/g/announce' => [\n            'account' => 'mozilla.org', 'group' => 'announce'\n        ],\n        'https://groups.google.com/g/ansible-project' => [\n            'account' => null, 'group' => 'ansible-project'\n        ],\n    ];\n\n    const XPATH_EXPRESSION_ITEM = '//div[@class=\"yhgbKd\"]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/span[@class=\"o1DPKc\"]';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/span[@class=\"WzoK\"]';\n    const XPATH_EXPRESSION_ITEM_URI = './/a[@class=\"ZLl54\"]/@href';\n    const XPATH_EXPRESSION_ITEM_AUTHOR = './/span[@class=\"z0zUgf\"][last()]';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/div[@class=\"tRlaM\"]';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = '';\n    const XPATH_EXPRESSION_ITEM_CATEGORIES = '';\n    const SETTING_FIX_ENCODING = true;\n\n    protected function getSourceUrl()\n    {\n        $source = self::URI;\n\n        $account = $this->getInput('account');\n        if ($account) {\n            $source = $source . '/a/' . $account;\n        }\n        return $source . '/g/' . $this->getInput('group');\n    }\n\n    protected function provideWebsiteContent()\n    {\n        return defaultLinkTo(getContents($this->getSourceUrl()), self::URI);\n    }\n\n    const URL_REGEX = '#^https://groups.google.com(?:/a/(?<account>\\S+))?(?:/g/(?<group>\\S+))#';\n\n    public function detectParameters($url)\n    {\n        $params = [];\n        if (preg_match(self::URL_REGEX, $url, $matches)) {\n            $params['group'] = $matches['group'];\n            $params['account'] = $matches['account'];\n            return $params;\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/GooglePlayStoreBridge.php",
    "content": "<?php\n\nclass GooglePlayStoreBridge extends BridgeAbstract\n{\n    const NAME = 'Google Play Store';\n    const URI = 'https://play.google.com/store/apps';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns the most recent version of an app with its changelog';\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://play.google.com/store/apps/details?id=com.ichi2.anki' => [\n            'id' => 'com.ichi2.anki'\n        ]\n    ];\n\n    const PARAMETERS = [[\n        'id' => [\n            'name' => 'Application ID',\n            'exampleValue' => 'com.ichi2.anki',\n            'required' => true\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $id = $this->getInput('id');\n        $url = 'https://play.google.com/store/apps/details?id=' . $id;\n        $html = getSimpleHTMLDOM($url);\n\n        $updatedAtElement = $html->find('div.TKjAsc div', 2);\n        // Updated onSep 27, 2023\n        $updatedAt = $updatedAtElement->plaintext;\n        $description = $html->find('div.bARER', 0);\n\n        $item = [];\n        $item['uri'] = $url;\n        $item['title'] = $id . ' ' . $updatedAt;\n        $item['content'] = $description->innertext ?? '';\n        $item['uid'] = 'GooglePlayStoreBridge/' . $updatedAt;\n        $this->items[] = $item;\n    }\n\n    public function detectParameters($url)\n    {\n        // Example: https://play.google.com/store/apps/details?id=com.ichi2.anki\n\n        $params = [];\n        $regex = '/^(https?:\\/\\/)?play\\.google\\.com\\/store\\/apps\\/details\\?id=([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['id'] = urldecode($matches[2]);\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/GoogleScholarBridge.php",
    "content": "<?php\n\nclass GoogleScholarBridge extends BridgeAbstract\n{\n    const NAME = 'Google Scholar';\n    const URI = 'https://scholar.google.com/';\n    const DESCRIPTION = 'Search for publications or follow authors on Google Scholar.';\n    const MAINTAINER = 'nicholasmccarthy';\n    const CACHE_TIMEOUT = 86400; // 24h\n\n    const PARAMETERS = [\n        'user' => [\n            'userId' => [\n                'name' => 'User ID',\n                'exampleValue' => 'qc6CJjYAAAAJ',\n                'required' => true\n            ]\n        ],\n        'query' => [\n            'q' => [\n                'name' => 'Search Query',\n                'title' => 'Search Query',\n                'required' => true,\n                'exampleValue' => 'machine learning'\n            ],\n            'cites' => [\n                'name' => 'Cites',\n                'required' => false,\n                'default' => '',\n                'exampleValue' => '1275980731835430123',\n                'title' => 'Parameter defines unique ID for an article to trigger Cited By searches. Usage of cites\n                will bring up a list of citing documents in Google Scholar. Example value: cites=1275980731835430123.\n                Usage of cites and q parameters triggers search within citing articles.'\n            ],\n            'language' => [\n                'name' => 'Language',\n                'required' => false,\n                'default' => '',\n                'exampleValue' => 'en',\n                'title' => 'Parameter defines the language to use for the Google Scholar search. '\n            ],\n            'minCitations' => [\n                'name' => 'Minimum Citations',\n                'required' => false,\n                'type' => 'number',\n                'default' => '0',\n                'title' => 'Parameter defines the minimum number of citations in order for the results to be included.'\n            ],\n            'sinceYear' => [\n                'name' => 'Since Year',\n                'required' => false,\n                'type' => 'number',\n                'default' => '0',\n                'title' => 'Parameter defines the year from which you want the results to be included.'\n            ],\n            'untilYear' => [\n                'name' => 'Until Year',\n                'required' => false,\n                'type' => 'number',\n                'default' => '0',\n                'title' => 'Parameter defines the year until which you want the results to be included.'\n            ],\n            'sortBy' => [\n                'name' => 'Sort By Date',\n                'type' => 'checkbox',\n                'default' => false,\n                'title' => 'Parameter defines articles added in the last year, sorted by date. Alternatively sorts\n                by relevance. This overrides Since-Until Year values.',\n            ],\n            'includePatents' => [\n                'name' => 'Include Patents',\n                'type' => 'checkbox',\n                'default' => false,\n                'title' => 'Include Patents',\n            ],\n            'includeCitations' => [\n                'name' => 'Include Citations',\n                'type' => 'checkbox',\n                'default' => true,\n                'title' => 'Parameter defines whether you would like to include citations or not.',\n            ],\n            'reviewArticles' => [\n                'name' => 'Only Review Articles',\n                'type' => 'checkbox',\n                'default' => false,\n                'title' => 'Parameter defines whether you would like to show only review articles or not (these\n                articles consist of topic reviews, or discuss the works or authors you have searched for).',\n            ],\n            'numResults' => [\n                'name' => 'Number of Results (max 20)',\n                'required' => false,\n                'type' => 'number',\n                'default' => 10,\n                'exampleValue' => 10,\n                'title' => 'Number of results to return'\n            ]\n        ],\n    ];\n\n\n    public function getIcon()\n    {\n        return 'https://scholar.google.com/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'user':\n                $userId = $this->getInput('userId');\n                $uri = self::URI . '/citations?hl=en&view_op=list_works&sortby=pubdate&user=' . $userId;\n                $html = getSimpleHTMLDOM($uri);\n\n                $publications = $html->find('tr[class=\"gsc_a_tr\"]');\n\n                foreach ($publications as $publication) {\n                    $articleUrl = self::URI . htmlspecialchars_decode($publication->find('a[class=\"gsc_a_at\"]', 0)->href);\n                    $articleTitle = $publication->find('a[class=\"gsc_a_at\"]', 0)->plaintext;\n\n                    # fetch the article itself to extract rest of content\n                    $contentArticle = getSimpleHTMLDOMCached($articleUrl);\n                    $articleEntries = $contentArticle->find('div[class=\"gs_scl\"]');\n\n                    $articleDate = '';\n                    $articleAbstract = '';\n                    $articleAuthor = '';\n                    $content = '';\n\n                    foreach ($articleEntries as $entry) {\n                        $field = $entry->find('div[class=\"gsc_oci_field\"]', 0)->plaintext;\n                        $value = $entry->find('div[class=\"gsc_oci_value\"]', 0)->plaintext;\n\n                        if ($field == 'Publication date') {\n                            $articleDate = $value;\n                        } elseif ($field == 'Description') {\n                            $articleAbstract = $value;\n                        } elseif ($field == 'Authors') {\n                            $articleAuthor = $value;\n                        } elseif ($field == 'Scholar articles' || $field == 'Total citations') {\n                            continue;\n                        } else {\n                            $content = $content . $field . ': ' . $value . '<br><br>';\n                        }\n                    }\n\n                    $content = $content . $articleAbstract;\n\n                    $item = [];\n\n                    $item['title'] = $articleTitle;\n                    $item['uri'] = $articleUrl;\n                    $item['timestamp'] = strtotime($articleDate);\n                    $item['author'] = $articleAuthor;\n                    $item['content'] = $content;\n\n                    $this->items[] = $item;\n\n                    if (count($this->items) >= 10) {\n                        break;\n                    }\n                }\n                break;\n            case 'query':\n                $query = urlencode($this->getInput('q'));\n                $cites = $this->getInput('cites');\n                $language = $this->getInput('language');\n                $sinceYear = $this->getInput('sinceYear');\n                $untilYear = $this->getInput('untilYear');\n                $minCitations = (int)$this->getInput('minCitations');\n                $includeCitations = $this->getInput('includeCitations');\n                $includePatents = $this->getInput('includePatents');\n                $reviewArticles = $this->getInput('reviewArticles');\n                $sortBy = $this->getInput('sortBy');\n                $numResults = $this->getInput('numResults');\n\n                # Build URI\n                $uri = self::URI . 'scholar?q=' . $query;\n                $uri .= $sinceYear != 0 ? '&as_ylo=' . $sinceYear : '';\n                $uri .= $untilYear != 0 ? '&as_yhi=' . $untilYear : '';\n                $uri .= $language != '' ? '&hl=' . $language : '';\n                $uri .= $includePatents ? '&as_vis=7' : '&as_vis=0';\n                $uri .= $includeCitations ? '&as_vis=0' : ($includePatents ? '&as_vis=1' : '');\n                $uri .= $reviewArticles ? '&as_rr=1' : '';\n                $uri .= $sortBy ? '&scisbd=1' : '';\n                $uri .= $numResults ? '&num=' . $numResults : '';\n\n                $html = getSimpleHTMLDOM($uri);\n\n                $publications = $html->find('div[class=\"gs_r gs_or gs_scl\"]');\n\n                foreach ($publications as $publication) {\n                    $articleTitleElement = $publication->find('h3[class=\"gs_rt\"]', 0);\n                    $articleUrl = $articleTitleElement->find('a', 0)->href;\n                    $articleTitle = $articleTitleElement->plaintext;\n\n                    // Break the loop if 'Check for Updates' is found in the article title\n                    if (strpos($articleTitle, 'Check for updates') !== false) {\n                        break;\n                    }\n\n                    $articleDateElement = $publication->find('div[class=\"gs_a\"]', 0);\n                    $articleDate = $articleDateElement ? $articleDateElement->plaintext : '';\n\n                    $articleAbstractElement = $publication->find('div[class=\"gs_rs\"]', 0);\n                    $articleAbstract = $articleAbstractElement ? $articleAbstractElement->plaintext : '';\n\n                    $articleAuthorElement = $publication->find('div[class=\"gs_a\"]', 0);\n                    $articleAuthor = $articleAuthorElement ? $articleAuthorElement->plaintext : '';\n\n                    $bottomRowElement = $publication->find('div[class=\"gs_fl\"]', 0);\n\n                    $item = [\n                        'title' => $articleTitle,\n                        'uri' => $articleUrl,\n                        'timestamp' => strtotime($articleDate),\n                        'author' => $articleAuthor,\n                        'content' => $articleAbstract\n                    ];\n\n                    switch ($this->queriedContext) {\n                        case 'user':\n                            $this->items[] = $item;\n                            break;\n                        case 'query':\n                            $citedBy = 0;\n                            if ($bottomRowElement) {\n                                $anchorTags = $bottomRowElement->find('a');\n                                foreach ($anchorTags as $anchorTag) {\n                                    if (strpos($anchorTag->plaintext, 'Cited') !== false) {\n                                        $parts = explode('Cited by ', $anchorTag->plaintext);\n                                        if (isset($parts[1])) {\n                                            $citedBy = (int)$parts[1];\n                                        }\n                                        break;\n                                    }\n                                }\n                            }\n                            if ($citedBy >= $minCitations) {\n                                $this->items[] = $item;\n                            }\n                            break;\n                    }\n                }\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GoogleSearchBridge.php",
    "content": "<?php\n\nclass GoogleSearchBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sebsauvage';\n    const NAME = 'Google search';\n    const URI = 'https://www.google.com/';\n    const CACHE_TIMEOUT = 60 * 30; // 30m\n    const DESCRIPTION = 'Returns max 100 results from the past year.';\n\n    const PARAMETERS = [[\n        'q' => [\n            'name' => 'keyword',\n            'required' => true,\n            'exampleValue' => 'rss-bridge',\n        ],\n        'verbatim' => [\n            'name' => 'Verbatim',\n            'type' => 'checkbox',\n            'title' => 'Use literal keyword(s) without making improvements',\n        ],\n    ]];\n\n    public function collectData()\n    {\n        // todo: wrap this in try..catch because 429 too many requests happens a lot\n        $dom = getSimpleHTMLDOM($this->getURI(), ['Accept-language: en-US']);\n        if (!$dom) {\n            throwServerException('No results for this query.');\n        }\n        $result = $dom->find('div[id=res]', 0);\n\n        if (!$result) {\n            return;\n        }\n\n        foreach ($result->find('div[class~=g]') as $element) {\n            $item = [];\n\n            $url = $element->find('a[href]', 0)->href;\n            $item['uri'] = htmlspecialchars_decode($url);\n            $item['title'] = $element->find('h3', 0)->plaintext;\n\n            $resultDom = $element->find('div[data-content-feature=1]', 0);\n            if ($resultDom) {\n                // Split by — or ·\n                $resultParts = preg_split('/( — | · )/', $resultDom->plaintext);\n                $resultDate = trim($resultParts[0]);\n                $resultContent = trim($resultParts[1] ?? '');\n            } else {\n                // Some search results don't have this particular dom identifier\n                $resultDate = null;\n                $resultContent = null;\n            }\n\n            if ($resultDate) {\n                try {\n                    $createdAt = new \\DateTime($resultDate);\n                    // Set to midnight for consistent datetime\n                    $createdAt->setTime(0, 0);\n                    $item['timestamp'] = $createdAt->format('U');\n                } catch (\\Exception $e) {\n                    $item['timestamp'] = 0;\n                }\n            } else {\n                $item['timestamp'] = 0;\n            }\n\n            if ($resultContent) {\n                $item['content'] = $resultContent;\n            }\n\n            $this->items[] = $item;\n        }\n        // Sort by descending date\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] <=> $a['timestamp'];\n        });\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('q')) {\n            $queryParameters = [\n                'q'         => $this->getInput('q'),\n                'hl'        => 'en',\n                'num'       => '100', // get 100 results\n                'complete'  => '0',\n                // in past year, sort by date, optionally verbatim\n                'tbs'       => 'qdr:y,sbd:1' . ($this->getInput('verbatim') ? ',li:1' : ''),\n            ];\n            return sprintf('https://www.google.com/search?%s', http_build_query($queryParameters));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('q'))) {\n            return $this->getInput('q') . ' - Google search';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/GovTrackBridge.php",
    "content": "<?php\n\nclass GovTrackBridge extends FeedExpander\n{\n    const NAME = 'GovTrack';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://www.govtrack.us/';\n    const DESCRIPTION = 'Returns posts and bills from GovTrack.us';\n    const PARAMETERS = [[\n        'feed' => [\n            'name' => 'Feed to track',\n            'type' => 'list',\n            'defaultValue' => 'posts',\n            'values' => [\n                'All Legislative Activity' => 'bill-activity',\n                'Bill Summaries' => 'bill-summaries',\n                'Legislation Coming Up' => 'coming-up',\n                'Major Legislative Activity' => 'major-bill-activity',\n                'New Bills and Resolutions' => 'introduced-bills',\n                'New Laws' => 'enacted-bills',\n                'News from Us' => 'posts'\n            ]\n        ],\n        'limit' => self::LIMIT\n    ]];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit') ?? 15;\n        if ($this->getInput('feed') == 'posts') {\n            $this->collectExpandableDatas($this->getURI() . '.rss', $limit);\n        } else {\n            $this->collectEvent($this->getURI(), $limit);\n        }\n    }\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $html = defaultLinkTo($html, parent::getURI());\n\n        $item['categories'] = [$html->find('.breadcrumb-item', 1)->plaintext];\n        $content = $html->find('#content .col-md', 1);\n        $item['author'] = explode(' by ', $content->firstChild()->plaintext)[1];\n        $content->removeChild($content->firstChild());\n        $item['content'] = $content->innertext;\n\n        return $item;\n    }\n\n    private function collectEvent($uri, $limit)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n        preg_match('/\"csrfmiddlewaretoken\" value=\"(.*)\"/', $html, $preg);\n        $header = [\n            \"cookie: csrftoken=$preg[1]\",\n            \"x-csrftoken: $preg[1]\",\n            'referer: ' . parent::getURI(),\n        ];\n        preg_match('/var selected_feed = \"(.*)\";/', $html, $preg);\n        $opt = [ CURLOPT_POSTFIELDS => [\n            'count' => $limit,\n            'feed' => $preg[1]\n        ]];\n\n        $html = getContents(parent::getURI() . 'events/_load_events', $header, $opt);\n        $html = defaultLinkTo(str_get_html($html), parent::getURI());\n\n        foreach ($html->find('.tracked_event') as $event) {\n            $bill = $event->find('.event_title a, .event_body a', 0);\n            $date = explode(' ', $event->find('.event_date', 0)->plaintext);\n            preg_match('/Sponsor:(.*)\\n/', $event->plaintext, $preg);\n\n            $item = [\n                'author' => $preg[1] ?? '',\n                'content' => $event->find('td', 1)->innertext,\n                'enclosures' => [$event->find('img', 0)->src],\n                'timestamp' => strtotime(implode(' ', array_slice($date, 2))),\n                'title' => explode(': ', $bill->innertext)[0],\n                'uri' => $bill->href,\n            ];\n\n            foreach ($event->find('.event_title, .event_type span') as $tag) {\n                if (!$tag->find('a', 0)) {\n                    $item['categories'][] = $tag->plaintext;\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if ($this->getInput('feed') != null) {\n            $name .= ' - ' . $this->getKey('feed');\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('feed') == 'posts') {\n            $url = parent::getURI() . $this->getInput('feed');\n        } else {\n            $url = parent::getURI() . 'events/' . $this->getInput('feed');\n        }\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/GrandComicsDatabaseBridge.php",
    "content": "<?php\n\nclass GrandComicsDatabaseBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'Grand Comics Database';\n    const URI = 'https://www.comics.org/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Returns the latest comics added to a series timeline';\n    const PARAMETERS = [ [\n        'series' => [\n            'name' => 'Series id (from the timeline URL)',\n            'required' => true,\n            'exampleValue' => '63051',\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $url = self::URI . 'series/' . $this->getInput('series') . '/details/timeline/';\n        $html = getSimpleHTMLDOM($url);\n\n        $table = $html->find('table', 0);\n        $list = array_reverse($table->find('[class^=row_even]'));\n        $seriesName = $html->find('span[id=series_name]', 0)->innertext;\n\n        // Get row headers\n        $rowHeaders = $table->find('th');\n        foreach ($list as $article) {\n            // Skip empty rows\n            $emptyRow = $article->find('td.empty_month');\n            if (count($emptyRow) != 0) {\n                continue;\n            }\n\n            $rows = $article->find('td');\n            $key_date = $rows[0]->innertext;\n\n            // Get URL too\n            $uri = 'https://www.comics.org' . $article->find('a')[0]->href;\n\n            // Build content\n            $content = '';\n            for ($i = 0; $i < count($rowHeaders); $i++) {\n                $headerItem = $rowHeaders[$i]->innertext;\n                $rowItem = $rows[$i]->innertext;\n                $content = $content . $headerItem . ': ' . $rowItem . '<br/>';\n            }\n\n            // Build final item\n            $content = str_replace('href=\"/', 'href=\"' . static::URI, $content);\n            $item = [];\n            $item['title'] = $seriesName . ' - ' . $key_date;\n            $item['timestamp'] = strtotime($key_date);\n            $item['content'] = str_get_html($content);\n            $item['uri'] = $uri;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/GroupBundNaturschutzBridge.php",
    "content": "<?php\n\nclass GroupBundNaturschutzBridge extends XPathAbstract\n{\n    const NAME = 'BUND Naturschutz in Bayern e.V. - Kreisgruppen';\n    const URI = 'https://www.bund-naturschutz.de/ueber-uns/organisation/kreisgruppen-ortsgruppen';\n    const DESCRIPTION = 'Returns the latest news from specified BUND Naturschutz in Bayern e.V. local group (Germany)';\n    const MAINTAINER = 'dweipert';\n\n    const PARAMETERS = [\n        [\n            'group' => [\n                'name' => 'Group',\n                'type' => 'list',\n                'values' => [\n                    // 'Aichach-Friedberg' => 'bn-aic.de', # non-uniform page\n                    'Altötting' => 'altoetting',\n                    'Amberg-Sulzbach' => 'amberg-sulzbach',\n                    'Ansbach' => 'ansbach',\n                    'Aschaffenburg' => 'aschaffenburg',\n                    'Augsburg' => 'augsburg',\n                    'Bad Kissingen' => 'bad-kissingen',\n                    'Bad Tölz' => 'bad-toelz',\n                    'Bamberg' => 'bamberg',\n                    'Bayreuth' => 'bayreuth', # single entry # different layout\n                    'Berchtesgadener Land' => 'berchtesgadener-land',\n                    'Cham' => 'cham',\n                    // 'Coburg' => 'coburg', # no real entries # different layout\n                    'Dachau' => 'dachau',\n                    'Deggendorf' => 'Deggendorf',\n                    'Dillingen' => 'dillingen',\n                    'Dingolfing-Landau' => 'dingolfing-landau',\n                    'Donau-Ries' => 'donauries',\n                    'Ebersberg' => 'ebersberg',\n                    'Eichstätt' => 'eichstaett', # single entry since 2020\n                    'Erding' => 'erding',\n                    'Erlangen' => 'erlangen',\n                    'Forchheim' => 'forchheim',\n                    'Freising' => 'freising',\n                    'Freyung-Grafenau' => 'freyung-grafenau',\n                    'Fürstenfeldbruck' => 'fuerstenfeldbruck',\n                    'Fürth-Land' => 'fuerth-land',\n                    'Fürth-Stadt' => 'fuerth',\n                    'Garmisch-Partenkirchen' => 'garmisch-partenkirchen',\n                    'Günzburg' => 'guenzburg',\n                    'Hassberge' => 'hassberge',\n                    'Höchstadt-Herzogenaurach' => 'hoechstadt-herzogenaurach',\n                    // 'Hof' => 'kreisgruppehof.bund-naturschutz.com', # non-uniform page\n                    'Ingolstadt' => 'ingolstadt',\n                    'Kelheim' => 'kelheim',\n                    'Kempten' => 'kempten',\n                    'Kitzingen' => 'kitzingen',\n                    'Kronach' => 'kronach',\n                    'Kulmbach' => 'kulmbach',\n                    'Landsberg' => 'landsberg',\n                    'Landshut' => 'landshut',\n                    'Lichtenfeld' => 'lichtenfels',\n                    'Lindau' => 'lindau',\n                    'Main-Spessart' => 'main-spessart',\n                    'Memmingen-Unterallgäu' => 'memmingen-unterallgaeu',\n                    'Miesbach' => 'miesbach',\n                    'Miltenberg' => 'miltenberg',\n                    'Mühldorf am Inn' => 'muehldorf',\n                    // 'München' => 'bn-muenchen.de', # non-uniform page\n                    'Neu-Ulm' => 'neu-ulm',\n                    'Neuburg-Schrobenhausen' => 'neuburg-schrobenhausen',\n                    'Neumarkt' => 'neumarkt',\n                    'Neustadt/Aisch-Bad Windsheim' => 'neustadt-aisch',\n                    'Neustadt/Waldnaab-Weiden' => 'neustadt-weiden',\n                    'Nürnberg Stadt' => 'nuernberg-stadt',\n                    'Nürnberger Land' => 'nuernberger-land',\n                    'Ostallgäu-Kaufbeuren' => 'Ostallgäu-Kaufbeuren',\n                    'Passau' => 'passau',\n                    'Pfaffenhofen/Ilm' => 'pfaffenhofen',\n                    'Regen' => 'regen',\n                    'Regensburg' => 'regensburg',\n                    'Rhön-Grabfeld' => 'rhoen-grabfeld',\n                    'Rosenheim' => 'rosenheim',\n                    'Roth' => 'roth',\n                    'Rottal-Inn' => 'rottal-inn',\n                    'Schwabach' => 'schwabach',\n                    'Schwandorf' => 'schwandorf',\n                    'Schweinfurt' => 'schweinfurt',\n                    'Starnberg' => 'starnberg',\n                    'Straubing-Bogen' => 'straubing',\n                    'Tirschenreuth' => 'tirschenreuth',\n                    'Traunstein' => 'traunstein',\n                    'Weilheim-Schongau' => 'weilheim-schongau',\n                    'Weißenburg-Gunzenhausen' => 'weissenburg-gunzenhausen',\n                    'Wunsiedel' => 'wunsiedel',\n                    'Würzburg' => 'wuerzburg',\n                ],\n            ],\n        ],\n    ];\n\n    const XPATH_EXPRESSION_ITEM = '//div[@itemtype=\"http://schema.org/Article\"]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/*[@itemprop=\"headline\"]';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/*[@itemprop=\"description\"]/text()';\n    const XPATH_EXPRESSION_ITEM_URI = './/a/@href';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/*[@itemprop=\"datePublished\"]/@datetime';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@src';\n\n    protected function getSourceUrl()\n    {\n        return 'https://' . $this->getInput('group') . '.bund-naturschutz.de/aktuelles';\n    }\n}\n"
  },
  {
    "path": "bridges/HDWallpapersBridge.php",
    "content": "<?php\n\nclass HDWallpapersBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'nel50n';\n    const NAME = 'HD Wallpapers';\n    const URI = 'https://www.hdwallpapers.in/';\n    const CACHE_TIMEOUT = 43200; //12h\n    const DESCRIPTION = 'Returns the latests wallpapers from HDWallpapers';\n\n    const PARAMETERS = [ [\n        'c' => [\n            'name' => 'category',\n            'required' => true,\n            'defaultValue' => 'latest_wallpapers'\n        ],\n        'm' => [\n            'name' => 'max number of wallpapers'\n        ],\n        'r' => [\n            'name' => 'resolution',\n            'required' => true,\n            'defaultValue' => 'HD',\n            'title' => 'e.g=HD OR 1920x1200 OR 1680x1050'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $category = $this->getInput('c');\n        if (strrpos($category, 'wallpapers') !== strlen($category) - strlen('wallpapers')) {\n            $category .= '-desktop-wallpapers';\n        }\n\n        $num = 0;\n        $max = $this->getInput('m') ?: 14;\n        $lastpage = 1;\n\n        for ($page = 1; $page <= $lastpage; $page++) {\n            $link = self::URI . $category . '/page/' . $page;\n            $html = getSimpleHTMLDOM($link);\n\n            if ($page === 1) {\n                preg_match('/page\\/(\\d+)$/', $html->find('.pagination a', -2)->href, $matches);\n                $lastpage = min($matches[1], ceil($max / 14));\n            }\n\n            $html = defaultLinkTo($html, self::URI);\n\n            foreach ($html->find('.wallpapers .wall a') as $element) {\n                $thumbnail = $element->find('img', 0);\n\n                $search = [self::URI, 'wallpapers.html'];\n                $replace = [self::URI . 'download/', $this->getInput('r') . '.jpg'];\n\n                $item = [];\n                $item['uri'] = str_replace($search, $replace, $element->href);\n\n                $item['timestamp'] = time();\n                $item['title'] = $element->find('em1', 0)->text();\n                $item['content'] = $item['title']\n                . '<br><a href=\"'\n                . $item['uri']\n                . '\"><img src=\"'\n                . $thumbnail->src\n                . '\" /></a>';\n\n                $item['enclosures'] = [$item['uri']];\n                $this->items[] = $item;\n\n                $num++;\n                if ($num >= $max) {\n                    break 2;\n                }\n            }\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('c')) && !is_null($this->getInput('r'))) {\n            return 'HDWallpapers - '\n            . str_replace(['__', '_'], [' & ', ' '], $this->getInput('c'))\n            . ' ['\n            . $this->getInput('r')\n            . ']';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/HackerNewsUserThreadsBridge.php",
    "content": "<?php\n\nclass HackerNewsUserThreadsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'rakoo';\n    const NAME = 'Hacker News User Threads';\n    const URI = 'https://news.ycombinator.com';\n    const CACHE_TIMEOUT = 7200; // 2 hours\n    const DESCRIPTION = 'Hacker News threads for a user (at https://news.ycombinator.com/threads?id=xxx)';\n    const PARAMETERS = [ [\n        'user' => [\n            'name' => 'User',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'nixcraft',\n            'title' => 'User whose threads you want to see'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = 'https://news.ycombinator.com/threads?id=' . $this->getInput('user');\n        $html = getSimpleHTMLDOM($url);\n\n        $item = [];\n        $articles = $html->find('tr[class*=\"comtr\"]');\n        $story = '';\n\n        foreach ($articles as $element) {\n            $id = $element->getAttribute('id');\n            $item['uri'] = 'https://news.ycombinator.com/item?id=' . $id;\n\n            $author = $element->find('span[class*=\"comhead\"]', 0)->find('a[class=\"hnuser\"]', 0)->innertext;\n            $newstory = $element->find('span[class*=\"comhead\"]', 0)->find('span[class=\"onstory\"]', 0);\n            if (count($newstory->find('a')) > 0) {\n                $story = $newstory->find('a', 0)->innertext;\n            }\n\n            $title = $author . ' | on ' . $story;\n            $item['author'] = $author;\n            $item['title'] = $title;\n            $item['timestamp'] = $element->find('span[class*=\"age\"]', 0)->find('a', 0)->innertext;\n            $contentDiv = $element->find('div[class*=\"commtext\"]', 0);\n            if (!empty($contentDiv)) {\n                $item['content'] = $contentDiv->innertext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/HanimeBridge.php",
    "content": "<?php\n\nclass HanimeBridge extends BridgeAbstract\n{\n    const NAME = 'Hanime';\n    const URI = 'https://hanime.tv';\n    const DESCRIPTION = 'Return recent Hanime.tv hentai video uploads';\n    const MAINTAINER = 'Miicat_47';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM('https://hanime.tv/')->find('.htv-carousel__scrolls', 0);\n        $html = defaultLinkTo($html, $this->getURI());\n\n        foreach ($html->find('.item') as $video) {\n            $item = [];\n\n            $video_uri = $video->find('.no-touch', 0)->href;\n\n            // Get video cover url\n            // Use regex to get video_uri title\n            $exp = '/\\/([A-Za-z\\-0-9]+$)/m';\n            preg_match_all($exp, $video_uri, $matches, PREG_SET_ORDER, 0);\n            // Use the video title as name for the cover file\n            $cover = 'https://cdn.statically.io/img/akidoo.top/images/covers/' . $matches[0][1] . '-cv1.png';\n\n            $item['uri'] = $video_uri;\n            $item['title'] = $video->find('.hv-title', 0)->plaintext;\n            $item['content'] = sprintf('<img src=\"%s\">', $cover);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/HarvardBusinessReviewBridge.php",
    "content": "<?php\n\nclass HarvardBusinessReviewBridge extends BridgeAbstract\n{\n    const NAME = 'Harvard Business Review - The Latest';\n    const MAINTAINER = 'yourname';\n    const URI = 'https://hbr.org';\n    const DESCRIPTION = 'Returns the latest articles from Harvard Business Review';\n    const CACHE_TIMEOUT = 3600; // 60min\n\n    const PARAMETERS = [[\n        'postcount' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => true,\n            'title' => 'Maximum number of items to return',\n            'defaultValue' => 6, //More requires clicking button \"Load more\"\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $url = self::URI . '/the-latest';\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('li.stream-entry') as $data) {\n            // Skip if $data is null\n            if ($data === null) {\n                continue;\n            }\n\n            try {\n                // Skip entries containing the text 'stream-ad-container'\n                if ($data->innertext !== null && strpos($data->innertext, 'stream-ad-container') !== false) {\n                    continue;\n                }\n\n                // Skip entries with class 'sponsored'\n                if ($data->hasClass('sponsored')) {\n                    continue;\n                }\n\n                $item = [];\n                $linkElement = $data->find('a', 0);\n                $titleElement = $data->find('h3.hed a', 0);\n                $authorElement = $data->find('ul.byline-list li', 0);\n                $timestampElement = $data->find('li.pubdate time', 0);\n                $contentElement = $data->find('div.dek', 0);\n\n                if ($linkElement) {\n                    $item['uri'] = self::URI . $linkElement->getAttribute('href');\n                } else {\n                    continue; // Skip this entry if no link is found\n                }\n                if ($titleElement) {\n                    $item['title'] = trim($titleElement->plaintext);\n                } else {\n                    continue; // Skip this entry if no title is found\n                }\n                if ($authorElement) {\n                    $item['author'] = trim($authorElement->plaintext);\n                } else {\n                    $item['author'] = 'Unknown'; // Default value if author is missing\n                }\n                if ($timestampElement) {\n                    $item['timestamp'] = strtotime($timestampElement->plaintext);\n                } else {\n                    $item['timestamp'] = time(); // Default to current time if timestamp is missing\n                }\n                if ($contentElement) {\n                    $item['content'] = trim($contentElement->plaintext);\n                } else {\n                    $item['content'] = ''; // Default to empty string if content is missing\n                }\n                $item['uid'] = hash('sha256', $item['title']);\n\n                $this->items[] = $item;\n\n                if (count($this->items) >= $this->getInput('postcount')) {\n                    break;\n                }\n            } catch (Exception $e) {\n                // Log the error if necessary\n                continue; // Skip to the next iteration on error\n            }\n        }\n    }\n}"
  },
  {
    "path": "bridges/HarvardHealthBlogBridge.php",
    "content": "<?php\n\nclass HarvardHealthBlogBridge extends BridgeAbstract\n{\n    const NAME = 'Harvard Health Blog';\n    const URI = 'https://www.health.harvard.edu/blog';\n    const DESCRIPTION = 'Retrieve articles from health.harvard.edu';\n    const MAINTAINER = 'tillcash';\n    const MAX_ARTICLES = 10;\n    const PARAMETERS = [\n        [\n            'image' => [\n                'name' => 'Article Image',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n            ],\n        ],\n    ];\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(self::URI);\n        $count = 0;\n\n        foreach ($dom->find('div[class=\"mb-16 md:flex\"]') as $element) {\n            if ($count >= self::MAX_ARTICLES) {\n                break;\n            }\n\n            $data = $element->find('a[class=\"hover:text-red transition-colors duration-200\"]', 0);\n            if (!$data) {\n                continue;\n            }\n\n            $url = $data->href;\n\n            $this->items[] = [\n                'content'   => $this->constructContent($url),\n                'timestamp' => $element->find('time', 0)->datetime,\n                'title'     => $data->plaintext,\n                'uid'       => $url,\n                'uri'       => $url,\n            ];\n\n            $count++;\n        }\n    }\n\n    private function constructContent($url)\n    {\n        $dom = getSimpleHTMLDOMCached($url);\n\n        $article = $dom->find('div[class*=\"content-repository-content\"]', 0);\n        if (!$article) {\n            return 'Content Not Found';\n        }\n\n        // remove article image\n        if (!$this->getInput('image')) {\n            $image = $article->find('p', 0);\n            $image->remove();\n        }\n\n        // remove ads\n        foreach ($article->find('.inline-ad') as $ad) {\n            $ad->outertext = '';\n        }\n\n        return $article->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/HashnodeBridge.php",
    "content": "<?php\n\nclass HashnodeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'liamka';\n    const NAME = 'Hashnode';\n    const URI = 'https://hashnode.com';\n    const CACHE_TIMEOUT = 3600; // 1hr\n    const DESCRIPTION = 'See trending or latest posts in Hashnode community.';\n    const LATEST_POSTS = 'https://hashnode.com/api/stories/recent?page=';\n\n    public function collectData()\n    {\n        $this->items = [];\n        for ($i = 0; $i < 5; $i++) {\n            $url = self::LATEST_POSTS . $i;\n            $content = getContents($url);\n            $array = json_decode($content, true);\n\n            if ($array['posts'] != null) {\n                foreach ($array['posts'] as $post) {\n                    $item = [];\n                    $item['title'] = $post['title'];\n                    $item['content'] = nl2br(htmlspecialchars($post['brief']));\n                    $item['timestamp'] = $post['dateAdded'];\n                    if ($post['partOfPublication'] === true) {\n                        $item['uri'] = sprintf(\n                            'https://%s.hashnode.dev/%s',\n                            $post['publication']['username'],\n                            $post['slug']\n                        );\n                    } else {\n                        $item['uri'] = sprintf('https://hashnode.com/post/%s', $post['slug']);\n                    }\n                    if (!isset($item['uri'])) {\n                        continue;\n                    }\n                    $this->items[] = $item;\n                }\n            }\n        }\n    }\n\n    public function getName()\n    {\n        return self::NAME . ': Recent posts';\n    }\n}\n"
  },
  {
    "path": "bridges/HaveIBeenPwnedBridge.php",
    "content": "<?php\n\n/**\n * Uses the API as documented here:\n * https://haveibeenpwned.com/API/v3#AllBreaches\n *\n * Gets the latest breaches by the date of the breach or when it was added to\n * HIBP.\n * */\nclass HaveIBeenPwnedBridge extends BridgeAbstract\n{\n    const NAME = 'Have I Been Pwned (HIBP)';\n    const URI = 'https://haveibeenpwned.com';\n    const DESCRIPTION = 'Returns list of Pwned websites';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n        'order' => [\n            'name' => 'Order by',\n            'type' => 'list',\n            'values' => [\n                'Breach date' => 'breachDate',\n                'Date added to HIBP' => 'dateAdded',\n            ],\n            'defaultValue' => 'dateAdded',\n        ],\n        'item_limit' => [\n            'name' => 'Limit number of returned items',\n            'type' => 'number',\n            'required' => true,\n            'defaultValue' => 20,\n        ]\n    ]];\n    const API_URI = 'https://haveibeenpwned.com/api/v3';\n\n    const CACHE_TIMEOUT = 3600;\n\n    private $breaches = [];\n\n    public function collectData()\n    {\n        $data = json_decode(getContents(self::API_URI . '/breaches'), true);\n\n        foreach ($data as $breach) {\n            $item = [];\n\n            $pwnCount = number_format($breach['PwnCount']);\n            $item['title'] = $breach['Title'] . ' - '\n                           . $pwnCount . ' breached accounts';\n            $item['dateAdded'] = $breach['AddedDate'];\n            $item['breachDate'] = $breach['BreachDate'];\n            $item['uri'] = self::URI . '/breach/' . $breach['Name'];\n\n            $item['content'] = '<p>' . $breach['Description'] . '</p>';\n            $item['content'] .= '<p>' . $this->breachType($breach) . '</p>';\n\n            $breachDate = date('j F Y', strtotime($breach['BreachDate']));\n            $addedDate = date('j F Y', strtotime($breach['AddedDate']));\n            $compData = implode(', ', $breach['DataClasses']);\n\n            $item['content'] .= <<<EOD\n<p>\n<strong>Breach date:</strong> {$breachDate}<br>\n<strong>Date added to HIBP:</strong> {$addedDate}<br>\n<strong>Compromised accounts:</strong> {$pwnCount}<br>\n<strong>Compromised data:</strong> {$compData}<br>\nEOD;\n            $item['uid'] = $breach['Name'];\n            $this->breaches[] = $item;\n        }\n\n        $this->orderBreaches();\n        $this->createItems();\n    }\n\n    private const BREACH_TYPES = [\n        'IsVerified' => [\n            false => 'Unverified breach, may be sourced from elsewhere'\n        ],\n        'IsFabricated' => [\n            true => 'Fabricated breach, likely not legitimate'\n        ],\n        'IsSensitive' => [\n            true => 'Sensitive breach, not publicly searchable'\n        ],\n        'IsRetired' => [\n            true => 'Retired breach, removed from system'\n        ],\n        'IsSpamList' => [\n            true => 'Spam list, used for spam marketing'\n        ],\n        'IsMalware' => [\n            true => 'Malware breach'\n        ],\n    ];\n\n    /**\n     * Extract data breach type(s)\n     */\n    private function breachType($breach)\n    {\n        $content = '';\n\n        foreach (self::BREACH_TYPES as $type => $message) {\n            if (isset($message[$breach[$type]])) {\n                $content .= $message[$breach[$type]] . '.<br>';\n            }\n        }\n\n        return $content;\n    }\n\n    /**\n     * Order Breaches by date added or date breached\n     */\n    private function orderBreaches()\n    {\n        $sortBy = $this->getInput('order');\n        $sort = [];\n\n        foreach ($this->breaches as $key => $item) {\n            $sort[$key] = $item[$sortBy];\n        }\n\n        array_multisort($sort, SORT_DESC, $this->breaches);\n    }\n\n    /**\n     * Create items from breaches array\n     */\n    private function createItems()\n    {\n        $limit = $this->getInput('item_limit');\n\n        if ($limit < 1) {\n            $limit = 20;\n        }\n\n        foreach ($this->breaches as $breach) {\n            $item = [];\n\n            $item['title'] = $breach['title'];\n            $item['timestamp'] = $breach[$this->getInput('order')];\n            $item['uri'] = $breach['uri'];\n            $item['content'] = $breach['content'];\n            $item['uid'] = $breach['uid'];\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/HeiseBridge.php",
    "content": "<?php\n\nclass HeiseBridge extends FeedExpander\n{\n    const MAINTAINER = 'Dreckiger-Dan';\n    const NAME = 'Heise Online';\n    const URI = 'https://heise.de/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                // source: https://www.heise.de/news-extern/news.html\n                'heise online News'\n                => 'https://www.heise.de/rss/heise-atom.xml',\n                'heise online IT'\n                => 'https://www.heise.de/rss/heise-Rubrik-IT-atom.xml',\n                'heise online Wissen'\n                => 'https://www.heise.de/rss/heise-Rubrik-Wissen-atom.xml',\n                'heise online Mobiles'\n                => 'https://www.heise.de/rss/heise-Rubrik-Mobiles-atom.xml',\n                'heise online Entertainment'\n                => 'https://www.heise.de/rss/heise-Rubrik-Entertainment-atom.xml',\n                'heise online Netzpolitik'\n                => 'https://www.heise.de/rss/heise-Rubrik-Netzpolitik-atom.xml',\n                'heise online Wirtschaft'\n                => 'https://www.heise.de/rss/heise-Rubrik-Wirtschaft-atom.xml',\n                'heise online Journal'\n                => 'https://www.heise.de/rss/heise-Rubrik-Journal-atom.xml',\n                // → https://www.heise.de/thema\n                'heise online > Thema > Open Source'\n                => 'https://www.heise.de/thema/Open-Source.xml',\n                'heise online Top-News'\n                => 'https://www.heise.de/rss/heise-top-atom.xml',\n                //'iMonitor – Internet-Störungen'\n                //=> 'https://www.heise.de/netze/netzwerk-tools/imonitor-internet-stoerungen/feed/aktuelle-meldungen/',\n                //'heise tipps+tricks 🦄💻📱'\n                //=> 'https://www.heise.de/rss/tipps-und-tricks-atom.xml',\n                'Alle Inhalte von heise+'\n                => 'https://www.heise.de/rss/heiseplus-atom.xml',\n                'heise Autos News'\n                => 'https://www.heise.de/autos/rss/news-atom.xml',\n                'heise Developer - Neueste Meldungen'\n                => 'https://www.heise.de/developer/rss/news-atom.xml',\n                'Der Dotnet-Doktor'\n                => 'https://www.heise.de/developer/rss/dotnet-doktor-blog-atom.xml',\n                'the next big thing'\n                => 'https://www.heise.de/developer/rss/next-big-thing-blog-atom.xml',\n                'Tales from the Web side'\n                => 'https://www.heise.de/developer/rss/tales-from-the-web-side-blog-atom.xml',\n                'Continuous Architecture'\n                => 'https://www.heise.de/developer/rss/continuous-architecture-blog-atom.xml',\n                'Der Pragmatische Architekt'\n                => 'https://www.heise.de/developer/rss/der-pragmatische-architekt-blog-atom.xml',\n                'Modernes C++'\n                => 'https://www.heise.de/developer/rss/modernes-cplusplus-blog-atom.xml',\n                'colspan'\n                => 'https://www.heise.de/developer/rss/colspan-dev-blog-atom.xml',\n                '\"Ich roll\\' dann mal aus\"'\n                => 'https://www.heise.de/developer/rss/ich-roll-dann-mal-aus-atom.xml',\n                'Well Organized'\n                => 'https://www.heise.de/developer/rss/well-organized-blog-atom.xml',\n                'Neuigkeiten von der Insel'\n                => 'https://www.heise.de/developer/rss/neuigkeiten-von-der-insel-blog-atom.xml',\n                'Von Menschen und Maschinen'\n                => 'https://www.heise.de/developer/rss/von-menschen-und-maschinen-blog-atom.xml',\n                'heise Foto'\n                => 'https://www.heise.de/foto/rss/news-atom.xml',\n                //'Top-Programme bei heise Download'\n                //=> 'https://www.heise.de/download/feed/top',\n                'heise Security'\n                => 'https://www.heise.de/security/rss/news-atom.xml',\n                'Security-Alert Meldungen'\n                => 'https://www.heise.de/security/rss/alert-news-atom.xml',\n                'c\\'t-Blog'\n                => 'https://www.heise.de/ct/blog/blog-atom.xml',\n                'c\\'t-Blog Labs'\n                => 'https://www.heise.de/ct/blog/blog-ctlabs-atom.xml',\n                'c\\'t-Blog Fair & Green IT'\n                => 'https://www.heise.de/ct/blog/blog-fgit-atom.xml',\n                'c\\'t-Blog RTFM'\n                => 'https://www.heise.de/ct/blog/blog-rtfm-atom.xml',\n                'c\\'t-Themen'\n                => 'https://www.heise.de/ct/rss/artikel-atom.xml',\n                'Make - Neueste Meldungen'\n                => 'https://www.heise.de/make/rss/hardware-hacks-atom.xml',\n                'iX News'\n                => 'https://www.heise.de/ix/rss/news-atom.xml',\n                'Mac & i'\n                => 'https://www.heise.de/mac-and-i/news-atom.xml',\n                'MIT Technology Review'\n                => 'https://www.heise.de/tr/rss/news-atom.xml',\n                'MIT Technology Review Blog'\n                => 'https://www.heise.de/tr/rss/blog-atom.xml',\n                //'Telepolis'\n                //=> 'https://www.heise.de/tp/news-atom.xml',\n                //'Aktuelle News von TechStage'\n                //=> 'https://www.techstage.de/rss.xml',\n            ]\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 5\n        ],\n        'sessioncookie' => [\n            'name' => 'Session Cookie',\n            'required' => false,\n            'title' => <<<'TITLE'\n                If you have a heise+ subscription,\n                you can enter your cookie (ssohls) here to\n                have heise+ articles displayed in full.\n                By default the cookie is 1 year valid.\n                TITLE,\n        ]\n    ]];\n    const LIMIT = 5;\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(\n            $this->getInput('category'),\n            $this->getInput('limit') ?: static::LIMIT\n        );\n    }\n\n    protected function parseItem(array $item)\n    {\n        $sessioncookie = $this->getInput('sessioncookie');\n\n        // strip rss parameter\n        $item['uri'] = explode('?', $item['uri'])[0];\n\n        // ignore TechStage articles\n        if (strpos($item['uri'], 'https://www.heise.de') !== 0) {\n            return $item;\n        }\n\n        // These cause memory leaks in simple_html_dom\n        $skipped = [\n            'https://www.heise.de/bestenlisten/testsieger/top-10-der-beste-mini-pc-mit-windows-11-im-test-amd-ryzen-dominiert/6cybv8w',\n            'https://www.heise.de/bestenlisten/testsieger/top-10-der-beste-maehroboter-ohne-begrenzungskabel-mit-kamera-gps-oder-lidar/gb7xhbg',\n        ];\n        if (in_array($item['uri'], $skipped)) {\n            $this->logger->debug(sprintf('skip: %s', $item['uri']));\n            return $item;\n        }\n\n        // abort on heise+ articles\n        if ($sessioncookie == '' && str_starts_with($item['title'], 'heise+ |')) {\n            $item['uri'] = 'https://archive.is/' . $item['uri'];\n            return $item;\n        }\n\n        $item['uri'] .= '?seite=all';\n        $article = getSimpleHTMLDOM($item['uri'], [\n            'cookie: ssohls=' . $sessioncookie\n        ]);\n\n        if ($article) {\n            $article = defaultLinkTo($article, $item['uri']);\n            $item = $this->addArticleToItem($item, $article);\n        }\n\n        $article->clear();\n\n        // Manually trigger gc to reduce memory usage\n        gc_collect_cycles();\n\n        return $item;\n    }\n\n    private function addArticleToItem($item, $article)\n    {\n        // relink URIs, as the previous a-img tags weren't recognized by this function\n        $article = defaultLinkTo($article, $item['uri']);\n\n        // remove unwanted stuff\n        foreach (\n            $article->find('figure.branding, figure.a-inline-image, a-ad, div.ho-text, a-img, .opt-in__title,\n            .a-toc__list, a-collapse, .opt-in__description, .opt-in__footnote, .opt-in__bg-image, .notice-banner__text, .notice-banner__link, .ad, .ad--inread') as $element\n        ) {\n            $element->remove();\n        }\n        foreach ($article->find('img') as $element) {\n            if (str_contains($element->alt, 'l+f')) {\n                $element->remove();\n            }\n        }\n        // reload html, as remove() is buggy\n        $article = str_get_html($article->outertext);\n\n        $header = $article->find('header.a-article-header', 0);\n        if ($header) {\n            $headerElements = $header->find('p, figure img, noscript img');\n            $item['content'] = implode('', $headerElements);\n\n            $authors = $header->find('.creator__names .creator__name');\n            if ($authors) {\n                $item['author'] = implode(', ', array_map(function ($e) {\n                    return $e->plaintext;\n                }, $authors));\n            }\n        }\n\n        //fix for embbedded youtube-videos\n        $oldlink = '';\n        foreach ($article->find('div.video__yt-container') as &$ytvideo) {\n            $ytResult = handleYoutube($ytvideo->innertext);\n            if ($ytResult) {\n                //check if video is in header or article for correct positioning\n                if (strpos($header->innertext, $ytvideo)) {\n                    $item['content'] .= $ytResult;\n                } else {\n                    $ytvideo->innertext .= $ytResult;\n                    $reloadneeded = 1;\n                }\n            }\n        }\n        if (isset($reloadneeded)) {\n            $article = str_get_html($article->outertext);\n        }\n\n        $categories = $article->find('.article-footer__topics ul.topics li.topics__item a-topic a');\n        foreach ($categories as $category) {\n            $item['categories'][] = trim($category->plaintext);\n        }\n\n        $content = $article->find('.article-content', 0);\n        if ($content) {\n            $contentElements = $content->find(\n                // phpcs:ignore\n                'p, h3, ul, ol, table, pre, noscript img, noscript iframe, a-bilderstrecke h2, a-bilderstrecke figure, a-bilderstrecke figcaption, figure figcaption.a-caption div.text'\n            );\n            $item['content'] .= implode('', $contentElements);\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/HinduTamilBridge.php",
    "content": "<?php\n\nclass HinduTamilBridge extends FeedExpander\n{\n    const NAME = 'HinduTamil';\n    const URI = 'https://www.hindutamil.in';\n    const FEED_BASE_URL = 'https://feeds.feedburner.com/Hindu_Tamil_';\n    const DESCRIPTION = 'Retrieve full articles from hindutamil.in feeds';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'name' => 'topic',\n                'type' => 'list',\n                'defaultValue' => 'crime',\n                'values' => [\n                    'Astrology' => 'astrology',\n                    'Blogs' => 'blogs',\n                    'Business' => 'business',\n                    'Cartoon' => 'cartoon',\n                    'Cinema' => 'cinema',\n                    'Crime' => 'crime',\n                    'Discussion' => 'discussion',\n                    'Education' => 'education',\n                    'Environment' => 'environment',\n                    'India' => 'india',\n                    'Lifestyle' => 'life-style',\n                    'Literature' => 'literature',\n                    'Opinion' => 'opinion',\n                    'Reporters' => 'reporters-page',\n                    'Socialmedia' => 'social-media',\n                    'Spirituals' => 'spirituals',\n                    'Sports' => 'sports',\n                    'Supplements' => 'supplements',\n                    'Tamilnadu' => 'tamilnadu',\n                    'Technology' => 'technology',\n                    'Tourism' => 'tourism',\n                    'World' => 'world',\n                ],\n            ],\n            'limit' => [\n                'name' => 'limit (max 100)',\n                'type' => 'number',\n                'defaultValue' => 10,\n            ],\n        ],\n    ];\n\n    public function getName()\n    {\n        $topic = $this->getKey('topic');\n        return self::NAME . ($topic ? ' - ' . $topic : '');\n    }\n\n    public function collectData()\n    {\n        $limit = min(100, $this->getInput('limit'));\n        $url = self::FEED_BASE_URL . $this->getInput('topic');\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem($item)\n    {\n        $dom = getSimpleHTMLDOMCached($item['uri']);\n        $content = $dom->find('#pgContentPrint', 0);\n\n        if ($content === null) {\n            return $item;\n        }\n\n        $item['timestamp'] = $this->getTimestamp($dom) ?? $item['timestamp'];\n        $item['content'] = $this->getImage($dom) . $this->cleanContent($content);\n\n        return $item;\n    }\n\n    private function cleanContent($content): string\n    {\n        foreach ($content->find('div[align=\"center\"], script, .adsplacement') as $remove) {\n            $remove->outertext = '';\n        }\n\n        return $content->innertext;\n    }\n\n    private function getTimestamp($dom): ?string\n    {\n        $date = $dom->find('meta[property=\"article:published_time\"]', 0);\n        return $date ? $date->getAttribute('content') : null;\n    }\n\n    private function getImage($dom): string\n    {\n        $image = $dom->find('meta[property=\"og:image\"]', 0);\n        return $image ? sprintf('<p><img src=\"%s\"></p>', $image->getAttribute('content')) : '';\n    }\n}\n"
  },
  {
    "path": "bridges/HonkaiImpactSeaBridge.php",
    "content": "<?php\n\nclass HonkaiImpactSeaBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'hpacleb';\n    const NAME = 'Honkai Impact SEA';\n    const URI = 'https://honkaiimpact3.hoyoverse.com/asia/en-us/news';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'News from the Honkai Impact SEA website';\n    const PARAMETERS = [\n        [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'Latest' => 403,\n                    'Info' => 404,\n                    'Updates' => 405,\n                    'Events' => 406,\n                    'Guides' => 407,\n                    'Other' => 408\n                ],\n                'defaultValue' => 403\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $category = $this->getInput('category');\n\n        $url = 'https://sg-content-static-sea.hoyoverse.com/content/bh3Sea/getContentList';\n        $url = $url . '?pageSize=10&pageNum=1&game_biz=bh3_os&channelId=' . $category;\n        $api_response = getContents($url);\n        $json_list = json_decode($api_response, true);\n\n        foreach ($json_list['data']['list'] as $json_item) {\n            $article_url = 'https://sg-content-static-sea.hoyoverse.com/content/bh3Sea/getContent?game_biz=bh3_os&';\n            $article_url = $article_url . 'contentId=' . $json_item['contentId'];\n            $article_res = getContents($article_url);\n            $article_json = json_decode($article_res, true);\n            $article_time = $article_json['data']['start_time'];\n            $timezone = 'Asia/Shanghai';\n            $article_timestamp = new DateTime($article_time, new DateTimeZone($timezone));\n\n            $item = [];\n\n            $item['title'] = $article_json['data']['title'];\n            $item['timestamp'] = $article_timestamp->format('U');\n            $item['content'] = $article_json['data']['content'];\n            $item['uri'] = $this->getArticleUri($json_item);\n            $item['id'] = $json_item['contentId'];\n\n            // Picture\n            foreach ($article_json['data']['ext'] as $ext) {\n                if ($ext['arrtName'] == '新闻封面' && count($ext['value']) == 1) {\n                    $item['enclosures'] = [$ext['value'][0]['url']];\n                    break;\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://honkaiimpact3.hoyoverse.com/favicon.ico';\n    }\n\n    private function getArticleUri($json_item)\n    {\n        return 'https://honkaiimpact3.hoyoverse.com/asia/en-us/news/' . $json_item['contentId'];\n    }\n}\n"
  },
  {
    "path": "bridges/HotUKDealsBridge.php",
    "content": "<?php\n\nclass HotUKDealsBridge extends PepperBridgeAbstract\n{\n    const NAME = 'HotUKDeals';\n    const URI = 'https://www.hotukdeals.com/';\n    const DESCRIPTION = 'Return the HotUKDeals search result using keywords';\n    const MAINTAINER = 'sysadminstory';\n    const PARAMETERS = [\n        'Search by keyword(s))' => [\n            'q' => [\n                'name' => 'Keyword(s)',\n                'type' => 'text',\n                'exampleValue' => 'lamp',\n                'required' => true\n            ],\n            'hide_expired' => [\n                'name' => 'Hide expired deals',\n                'type' => 'checkbox',\n            ],\n            'hide_local' => [\n                'name' => 'Hide local deals',\n                'type' => 'checkbox',\n                'title' => 'Hide deals in physical store',\n            ],\n            'priceFrom' => [\n                'name' => 'Minimal Price',\n                'type' => 'text',\n                'title' => 'Minmal Price in Pounds',\n                'required' => false\n            ],\n            'priceTo' => [\n                'name' => 'Maximum Price',\n                'type' => 'text',\n                'title' => 'Maximum Price in Pounds',\n                'required' => false\n            ],\n        ],\n\n        'Deals per group' => [\n            'group' => [\n                'name' => 'Group',\n                'type' => 'text',\n                'exampleValue' => 'broadband',\n                'title' => 'Group name in the URL : The group name that must be entered is present after \"https://www.hotukdeals.com/tag/\" and before any \"?\".\nExample: If the URL of the group displayed in the browser is :\nhttps://www.hotukdeals.com/tag/broadband?sortBy=temp\nThen enter :\nbroadband',\n            ],\n            'subgroups' => [\n                'name' => 'category',\n                'type' => 'text',\n                'exampleValue' => '343563',\n                'title' => 'Category number in the URL : The category number that must be entered is present after \"groups=\" and before any \"&\".\nExample: If the URL of the group displayed in the browser is :\nhttps://www.hotukdeals.com/tag/broadband?groups=343563&sortBy=new\nThen enter :\n343563',\n            ],\n            'order' => [\n                'name' => 'Order by',\n                'type' => 'list',\n                'title' => 'Sort order of deals',\n                'values' => [\n                    'From the most to the least hot deal' => '-hot',\n                    'From the most recent deal to the oldest' => '-new',\n                ]\n            ]\n        ],\n        'Discussion Monitoring' => [\n            'url' => [\n                'name' => 'Discussion URL',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Discussion URL to monitor. Ex: https://www.hotukdeals.com/discussions/title-123',\n                'exampleValue' => 'https://www.hotukdeals.com/discussions/the-hukd-lego-thread-3599357',\n                ],\n            'only_with_url' => [\n                'name' => 'Exclude comments without URL',\n                'type' => 'checkbox',\n                'title' => 'Exclude comments that does not contains URL in the feed',\n                'defaultValue' => false,\n                ]\n            ]\n\n\n    ];\n\n    public $lang = [\n        'bridge-uri' => self::URI,\n        'bridge-name' => self::NAME,\n        'context-keyword' => 'Search by keyword(s))',\n        'context-group' => 'Deals per group',\n        'context-talk' => 'Discussion Monitoring',\n        'uri-group' => 'tag/',\n        'uri-deal' => 'deals/',\n        'uri-merchant' => 'search/deals?merchant-id=',\n        'image-host' => 'https://images.hotukdeals.com/',\n        'request-error' => 'Could not request HotUKDeals',\n        'thread-error' => 'Unable to determine the thread ID. Check the URL you entered',\n        'currency' => '£',\n        'price' => 'Price',\n        'shipping' => 'Shipping',\n        'origin' => 'Origin',\n        'discount' => 'Discount',\n        'title-keyword' => 'Search',\n        'title-group' => 'Group',\n        'title-talk' => 'Discussion Monitoring',\n        'deal-type' => 'Deal Type',\n        'localdeal' => 'Local deal',\n        'context-hot' => '-hot',\n        'context-new' => '-new',\n    ];\n}\n"
  },
  {
    "path": "bridges/HumbleBundleBridge.php",
    "content": "<?php\n\nclass HumbleBundleBridge extends BridgeAbstract\n{\n    const NAME = 'Humble Bundle';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://humblebundle.com/';\n    const DESCRIPTION = 'Returns bundles from Humble Bundle.';\n    const PARAMETERS = [[\n        'type' => [\n            'name' => 'Bundle type',\n            'type' => 'list',\n            'defaultValue' => 'bundles',\n            'values' => [\n                'All' => 'bundles',\n                'Books' => 'books',\n                'Games' => 'games',\n                'Software' => 'software',\n                ]\n            ]\n    ]];\n\n    public function collectData()\n    {\n        $page = getSimpleHTMLDOMCached($this->getURI());\n        $json_text = $page->find('#landingPage-json-data', 0)->innertext;\n        $json = json_decode(html_entity_decode($json_text), true)['data'];\n\n        $products = [];\n        $types = ['books', 'games', 'software'];\n        $types = $this->getInput('type') === 'bundles' ? $types : [$this->getInput('type')];\n        foreach ($types as $type) {\n            $products = array_merge($products, $json[$type]['mosaic'][0]['products']);\n        }\n\n        foreach ($products as $element) {\n            $dom = new simple_html_dom();\n            $body = $dom->createElement('div');\n            $item = [\n                'author' => $element['author'],\n                'categories' => $element['hover_highlights'],\n                'content' => $body,\n                'timestamp' => $element['start_date|datetime'],\n                'title' => $element['tile_short_name'],\n                'uid' => $element['machine_name'],\n                'uri' => parent::getURI() . $element['product_url'],\n            ];\n\n            array_unshift($item['categories'], explode(':', $element['tile_name'])[0]);\n            array_unshift($item['categories'], $element['tile_stamp']);\n\n            $this->createChild($dom, $body, 'img', null, ['src' => $element['tile_logo']]);\n            $this->createChild($dom, $body, 'img', null, ['src' => $element['high_res_tile_image']]);\n            $this->createChild($dom, $body, 'h2', $element['short_marketing_blurb']);\n            $this->createChild($dom, $body, 'p', $element['detailed_marketing_blurb']);\n\n            $this->items[] = $this->processBundle($item, $dom, $body);\n        }\n    }\n\n    private function createChild($dom, $body, $name = null, $val = null, $args = [])\n    {\n        if ($name == null) {\n            $elem = $dom->createTextNode($val);\n        } else {\n            $elem = $dom->createElement($name, $val);\n        }\n        foreach ($args as $arg => $val) {\n            $elem->setAttribute($arg, $val);\n        }\n        $body->appendChild($elem);\n        return $elem;\n    }\n\n    private function processBundle($item, $dom, $body)\n    {\n        $page = getSimpleHTMLDOMCached($item['uri']);\n        $json_text = $page->find('#webpack-bundle-page-data', 0)->innertext;\n        $json = json_decode(html_entity_decode($json_text), true)['bundleData'];\n        $tiers = $json['tier_display_data'];\n        ksort($tiers, SORT_NATURAL);\n        # `initial` element gets sorted to the end as bt# (bundle tiers) precede it alphabetically\n        array_unshift($tiers, array_pop($tiers));\n\n        $seen = [];\n        $toc = $this->createChild($dom, $body, 'ul');\n        foreach ($tiers as $tiername => $tier) {\n            $this->createChild($dom, $body, 'h2', $tier['header'], ['id' => $tiername]);\n            $li = $this->createChild($dom, $toc, 'li');\n            $this->createChild($dom, $li, 'a', $tier['header'], ['href' => \"#$tiername\"]);\n            $toc_tier = $this->createChild($dom, $toc, 'ul');\n            foreach ($tier['tier_item_machine_names'] as $name) {\n                if (in_array($name, $seen)) {\n                    continue;\n                }\n                array_push($seen, $name);\n\n                $element = $json['tier_item_data'][$name];\n                $head = $this->createChild($dom, $body, 'h3', null, ['id' => $name]);\n                $head_link = $this->createChild($dom, $head, 'a', $element['human_name'], ['id' => $name]);\n                $li = $this->createChild($dom, $toc_tier, 'li');\n                $this->createChild($dom, $li, 'a', $element['human_name'], ['href' => \"#$name\"]);\n                $this->createChild($dom, $body, 'img', null, ['src' => $element['resolved_paths']['featured_image']]);\n                $this->createChild($dom, $body, 'img', null, ['src' => $element['resolved_paths']['preview_image']]);\n                $this->createChild($dom, $body, 'br');\n                if ($element['description_text']) {\n                    $body->appendChild(str_get_html($element['description_text'])->root);\n                }\n                if ($element['youtube_link']) {\n                    $head_link->href = 'https://youtu.be/' . $element['youtube_link'];\n                }\n                if ($element['book_preview']) {\n                    $head_link->href = $element['book_preview']['preview_file_link'];\n                }\n            }\n        }\n\n        return $item;\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        $name .= $this->getInput('type') ? ' - ' . $this->getInput('type') : '';\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI() . $this->getInput('type');\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/HuntShowdownNewsBridge.php",
    "content": "<?php\n\nclass HuntShowdownNewsBridge extends BridgeAbstract\n{\n    const NAME = 'Hunt Showdown News';\n    const MAINTAINER = 'deffy92';\n    const URI = 'https://www.huntshowdown.com';\n    const DESCRIPTION = 'Returns the latest news from HuntShowdown.com/news';\n    const BASE_URI = 'https://www.huntshowdown.com/';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM('https://www.huntshowdown.com/news/tagged/news');\n        $articles = defaultLinkTo($html, self::URI)->find('.col');\n\n        // Removing first element because it's a \"load more\" button\n        array_shift($articles);\n        foreach ($articles as $article) {\n            $item = [];\n\n            $article_title = $article->find('h3', 0)->plaintext;\n            $article_content = $article->find('p', 0)->plaintext;\n            $article_cover = $article->find('img', 0)->src;\n\n            // If there is a cover, add it to the content\n            if (!empty($article_cover)) {\n                $article_cover = '<img src=\"' . $article_cover . '\" alt=\"' . $article_title . '\"> <br/> <br/>';\n                $article_content = $article_cover . $article_content;\n            }\n\n            $item['uri'] = $article->find('a', 0)->href;\n            $item['title'] = $article_title;\n            $item['content'] = $article_content;\n            $item['enclosures'] = [$article_cover];\n            $item['timestamp'] = $article->find('span', 0)->plaintext;\n\n            $this->items[] = $item;\n        }\n    }\n}"
  },
  {
    "path": "bridges/HytaleBridge.php",
    "content": "<?php\n\nclass HytaleBridge extends BridgeAbstract\n{\n    const NAME = 'Hytale';\n    const URI = 'https://hytale.com/news';\n    const DESCRIPTION = 'All blog posts from Hytale\\'s news blog.';\n    const MAINTAINER = 'llamasblade';\n\n    const _API_URL_PUBLISHED = 'https://hytale.com/api/blog/post/published';\n    const _API_URL_BLOG_POST = 'https://hytale.com/api/blog/post/slug/';\n    const _BLOG_THUMB_URL = 'https://cdn.hytale.com/variants/blog_thumb_';\n    const _BLOG_COVER_URL = 'https://cdn.hytale.com/variants/blog_cover_';\n    const _IMG_REGEX = '#https://cdn\\.hytale\\.com/\\w+\\.(?:jpg|png)#';\n\n    public function collectData()\n    {\n        $blogPosts = json_decode(getContents(self::_API_URL_PUBLISHED));\n        $length = count($blogPosts);\n\n        for ($i = 0; $i < $length; $i += 3) {\n            $slug = $blogPosts[$i]->slug;\n\n            $blogPost = json_decode(getContents(self::_API_URL_BLOG_POST . $slug));\n\n            if (property_exists($blogPost, 'next')) {\n                $this->addBlogPost($blogPost->next);\n            }\n\n            $this->addBlogPost($blogPost);\n\n            if (property_exists($blogPost, 'previous')) {\n                $this->addBlogPost($blogPost->previous);\n            }\n        }\n\n        if (($length >= 3) && ($length % 3 == 0)) {\n            $slug = $blogPosts[$length - 1]->slug;\n\n            $blogPost = json_decode(getContents(self::_API_URL_BLOG_POST . $slug));\n\n            $this->addBlogPost($blogPost);\n        }\n    }\n\n    private function addBlogPost($blogPost)\n    {\n        $item = [];\n\n        $splittedTimestamp = explode('-', $blogPost->publishedAt);\n        $year = $splittedTimestamp[0];\n        $month = $splittedTimestamp[1];\n        $slug = $blogPost->slug;\n        $uri = 'https://hytale.com/news/' . $year . '/' . $month . '/' . $slug;\n\n        $item['uri'] = $uri;\n        $item['title'] = $blogPost->title;\n        $item['author'] = $blogPost->author;\n        $item['timestamp'] = $blogPost->publishedAt;\n        $item['content'] = $blogPost->body;\n\n        $blogCoverS3Key = $blogPost->coverImage->s3Key;\n        $coverImagesURLs = [\n            self::_BLOG_COVER_URL . $blogCoverS3Key,\n            self::_BLOG_THUMB_URL . $blogCoverS3Key,\n        ];\n\n        if (preg_match_all(self::_IMG_REGEX, $blogPost->body, $bodyImagesURLs)) {\n            $item['enclosures'] = array_merge($coverImagesURLs, $bodyImagesURLs[0]);\n        } else {\n            $item['enclosures'] = $coverImagesURLs;\n        }\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/I4wifiBridge.php",
    "content": "<?php\n\n/**\n *\n * The website i4wifi.cz is a wholesale distributor specializing in wireless, networking, and photovoltaic equipment, offering products from brands like MikroTik, Ubiquiti, and Hikvision. It provides a wide range of network solutions, technical support, and training services for businesses and professional installers in the Czech Republic and beyond.\n */\n\nclass I4wifiBridge extends BridgeAbstract\n{\n    const NAME = 'i4wifi';\n    const URI = 'https://www.i4wifi.cz';\n    const DESCRIPTION = 'Product news not only from the wireless, network and security technology sector from i4wifi.cz - Czech Republic';\n    const MAINTAINER = 'pprenghyorg';\n\n    // Only Articles are supported\n    const PARAMETERS = [\n        'Product news' => [\n        ],\n    ];\n\n    /**\n     * Fetches and processes data based on the selected context.\n     *\n     * This function retrieves the HTML content for the specified context's URI,\n     * resolves relative links within the content, and then delegates the data\n     * extraction to the appropriate method (currently only `collectNews`).\n     */\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI(), 86400);\n\n        defaultLinkTo($html, static::URI);\n\n        // Router\n        switch ($this->queriedContext) {\n            case 'Product news':\n                $this->collectNews($html);\n                break;\n        }\n    }\n\n    /**\n     * Returns the icon for the bridge.\n     *\n     * @return string The icon URL.\n     */\n    public function getURI()\n    {\n        $uri = static::URI;\n\n        // URI Router\n        switch ($this->queriedContext) {\n            case 'Product news':\n                $uri .= '/';\n                break;\n        }\n\n        return $uri;\n    }\n\n    /**\n     * Returns the name for the bridge.\n     *\n     * @return string The Name.\n     */\n    public function getName()\n    {\n        $name = static::NAME;\n\n        $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';\n\n        switch ($this->queriedContext) {\n            case 'Product news':\n                break;\n        }\n\n        return $name;\n    }\n\n    /**\n     * Parse most used date formats\n     *\n     * Basically strtotime doesn't convert dates correctly due to formats\n     * being hard to interpret. So we use the DateTime object, manually\n     * fixing dates and times (set to 00:00:00.000).\n     *\n     * We don't know the timezone, so just assume +00:00 (or whatever\n     * DateTime chooses)\n     */\n    private function fixDate($date)\n    {\n        $df = $this->parseDateTimeFromString($date);\n\n        return date_format($df, 'U');\n    }\n\n    /**\n     * Extracts the images from the article.\n     *\n     * @param object $article The article object.\n     * @return array An array of image URLs.\n     */\n    private function extractImages($article)\n    {\n        // Notice: We can have zero or more images (though it should mostly be 1)\n        $elements = $article->find('img');\n\n        $images = [];\n\n        foreach ($elements as $img) {\n            $images[] = $img->src;\n        }\n\n        return $images;\n    }\n\n    #region Articles\n\n    /**\n     * Collects uri, timestamp, title, content and images in the news articles from the HTML and transforms to rss.\n     *\n     * @param object $html The HTML object.\n     * @return void\n     */\n    private function collectNews($html)\n    {\n        $articles = $html->find('.timeline-item.timeline-item-right')\n            or throwServerException('No articles found! Layout might have changed!');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            // get uri of product\n            $item['uri'] = $this->extractNewsUri($article);\n            // Add content\n            $item['content'] = $this->extractNewsDescription($article);\n            // Add images\n            $item['title'] = $this->extractNewsTitle($article);\n            // Add images\n            $item['enclosures'] = $this->extractImages($article);\n            // Add timestamp\n            $item['timestamp'] = $this->extractNewsDate($article);\n\n            // collect sources into rss article\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extracts the URI of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The URI of the news article.\n     */\n    private function extractNewsUri($article)\n    {\n        // Return URI of the article\n        $element = $article->find('a', 0)\n            or throwServerException('Anchor not found!');\n\n        return $element->href;\n    }\n\n    /**\n     * Extracts the date of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The date of the news article.\n     */\n    private function extractNewsDate($article)\n    {\n        // Check if date is set\n        $element = $article->find('.timeline-item-info', 0)\n            or throwServerException('Date not found!');\n\n        // Format date\n        return $this->fixDate($element->plaintext);\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription($article)\n    {\n        // Extract description\n        $element = $article->find('p', 0)\n            or throwServerException('Description not found!');\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the title of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The title of the news article.\n     */\n    private function extractNewsTitle($article)\n    {\n        // Extract title\n        $element = $article->find('img', 0)\n            or throwServerException('Title not found!');\n\n        return $element->alt;\n    }\n\n    /**\n     * It attempts to recognize the date/time format in a string and create a DateTime object.\n     *\n     * It goes through the list of defined formats and tries to apply them to the input string.\n     * Returns the first successfully parsed DateTime object that matches the entire string.\n     *\n     * @param string $dateString A string potentially containing a date and/or time.\n     * @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null.\n     */\n    private function parseDateTimeFromString(string $dateString): ?DateTime\n    {\n        // List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs!\n        // Order may matter if the formats are ambiguous.\n        // It is recommended to give more specific formats (with time, full year) before more general ones.\n        $possibleFormats = [\n            // Czech formats (day.month.year)\n            'd.m.Y H:i:s',  // 10.04.2025 10:57:47\n            'j.n.Y H:i:s',  // 10.4.2025 10:57:47\n            'd. m. Y H:i:s', // 10. 04. 2025 10:57:47\n            'j. n. Y H:i:s', // 10. 4. 2025 10:57:47\n            'd.m.Y H:i',    // 10.04.2025 10:57\n            'j.n.Y H:i',    // 10.4.2025 10:57\n            'd. m. Y H:i',   // 10. 04. 2025 10:57\n            'j. n. Y H:i',   // 10. 4. 2025 10:57\n            'd.m.Y',        // 10.04.2025\n            'j.n.Y',        // 10.4.2025\n            'd. m. Y',       // 10. 04. 2025\n            'j. n. Y',       // 10. 4. 2025\n\n            // ISO 8601 and international formats (year-month-day)\n            'Y-m-d H:i:s',  // 2025-04-10 10:57:47\n            'Y-m-d H:i',    // 2025-04-10 10:57\n            'Y-m-d',        // 2025-04-10\n            'YmdHis',       // 20250410105747\n            'Ymd',          // 20250410\n\n            // American formats (month/day/year) - beware of ambiguity!\n            'm/d/Y H:i:s',  // 04/10/2025 10:57:47\n            'n/j/Y H:i:s',  // 4/10/2025 10:57:47\n            'm/d/Y H:i',    // 04/10/2025 10:57\n            'n/j/Y H:i',    // 4/10/2025 10:57\n            'm/d/Y',        // 04/10/2025\n            'n/j/Y',        // 4/10/2025\n\n            // Standard formats (including time zone)\n            DateTime::ATOM,             // example. 2025-04-10T10:57:47+02:00\n            DateTime::RFC3339,          // example. 2025-04-10T10:57:47+02:00\n            DateTime::RFC3339_EXTENDED, // example. 2025-04-10T10:57:47.123+02:00\n            DateTime::RFC2822,          // example. Thu, 10 Apr 2025 10:57:47 +0200\n            DateTime::ISO8601,          // example. 2025-04-10T105747+0200\n            'Y-m-d\\TH:i:sP',            // ISO 8601 s 'T' oddělovačem\n            'Y-m-d\\TH:i:s.uP',          // ISO 8601 s mikrosekundami\n\n            // You can add more formats as needed...\n            // e.g. 'd-M-Y' (10-Apr-2025) - requires English locale\n            // e.g. 'j. F Y' (10. abren 2025) - requires Czech locale\n        ];\n\n            // Set locale for parsing month/day names (if using F, M, l, D)\n            // E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8');\n\n        foreach ($possibleFormats as $format) {\n            // We will try to create a DateTime object from the given format\n            $dateTime = DateTime::createFromFormat($format, $dateString);\n\n            // We check that the parsing was successful AND ALSO\n            // that there were no errors or warnings during the parsing.\n            // This is important to ensure that the format matches the ENTIRE string.\n            if ($dateTime !== false) {\n                $errors = DateTime::getLastErrors();\n                if (!($errors)) {\n                    // Success! We found a valid format for the entire string.\n                    return $dateTime;\n                }\n            }\n        }\n\n        // If no format matches or parsing failed\n        return null;\n    }\n\n    #endregion\n}\n"
  },
  {
    "path": "bridges/IGNBridge.php",
    "content": "<?php\n\nclass IGNBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'IGN';\n    const URI = 'https://www.ign.com/';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'RSS Feed For IGN';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('http://feeds.ign.com/ign/all', 2);\n    }\n\n    // IGNs feed is both hidden and incomplete. This bridge tries to fix this.\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOM($item['uri']);\n\n        // List of BS elements\n        $uselessElements = [\n            '.wiki-page-tools',\n            '.feedback-container',\n            '.paging-container',\n            '.dropdown-wrapper',\n            '.mw-editsection',\n            '.jsx-4115608983',\n            '.jsx-4213937408',\n            '.commerce-container',\n            '.widget-container',\n            '.newsletter-signup-button',\n        ];\n\n        // Remove useless elements\n        foreach ($uselessElements as $uslElement) {\n            foreach ($articlePage->find($uslElement) as $jsWidget) {\n                $jsWidget->remove();\n            }\n        }\n\n        /*\n        * NOTE: Though articles and wiki/howtos have seperate styles of pages, there is no mechanism\n        * for handling them seperately as it just ignores the DOM querys which it does not find.\n        * (and their scraping)\n        */\n\n        // For Articles\n        $article = $articlePage->find('section.article-page', 0);\n        // add in verdicts in articles, reviews etc\n        foreach ($articlePage->find('div.article-section') as $element) {\n            $article = $article . $element;\n        }\n\n        // For Wikis and HowTos\n        foreach ($articlePage->find('.wiki-page') as $wikiContents) {\n            $article = $article . $wikiContents;\n        }\n\n        // Add content to feed\n        $item['content'] = $article;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/IKWYDBridge.php",
    "content": "<?php\n\nclass IKWYDBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'DevonHess';\n    const NAME = 'I Know What You Download';\n    const URI = 'https://iknowwhatyoudownload.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns torrent downloads and distributions for an IP address';\n    const PARAMETERS = [\n        [\n            'ip' => [\n                'name' => 'IP Address',\n                'exampleValue' => '8.8.8.8',\n                'required' => true\n            ],\n            'update' => [\n                'name' => 'Update last seen',\n                'type' => 'checkbox',\n                'title' => 'Update timestamp every time \"last seen\" changes'\n            ]\n        ]\n    ];\n    private $name;\n    private $uri;\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        $regex = '/^(https?:\\/\\/)?iknowwhatyoudownload\\.com\\/';\n        $regex .= '(?:en|ru)\\/peer\\/\\?ip=(\\d+\\.\\d+\\.\\d+\\.\\d+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['ip'] = urldecode($matches[2]);\n            return $params;\n        }\n\n        $regex = '/^(https?:\\/\\/)?iknowwhatyoudownload\\.com\\/';\n        $regex .= '(?:(?:en|ru)\\/peer\\/)?/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['ip'] = $_SERVER['REMOTE_ADDR'];\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        if ($this->name) {\n            return $this->name;\n        } else {\n            return self::NAME;\n        }\n    }\n\n    public function getURI()\n    {\n        if ($this->uri) {\n            return $this->uri;\n        } else {\n            return self::URI;\n        }\n    }\n\n    public function collectData()\n    {\n        $ip = $this->getInput('ip');\n        $root = self::URI . 'en/peer/?ip=' . $ip;\n        $html = getSimpleHTMLDOM($root);\n\n        $this->name = 'IKWYD: ' . $ip;\n        $this->uri = $root;\n\n        foreach ($html->find('.table > tbody > tr') as $download) {\n            $download = defaultLinkTo($download, self::URI);\n            $firstSeen = $download->find(\n                '.date-column',\n                0\n            )->innertext;\n            $lastSeen = $download->find(\n                '.date-column',\n                1\n            )->innertext;\n            $category = $download->find(\n                '.category-column',\n                0\n            )->innertext;\n            $torlink = $download->find(\n                '.name-column > div > a',\n                0\n            );\n            $tortitle = strip_tags($torlink);\n            $size = $download->find('td', 4)->innertext;\n            $title = $tortitle;\n            $author = $ip;\n\n            if ($this->getInput('update')) {\n                $timestamp = strtotime($lastSeen);\n            } else {\n                $timestamp = strtotime($firstSeen);\n            }\n\n            $uri = $torlink->href;\n\n            $content = 'IP address: <a href=\"' . $root . '\">';\n            $content .= $ip . '</a><br>';\n            $content .= 'First seen: ' . $firstSeen . '<br>';\n            $content .= ($this->getInput('update') ? 'Last seen: ' .\n                $lastSeen . '<br>' : '');\n            $content .= ($category ? 'Category: ' .\n                $category . '<br>' : '');\n            $content .= 'Title: ' . $torlink . '<br>';\n            $content .= 'Size: ' . $size;\n\n            $item = [];\n            $item['uri'] = $uri;\n            $item['title'] = $title;\n            $item['author'] = $author;\n            $item['timestamp'] = $timestamp;\n            $item['content'] = $content;\n            if ($category) {\n                $item['categories'] = [$category];\n            }\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/IPBBridge.php",
    "content": "<?php\n\nclass IPBBridge extends FeedExpander\n{\n    const NAME = 'IPB';\n    const URI = 'https://www.invisionpower.com';\n    const DESCRIPTION = 'Returns feeds for forums powered by IPB';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        [\n            'uri' => [\n                'name' => 'URI',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert forum, subforum or topic URI',\n                'exampleValue' => 'https://invisioncommunity.com/forums/forum/499-feedback-and-ideas/'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specifies the number of items to return on each request (-1: all)',\n                'defaultValue' => 10\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 3600;\n\n    // Constants for internal use\n    const FORUM_TYPE_LIST_FILTER = '.cForumTopicTable';\n    const FORUM_TYPE_TABLE_FILTER = '#forum_table';\n\n    const TOPIC_TYPE_ARTICLE = 'article';\n    const TOPIC_TYPE_DIV = 'div.post_block';\n\n    public function getURI()\n    {\n        return $this->getInput('uri') ?: parent::getURI();\n    }\n\n    public function collectData()\n    {\n        // The URI cannot be the mainpage (or anything related)\n        switch (parse_url($this->getInput('uri'), PHP_URL_PATH)) {\n            case null:\n            case '/index.php':\n                throwClientException('Provided URI is invalid!');\n                break;\n            default:\n                break;\n        }\n\n        // Sanitize the URI (because else it won't work)\n        $uri = rtrim($this->getInput('uri'), '/'); // No trailing slashes!\n\n        // Forums might provide feeds, though that's optional *facepalm*\n        // Let's check if there is a valid feed available\n        $headers = get_headers($uri . '.xml');\n\n        if ($headers[0] === 'HTTP/1.1 200 OK') { // Heureka! It's a valid feed!\n            return $this->collectExpandableDatas($uri . '.xml');\n        }\n\n        // No valid feed, so do it the hard way\n        $html = getSimpleHTMLDOM($uri);\n\n        $limit = $this->getInput('limit');\n\n        // Determine if this is a topic or a forum\n        switch (true) {\n            case $this->isTopic($html):\n                $this->collectTopic($html, $limit);\n                break;\n            case $this->isForum($html):\n                $this->collectForum($html);\n                break;\n            default:\n                throwClientException('Unknown type!');\n                break;\n        }\n    }\n\n    private function isForum($html)\n    {\n        return !is_null($html->find('div[data-controller*=forums.front.forum.forumPage]', 0))\n        || !is_null($html->find(static::FORUM_TYPE_TABLE_FILTER, 0));\n    }\n\n    private function isTopic($html)\n    {\n        return !is_null($html->find('div[data-controller*=core.front.core.commentFeed]', 0))\n        || !is_null($html->find(static::TOPIC_TYPE_DIV, 0));\n    }\n\n    private function collectForum($html)\n    {\n        // There are multiple forum designs in use (depends on version?)\n        // 1 - Uses an ordered list (based on https://invisioncommunity.com/forums)\n        // 2 - Uses a table (based on https://onehallyu.com)\n\n        switch (true) {\n            case !is_null($html->find(static::FORUM_TYPE_LIST_FILTER, 0)):\n                $this->collectForumList($html);\n                break;\n            case !is_null($html->find(static::FORUM_TYPE_TABLE_FILTER, 0)):\n                $this->collectForumTable($html);\n                break;\n            default:\n                throwClientException('Unknown forum format!');\n                break;\n        }\n    }\n\n    private function collectForumList($html)\n    {\n        foreach ($html->find(static::FORUM_TYPE_LIST_FILTER, 0)->children() as $row) {\n            // Columns: Title, Statistics, Last modified\n            $item = [];\n\n            $item['uri'] = $row->find('a', 0)->href;\n            $item['title'] = $row->find('a', 0)->title;\n            $item['author'] = $row->find('a', 1)->innertext;\n            $item['timestamp'] = strtotime($row->find('time', 0)->getAttribute('datetime'));\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function collectForumTable($html)\n    {\n        foreach ($html->find(static::FORUM_TYPE_TABLE_FILTER, 0)->children() as $row) {\n            // Columns: Icon, Content, Preview, Statistics, Last modified\n            $item = [];\n\n            // Skip header row\n            if (!is_null($row->find('th', 0))) {\n                continue;\n            }\n\n            $item['uri'] = $row->find('a', 0)->href;\n            $item['title'] = $row->find('.title', 0)->plaintext;\n            $item['timestamp'] = strtotime($row->find('[itemprop=dateCreated]', 0)->plaintext);\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function collectTopic($html, $limit)\n    {\n        // There are multiple topic designs in use (depends on version?)\n        // 1 - Uses articles (based on https://invisioncommunity.com/forums)\n        // 2 - Uses divs (based on https://onehallyu.com)\n\n        switch (true) {\n            case !is_null($html->find(static::TOPIC_TYPE_ARTICLE, 0)):\n                $this->collectTopicHistory($html, $limit, 'collectTopicArticle');\n                break;\n            case !is_null($html->find(static::TOPIC_TYPE_DIV, 0)):\n                $this->collectTopicHistory($html, $limit, 'collectTopicDiv');\n                break;\n            default:\n                throwClientException('Unknown topic format!');\n                break;\n        }\n    }\n\n    private function collectTopicHistory($html, $limit, $callback)\n    {\n        // Make sure the callback is valid!\n        if (!method_exists($this, $callback)) {\n            throwServerException('Unknown function (\\'' . $callback . '\\')!');\n        }\n\n        $next = null; // Holds the URI of the next page\n\n        while (true) {\n            $next = $this->$callback($html, is_null($next));\n\n            if (is_null($next) || ($limit > 0 && count($this->items) >= $limit)) {\n                break;\n            }\n\n            $html = getSimpleHTMLDOMCached($next);\n        }\n\n        // We might have more items than specified, remove excess\n        $this->items = array_slice($this->items, 0, $limit);\n    }\n\n    private function collectTopicArticle($html, $firstrun = true)\n    {\n        $title = $html->find('h1.ipsType_pageTitle', 0)->plaintext;\n\n        // Are we on last page?\n        if ($firstrun && !is_null($html->find('.ipsPagination', 0))) {\n            $last = $html->find('.ipsPagination_last a', 0)->{'data-page'};\n            $active = $html->find('.ipsPagination_active a', 0)->{'data-page'};\n\n            if ($active !== $last) {\n                // Load last page into memory (cached)\n                $html = getSimpleHTMLDOMCached($html->find('.ipsPagination_last a', 0)->href);\n            }\n        }\n\n        foreach (array_reverse($html->find(static::TOPIC_TYPE_ARTICLE)) as $article) {\n            $item = [];\n\n            $item['uri'] = $article->find('time', 0)->parent()->href;\n            $item['author'] = $article->find('aside a', 0)->plaintext;\n            $item['title'] = $item['author'] . ' - ' . $title;\n            $item['timestamp'] = strtotime($article->find('time', 0)->getAttribute('datetime'));\n\n            $content = $article->find('[data-role=commentContent]', 0);\n            $content = $this->scaleImages($content);\n            $item['content'] = $this->fixContent($content);\n            $item['enclosures'] = $this->findImages($article->find('[data-role=commentContent]', 0)) ?: null;\n\n            $this->items[] = $item;\n        }\n\n        // Return whatever page comes next (previous, as we add in inverse order)\n        // Do we have a previous page? (inactive means no)\n        if (!is_null($html->find('li[class=ipsPagination_prev ipsPagination_inactive]', 0))) {\n            return null; // No, or no more\n        } elseif (!is_null($html->find('li[class=ipsPagination_prev]', 0))) {\n            return $html->find('.ipsPagination_prev a', 0)->href;\n        }\n\n        return null;\n    }\n\n    private function collectTopicDiv($html, $firstrun = true)\n    {\n        $title = $html->find('h1.ipsType_pagetitle', 0)->plaintext;\n\n        // Are we on last page?\n        if ($firstrun && !is_null($html->find('.pagination', 0))) {\n            $active = $html->find('li[class=page active]', 0)->plaintext;\n\n            // There are two ways the 'last' page is displayed:\n            // - With a distict 'last' button (only if there are enough pages)\n            // - With a button for each page (use last button)\n            if (!is_null($html->find('li.last', 0))) {\n                $last = $html->find('li.last a', 0);\n            } else {\n                $last = $html->find('li[class=page] a', -1);\n            }\n\n            if ($active !== $last->plaintext) {\n                // Load last page into memory (cached)\n                $html = getSimpleHTMLDOMCached($last->href);\n            }\n        }\n\n        foreach (array_reverse($html->find(static::TOPIC_TYPE_DIV)) as $article) {\n            $item = [];\n\n            $item['uri'] = $article->find('a[rel=bookmark]', 0)->href;\n            $item['author'] = $article->find('.author', 0)->plaintext;\n            $item['title'] = $item['author'] . ' - ' . $title;\n            $item['timestamp'] = strtotime($article->find('.published', 0)->getAttribute('title'));\n\n            $content = $article->find('[itemprop=commentText]', 0);\n            $content = $this->scaleImages($content);\n            $item['content'] = $this->fixContent($content);\n\n            $item['enclosures'] = $this->findImages($article->find('.post_body', 0)) ?: null;\n\n            $this->items[] = $item;\n        }\n\n        // Return whatever page comes next (previous, as we add in inverse order)\n        // Do we have a previous page?\n        if (!is_null($html->find('li.prev', 0))) {\n            return $html->find('li.prev a', 0)->href;\n        }\n\n        return null;\n    }\n\n    /** Returns all images from the provide HTML DOM */\n    private function findImages($html)\n    {\n        $images = [];\n\n        foreach ($html->find('img') as $img) {\n            $images[] = $img->src;\n        }\n\n        return $images;\n    }\n\n    /** Sets the maximum width and height for all images */\n    private function scaleImages($html, $width = 400, $height = 400)\n    {\n        foreach ($html->find('img') as $img) {\n            $img->style = \"max-width: {$width}px; max-height: {$height}px;\";\n        }\n\n        return $html;\n    }\n\n    /** Removes all unnecessary tags and adds formatting */\n    private function fixContent($html)\n    {\n        // Restore quote highlighting\n        foreach ($html->find('blockquote') as $quote) {\n            $quote->style = <<<EOD\npadding: 0px 15px;\nborder-width: 1px 1px 1px 2px;\nborder-style: solid;\nborder-color: #ededed #e8e8e8 #dbdbdb #666666;\nbackground: #fbfbfb;\nEOD;\n        }\n\n        // Remove unnecessary tags\n        $content = strip_tags(\n            $html->innertext,\n            '<p><a><img><ol><ul><li><table><tr><th><td><strong><blockquote><br><hr><h>'\n        );\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/IdealoBridge.php",
    "content": "<?php\n\nclass IdealoBridge extends BridgeAbstract\n{\n    const NAME = 'idealo.de / idealo.fr / idealo.es';\n    const URI = 'https://www.idealo.de';\n    const DESCRIPTION = 'Tracks the price for a product on idealo.de / idealo.fr / idealo.es. Pricealarm if specific price is set';\n    const MAINTAINER = 'SebLaus';\n    const CACHE_TIMEOUT = 60 * 30; // 30 min\n    const PARAMETERS = [\n        [\n            'Link' => [\n                'name'          => 'idealo.de / idealo.fr / idealo.es Link to productpage',\n                'required'      => true,\n                'exampleValue'  => 'https://www.idealo.de/preisvergleich/OffersOfProduct/202007367_-s7-pro-ultra-roborock.html'\n            ],\n            'ExcludeNew' => [\n                'name' => 'Priceupdate: Do not track new items',\n                'type' => 'checkbox',\n                'value' => 'c'\n            ],\n            'ExcludeUsed' => [\n                'name' => 'Priceupdate: Do not track used items',\n                'type' => 'checkbox',\n                'value' => 'uc'\n            ],\n            'MaxPriceNew' => [\n                'name'          => 'Pricealarm: Maximum price for new Product',\n                'type'          => 'number'\n            ],\n            'MaxPriceUsed' => [\n                'name'          => 'Pricealarm: Maximum price for used Product',\n                'type'          => 'number'\n            ],\n        ]\n    ];\n\n    private $headers = [\n        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',\n        'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',\n        'Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.5,en;q=0.3'\n    ];\n    private $options = [\n        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n        CURLOPT_TRANSFER_ENCODING => 1,\n        CURLOPT_ACCEPT_ENCODING => 'gzip, deflate, br'\n    ];\n\n    public function getIcon()\n    {\n        return 'https://cdn.idealo.com/storage/ids-assets/ico/favicon.ico';\n    }\n\n    /**\n     * Returns the RSS Feed title when a RSS feed is rendered\n     * @return string the RSS feed Title\n     */\n    private function getFeedTitle()\n    {\n        $cacheDuration = 604800;\n        $link = $this->getInput('Link');\n        $keyTITLE = $link . 'TITLE';\n        $product = $this->loadCacheValue($keyTITLE);\n\n        // The cache does not contain the title of the bridge, we must get it and save it in the cache\n        if ($product === null) {\n            $html = getSimpleHTMLDOM($link, $this->headers, $this->options);\n            $product = $html->find('.oopStage-title', 0)->find('span', 0)->plaintext;\n            $this->saveCacheValue($keyTITLE, $product);\n        }\n\n        $MaxPriceUsed = $this->getInput('MaxPriceUsed');\n        $MaxPriceNew = $this->getInput('MaxPriceNew');\n        $titleParts = [];\n\n        $titleParts[] = $product;\n\n        // Add Max Prices to the title\n        if ($MaxPriceUsed !== null) {\n            $titleParts[] = 'Max Price Used : ' . $MaxPriceUsed . '€';\n        }\n        if ($MaxPriceNew !== null) {\n            $titleParts[] = 'Max Price New : ' . $MaxPriceNew . '€';\n        }\n\n        $title = implode(' ', $titleParts);\n\n\n        return $title . ' - ' . $this::NAME;\n    }\n\n    /**\n     * Returns the Price as float\n     * @return float rhe price converted in float\n     */\n    private function convertPriceToFloat($price)\n    {\n        // Every price is stored / displayed as \"xxx,xx €\", but PHP can't convert it as float\n\n        if ($price !== null) {\n            // Convert comma as dot\n            $price = str_replace(',', '.', $price);\n            // Remove the '€' char\n            $price = str_replace('€', '', $price);\n            // Convert to float\n            return floatval($price);\n        } else {\n            return $price;\n        }\n    }\n\n    /**\n     * Returns the Price Trend emoji\n     * @return string the Price Trend Emoji\n     */\n    private function getPriceTrend($NewPrice, $OldPrice)\n    {\n        $NewPrice = $this->convertPriceToFloat($NewPrice);\n        $OldPrice = $this->convertPriceToFloat($OldPrice);\n        // In case there is no old Price, then show no trend\n        if ($OldPrice === null || $OldPrice == 0) {\n            $trend = '';\n        } else if ($NewPrice > $OldPrice) {\n            $trend = '&#x2197;';\n        } else if ($NewPrice == $OldPrice) {\n            $trend = '&#x27A1;';\n        } else if ($NewPrice < $OldPrice) {\n            $trend = '&#x2198;';\n        }\n        return $trend;\n    }\n    public function collectData()\n    {\n        $link = $this->getInput('Link');\n        $html = getSimpleHTMLDOM($link, $this->headers, $this->options);\n\n        // Get Productname\n        $titleobj = $html->find('.oopStage-title', 0);\n        $Productname = $titleobj->find('span', 0)->plaintext;\n\n        // Create product specific Cache Keys with the link\n        $KeyNEW = $link;\n        $KeyNEW .= 'NEW';\n\n        $KeyUSED = $link;\n        $KeyUSED .= 'USED';\n\n        // Load previous Price\n        $OldPriceNew = $this->loadCacheValue($KeyNEW);\n        $OldPriceUsed = $this->loadCacheValue($KeyUSED);\n\n        // First button contains the new price. Found at oopStage-conditionButton-wrapper-text class (.)\n        $ActualNewPrice = $html->find('div[id=oopStage-conditionButton-new]', 0);\n        // Second Button contains the used product price\n        $ActualUsedPrice = $html->find('div[id=oopStage-conditionButton-used]', 0);\n        // Get the first item of the offers list to have an option if there is no New/Used Button available\n        $altPrice = $html->find('.productOffers-listItemOfferPrice', 0);\n\n        if ($ActualNewPrice) {\n            $PriceNew = $ActualNewPrice->find('strong', 0)->plaintext;\n            // Save current price\n            $this->saveCacheValue($KeyNEW, $PriceNew);\n        } else if ($altPrice) {\n            // Get price from first List item if no New/used Buttons available\n            $PriceNew = trim($altPrice->plaintext);\n            $this->saveCacheValue($KeyNEW, $PriceNew);\n        } else if (($ActualNewPrice === null || $altPrice === null) && $ActualUsedPrice !== null) {\n            // In case there is no actual New Price and a Used Price exists, then delete the previous value in the cache\n            $this->cache->delete($this->getShortName() . '_' . $KeyNEW);\n        }\n\n        // Second Button contains the used product price\n        if ($ActualUsedPrice) {\n            $PriceUsed = $ActualUsedPrice->find('strong', 0)->plaintext;\n            // Save current price\n            $this->saveCacheValue($KeyUSED, $PriceUsed);\n        } else if ($ActualUsedPrice === null && ($ActualNewPrice !== null || $altPrice !== null)) {\n            // In case there is no actual Used Price and a New Price exists, then delete the previous value in the cache\n            $this->cache->delete($this->getShortName() . '_' . $KeyUSED);\n        }\n\n        // Only continue if a price has changed and there exists a New, Used or Alternative price (sometimes no new Price _and_ Used Price are shown)\n        if (!($ActualNewPrice === null && $ActualUsedPrice === null && $altPrice === null) && ($PriceNew != $OldPriceNew || $PriceUsed != $OldPriceUsed)) {\n            // Get Product Image\n            $image = $html->find('.datasheet-cover-image', 0)->src;\n\n            $content = '';\n\n            // Generate Content\n            if (isset($PriceNew) && $this->convertPriceToFloat($PriceNew) > 0) {\n                $content .= sprintf('<p><b>Price New:</b><br>%s %s</p>', $PriceNew, $this->getPriceTrend($PriceNew, $OldPriceNew));\n                $content .= \"<p><b>Price New before:</b><br>$OldPriceNew</p>\";\n            }\n\n            if ($this->getInput('MaxPriceNew') != '') {\n                $content .= sprintf('<p><b>Max Price New:</b><br>%s,00 €</p>', $this->getInput('MaxPriceNew'));\n            }\n\n            if (isset($PriceUsed) && $this->convertPriceToFloat($PriceUsed) > 0) {\n                $content .= sprintf('<p><b>Price Used:</b><br>%s %s</p>', $PriceUsed, $this->getPriceTrend($PriceUsed, $OldPriceUsed));\n                $content .= \"<p><b>Price Used before:</b><br>$OldPriceUsed</p>\";\n            }\n\n            if ($this->getInput('MaxPriceUsed') != '') {\n                $content .= sprintf('<p><b>Max Price Used:</b><br>%s,00 €</p>', $this->getInput('MaxPriceUsed'));\n            }\n\n            $content .= \"<img src=$image>\";\n\n\n            $now = date('d/m/Y H:i');\n\n            $Pricealarm = 'Pricealarm %s: %s %s - %s';\n\n            // Currently under Max new price\n            if ($this->getInput('MaxPriceNew') != '') {\n                if (isset($PriceNew) && $this->convertPriceToFloat($PriceNew) < $this->getInput('MaxPriceNew')) {\n                    $title = sprintf($Pricealarm, 'New', $PriceNew, $Productname, $now);\n                    $item = [\n                        'title'     => $title,\n                        'uri'       => $link,\n                        'content'   => $content,\n                        'uid'       => md5($title)\n                    ];\n                    $this->items[] = $item;\n                }\n            }\n\n            // Currently under Max used price\n            if ($this->getInput('MaxPriceUsed') != '') {\n                if (isset($PriceUsed) && $this->convertPriceToFloat($PriceUsed) < $this->getInput('MaxPriceUsed')) {\n                    $title = sprintf($Pricealarm, 'Used', $PriceUsed, $Productname, $now);\n                    $item = [\n                        'title'     => $title,\n                        'uri'       => $link,\n                        'content'   => $content,\n                        'uid'       => md5($title)\n                    ];\n                    $this->items[] = $item;\n                }\n            }\n\n            // General Priceupdate Without any Max Price for new and Used product\n            if ($this->getInput('MaxPriceUsed') == '' && $this->getInput('MaxPriceNew') == '') {\n                // check if a relevant pricechange happened\n                if (\n                    (!$this->getInput('ExcludeNew') && $PriceNew != $OldPriceNew ) ||\n                    (!$this->getInput('ExcludeUsed') && $PriceUsed != $OldPriceUsed )\n                ) {\n                    $title = 'Priceupdate! ';\n\n                    if (!$this->getInput('ExcludeNew') && isset($PriceNew)) {\n                        $title .= 'NEW' . $this->getPriceTrend($PriceNew, $OldPriceNew) . ' ';\n                    }\n\n                    if (!$this->getInput('ExcludeUsed') && isset($PriceUsed)) {\n                        $title .= 'USED' . $this->getPriceTrend($PriceUsed, $OldPriceUsed) . ' ';\n                    }\n                    $title .= $Productname;\n                    $title .= ' - ';\n                    $title .= $now;\n\n                    $item = [\n                        'title'     => $title,\n                        'uri'       => $link,\n                        'content'   => $content,\n                        'uid'       => md5($title)\n                    ];\n                    $this->items[] = $item;\n                }\n            }\n        }\n    }\n\n    /**\n     * Returns the RSS Feed title according to the parameters\n     * @return string the RSS feed Tile\n     */\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case '0':\n                return $this->getFeedTitle();\n            default:\n                return parent::getName();\n        }\n    }\n\n    /**\n     * Returns the RSS Feed URL according to the parameters\n     * @return string the RSS feed URL\n     */\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case '0':\n                return $this->getInput('Link');\n            default:\n                return parent::getURI();\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/IdenticaBridge.php",
    "content": "<?php\n\nclass IdenticaBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Identica';\n    const URI = 'https://identi.ca/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns user timelines';\n\n    const PARAMETERS = [ [\n        'u' => [\n            'name' => 'username',\n            'exampleValue' => 'jxself',\n            'required' => true\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('li.major') as $dent) {\n            $item = [];\n\n            // get dent link\n            $item['uri'] = html_entity_decode($dent->find('a', 0)->href);\n\n            // extract dent timestamp\n            $item['timestamp'] = strtotime($dent->find('abbr.easydate', 0)->plaintext);\n\n            // extract dent text\n            $item['content'] = trim($dent->find('div.activity-content', 0)->innertext);\n            $item['title'] = $this->getInput('u') . ' | ' . $item['content'];\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return $this->getInput('u') . ' - Identica';\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return self::URI . urlencode($this->getInput('u'));\n        }\n\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/ImgsedBridge.php",
    "content": "<?php\n\nclass ImgsedBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sysadminstory';\n    const NAME = 'Imgsed';\n    const URI = 'https://imgsed.com/';\n    const INSTAGRAMURI = 'https://www.instagram.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns Imgsed (Instagram viewer) content by user';\n\n    const PARAMETERS = [\n        'Username' => [\n            'u' => [\n                'name' => 'username',\n                'type' => 'text',\n                'title' => 'Instagram username you want to follow',\n                'exampleValue' => 'aesoprockwins',\n                'required' => true,\n            ],\n            'post' => [\n                'name' => 'posts',\n                'type' => 'checkbox',\n                'title' => 'Show posts for this Instagram user',\n                'defaultValue' => 'checked',\n            ],\n            'story' => [\n                'name' => 'stories',\n                'type' => 'checkbox',\n                'title' => 'Show stories for this Instagram user',\n            ],\n            'tagged' => [\n                'name' => 'tagged',\n                'type' => 'checkbox',\n                'title' => 'Show tagged post for this Instagram user',\n            ],\n        ]\n    ];\n    const TEST_DETECT_PARAMETERS = [\n        'https://www.instagram.com/instagram/' => ['context' => 'Username', 'u' => 'instagram', 'post' => 'on', 'story' => 'on', 'tagged' => 'on'],\n        'https://instagram.com/instagram/' => ['context' => 'Username', 'u' => 'instagram', 'post' => 'on', 'story' => 'on', 'tagged' => 'on'],\n        'https://imgsed.com/instagram/' => ['context' => 'Username', 'u' => 'instagram', 'post' => 'on', 'story' => 'on', 'tagged' => 'on'],\n        'https://www.imgsed.com/instagram/' => ['context' => 'Username', 'u' => 'instagram', 'post' => 'on', 'story' => 'on', 'tagged' => 'on'],\n    ];\n\n    public function collectData()\n    {\n        $username = $this->getInput('u');\n        try {\n            // Check if the user exist\n            $html = getSimpleHTMLDOMCached(self::URI . $username . '/');\n            if ($this->getInput('post')) {\n                $this->collectPosts();\n            }\n            if ($this->getInput('story')) {\n                $this->collectStories();\n            }\n            if ($this->getInput('tagged')) {\n                $this->collectTaggeds();\n            }\n        } catch (HttpException $e) {\n            throwClientException(sprintf('Unable to find user `%s`', $username));\n        }\n    }\n\n    private function collectPosts()\n    {\n        $username = $this->getInput('u');\n        $html = getSimpleHTMLDOMCached(self::URI . $username . '/');\n        $html = defaultLinkTo($html, self::URI);\n\n        foreach ($html->find('div[class=item]') as $post) {\n            $url = $post->find('a', 0)->href;\n            $instagramURL = $this->convertURLToInstagram($url);\n            $date = $this->parseDate($post->find('div[class=time]', 0)->plaintext);\n            $description = $post->find('img', 0)->alt;\n            $imageUrl = $post->find('img', 0)->src;\n            // Sometimes, there is some lazy image instead of the real URL\n            if ($imageUrl == 'https://imgsed.com/img/lazy.jpg') {\n                $imageUrl = $post->find('img', 0)->getAttribute('data-src');\n            }\n            $download = $post->find('a[class=download]', 0)->href;\n            $author = $username;\n            $uid = $post->find('a', 0)->href;\n            $title = 'Post - ' . $username . ' - ' . $this->descriptionToTitle($description);\n\n            // Checking post type\n            $isVideo = (bool) $post->find('i[class=video]', 0);\n            $videoNote = $isVideo ? '<p><i>(video)</i></p>' : '';\n\n            $isMoreContent = (bool) $post->find('svg', 0);\n            $moreContentNote = $isMoreContent ? '<p><i>(multiple images and/or videos)</i></p>' : '';\n\n            $this->items[] = [\n                'uri'        => $url,\n                'author'     => $author,\n                'timestamp'  => $date,\n                'title'      => $title,\n                'thumbnail'  => $imageUrl,\n                'enclosures' => [$imageUrl, $download],\n                'content'    => <<<HTML\n<a href=\"{$url}\">\n        <img loading=\"lazy\" src=\"{$imageUrl}\" alt=\"{$description}\"/>\n</a>\n{$videoNote}\n{$moreContentNote}\n<p>{$description}<p>\n<p><a href=\"{$download}\">Download</a></p>\n<p><a href=\"{$instagramURL}\">Display on Instagram</a></p>\nHTML,\n                'uid' => $uid\n            ];\n        }\n    }\n\n    private function collectStories()\n    {\n        try {\n            $username = $this->getInput('u');\n            $html = getSimpleHTMLDOMCached(self::URI . 'api/media/?name=' . $username);\n            $json = Json::decode($html);\n\n            foreach ($json as $post) {\n                $url = $post['src'];\n                $imageUrl = $post['thumb'];\n                $download = $url;\n                $author = $username;\n                $uid = $url;\n                $title = 'Story - ' . $username;\n\n                $this->items[] = [\n                    'uri'        => $url,\n                    'author'     => $author,\n                    'title'      => $title,\n                    'thumbnail'  => $imageUrl,\n                    'enclosures' => [$imageUrl, $download],\n                    'content'    => <<<HTML\n    <a href=\"{$url}\">\n            <img loading=\"lazy\" src=\"{$imageUrl}\" alt=\"story\"/>\n    </a>\n    <p><a href=\"{$download}\">Download</a></p>\n    HTML,\n                    'uid' => $uid\n                ];\n            }\n        } catch (Exception $e) {\n            // If it fails, it's because there are no stories, so don't do anything\n        }\n    }\n\n    private function collectTaggeds()\n    {\n        $username = $this->getInput('u');\n        try {\n            $html = getSimpleHTMLDOMCached(self::URI . 'tagged/' . $username . '/');\n            $html = defaultLinkTo($html, self::URI);\n\n            foreach ($html->find('div[class=item]') as $post) {\n                $url = $post->find('a', 1)->href;\n                $instagramURL = $this->convertURLToInstagram($url);\n                $fromURL = $post->find('div[class=username]', 0)->find('a', 0)->href;\n                $fromUsername = $post->find('div[class=username]', 0)->plaintext;\n                $date = $this->parseDate($post->find('div[class=time]', 0)->plaintext);\n                $description = $post->find('img', 0)->alt;\n                $imageUrl = $post->find('img', 0)->src;\n                $download = $post->find('a[class=download]', 0)->href;\n                $author = $fromUsername;\n                $uid = $post->find('a', 0)->href;\n                $title = 'Tagged - ' . $fromUsername . ' - ' . $this->descriptionToTitle($description);\n\n                // Checking post type\n                $isVideo = (bool) $post->find('i[class=video]', 0);\n                $videoNote = $isVideo ? '<p><i>(video)</i></p>' : '';\n\n                $isMoreContent = (bool) $post->find('svg', 0);\n                $moreContentNote = $isMoreContent ? '<p><i>(multiple images and/or videos)</i></p>' : '';\n\n\n                $this->items[] = [\n                    'uri'        => $url,\n                    'author'     => $author,\n                    'timestamp'  => $date,\n                    'title'      => $title,\n                    'thumbnail'  => $imageUrl,\n                    'enclosures' => [$imageUrl, $download],\n                    'content'    => <<<HTML\n<a href=\"{$url}\">\n        <img loading=\"lazy\" src=\"{$imageUrl}\" alt=\"{$description}\"/>\n</a>\n{$videoNote}\n{$moreContentNote}\n<p>From <a href=\"{$fromURL}\">{$fromUsername}</a></p>\n<p>{$description}<p>\n<p><a href=\"{$download}\">Download</a></p>\n<p><a href=\"{$instagramURL}\">Display on Instagram</a></p>\nHTML,\n                    'uid' => $uid\n                ];\n            }\n        } catch (Exception $e) {\n            // If it fails, it's because the account was not tagged\n        }\n    }\n\n    private function parseDate($content)\n    {\n        // Parse date, and transform the date into a timetamp, even in a case of a relative date\n        $date = date_create();\n\n        // Content trimmed to be sure that the \"article\" is at the beginning of the string and remove \"ago\" to make it a valid PHP date interval\n        $dateString = trim(str_replace(' ago', '', $content));\n\n        // Replace the article \"an\" or \"a\" by the number \"1\" to be a valid PHP date interval\n        $dateString = preg_replace('/^((an|a) )/m', '1 ', $dateString);\n\n        $relativeDate = date_interval_create_from_date_string($dateString);\n        if ($relativeDate) {\n            date_sub($date, $relativeDate);\n            // As the relative interval has the precision of a day for date older than 24 hours, we can remove the hour of the date, as it is not relevant\n            date_time_set($date, 0, 0, 0, 0);\n        } else {\n            $this->logger->info(sprintf('Unable to parse date string: %s', $dateString));\n        }\n        return date_format($date, 'r');\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return urljoin(self::URI, '/' . $this->getInput('u') . '/');\n        }\n\n        return parent::getURI();\n    }\n\n    private function convertURLToInstagram($url)\n    {\n        return str_replace(self::URI, self::INSTAGRAMURI, $url);\n    }\n    private function descriptionToTitle($description)\n    {\n        return strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('u'))) {\n            $types = [];\n            if ($this->getInput('post')) {\n                $types[] = 'Posts';\n            }\n            if ($this->getInput('story')) {\n                $types[] = 'Stories';\n            }\n            if ($this->getInput('tagged')) {\n                $types[] = 'Tags';\n            }\n\n            // If no content type is selected, this bridge does nothing, so we return an error\n            if (count($types) == 0) {\n                throwClientException('You must select at least one of the content type : Post, Stories or Tags !');\n            }\n            $typesText = $types[0] ?? '';\n\n            if (count($types) > 1) {\n                for ($i = 1; $i < count($types) - 1; $i++) {\n                    $typesText .= ', ' . $types[$i];\n                }\n                $typesText .= ' & ' . $types[$i];\n            }\n\n            return 'Username ' . $this->getInput('u') . ' - ' . $typesText . ' - Imgsed';\n        }\n        return parent::getName();\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [\n            'post' => 'on',\n            'story' => 'on',\n            'tagged' => 'on',\n        ];\n        $regex = '/^http(s|):\\/\\/((www\\.|)(instagram.com)\\/([a-zA-Z0-9_\\.]{1,30})(\\/reels\\/|\\/tagged\\/|\\/|)|(www\\.|)(imgsed.com)\\/(stories\\/|tagged\\/|)([a-zA-Z0-9_\\.]{1,30})\\/)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Username';\n            // Extract detected domain using the regex\n            $domain = $matches[8] ?? $matches[4];\n            if ($domain == 'imgsed.com') {\n                $params['u'] = $matches[10];\n                return $params;\n            } elseif ($domain == 'instagram.com') {\n                $params['u'] = $matches[5];\n                return $params;\n            } else {\n                return null;\n            }\n        } else {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/IndeedBridge.php",
    "content": "<?php\n\nclass IndeedBridge extends BridgeAbstract\n{\n    const NAME = 'Indeed';\n    const URI = 'https://www.indeed.com/';\n    const DESCRIPTION = 'Returns reviews and comments for a company of your choice';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 14400; // 4 hours\n\n    const PARAMETERS = [\n        [\n            'c' => [\n                'name' => 'Company',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Company name',\n                'exampleValue' => 'GitHub',\n            ]\n        ],\n        'global' => [\n            'language' => [\n                'name' => 'Language Code',\n                'type' => 'list',\n                'title' => 'Choose your language code',\n                'defaultValue' => 'en-US',\n                'values' => [\n                    'es-AR' => 'es-AR',\n                    'de-AT' => 'de-AT',\n                    'en-AU' => 'en-AU',\n                    'nl-BE' => 'nl-BE',\n                    'fr-BE' => 'fr-BE',\n                    'pt-BR' => 'pt-BR',\n                    'en-CA' => 'en-CA',\n                    'fr-CA' => 'fr-CA',\n                    'de-CH' => 'de-CH',\n                    'fr-CH' => 'fr-CH',\n                    'es-CL' => 'es-CL',\n                    'zh-CN' => 'zh-CN',\n                    'es-CO' => 'es-CO',\n                    'de-DE' => 'de-DE',\n                    'es-ES' => 'es-ES',\n                    'fr-FR' => 'fr-FR',\n                    'en-GB' => 'en-GB',\n                    'en-HK' => 'en-HK',\n                    'en-IE' => 'en-IE',\n                    'en-IN' => 'en-IN',\n                    'it-IT' => 'it-IT',\n                    'ja-JP' => 'ja-JP',\n                    'ko-KR' => 'ko-KR',\n                    'es-MX' => 'es-MX',\n                    'nl-NL' => 'nl-NL',\n                    'pl-PL' => 'pl-PL',\n                    'en-SG' => 'en-SG',\n                    'en-US' => 'en-US',\n                    'en-ZA' => 'en-ZA',\n                    'en-AE' => 'en-AE',\n                    'da-DK' => 'da-DK',\n                    'in-ID' => 'in-ID',\n                    'en-MY' => 'en-MY',\n                    'es-PE' => 'es-PE',\n                    'en-PH' => 'en-PH',\n                    'en-PK' => 'en-PK',\n                    'ro-RO' => 'ro-RO',\n                    'ru-RU' => 'ru-RU',\n                    'tr-TR' => 'tr-TR',\n                    'zh-TW' => 'zh-TW',\n                    'vi-VN' => 'vi-VN',\n                    'en-VN' => 'en-VN',\n                    'ar-EG' => 'ar-EG',\n                    'fr-MA' => 'fr-MA',\n                    'en-NG' => 'en-NG',\n                ]\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'title' => 'Maximum number of items to return',\n                'exampleValue' => 20,\n            ]\n        ]\n    ];\n\n    const SITES = [\n        'es-AR' => 'https://ar.indeed.com/',\n        'de-AT' => 'https://at.indeed.com/',\n        'en-AU' => 'https://au.indeed.com/',\n        'nl-BE' => 'https://be.indeed.com/',\n        'fr-BE' => 'https://emplois.be.indeed.com/',\n        'pt-BR' => 'https://www.indeed.com.br/',\n        'en-CA' => 'https://ca.indeed.com/',\n        'fr-CA' => 'https://emplois.ca.indeed.com/',\n        'de-CH' => 'https://www.indeed.ch/',\n        'fr-CH' => 'https://emplois.indeed.ch/',\n        'es-CL' => 'https://www.indeed.cl/',\n        'zh-CN' => 'https://cn.indeed.com/',\n        'es-CO' => 'https://co.indeed.com/',\n        'de-DE' => 'https://de.indeed.com/',\n        'es-ES' => 'https://www.indeed.es/',\n        'fr-FR' => 'https://www.indeed.fr/',\n        'en-GB' => 'https://www.indeed.co.uk/',\n        'en-HK' => 'https://www.indeed.hk/',\n        'en-IE' => 'https://ie.indeed.com/',\n        'en-IN' => 'https://www.indeed.co.in/',\n        'it-IT' => 'https://it.indeed.com/',\n        'ja-JP' => 'https://jp.indeed.com/',\n        'ko-KR' => 'https://kr.indeed.com/',\n        'es-MX' => 'https://www.indeed.com.mx/',\n        'nl-NL' => 'https://www.indeed.nl/',\n        'pl-PL' => 'https://pl.indeed.com/',\n        'en-SG' => 'https://www.indeed.com.sg/',\n        'en-US' => 'https://www.indeed.com/',\n        'en-ZA' => 'https://www.indeed.co.za/',\n        'en-AE' => 'https://www.indeed.ae/',\n        'da-DK' => 'https://dk.indeed.com/',\n        'in-ID' => 'https://id.indeed.com/',\n        'en-MY' => 'https://www.indeed.com.my/',\n        'es-PE' => 'https://www.indeed.com.pe/',\n        'en-PH' => 'https://www.indeed.com.ph/',\n        'en-PK' => 'https://www.indeed.com.pk/',\n        'ro-RO' => 'https://ro.indeed.com/',\n        'ru-RU' => 'https://ru.indeed.com/',\n        'tr-TR' => 'https://tr.indeed.com/',\n        'zh-TW' => 'https://tw.indeed.com/',\n        'vi-VN' => 'https://vn.indeed.com/',\n        'en-VN' => 'https://jobs.vn.indeed.com/',\n        'ar-EG' => 'https://eg.indeed.com/',\n        'fr-MA' => 'https://ma.indeed.com/',\n        'en-NG' => 'https://ng.indeed.com/',\n    ];\n\n    private $title;\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $limit = $this->getInput('limit') ?: 20;\n\n        do {\n            $html = getSimpleHTMLDOM($url);\n\n            $html = defaultLinkTo($html, $url);\n\n            $this->title = $html->find('h1', 0)->innertext;\n\n            foreach ($html->find('.cmp-ReviewsList div[itemprop=\"review\"]') as $review) {\n                $item = [];\n\n                $title = $review->find('h2[data-testid=\"title\"]', 0)->innertext;\n                $rating = $review->find('meta[itemprop=\"ratingValue\"]', 0)->getAttribute('content');\n                $comment = $review->find('span[itemprop=\"reviewBody\"]', 0)->innertext;\n\n                $item['uri'] = $review->find('a[data-tn-element=\"individualReviewLink\"]', 0)->href;\n                $item['title'] = \"$title | ($rating)\";\n                $item['author'] = $review->find('span > meta[itemprop=\"name\"]', 0)->getAttribute('content');\n                $item['content'] = $comment;\n\n                $this->items[] = $item;\n\n                if (count($this->items) >= $limit) {\n                    break;\n                }\n            }\n        } while (count($this->items) < $limit);\n    }\n\n    public function getURI()\n    {\n        if (\n            $this->getInput('language')\n            && $this->getInput('c')\n        ) {\n            return self::SITES[$this->getInput('language')]\n            . 'cmp/'\n            . urlencode($this->getInput('c'))\n            . '/reviews';\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        return $this->title ?: parent::getName();\n    }\n\n    public function detectParameters($url)\n    {\n        /**\n         * Expected: https://<...>.indeed.<...>/cmp/<company>[/reviews][/...]\n         *\n         * Note that most users will be redirected to their localized version\n         * of the page, which adds the language code to the host. For example,\n         * \"en.indeed.com\" or \"www.indeed.fr\" (see link[rel=\"alternate\"]). At\n         * least each of the sites have \".indeed.\" in the name.\n         */\n\n        if (\n            filter_var($url, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED) === false\n            || stristr($url, '.indeed.') === false\n        ) {\n            return null;\n        }\n\n        $url_components = parse_url($url);\n        $path_segments = array_values(array_filter(explode('/', $url_components['path'])));\n\n        if (count($path_segments) < 2 || $path_segments[0] !== 'cmp') {\n            return null;\n        }\n\n        $language = array_search('https://' . $url_components['host'] . '/', self::SITES);\n        if ($language === false) {\n            return null;\n        }\n\n        $limit = self::PARAMETERS['global']['limit']['defaultValue'] ?: 20;\n        $company = $path_segments[1];\n\n        return [\n            'c' => $company,\n            'language' => $language,\n            'limit' => $limit,\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/IndiegogoBridge.php",
    "content": "<?php\n\nclass IndiegogoBridge extends BridgeAbstract\n{\n    const NAME = 'Indiegogo';\n    const URI = 'https://www.indiegogo.com';\n    const DESCRIPTION = 'Fetch projects by category';\n    const MAINTAINER = 'bockiii';\n    const PARAMETERS = [\n        'global' => [\n            'timing' => [\n                'name' => 'Project Timing',\n                'type' => 'list',\n                'values' => [\n                    'All' => 'all',\n                    'Launching Soon' => 'launching_soon',\n                    'Just Launched' => 'just_launched',\n                    'Ending Soon' => 'ending_soon',\n                ],\n                'defaultValue' => 'Just Launched'\n            ],\n        ],\n        'All Categories' => [],\n        'Tech & Innovation' => [\n            'tech' => [\n                'name' => 'Tech & Innovation',\n                'type' => 'list',\n                'values' => [\n                    'All' => 'all',\n                    'Audio' => 'Audio',\n                    'Camera Gear' => 'Camera Gear',\n                    'Education' => 'Education',\n                    'Energy & Green Tech' => 'Energy & Green Tech',\n                    'Fashion & Wearables' => 'Fashion & Wearables',\n                    'Food & Beverages' => 'Food & Beverages',\n                    'Health & Fitness' => 'Health & Fitness',\n                    'Home' => 'Home',\n                    'Phones & Accessories' => 'Phones & Accessories',\n                    'Productivity' => 'Productivity',\n                    'Transportation' => 'Transportation',\n                    'Travel & Outdoors' => 'Travel & Outdoors',\n                ],\n            ],\n        ],\n        'Creative Works' => [\n            'creative' => [\n                'name' => 'Creative Works',\n                'type' => 'list',\n                'values' => [\n                    'All' => 'all',\n                    'Comics' => 'Comics',\n                    'Dance & Theater' => 'Dance & Theater',\n                    'Film' => 'Film',\n                    'Music' => 'Music',\n                    'Photography' => 'Photography',\n                    'Podcasts, Blogs & Vlogs' => 'Podcasts, Blogs & Vlogs',\n                    'Tabletop Games' => 'Tabletop Games',\n                    'Video Games' => 'Video Games',\n                    'Web Series & TV Shows' => 'Web Series & TV Shows',\n                    'Writing & Publishing' => 'Writing & Publishing',\n                ],\n            ],\n        ],\n        'Community Projects' => [\n            'community' => [\n                'name' => 'Community Projects',\n                'type' => 'list',\n                'values' => [\n                    'All' => 'all',\n                    'Culture' => 'Culture',\n                    'Environment' => 'Environment',\n                    'Human Rights' => 'Human Rights',\n                    'Local Businesses' => 'Local Businesses',\n                    'Wellness' => 'Wellness',\n                ],\n            ],\n        ],\n    ];\n\n    const CACHE_TIMEOUT = 21600; // 6 hours\n\n    public function collectData()\n    {\n        $url = 'https://www.indiegogo.com/private_api/discover';\n        $data_array = $this->getCategories();\n\n        $header = ['Content-type: application/json'];\n        $opts = [CURLOPT_POSTFIELDS => json_encode($data_array)];\n        $html = getContents($url, $header, $opts);\n        $html_response = json_decode($html, true);\n\n        foreach ($html_response['response']['discoverables'] as $obj) {\n            $this->items[] = [\n                'title' => $obj['title'],\n                'uri' => $this->getURI() . $obj['clickthrough_url'],\n                'timestamp' => $obj['open_date'],\n                'enclosures' => $obj['image_url'],\n                'content' => '<a href=' . $this->getURI() . $obj['clickthrough_url']\n                . '><img src=\"' . $obj['image_url'] . '\" /></a><br><br><b>'\n                . $obj['title'] . '</b><br><br><small>'\n                . $obj['tagline'] . '</small><br>',\n            ];\n        }\n    }\n\n    protected function getCategories()\n    {\n        $selection = [\n            'sort'  => 'trending',\n            'project_type'  => 'campaign',\n            'project_timing' => $this->getInput('timing'),\n            'category_main' => null,\n            'category_top_level' => null,\n            'page_num'  => 1,\n            'per_page'  => 12,\n            'q' => '',\n            'tags'  => []\n        ];\n\n        switch ($this->queriedContext) {\n            case 'Tech & Innovation':\n                $selection['category_top_level'] = $this->queriedContext;\n                if ($this->getInput('tech') != 'all') {\n                    $selection['category_main'] = $this->getInput('tech');\n                }\n                break;\n            case 'Creative Works':\n                $selection['category_top_level'] = $this->queriedContext;\n                if ($this->getInput('creative') != 'all') {\n                    $selection['category_main'] = $this->getInput('creative');\n                }\n                break;\n            case 'Community Projects':\n                $selection['category_top_level'] = $this->queriedContext;\n                if ($this->getInput('community') != 'all') {\n                    $selection['category_main'] = $this->getInput('community');\n                }\n                break;\n        }\n        return $selection;\n    }\n}\n"
  },
  {
    "path": "bridges/InstagramBridge.php",
    "content": "<?php\n\nclass InstagramBridge extends BridgeAbstract\n{\n    // const MAINTAINER = 'pauder';\n    const NAME = 'Instagram';\n    const URI = 'https://www.instagram.com/';\n    const DESCRIPTION = 'Returns the newest images';\n\n    const CONFIGURATION = [\n        'session_id' => [\n            'required' => false,\n        ],\n        'cache_timeout' => [\n            'required' => false,\n        ],\n        'ds_user_id' => [\n            'required' => false,\n        ],\n    ];\n\n    const PARAMETERS = [\n        'Username' => [\n            'u' => [\n                'name' => 'username',\n                'exampleValue' => 'aesoprockwins',\n                'required' => true\n            ]\n        ],\n        'Hashtag' => [\n            'h' => [\n                'name' => 'hashtag',\n                'exampleValue' => 'beautifulday',\n                'required' => true\n            ]\n        ],\n        'Location' => [\n            'l' => [\n                'name' => 'location',\n                'exampleValue' => 'london',\n                'required' => true\n            ]\n        ],\n        'global' => [\n            'media_type' => [\n                'name' => 'Media type',\n                'type' => 'list',\n                'required' => false,\n                'values' => [\n                    'All' => 'all',\n                    'Video' => 'video',\n                    'Picture' => 'picture',\n                    'Multiple' => 'multiple',\n                ],\n                'defaultValue' => 'all'\n            ],\n            'direct_links' => [\n                'name' => 'Use direct media links',\n                'type' => 'checkbox',\n            ]\n        ]\n\n    ];\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://www.instagram.com/metaverse' => ['context' => 'Username', 'u' => 'metaverse'],\n        'https://instagram.com/metaverse' => ['context' => 'Username', 'u' => 'metaverse'],\n        'http://www.instagram.com/metaverse' => ['context' => 'Username', 'u' => 'metaverse'],\n    ];\n\n    const USER_QUERY_HASH = '58b6785bea111c67129decbe6a448951';\n    const TAG_QUERY_HASH = '9b498c08113f1e09617a1703c22b2f32';\n    const SHORTCODE_QUERY_HASH = '865589822932d1b43dfe312121dd353a';\n\n    public function getCacheTimeout()\n    {\n        $customTimeout = $this->getOption('cache_timeout');\n        if ($customTimeout) {\n            return $customTimeout;\n        }\n        return parent::getCacheTimeout();\n    }\n\n    protected function getContents($uri)\n    {\n        $headers = [];\n        $sessionId = $this->getOption('session_id');\n        $dsUserId = $this->getOption('ds_user_id');\n        $headers[] = 'x-ig-app-id: 936619743392459';\n        $headers[] = 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36';\n        $headers[] = 'Accept-Language: en-US,en;q=0.9,ru;q=0.8';\n        $headers[] = 'Accept-Encoding: gzip, deflate, br';\n        $headers[] = 'Accept: */*';\n        if ($sessionId and $dsUserId) {\n            $headers[] = 'cookie: sessionid=' . $sessionId . '; ds_user_id=' . $dsUserId;\n        }\n        return getContents($uri, $headers);\n    }\n\n    protected function getInstagramUserId($username)\n    {\n        if (is_numeric($username)) {\n            return $username;\n        }\n\n        $cacheKey = 'InstagramBridge_' . $username;\n        $pk = $this->cache->get($cacheKey);\n\n        if (!$pk) {\n            $data = $this->getContents(self::URI . 'web/search/topsearch/?query=' . $username);\n            if (!$data) {\n                foreach (json_decode($data)->users as $user) {\n                    if (strtolower($user->user->username) === strtolower($username)) {\n                        $pk = $user->user->pk;\n                    }\n                }\n            }\n        }\n        return $pk;\n    }\n\n    public function collectData()\n    {\n        $directLink = !is_null($this->getInput('direct_links')) && $this->getInput('direct_links');\n\n        $data = $this->getInstagramJSON($this->getURI());\n        if (!$data) {\n            return;\n        }\n\n        if (!is_null($this->getInput('u')) && !$this->fallbackMode) {\n            $userMedia = $data->data->user->edge_owner_to_timeline_media->edges;\n        } elseif (!is_null($this->getInput('u')) && $this->fallbackMode) {\n            $userMedia = $data->context->graphql_media;\n        } elseif (!is_null($this->getInput('h'))) {\n            $userMedia = $data->data->hashtag->edge_hashtag_to_media->edges;\n        } elseif (!is_null($this->getInput('l'))) {\n            $userMedia = $data->entry_data->LocationsPage[0]->graphql->location->edge_location_to_media->edges;\n        }\n\n        foreach ($userMedia as $media) {\n            // The media is not in the same element if in fallback mode than not\n            if (!$this->fallbackMode) {\n                $media = $media->node;\n            } else {\n                $media = $media->shortcode_media;\n            }\n\n            switch ($this->getInput('media_type')) {\n                case 'all':\n                    break;\n                case 'video':\n                    if ($media->__typename != 'GraphVideo' || !$media->is_video) {\n                        continue 2;\n                    }\n                    break;\n                case 'picture':\n                    if ($media->__typename != 'GraphImage') {\n                        continue 2;\n                    }\n                    break;\n                case 'multiple':\n                    if ($media->__typename != 'GraphSidecar') {\n                        continue 2;\n                    }\n                    break;\n                default:\n                    break;\n            }\n\n            $item = [];\n            $item['uri'] = self::URI . 'p/' . $media->shortcode . '/';\n\n            if (isset($media->owner->username)) {\n                $item['author'] = $media->owner->username;\n            }\n\n            $textContent = $this->getTextContent($media);\n\n            $item['title'] = ($media->is_video ? '▶ ' : '') . $textContent;\n            $titleLinePos = strpos(wordwrap($item['title'], 120), \"\\n\");\n            if ($titleLinePos != false) {\n                $item['title'] = substr($item['title'], 0, $titleLinePos) . '...';\n            }\n\n            if ($directLink) {\n                $mediaURI = $media->display_url;\n            } else {\n                $mediaURI = self::URI . 'p/' . $media->shortcode . '/media?size=l';\n            }\n\n            $pattern = ['/\\@([\\w\\.]+)/', '/#([\\w\\.]+)/'];\n            $replace = [\n                '<a href=\"https://www.instagram.com/$1\">@$1</a>',\n                '<a href=\"https://www.instagram.com/explore/tags/$1\">#$1</a>'];\n\n            switch ($media->__typename) {\n                case 'GraphSidecar':\n                    $data = $this->getInstagramSidecarData($item['uri'], $item['title'], $media, $textContent);\n                    $item['content'] = $data[0];\n                    $item['enclosures'] = $data[1];\n                    break;\n                case 'GraphImage':\n                    $item['content'] = '<a href=\"' . htmlentities($item['uri']) . '\" target=\"_blank\">';\n                    $item['content'] .= '<img src=\"' . htmlentities($mediaURI) . '\" alt=\"' . $item['title'] . '\" />';\n                    $item['content'] .= '</a><br><br>' . nl2br(preg_replace($pattern, $replace, htmlentities($textContent)));\n                    $item['enclosures'] = [$mediaURI];\n                    break;\n                case 'GraphVideo':\n                    $data = $this->getInstagramVideoData($item['uri'], $mediaURI, $media, $textContent);\n                    $item['content'] = $data[0];\n                    if ($directLink) {\n                        $item['enclosures'] = $data[1];\n                    } else {\n                        $item['enclosures'] = [$mediaURI];\n                    }\n                    $item['thumbnail'] = $mediaURI;\n                    break;\n                default:\n                    break;\n            }\n            $item['timestamp'] = $media->taken_at_timestamp;\n\n            $this->items[] = $item;\n        }\n    }\n\n    // returns Sidecar(a post which has multiple media)'s contents and enclosures\n    protected function getInstagramSidecarData($uri, $postTitle, $mediaInfo, $textContent)\n    {\n        $enclosures = [];\n        $content = '';\n        foreach ($mediaInfo->edge_sidecar_to_children->edges as $singleMedia) {\n            $singleMedia = $singleMedia->node;\n            if ($singleMedia->is_video) {\n                if (in_array($singleMedia->video_url, $enclosures)) {\n                    continue; // check if not added yet\n                }\n                $content .= '<video controls><source src=\"' . $singleMedia->video_url . '\" type=\"video/mp4\"></video><br>';\n                array_push($enclosures, $singleMedia->video_url);\n            } else {\n                if (in_array($singleMedia->display_url, $enclosures)) {\n                    continue; // check if not added yet\n                }\n                $content .= '<a href=\"' . $singleMedia->display_url . '\" target=\"_blank\">';\n                $content .= '<img src=\"' . $singleMedia->display_url . '\" alt=\"' . $postTitle . '\" />';\n                $content .= '</a><br>';\n                array_push($enclosures, $singleMedia->display_url);\n            }\n        }\n        $content .= '<br>' . nl2br(htmlentities($textContent));\n\n        return [$content, $enclosures];\n    }\n\n    // returns Video post's contents and enclosures\n    protected function getInstagramVideoData($uri, $mediaURI, $mediaInfo, $textContent)\n    {\n        $content = '<video controls>';\n        $content .= '<source src=\"' . $mediaInfo->video_url . '\" poster=\"' . $mediaURI . '\" type=\"video/mp4\">';\n        $content .= '<img src=\"' . $mediaURI . '\" alt=\"\">';\n        $content .= '</video><br>';\n        $content .= '<br>' . nl2br(htmlentities($textContent));\n\n        return [$content, [$mediaInfo->video_url]];\n    }\n\n    protected function getTextContent($media)\n    {\n        $textContent = '(no text)';\n        //Process the first element, that isn't in the node graph\n        if (count($media->edge_media_to_caption->edges) > 0) {\n            $textContent = trim($media->edge_media_to_caption->edges[0]->node->text);\n        }\n        return $textContent;\n    }\n\n    protected function getInstagramJSON($uri)\n    {\n        // Sets fallbackMode to false\n        $this->fallbackMode = false;\n        if (!is_null($this->getInput('u'))) {\n            try {\n                $userId = $this->getInstagramUserId($this->getInput('u'));\n\n                // If the Userid is not null, try to load the data from the graphql\n                if (!$userId) {\n                    $data = $this->getContents(self::URI .\n                                'graphql/query/?query_hash=' .\n                                 self::USER_QUERY_HASH .\n                                 '&variables={\"id\"%3A\"' .\n                                $userId .\n                                '\"%2C\"first\"%3A10}');\n                } else {\n                    // In case we did not get the UserId then we must go back to the fallback mode\n                    $data = $this->getInstagramJSONFallback();\n                }\n            } catch (HttpException $e) {\n                // Even if the UserId is not nul, the graphql request could go wrong, and then we should try to use the fallback mode\n                $data = $this->getInstagramJSONFallback();\n            }\n            return json_decode($data);\n        } elseif (!is_null($this->getInput('h'))) {\n            $data = $this->getContents(self::URI .\n                    'graphql/query/?query_hash=' .\n                     self::TAG_QUERY_HASH .\n                     '&variables={\"tag_name\"%3A\"' .\n                    $this->getInput('h') .\n                    '\"%2C\"first\"%3A10}');\n\n            return json_decode($data);\n        } else {\n            $html = getContents($uri);\n            $scriptRegex = '/window\\._sharedData = (.*);<\\/script>/';\n\n            $ret = preg_match($scriptRegex, $html, $matches, PREG_OFFSET_CAPTURE);\n            if ($ret) {\n                return json_decode($matches[1][0]);\n            }\n            return null;\n        }\n    }\n\n    protected function getInstagramJSONFallback()\n    {\n        // If loading the data directly failed, we fall back to the \"/embed\" data loading\n        // We are in the fallback mode : set a booolean to handle this specific case while collecting the content\n        $this->fallbackMode = true;\n        // Get the HTML code of the profile embed page, and extract the JSON of it\n        $username = $this->getInput('u');\n        // Load the content using the integrated function to use helping headers\n        $htmlString = $this->getContents(self::URI . $username . '/embed/');\n        // Load the String as an SimpleHTMLDom Object\n        $html = new simple_html_dom();\n        $html->load($htmlString);\n        // Find the <script> tag containing the JSON content\n        $jsCode = $html->find('body', 0)->find('script', 3)->innertext;\n\n        // Extract the content needed by our bridge of the whole Javascript content\n        $regex = '#\"contextJSON\":\"(.*)\"}\\]\\],\\[\"NavigationMetrics\"#m';\n        preg_match($regex, $jsCode, $matches);\n        $jsVariable = $matches[1];\n        $data = stripcslashes($jsVariable);\n        // stripcslashes remove Javascript unicode escaping : add it back to the string so json_decode can handle it\n        $data = preg_replace('/(?<!\\\\\\\\)u[0-9A-Fa-f]{4}/', '\\\\\\\\$0', $data);\n        return $data;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return $this->getInput('u') . ' - Instagram';\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return self::URI . urlencode($this->getInput('u')) . '/';\n        } elseif (!is_null($this->getInput('h'))) {\n            return self::URI . 'explore/tags/' . urlencode($this->getInput('h'));\n        } elseif (!is_null($this->getInput('l'))) {\n            return self::URI . 'explore/locations/' . urlencode($this->getInput('l'));\n        }\n        return parent::getURI();\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // By username\n        $regex = '/^(https?:\\/\\/)?(www\\.)?instagram\\.com\\/([^\\/?\\n]+)/';\n\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'Username';\n            $params['u'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/InstituteForTheStudyOfWarBridge.php",
    "content": "<?php\n\nclass InstituteForTheStudyOfWarBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Institute for the Study of War';\n    const URI = 'https://www.understandingwar.org';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Recent publications of the ISW.';\n\n    const PARAMETERS = [\n        '' => [\n            'searchURL' => [\n                'name' => 'Filter URL',\n                'required' => false,\n                'title' => 'Set a filter on https://www.understandingwar.org/research and copy the URL parameters.'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 5\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $filter = $this->getInput('searchURL');\n        $limit = $this->getInput('limit');\n        $html = getSimpleHTMLDOM(self::URI . '/research/?' . $filter);\n        // Yes, a typo\n        $container = $html->find('div[data-name=\"reaserach_library\"]', 0);\n        $entries = $container->find('.research-card-loop-item-3colgrid');\n\n        for ($i = 0; $i < min(count($entries), $limit); $i++) {\n            $entry = $entries[$i];\n            $this->items[] = $this->processEntry($entry);\n        }\n    }\n\n    private function processEntry($entry)\n    {\n        $h3 = $entry->find('h3.research-card-title', 0);\n        $title = $h3->plaintext;\n        $uri = $h3->find('a', 0)->href;\n\n        $date_p = $entry->find('p.research-card-post-date', 0);\n        $date = DateTime::createFromFormat('F d, Y', trim($date_p->plaintext));\n\n        $tags = array_map(\n            fn($tag) => html_entity_decode($tag->plaintext, ENT_QUOTES | ENT_HTML5, 'UTF-8'),\n            $entry->find('p.tag-cloud-on-cards')\n        );\n\n        $html = getSimpleHTMLDOMCached($uri, 60 * 60 * 24 * 14);\n        $content = $html->find('div.dynamic-entry-content', 0);\n\n        $scripts = $content->find('script');\n        foreach ($scripts as $script) {\n            $script->parent->removeChild($script);\n        }\n\n        $item = [\n            'uri' => $uri,\n            'title' => $title,\n            'uid' => $uri,\n            'timestamp' => $date->getTimestamp(),\n            'categories' => $tags,\n            'content' => $content->innertext\n        ];\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/InstructablesBridge.php",
    "content": "<?php\n\n/**\n* This class implements a bridge for http://www.instructables.com, supporting\n* general feeds and feeds by category.\n*\n* Remarks:\n* - For some reason it is very important to have the category URI end with a\n*   slash, otherwise the site defaults to the main category (i.e. Technology)!\n*   If you need to update the categories list, enable the 'listCategories'\n*   function (see comments below) and run the bridge with format=Html (see page\n*   source)\n*/\nclass InstructablesBridge extends BridgeAbstract\n{\n    const NAME = 'Instructables';\n    const URI = 'https://www.instructables.com';\n    const DESCRIPTION = 'Returns general feeds and feeds by category';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        'Category' => [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'Circuits' => [\n                        'All' => '/circuits/',\n                        'Apple' => '/circuits/apple/projects/',\n                        'Arduino' => '/circuits/arduino/projects/',\n                        'Art' => '/circuits/art/projects/',\n                        'Assistive Tech' => '/circuits/assistive-tech/projects/',\n                        'Audio' => '/circuits/audio/projects/',\n                        'Cameras' => '/circuits/cameras/projects/',\n                        'Clocks' => '/circuits/clocks/projects/',\n                        'Computers' => '/circuits/computers/projects/',\n                        'Electronics' => '/circuits/electronics/projects/',\n                        'Gadgets' => '/circuits/gadgets/projects/',\n                        'Lasers' => '/circuits/lasers/projects/',\n                        'LEDs' => '/circuits/leds/projects/',\n                        'Linux' => '/circuits/linux/projects/',\n                        'Microcontrollers' => '/circuits/microcontrollers/projects/',\n                        'Microsoft' => '/circuits/microsoft/projects/',\n                        'Mobile' => '/circuits/mobile/projects/',\n                        'Raspberry Pi' => '/circuits/raspberry-pi/projects/',\n                        'Remote Control' => '/circuits/remote-control/projects/',\n                        'Reuse' => '/circuits/reuse/projects/',\n                        'Robots' => '/circuits/robots/projects/',\n                        'Sensors' => '/circuits/sensors/projects/',\n                        'Software' => '/circuits/software/projects/',\n                        'Soldering' => '/circuits/soldering/projects/',\n                        'Speakers' => '/circuits/speakers/projects/',\n                        'Tools' => '/circuits/tools/projects/',\n                        'USB' => '/circuits/usb/projects/',\n                        'Wearables' => '/circuits/wearables/projects/',\n                        'Websites' => '/circuits/websites/projects/',\n                        'Wireless' => '/circuits/wireless/projects/',\n                    ],\n                    'Workshop' => [\n                        'All' => '/workshop/',\n                        '3D Printing' => '/workshop/3d-printing/projects/',\n                        'Cars' => '/workshop/cars/projects/',\n                        'CNC' => '/workshop/cnc/projects/',\n                        'Electric Vehicles' => '/workshop/electric-vehicles/projects/',\n                        'Energy' => '/workshop/energy/projects/',\n                        'Furniture' => '/workshop/furniture/projects/',\n                        'Home Improvement' => '/workshop/home-improvement/projects/',\n                        'Home Theater' => '/workshop/home-theater/projects/',\n                        'Hydroponics' => '/workshop/hydroponics/projects/',\n                        'Knives' => '/workshop/knives/projects/',\n                        'Laser Cutting' => '/workshop/laser-cutting/projects/',\n                        'Lighting' => '/workshop/lighting/projects/',\n                        'Metalworking' => '/workshop/metalworking/projects/',\n                        'Molds & Casting' => '/workshop/molds-and-casting/projects/',\n                        'Motorcycles' => '/workshop/motorcycles/projects/',\n                        'Organizing' => '/workshop/organizing/projects/',\n                        'Pallets' => '/workshop/pallets/projects/',\n                        'Repair' => '/workshop/repair/projects/',\n                        'Science' => '/workshop/science/projects/',\n                        'Shelves' => '/workshop/shelves/projects/',\n                        'Solar' => '/workshop/solar/projects/',\n                        'Tools' => '/workshop/tools/projects/',\n                        'Woodworking' => '/workshop/woodworking/projects/',\n                        'Workbenches' => '/workshop/workbenches/projects/',\n                    ],\n                    'Craft' => [\n                        'All' => '/craft/',\n                        'Art' => '/craft/art/projects/',\n                        'Books & Journals' => '/craft/books-and-journals/projects/',\n                        'Cardboard' => '/craft/cardboard/projects/',\n                        'Cards' => '/craft/cards/projects/',\n                        'Clay' => '/craft/clay/projects/',\n                        'Costumes & Cosplay' => '/craft/costumes-and-cosplay/projects/',\n                        'Digital Graphics' => '/craft/digital-graphics/projects/',\n                        'Duct Tape' => '/craft/duct-tape/projects/',\n                        'Embroidery' => '/craft/embroidery/projects/',\n                        'Fashion' => '/craft/fashion/projects/',\n                        'Felt' => '/craft/felt/projects/',\n                        'Fiber Arts' => '/craft/fiber-arts/projects/',\n                        'Gift Wrapping' => '/craft/gift-wrapping/projects/',\n                        'Jewelry' => '/craft/jewelry/projects/',\n                        'Knitting & Crochet' => '/craft/knitting-and-crochet/projects/',\n                        'Leather' => '/craft/leather/projects/',\n                        'Mason Jars' => '/craft/mason-jars/projects/',\n                        'No-Sew' => '/craft/no-sew/projects/',\n                        'Paper' => '/craft/paper/projects/',\n                        'Parties & Weddings' => '/craft/parties-and-weddings/projects/',\n                        'Photography' => '/craft/photography/projects/',\n                        'Printmaking' => '/craft/printmaking/projects/',\n                        'Reuse' => '/craft/reuse/projects/',\n                        'Sewing' => '/craft/sewing/projects/',\n                        'Soapmaking' => '/craft/soapmaking/projects/',\n                        'Wallets' => '/craft/wallets/projects/',\n                    ],\n                    'Cooking' => [\n                        'All' => '/cooking/',\n                        'Bacon' => '/cooking/bacon/projects/',\n                        'BBQ & Grilling' => '/cooking/bbq-and-grilling/projects/',\n                        'Beverages' => '/cooking/beverages/projects/',\n                        'Bread' => '/cooking/bread/projects/',\n                        'Breakfast' => '/cooking/breakfast/projects/',\n                        'Cake' => '/cooking/cake/projects/',\n                        'Candy' => '/cooking/candy/projects/',\n                        'Canning & Preserving' => '/cooking/canning-and-preserving/projects/',\n                        'Cocktails & Mocktails' => '/cooking/cocktails-and-mocktails/projects/',\n                        'Coffee' => '/cooking/coffee/projects/',\n                        'Cookies' => '/cooking/cookies/projects/',\n                        'Cupcakes' => '/cooking/cupcakes/projects/',\n                        'Dessert' => '/cooking/dessert/projects/',\n                        'Homebrew' => '/cooking/homebrew/projects/',\n                        'Main Course' => '/cooking/main-course/projects/',\n                        'Pasta' => '/cooking/pasta/projects/',\n                        'Pie' => '/cooking/pie/projects/',\n                        'Pizza' => '/cooking/pizza/projects/',\n                        'Salad' => '/cooking/salad/projects/',\n                        'Sandwiches' => '/cooking/sandwiches/projects/',\n                        'Snacks & Appetizers' => '/cooking/snacks-and-appetizers/projects/',\n                        'Soups & Stews' => '/cooking/soups-and-stews/projects/',\n                        'Vegetarian & Vegan' => '/cooking/vegetarian-and-vegan/projects/',\n                    ],\n                    'Living' => [\n                        'All' => '/living/',\n                        'Beauty' => '/living/beauty/projects/',\n                        'Christmas' => '/living/christmas/projects/',\n                        'Cleaning' => '/living/cleaning/projects/',\n                        'Decorating' => '/living/decorating/projects/',\n                        'Education' => '/living/education/projects/',\n                        'Gardening' => '/living/gardening/projects/',\n                        'Halloween' => '/living/halloween/projects/',\n                        'Health' => '/living/health/projects/',\n                        'Hiding Places' => '/living/hiding-places/projects/',\n                        'Holidays' => '/living/holidays/projects/',\n                        'Homesteading' => '/living/homesteading/projects/',\n                        'Kids' => '/living/kids/projects/',\n                        'Kitchen' => '/living/kitchen/projects/',\n                        'LEGO & KNEX' => '/living/lego-and-knex/projects/',\n                        'Life Hacks' => '/living/life-hacks/projects/',\n                        'Music' => '/living/music/projects/',\n                        'Office Supply Hacks' => '/living/office-supply-hacks/projects/',\n                        'Organizing' => '/living/organizing/projects/',\n                        'Pest Control' => '/living/pest-control/projects/',\n                        'Pets' => '/living/pets/projects/',\n                        'Pranks, Tricks, & Humor' => '/living/pranks-tricks-and-humor/projects/',\n                        'Relationships' => '/living/relationships/projects/',\n                        'Toys & Games' => '/living/toys-and-games/projects/',\n                        'Travel' => '/living/travel/projects/',\n                        'Video Games' => '/living/video-games/projects/',\n                    ],\n                    'Outside' => [\n                        'All' => '/outside/',\n                        'Backyard' => '/outside/backyard/projects/',\n                        'Beach' => '/outside/beach/projects/',\n                        'Bikes' => '/outside/bikes/projects/',\n                        'Birding' => '/outside/birding/projects/',\n                        'Boats' => '/outside/boats/projects/',\n                        'Camping' => '/outside/camping/projects/',\n                        'Climbing' => '/outside/climbing/projects/',\n                        'Fire' => '/outside/fire/projects/',\n                        'Fishing' => '/outside/fishing/projects/',\n                        'Hunting' => '/outside/hunting/projects/',\n                        'Kites' => '/outside/kites/projects/',\n                        'Knots' => '/outside/knots/projects/',\n                        'Launchers' => '/outside/launchers/projects/',\n                        'Paracord' => '/outside/paracord/projects/',\n                        'Rockets' => '/outside/rockets/projects/',\n                        'Siege Engines' => '/outside/siege-engines/projects/',\n                        'Skateboarding' => '/outside/skateboarding/projects/',\n                        'Snow' => '/outside/snow/projects/',\n                        'Sports' => '/outside/sports/projects/',\n                        'Survival' => '/outside/survival/projects/',\n                        'Water' => '/outside/water/projects/',\n                    ],\n                    'Makeymakey' => [\n                        'All' => '/makeymakey/',\n                        'Makey Makey on Instructables' => '/makeymakey/',\n                    ],\n                    'Teachers' => [\n                        'All' => '/teachers/',\n                        'ELA' => '/teachers/ela/projects/',\n                        'Math' => '/teachers/math/projects/',\n                        'Science' => '/teachers/science/projects/',\n                        'Social Studies' => '/teachers/social-studies/projects/',\n                        'Engineering' => '/teachers/engineering/projects/',\n                        'Coding' => '/teachers/coding/projects/',\n                        'Electronics' => '/teachers/electronics/projects/',\n                        'Robotics' => '/teachers/robotics/projects/',\n                        'Arduino' => '/teachers/arduino/projects/',\n                        'CNC' => '/teachers/cnc/projects/',\n                        'Laser Cutting' => '/teachers/laser-cutting/projects/',\n                        '3D Printing' => '/teachers/3d-printing/projects/',\n                        '3D Design' => '/teachers/3d-design/projects/',\n                        'Art' => '/teachers/art/projects/',\n                        'Music' => '/teachers/music/projects/',\n                        'Theatre' => '/teachers/theatre/projects/',\n                        'Wood Shop' => '/teachers/wood-shop/projects/',\n                        'Metal Shop' => '/teachers/metal-shop/projects/',\n                        'Resources' => '/teachers/resources/projects/',\n                    ],\n                ],\n                'title' => 'Select your category (required)',\n                'defaultValue' => 'Circuits'\n            ],\n            'filter' => [\n                'name' => 'Filter',\n                'type' => 'list',\n                'values' => [\n                    'Featured' => ' ',\n                    'Recent' => 'recent/',\n                    'Popular' => 'popular/',\n                    'Views' => 'views/',\n                    'Contest Winners' => 'winners/'\n                ],\n                'title' => 'Select a filter',\n                'defaultValue' => 'Featured'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $category = $this->getInput('category');\n        $filter = $this->getInput('filter');\n\n        $api = 'https://www.instructables.com/api_proxy/search/collections/projects/documents/search';\n        //$sortBy = 'views:desc';\n        $sortBy = 'publishDate:desc';\n        //$filterBy = 'featureFlag:=true && category:=Circuits && channel: [Apple,Linux]';\n        $filterBy = 'featureFlag:=true && category:=Circuits';\n        //$filterBy = 'featureFlag:=true && teachers:=Teachers';\n        //$filterBy = 'featureFlag:=true && category:=Craft';\n        $params = [\n            'q'                 => '*',\n            'query_by'          => 'title,stepBody,screenName',\n            'page'              => '1',\n            'sort_by'           => $sortBy,\n            'include_fields'    => 'title,urlString,coverImageUrl,screenName,favorites,views,primaryClassification,featureFlag,prizeLevel,IMadeItCount',\n            'filter_by'         => $filterBy,\n            'per_page'          => '50',\n        ];\n\n        $url = $api . '?' . http_build_query($params);\n        /* phpcs:ignore */\n        $key = 'TUIxY0xkNjdHV09KaFV1dEVxYVRHNGs1QW1sbzlNVVZBaVZKV2VrODc0VT02ZWFYeyJleGNsdWRlX2ZpZWxkcyI6WyJvdXRfb2YiLCJzZWFyY2hfdGltZV9tcyIsInN0ZXBCb2R5Il0sInBlcl9wYWdlIjo2MH0=';\n        $json = getContents($url, [\"x-typesense-api-key: $key\"]);\n        $data = Json::decode($json, false);\n\n        foreach ($data->hits as $hit) {\n            $document = $hit->document;\n            $item = [];\n            $item['uri'] = 'https://www.instructables.com/' . $document->urlString;\n            $item['author'] = $document->screenName;\n            $item['title'] = $document->title;\n            $item['content'] = '<pre>' . Json::encode($document) . '</pre>';\n            $item['enclosures'] = [$document->coverImageUrl];\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/InternationalInstituteForStrategicStudiesBridge.php",
    "content": "<?php\n\nclass InternationalInstituteForStrategicStudiesBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'International Institute For Strategic Studies';\n    const URI = 'https://www.iiss.org';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Returns the latest blog posts from the IISS';\n\n    const TEMPLATE_ID = ['BlogArticlePage', 'BlogPage'];\n    const COMPONENT_ID = '9b0c6919-c78b-4910-9be9-d73e6ee40e50';\n\n    public function collectData()\n    {\n        $url = 'https://www.iiss.org/api/filteredlist/filter';\n        $opts = [\n            CURLOPT_CUSTOMREQUEST => 'POST',\n            CURLOPT_POSTFIELDS => json_encode([\n                'templateId' => self::TEMPLATE_ID,\n                'componentId' => self::COMPONENT_ID,\n                'page' => '1',\n                'amount' => 1,\n                'filter' => (object)[],\n                'tags' => null,\n                'sortType' => 'Newest',\n                'restrictionType' => 'Any'\n            ])\n        ];\n        $headers = [\n            'Accept: application/json, text/plain, */*',\n            'Content-Type: application/json;charset=UTF-8',\n        ];\n        $json = getContents($url, $headers, $opts);\n        $data = json_decode($json);\n\n        foreach ($data->model->Results as $record) {\n            [$content, $enclosures] = $this->getContents(self::URI . $record->Link);\n            $this->items[] = [\n                'uri' => self::URI . $record->Link,\n                'title' => $record->Heading,\n                'categories' => [$record->Topic],\n                'author' => join(', ', array_map(function ($author) {\n                    return $author->Name;\n                }, $record->Authors)),\n                'timestamp' => DateTime::createFromFormat('jS F Y', $record->Date)->format('U'),\n                'content' => $content,\n                'enclosures' => $enclosures\n            ];\n        }\n    }\n\n    private function getContents($uri)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n        $body = $html->find('body', 0);\n        $scripts = $body->find('script');\n        $result = '';\n\n        $enclosures = [];\n\n        foreach ($scripts as $script) {\n            $script_text = $script->innertext;\n            if (str_contains($script_text, 'ReactDOM.render(React.createElement(Components.Reading')) {\n                $args = $this->getRenderArguments($script_text);\n                $result .= $args->Html;\n            } elseif (str_contains($script_text, 'ReactDOM.render(React.createElement(Components.ImagePanel')) {\n                $args = $this->getRenderArguments($script_text);\n                $result .= '<figure><img src=\"' . self::URI . $args->Image . '\"></img></figure>';\n            } elseif (str_contains($script_text, 'ReactDOM.render(React.createElement(Components.Intro')) {\n                $args = $this->getRenderArguments($script_text);\n                $result .= '<p>' . $args->Intro . '</p>';\n            } elseif (str_contains($script_text, 'ReactDOM.render(React.createElement(Components.Footnotes')) {\n                $args = $this->getRenderArguments($script_text);\n                $result .= '<p>' . $args->Content . '</p>';\n            } elseif (str_contains($script_text, 'ReactDOM.render(React.createElement(Components.List')) {\n                $args = $this->getRenderArguments($script_text);\n                foreach ($args->Items as $item) {\n                    if ($item->Url != null) {\n                        $match = preg_match('/\\\\\"(.*)\\\\\"/', $item->Url, $matches);\n                        if ($match > 0) {\n                            array_push($enclosures, self::URI . $matches[1]);\n                        }\n                    }\n                }\n            }\n        }\n        return [$result, $enclosures];\n    }\n\n    private function getRenderArguments($script_text)\n    {\n        $matches = [];\n        preg_match('/React\\.createElement\\(Components\\.\\w+, {(.*)}\\),/', $script_text, $matches);\n        return json_decode('{' . $matches[1] . '}');\n    }\n}\n"
  },
  {
    "path": "bridges/InternetArchiveBridge.php",
    "content": "<?php\n\nclass InternetArchiveBridge extends BridgeAbstract\n{\n    const NAME = 'Internet Archive';\n    const URI = 'https://archive.org';\n    const DESCRIPTION = 'Returns newest uploads, posts and more from an account';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'Account' => [\n            'username' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '@verifiedjoseph',\n            ],\n            'content' => [\n                'name' => 'Content',\n                'type' => 'list',\n                'values' => [\n                    'Uploads' => 'uploads',\n                    'Posts' => 'posts',\n                    'Reviews' => 'reviews',\n                    'Collections' => 'collections',\n                    'Web Archives' => 'web-archive',\n                ],\n                'defaultValue' => 'uploads',\n            ],\n            'limit' => self::LIMIT,\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 900; // 15 mins\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://archive.org/details/@verifiedjoseph' => [\n            'context' => 'Account', 'username' => 'verifiedjoseph', 'content' => 'uploads'\n        ],\n        'https://archive.org/details/@verifiedjoseph?tab=collections' => [\n            'context' => 'Account', 'username' => 'verifiedjoseph', 'content' => 'collections'\n        ],\n    ];\n\n    private $skipClasses = [\n        'item-ia mobile-header hidden-tiles',\n        'item-ia account-ia'\n    ];\n\n    private $detectParamsRegex = '/https?:\\/\\/archive\\.org\\/details\\/@([\\w]+)(?:\\?tab=([a-z-]+))?/';\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        if (preg_match($this->detectParamsRegex, $url, $matches) > 0) {\n            $params['context'] = 'Account';\n            $params['username'] = $matches[1];\n            $params['content'] = 'uploads';\n\n            if (isset($matches[2])) {\n                $params['content'] = $matches[2];\n            }\n\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $html = defaultLinkTo($html, $this->getURI());\n\n        if ($this->getInput('content') !== 'posts') {\n            $detailsDivNumber = 0;\n\n            $results = $html->find('div.results > div[data-id]');\n            foreach ($results as $index => $result) {\n                $item = [];\n\n                if (in_array($result->class, $this->skipClasses)) {\n                    continue;\n                }\n\n                switch ($result->class) {\n                    case 'item-ia':\n                        switch ($this->getInput('content')) {\n                            case 'reviews':\n                                $item = $this->processReview($result);\n                                break;\n                            case 'uploads':\n                                $item = $this->processUpload($result);\n                                break;\n                        }\n\n                        break;\n                    case 'item-ia url-item':\n                        $item = $this->processWebArchives($result);\n                        break;\n                    case 'item-ia collection-ia':\n                        $item = $this->processCollection($result);\n                        break;\n                }\n\n                if ($this->getInput('content') !== 'reviews') {\n                    $hiddenDetails = $this->processHiddenDetails($html, $detailsDivNumber, $item);\n\n                    $this->items[] = array_merge($item, $hiddenDetails);\n                } else {\n                    $this->items[] = $item;\n                }\n\n                $detailsDivNumber++;\n\n                $limit = $this->getInput('limit') ?? 10;\n                if (count($this->items) >= $limit) {\n                    break;\n                }\n            }\n        }\n\n        if ($this->getInput('content') === 'posts') {\n            $this->items = $this->processPosts($html);\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('username')) && !is_null($this->getInput('content'))) {\n            return self::URI . '/details/' . $this->processUsername() . '&tab=' . $this->getInput('content');\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('username')) && !is_null($this->getInput('content'))) {\n            return $this->getKey('content') . ' - '\n                . $this->processUsername() . ' - Internet Archive';\n        }\n\n        return parent::getName();\n    }\n\n    private function processUsername()\n    {\n        if (substr($this->getInput('username'), 0, 1) !== '@') {\n            return '@' . $this->getInput('username');\n        }\n\n        return $this->getInput('username');\n    }\n\n    private function processUpload($result)\n    {\n        $item = [];\n\n        $collection = $result->find('a.stealth', 0);\n        $collectionLink = $collection->href;\n        $collectionTitle = $collection->find('div.item-parent-ttl', 0)->plaintext;\n\n        $item['title'] = trim($result->find('div.ttl', 0)->innertext);\n        $item['timestamp'] = strtotime($result->find('div.hidden-tiles.pubdate.C.C3', 0)->children(0)->plaintext);\n        $item['uri'] = $result->find('div.item-ttl.C.C2 > a', 0)->href;\n\n        if ($result->find('div.by.C.C4', 0)->children(2)) {\n            $item['author'] = $result->find('div.by.C.C4', 0)->children(2)->plaintext;\n        }\n\n        $item['content'] = <<<EOD\n<p>Media Type: {$result->attr['data-mediatype']}<br>\nCollection: <a href=\"{$collectionLink}\">{$collectionTitle}</a></p>\nEOD;\n\n        $item['enclosures'][] = self::URI . $result->find('img.item-img', 0)->source;\n\n        return $item;\n    }\n\n    private function processReview($result)\n    {\n        $item = [];\n\n        $item['title'] = trim($result->find('div.ttl', 0)->innertext);\n        $item['timestamp'] = strtotime($result->find('div.hidden-tiles.pubdate.C.C3', 0)->children(0)->plaintext);\n        $item['uri'] = $result->find('div.review-title', 0)->children(0)->href;\n\n        if ($result->find('div.by.C.C4', 0)->children(2)) {\n            $item['author'] = $result->find('div.by.C.C4', 0)->children(2)->plaintext;\n        }\n\n        $item['content'] = <<<EOD\n<p><strong>Subject: {$result->find('div.review-title', 0)->plaintext}</strong></p>\n<p>{$result->find('div.hidden-lists.review', 0)->children(1)->plaintext}</p>\nEOD;\n\n        $item['enclosures'][] = self::URI . $result->find('img.item-img', 0)->source;\n\n        return $item;\n    }\n\n    private function processWebArchives($result)\n    {\n        $item = [];\n\n        $item['title'] = trim($result->find('div.ttl', 0)->plaintext);\n        $item['timestamp'] = strtotime($result->find('div.hidden-lists', 0)->children(0)->plaintext);\n        $item['uri'] = $result->find('div.item-ttl.C.C2 > a', 0)->href;\n\n        $item['content'] = <<<EOD\n{$this->processUsername()} archived <a href=\"{$item['uri']}\">{$result->find('div.ttl', 0)->plaintext}</a>\nEOD;\n\n        $item['enclosures'][] = $result->find('img.item-img', 0)->source;\n\n        return $item;\n    }\n\n    private function processCollection($result)\n    {\n        $item = [];\n\n        $title = trim($result->find('div.collection-title.C.C2', 0)->children(0)->plaintext);\n        $itemCount = strtolower(trim($result->find('div.num-items.topinblock', 0)->plaintext));\n\n        $item['title'] = $title . ' (' . $itemCount . ')';\n        $item['timestamp'] = strtotime($result->find('div.hidden-tiles.pubdate.C.C3', 0)->children(0)->plaintext);\n        $item['uri'] = $result->find('div.collection-title.C.C2 > a', 0)->href;\n\n        $item['content'] = '';\n\n        if ($result->find('img.item-img', 0)) {\n            $item['enclosures'][] = self::URI . $result->find('img.item-img', 0)->source;\n        }\n\n        return $item;\n    }\n\n    private function processHiddenDetails($html, $detailsDivNumber, $item)\n    {\n        $description = '';\n\n        if ($html->find('div.details-ia.hidden-tiles', $detailsDivNumber)) {\n            $detailsDiv = $html->find('div.details-ia.hidden-tiles', $detailsDivNumber);\n\n            if ($detailsDiv->find('div.C234', 0)->children(0)) {\n                $description = $detailsDiv->find('div.C234', 0)->children(0)->plaintext;\n\n                $detailsDiv->find('div.C234', 0)->children(0)->innertext = '';\n            }\n\n            $topics = trim($detailsDiv->find('div.C234', 0)->plaintext);\n\n            if (!empty($topics)) {\n                $topics = trim($detailsDiv->find('div.C234', 0)->plaintext);\n                $topics = trim(substr($topics, 7));\n\n                $item['categories'] = explode(',', $topics);\n            }\n\n            $item['content'] = '<p>' . $description . '</p>' . $item['content'];\n        }\n\n        return $item;\n    }\n\n    private function processPosts($html)\n    {\n        $items = [];\n\n        foreach ($html->find('table.forumTable > tr') as $index => $tr) {\n            $item = [];\n\n            if ($index === 0) {\n                continue;\n            }\n\n            $item['title'] = $tr->find('td', 0)->plaintext;\n            $item['timestamp'] = strtotime($tr->find('td', 4)->children(0)->plaintext);\n            $item['uri'] = $tr->find('td', 0)->children(0)->href;\n\n            $formLink = <<<EOD\n<a href=\"{$tr->find('td', 2)->children(0)->href}\">{$tr->find('td', 2)->children(0)->plaintext}</a>\nEOD;\n\n            $postDate = $tr->find('td', 4)->children(0)->plaintext;\n\n            $postPageHtml = getSimpleHTMLDOMCached($item['uri'], 3600);\n\n            $postPageHtml = defaultLinkTo($postPageHtml, $this->getURI());\n\n            $post = $postPageHtml->find('div.box.well.well-sm', 0);\n\n            $parentLink = '';\n            $replyLink = <<<EOD\n<a href=\"{$post->find('a', 0)->href}\">Reply</a>\nEOD;\n\n            if ($post->find('a', 1)->innertext = 'See parent post') {\n                $parentLink = <<<EOD\n<a href=\"{$post->find('a', 1)->href}\">View parent post</a>\nEOD;\n            }\n\n            $post->find('h1', 0)->outertext = '';\n            $post->find('h2', 0)->outertext = '';\n\n            $item['content'] = <<<EOD\n<p>{$post->innertext}</p>{$replyLink} - {$parentLink} - Posted in {$formLink} on {$postDate}\nEOD;\n\n            $items[] = $item;\n\n            if (count($items) >= $this->getInput('limit') ?? 10) {\n                break;\n            }\n        }\n\n        return $items;\n    }\n}\n"
  },
  {
    "path": "bridges/InvestorsObserverBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass InvestorsObserverBridge extends BridgeAbstract\n{\n    const NAME          = 'InvestorsObserver';\n    const URI           = 'https://investorsobserver.com';\n    const DESCRIPTION   = 'Fetches the latest stock news';\n    const MAINTAINER    = 'tillcash';\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const MAX_ARTICLES  = 5;\n\n    public function collectData()\n    {\n        $urls = get_sitemap(self::URI . '/news-sitemap.xml');\n\n        foreach ($urls as $entry) {\n            $title = null;\n            $pubDate = null;\n\n            $url     = trim((string) $entry['loc']);\n            $lastmod = trim((string) $entry['lastmod']);\n\n            if (!$url) {\n                continue;\n            }\n\n            if (isset($entry['news'])) {\n                $news = $entry['news'];\n\n                if ($news) {\n                    $title = trim((string) $news['title']);\n                    $pubDate = trim((string) $news['publication_date']);\n                }\n            }\n\n            if (!$title) {\n                continue;\n            }\n\n            $timestamp = $pubDate ? strtotime($pubDate) : ($lastmod ? strtotime($lastmod) : '');\n\n            $this->items[] = [\n                'title'     => $title,\n                'uri'       => $url,\n                'uid'       => $url,\n                'timestamp' => $timestamp,\n                'content'   => $this->fetchFullArticle($url),\n            ];\n\n            if (count($this->items) >= self::MAX_ARTICLES) {\n                break;\n            }\n        }\n    }\n\n    private function fetchFullArticle(string $url): string\n    {\n        $html = getSimpleHTMLDOMCached($url);\n\n        if (!$html) {\n            return 'Unable to fetch article content';\n        }\n\n        $article = $html->find('article', 0);\n\n        if (!$article) {\n            return 'Unable to parse article content';\n        }\n\n         // Remove unnecessary elements\n        $removeSelectors = [\n            'script',\n            'style',\n            'div.links-bar',\n            'div.a-wrapper',\n            'div.related-articles',\n            'hr.space_media-size',\n        ];\n\n        foreach ($removeSelectors as $selector) {\n            foreach ($article->find($selector) as $element) {\n                $element->outertext = '';\n            }\n        }\n\n        return $article->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/ItakuBridge.php",
    "content": "<?php\n\nclass ItakuBridge extends BridgeAbstract\n{\n    const NAME = 'Itaku.ee';\n    const URI = 'https://itaku.ee';\n    const CACHE_TIMEOUT = 900; // 15mn\n    const MAINTAINER = 'mruac';\n    const DESCRIPTION = 'Bridges for Itaku.ee';\n    const PARAMETERS = [\n        'Image Search' => [\n            'text' => [\n                'name' => 'Text to search',\n                'title' => 'Search includes title, description and tags.',\n                'type' => 'text',\n                'exampleValue' => 'Text (incl. tags)'\n            ],\n            'tags' => [\n                'name' => 'Tags to search',\n                'title' => 'Space seperated tags to include in search. Prepend with \"-\" to exclude, \"~\" for optional.',\n                'type' => 'text',\n                'exampleValue' => 'tag1 -tag2 ~tag3'\n            ],\n            'order' => [\n                'name' => 'Sort by',\n                'type' => 'list',\n                'values' => [\n                    'Trending' => '-hotness_score',\n                    'Newest' => '-date_added',\n                    'Oldest' => 'date_added',\n                    'Top' => '-num_likes',\n                    'Bottom' => 'num_likes'\n                ],\n                'defaultValue' => '-date_added'\n            ],\n            'range' => [\n                'name' => 'Date range',\n                'type' => 'list',\n                'values' => [\n                    'Today' => 'today',\n                    'Yesterday' => 'yesterday',\n                    'Past 3 days' => '3_days',\n                    'Past week' => 'week',\n                    'Past month' => '30_days',\n                    'Past year' => '365_days',\n                    'All time' => ''\n                ],\n                'defaultValue' => 'All time'\n            ],\n            'video_only' => [\n                'name' => 'Video only?',\n                'type' => 'checkbox'\n            ],\n            'rating_s' => [\n                'name' => 'Include SFW',\n                'type' => 'checkbox'\n            ],\n            'rating_q' => [\n                'name' => 'Include Questionable',\n                'type' => 'checkbox'\n            ],\n            'rating_e' => [\n                'name' => 'Include NSFW',\n                'type' => 'checkbox'\n            ]\n        ],\n        'Post Search' => [\n            'tags' => [\n                'name' => 'Tags to search',\n                'title' => 'Space seperated tags to include in search. Prepend with \"-\" to exclude, \"~\" for optional.',\n                'type' => 'text',\n                'exampleValue' => 'tag1 -tag2 ~tag3'\n            ],\n            'order' => [\n                'name' => 'Sort by',\n                'type' => 'list',\n                'values' => [\n                    'Trending' => '-hotness_score',\n                    'Newest' => '-date_added',\n                    'Oldest' => 'date_added',\n                    'Top' => '-num_likes',\n                    'Bottom' => 'num_likes'\n                ],\n                'defaultValue' => '-date_added'\n            ],\n            'range' => [\n                'name' => 'Date range',\n                'type' => 'list',\n                'values' => [\n                    'Today' => 'today',\n                    'Yesterday' => 'yesterday',\n                    'Past 3 days' => '3_days',\n                    'Past week' => 'week',\n                    'Past month' => '30_days',\n                    'Past year' => '365_days',\n                    'All time' => ''\n                ],\n                'defaultValue' => 'All time'\n            ],\n            'text_only' => [\n                'name' => 'Only include posts with text?',\n                'type' => 'checkbox'\n            ],\n            'rating_s' => [\n                'name' => 'Include SFW',\n                'type' => 'checkbox'\n            ],\n            'rating_q' => [\n                'name' => 'Include Questionable',\n                'type' => 'checkbox'\n            ],\n            'rating_e' => [\n                'name' => 'Include NSFW',\n                'type' => 'checkbox'\n            ]\n        ],\n        'User profile' => [\n            'user' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true\n            ],\n            'user_id' => [\n                'name' => 'User ID',\n                'type' => 'number',\n                'title' => 'User ID, if known.'\n            ],\n            'reshares' => [\n                'name' => 'Include reshares',\n                'type' => 'checkbox'\n            ],\n            'rating_s' => [\n                'name' => 'Include SFW',\n                'type' => 'checkbox'\n            ],\n            'rating_q' => [\n                'name' => 'Include Questionable',\n                'type' => 'checkbox'\n            ],\n            'rating_e' => [\n                'name' => 'Include NSFW',\n                'type' => 'checkbox'\n            ]\n        ],\n        'Home feed' => [\n            'order' => [\n                'name' => 'Sort by',\n                'type' => 'list',\n                'values' => [\n                    'Trending' => '-hotness_score',\n                    'Newest' => '-date_added'\n                ],\n                'defaultValue' => '-date_added'\n            ],\n            'range' => [\n                'name' => 'Date range',\n                'type' => 'list',\n                'values' => [\n                    'Today' => 'today',\n                    'Yesterday' => 'yesterday',\n                    'Past 3 days' => '3_days',\n                    'Past week' => 'week',\n                    'Past month' => '30_days',\n                    'Past year' => '365_days',\n                    'All time' => ''\n                ],\n                'defaultValue' => 'All time'\n            ],\n            'reshares' => [\n                'name' => 'Include reshares',\n                'type' => 'checkbox'\n            ],\n            'rating_s' => [\n                'name' => 'Include SFW',\n                'type' => 'checkbox'\n            ],\n            'rating_q' => [\n                'name' => 'Include Questionable',\n                'type' => 'checkbox'\n            ],\n            'rating_e' => [\n                'name' => 'Include NSFW',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        if ($this->queriedContext === 'Image Search') {\n            $opt = [\n                'text' => $this->getInput('text'),\n                'optional_tags' => [],\n                'negative_tags' => [],\n                'required_tags' => [],\n                'order' => $this->getInput('order'),\n                'range' => $this->getInput('range'),\n                'video_only' => $this->getInput('video_only'),\n                'rating_s' => $this->getInput('rating_s'),\n                'rating_q' => $this->getInput('rating_q'),\n                'rating_e' => $this->getInput('rating_e')\n            ];\n\n            $tag_arr = explode(' ', $this->getInput('tags') ?? '');\n            foreach ($tag_arr as $str) {\n                switch ($str[0]) {\n                    case '-':\n                        $opt['negative_tags'][] = substr($str, 1);\n                        break;\n\n                    case '~':\n                        $opt['optional_tags'][] = substr($str, 1);\n                        break;\n\n                    default:\n                        $opt['required_tags'][] = substr($str, 1);\n                        break;\n                }\n            }\n\n            $data = $this->getImagesSearch($opt);\n\n            foreach ($data['results'] as $record) {\n                $item = $this->getImage($record['id']);\n                $this->addItem($item);\n            }\n        }\n\n        if ($this->queriedContext === 'Post Search') {\n            $opt = [\n                'optional_tags' => [],\n                'negative_tags' => [],\n                'required_tags' => [],\n                'order' => $this->getInput('order'),\n                'range' => $this->getInput('range'),\n                'text_only' => $this->getInput('text_only'),\n                'rating_s' => $this->getInput('rating_s'),\n                'rating_q' => $this->getInput('rating_q'),\n                'rating_e' => $this->getInput('rating_e')\n            ];\n\n            $tag_arr = explode(' ', $this->getInput('tags'));\n            foreach ($tag_arr as $str) {\n                switch ($str[0]) {\n                    case '-':\n                        $opt['negative_tags'][] = substr($str, 1);\n                        break;\n\n                    case '~':\n                        $opt['optional_tags'][] = substr($str, 1);\n                        break;\n\n                    default:\n                        $opt['required_tags'][] = substr($str, 1);\n                        break;\n                }\n            }\n\n            $data = $this->getPostsSearch($opt);\n\n            foreach ($data['results'] as $record) {\n                $item = $this->getPost($record['id'], $record);\n                $this->addItem($item);\n            }\n        }\n\n        if (\n            $this->queriedContext === 'User profile'\n            || $this->queriedContext === 'Home feed'\n        ) {\n            $opt = [\n                'reshares' => $this->getInput('reshares'),\n                'rating_s' => $this->getInput('rating_s'),\n                'rating_q' => $this->getInput('rating_q'),\n                'rating_e' => $this->getInput('rating_e')\n            ];\n\n            if ($this->queriedContext === 'User profile') {\n                $opt['order'] = '-date_added';\n                $opt['range'] = '';\n                $user_id = $this->getInput('user_id') ?? $this->getOwnerID($this->getInput('user'));\n\n                $data = $this->getFeedData(\n                    $opt,\n                    $user_id\n                );\n            }\n\n            if ($this->queriedContext === 'Home feed') {\n                $opt['order'] = $this->getInput('order');\n                $opt['range'] = $this->getInput('range');\n                $data = $this->getFeedData($opt);\n            }\n\n            foreach ($data['results'] as $record) {\n                switch ($record['content_type']) {\n                    case 'reshare':\n                        //get type of reshare and its id\n                        $id = $record['content_object']['content_object']['id'];\n                        switch ($record['content_object']['content_type']) {\n                            case 'galleryimage':\n                                $item = $this->getImage($id);\n                                $item['title'] = \"{$record['owner_username']} shared: {$item['title']}\";\n                                break;\n\n                            case 'commission':\n                                $item = $this->getCommission($id, $record['content_object']['content_object']);\n                                $item['title'] = \"{$record['owner_username']} shared: {$item['title']}\";\n                                break;\n\n                            case 'post':\n                                $item = $this->getPost($id, $record['content_object']['content_object']);\n                                $item['title'] = \"{$record['owner_username']} shared: {$item['title']}\";\n                                break;\n                        };\n                        break;\n                    case 'galleryimage':\n                        $item = $this->getImage($record['content_object']['id']);\n                        break;\n\n                    case 'commission':\n                        $item = $this->getCommission($record['content_object']['id'], $record['content_object']);\n                        break;\n\n                    case 'post':\n                        $item = $this->getPost($record['content_object']['id'], $record['content_object']);\n                        break;\n                }\n\n                $this->addItem($item);\n            }\n        }\n    }\n\n    public function getName()\n    {\n        return self::NAME;\n    }\n\n    public function getURI()\n    {\n        return self::URI;\n    }\n\n    private function getImagesSearch(array $opt)\n    {\n        $url = self::URI . \"/api/galleries/images/?by_following=false&date_range={$opt['range']}&ordering={$opt['order']}&is_video={$opt['video_only']}\";\n        $url .= \"&text={$opt['text']}&visibility=PUBLIC&visibility=PROFILE_ONLY&page=1&page_size=30&format=json\";\n\n        if (count($opt['optional_tags']) > 0) {\n            foreach ($opt['optional_tags'] as $tag) {\n                $url .= \"&optional_tags=$tag\";\n            }\n        }\n        if (count($opt['negative_tags']) > 0) {\n            foreach ($opt['negative_tags'] as $tag) {\n                $url .= \"&negative_tags=$tag\";\n            }\n        }\n        if (count($opt['required_tags']) > 0) {\n            foreach ($opt['required_tags'] as $tag) {\n                $url .= \"&required_tags=$tag\";\n            }\n        }\n        if ($opt['rating_s']) {\n            $url .= '&maturity_rating=SFW';\n        }\n        if ($opt['rating_q']) {\n            $url .= '&maturity_rating=Questionable';\n        }\n        if ($opt['rating_e']) {\n            $url .= '&maturity_rating=NSFW';\n        }\n\n        return $this->getData($url, false, true);\n    }\n\n\n    private function getPostsSearch(array $opt)\n    {\n        $url = self::URI . \"/api/posts/?by_following=false&date_range={$opt['range']}&ordering={$opt['order']}\";\n        $url .= '&visibility=PUBLIC&visibility=PROFILE_ONLY&page=1&page_size=30&format=json';\n\n        if (count($opt['optional_tags']) > 0) {\n            foreach ($opt['optional_tags'] as $tag) {\n                $url .= \"&optional_tags=$tag\";\n            }\n        }\n        if (count($opt['negative_tags']) > 0) {\n            foreach ($opt['negative_tags'] as $tag) {\n                $url .= \"&negative_tags=$tag\";\n            }\n        }\n        if (count($opt['required_tags']) > 0) {\n            foreach ($opt['required_tags'] as $tag) {\n                $url .= \"&required_tags=$tag\";\n            }\n        }\n        if ($opt['rating_s']) {\n            $url .= '&maturity_rating=SFW';\n        }\n        if ($opt['rating_q']) {\n            $url .= '&maturity_rating=Questionable';\n        }\n        if ($opt['rating_e']) {\n            $url .= '&maturity_rating=NSFW';\n        }\n\n        return $this->getData($url, false, true);\n    }\n\n    private function getFeedData(array $opt, $ownerID = null)\n    {\n        $url = self::URI . \"/api/feed/?date_range={$opt['range']}&ordering={$opt['order']}&page=1&page_size=30&format=json\";\n\n        if (is_null($ownerID)) {\n            $url .= '&visibility=PUBLIC&by_following=false';\n        } else {\n            $url .= \"&owner={$ownerID}\";\n        }\n\n        if (!$opt['reshares']) {\n            $url .= '&hide_reshares=true';\n        }\n        if ($opt['rating_s']) {\n            $url .= '&maturity_rating=SFW';\n        }\n        if ($opt['rating_q']) {\n            $url .= '&maturity_rating=Questionable';\n        }\n        if ($opt['rating_e']) {\n            $url .= '&maturity_rating=NSFW';\n        }\n\n        return $this->getData($url, false, true);\n    }\n\n    private function getOwnerID($username)\n    {\n        $url = self::URI . \"/api/user_profiles/{$username}/?format=json\";\n        $data = $this->getData($url, true, true);\n\n        return $data['owner'];\n    }\n\n    private function getPost($id, ?array $metadata = null)\n    {\n        if (isset($metadata) && count($metadata['gallery_images']) < $metadata['num_images']) {\n            $metadata = null; //force re-fetch of metadata\n        }\n        $uri = self::URI . '/posts/' . $id;\n        $url = self::URI . '/api/posts/' . $id . '/?format=json';\n        $data = $metadata ?? $this->getData($url, true, true);\n\n        $content_str = nl2br($data['content']);\n        $content = \"<p>{$content_str}</p><br/>\"; //TODO: Add link and itaku user mention detection and convert into links.\n\n        if (array_key_exists('tags', $data) && count($data['tags']) > 0) {\n            $tag_types = [\n                'ARTIST' => '',\n                'COPYRIGHT' => '',\n                'CHARACTER' => '',\n                'SPECIES' => '',\n                'GENERAL' => '',\n                'META' => ''\n            ];\n            foreach ($data['tags'] as $tag) {\n                $url = self::URI . '/tags/' . $tag['id'];\n                $str = \"<a href=\\\"{$url}\\\">#{$tag['name']}</a> \";\n                $tag_types[$tag['tag_type']] .= $str;\n            }\n\n            foreach ($tag_types as $type => $str) {\n                if (strlen($str) > 0) {\n                    $content .= \"🏷 <b>{$type}:</b> {$str}<br/>\";\n                }\n            }\n        }\n\n        if (count($data['folders']) > 0) {\n            $content .= '📁 In Folder(s): ';\n            foreach ($data['folders'] as $folder) {\n                $url = self::URI . '/profile/' . $data['owner_username'] . '/posts/' . $folder['id'];\n                $content .= \"<a href=\\\"{$url}\\\">#{$folder['title']}</a> \";\n            }\n        }\n\n        $content .= '<hr/>';\n        if (count($data['gallery_images']) > 0) {\n            foreach ($data['gallery_images'] as $media) {\n                $title = $media['title'];\n                $url = self::URI . '/images/' . $media['id'];\n                $src = $media['image_xl'];\n                $content .= '<p>';\n                $content .= \"<a href=\\\"{$url}\\\"><b>{$title}</b></a><br/>\";\n                if ($media['is_thumbnail_for_video']) {\n                    $url = self::URI . '/api/galleries/images/' . $media['id'] . '/?format=json';\n                    $media_data = $this->getData($url, true, true);\n                    $content .= \"<video controls src=\\\"{$media_data['video']['video']}\\\" poster=\\\"{$media['image_xl']}\\\"/>\";\n                } else {\n                    $content .= \"<a href=\\\"{$url}\\\"><img src=\\\"{$src}\\\"></a>\";\n                }\n                $content .= '</p><br/>';\n            }\n        }\n\n        return [\n            'uri' => $uri,\n            'title' => $data['title'],\n            'timestamp' => $data['date_added'],\n            'author' => $data['owner_username'],\n            'content' => $content,\n            'categories' => ['post'],\n            'uid' => $uri\n        ];\n    }\n\n    private function getCommission($id, ?array $metadata = null)\n    {\n        $url = self::URI . '/api/commissions/' . $id . '/?format=json';\n        $uri = self::URI . '/commissions/' . $id;\n\n        $data = $metadata ?? $this->getData($url, true, true);\n\n        $content_str = nl2br($data['description']);\n        $content = \"<p>{$content_str}</p><br>\";\n        //TODO: Add link and itaku user mention detection and convert into links.\n\n        if (array_key_exists('tags', $data) && count($data['tags']) > 0) {\n            // $content .= \"🏷 Tag(s): \";\n            $tag_types = [\n                'ARTIST' => '',\n                'COPYRIGHT' => '',\n                'CHARACTER' => '',\n                'SPECIES' => '',\n                'GENERAL' => '',\n                'META' => ''\n            ];\n            foreach ($data['tags'] as $tag) {\n                $url = self::URI . '/tags/' . $tag['id'];\n                $str = \"<a href=\\\"{$url}\\\">#{$tag['name']}</a> \";\n                $tag_types[$tag['tag_type']] .= $str;\n            }\n\n            foreach ($tag_types as $type => $str) {\n                if (strlen($str) > 0) {\n                    $content .= \"🏷 <b>{$type}:</b> {$str}<br/>\";\n                }\n            }\n        }\n\n        if (array_key_exists('reference_gallery_sections', $data) && count($data['reference_gallery_sections']) > 0) {\n            $content .= '📁 Example folder(s): ';\n            foreach ($data['folders'] as $folder) {\n                $url = self::URI . '/profile/' . $data['owner_username'] . '/gallery/' . $folder['id'];\n                $folder_name = $folder['title'];\n                if (!is_null($folder['group'])) {\n                    $folder_name = $folder['group']['title'] . '/' . $folder_name;\n                }\n                $content .= \"<a href=\\\"{$url}\\\">#{$folder_name}</a> \";\n            }\n        }\n\n        $content .= '<hr/>';\n        if (!is_null($data['thumbnail_detail'])) {\n            $content .= '<p>';\n            $content .= \"<a href=\\\"{$uri}\\\"><b>{$data['thumbnail_detail']['title']}</b></a><br/>\";\n            if ($data['thumbnail_detail']['is_thumbnail_for_video']) {\n                $url = self::URI . '/api/galleries/images/' . $data['thumbnail_detail']['id'] . '/?format=json';\n                $media_data = $this->getData($url, true, true);\n                $content .= \"<video controls src=\\\"{$media_data['video']['video']}\\\" poster=\\\"{$data['thumbnail_detail']['image_lg']}\\\"/>\";\n            } else {\n                $content .= \"<a href=\\\"{$uri}\\\"><img src=\\\"{$data['thumbnail_detail']['image_lg']}\\\"></a>\";\n            }\n\n            $content .= '</p>';\n        }\n\n        return [\n            'uri' => $uri,\n            'title' => \"{$data['comm_type']}: {$data['title']}\",\n            'timestamp' => $data['date_added'],\n            'author' => $data['owner_username'],\n            'content' => $content,\n            'categories' => ['commission', $data['comm_type']],\n            'uid' => $uri\n        ];\n    }\n\n    private function getImage($id /* array $metadata = null */) //$metadata disabled due to no essential information available in ./api/feed/ or ./api/galleries/images/ results.\n    {\n        $uri = self::URI . '/images/' . $id;\n        $url = self::URI . '/api/galleries/images/' . $id . '/?format=json';\n        $data = /* $metadata ?? */ $this->getData($url, true, true);\n\n        $content_str = nl2br($data['description']);\n        $content = \"<p>{$content_str}</p><br/>\"; //TODO: Add link and itaku user mention detection and convert into links.\n\n        if (array_key_exists('tags', $data) && count($data['tags']) > 0) {\n            // $content .= \"🏷 Tag(s): \";\n            $tag_types = [\n                'ARTIST' => '',\n                'COPYRIGHT' => '',\n                'CHARACTER' => '',\n                'SPECIES' => '',\n                'GENERAL' => '',\n                'META' => ''\n            ];\n            foreach ($data['tags'] as $tag) {\n                $url = self::URI . '/tags/' . $tag['id'];\n                $str = \"<a href=\\\"{$url}\\\">#{$tag['name']}</a> \";\n                $tag_types[$tag['tag_type']] .= $str;\n            }\n\n            foreach ($tag_types as $type => $str) {\n                if (strlen($str) > 0) {\n                    $content .= \"🏷 <b>{$type}:</b> {$str}<br/>\";\n                }\n            }\n        }\n\n        if (array_key_exists('sections', $data) && count($data['sections']) > 0) {\n            $content .= '📁 In Folder(s): ';\n            foreach ($data['sections'] as $folder) {\n                $url = self::URI . '/profile/' . $data['owner_username'] . '/gallery/' . $folder['id'];\n                $folder_name = $folder['title'];\n                if (!is_null($folder['group'])) {\n                    $folder_name = $folder['group']['title'] . '/' . $folder_name;\n                }\n                $content .= \"<a href=\\\"{$url}\\\">#{$folder_name}</a> \";\n            }\n        }\n\n        $content .= '<hr/>';\n\n        if (array_key_exists('is_thumbnail_for_video', $data)) {\n            $url = self::URI . '/api/galleries/images/' . $data['id'] . '/?format=json';\n            $media_data = $this->getData($url, true, true);\n            $content .= \"<video controls src=\\\"{$media_data['video']['video']}\\\" poster=\\\"{$data['image_xl']}\\\"/>\";\n        } else {\n            if (array_key_exists('video', $data) && is_null($data['video'])) {\n                $content .= \"<a href=\\\"{$uri}\\\"><img src=\\\"{$data['image_xl']}\\\"></a>\";\n            } else {\n                $content .= \"<video controls src=\\\"{$data['video']['video']}\\\" poster=\\\"{$data['image_xl']}\\\"/>\";\n            }\n        }\n\n        return [\n            'uri' => $uri,\n            'title' => $data['title'],\n            'timestamp' => $data['date_added'],\n            'author' => $data['owner_username'],\n            'content' => $content,\n            'categories' => ['image'],\n            'uid' => $uri\n        ];\n    }\n\n    private function getData(string $url, bool $cache = false, bool $getJSON = false, array $httpHeaders = [], array $curlOptions = [])\n    {\n        if ($getJSON) { //get JSON object\n            if ($cache) {\n                $data = $this->loadCacheValue($url);\n                if (!$data) {\n                    $data = getContents($url, $httpHeaders, $curlOptions);\n                    $this->saveCacheValue($url, $data);\n                }\n            } else {\n                $data = getContents($url, $httpHeaders, $curlOptions);\n            }\n            return json_decode($data, true);\n        } else { //get simpleHTMLDOM object\n            if ($cache) {\n                $html = getSimpleHTMLDOMCached($url, 86400); // 24 hours\n            } else {\n                $html = getSimpleHTMLDOM($url);\n            }\n            $html = defaultLinkTo($html, $url);\n            return $html;\n        }\n    }\n\n    private function addItem($item)\n    {\n        if (is_null($item)) {\n            return;\n        }\n\n        if (is_array($item) || is_object($item)) {\n            $this->items[] = $item;\n        } else {\n            throwServerException(\"Incorrectly parsed item. Check the code!\\nType: \" . gettype($item) . \"\\nprint_r(item:)\\n\" . var_dump($item));\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ItchioBridge.php",
    "content": "<?php\n\nclass ItchioBridge extends BridgeAbstract\n{\n    const NAME = 'itch.io';\n    const URI = 'https://itch.io';\n    const DESCRIPTION = 'Fetches the file uploads for a product';\n    const MAINTAINER = 'jacquesh';\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Product URL',\n            'exampleValue' => 'https://remedybg.itch.io/remedybg',\n            'required' => true,\n        ]\n    ]];\n    const CACHE_TIMEOUT = 21600; // 6 hours\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n        $html = getSimpleHTMLDOM($url);\n        // if the page is password protected, abort\n        if ($html->find('.game_password_page', 0) !== null) {\n            throwClientException('The requested page is password protected.');\n        }\n        $title = $html->find('.game_title', 0)->innertext;\n\n        $content = 'The following files are available to download:<br/>';\n        foreach ($html->find('div.upload') as $element) {\n            $filename = $element->find('strong.name', 0)->innertext;\n            $filesize = $element->find('span.file_size', 0)->first_child()->innertext;\n            $content = $content . $filename . ' (' . $filesize . ')<br/>';\n        }\n\n        // On 2021-04-28/29, itch.io changed their project page format so that the\n        // 'last updated' timestamp is only shown to logged-in users.\n        // Since we can't use the last-updated date to identify a post, we include\n        // the description text in the input for the UID hash so that if the\n        // project posts an update that changes the description but does not add\n        // or rename any files, we'll still flag it as an update.\n        $project_description = $html->find('div.formatted_description', 0)->plaintext;\n        $uidContent = $project_description . $content;\n\n        $item = [];\n        $item['uri'] = $url;\n        $item['uid'] = $uidContent;\n        $item['title'] = 'Update for ' . $title;\n        $item['content'] = $content;\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/IvooxBridge.php",
    "content": "<?php\n\n/**\n * IvooxRssBridge\n * Returns the latest search result\n * TODO: support podcast episodes list\n */\nclass IvooxBridge extends BridgeAbstract\n{\n    const NAME = 'Ivoox';\n    const URI = 'https://www.ivoox.com/';\n    const CACHE_TIMEOUT = 10800; // 3h\n    const DESCRIPTION = 'Returns the 10 newest episodes by keyword search';\n    const MAINTAINER = 'xurxof'; // based on YoutubeBridge by mitsukarenai\n    const PARAMETERS = [\n        'Search result' => [\n            's' => [\n                'name' => 'keyword',\n                'required' => true,\n                'exampleValue' => 'car'\n            ]\n        ]\n    ];\n\n    private function ivBridgeAddItem(\n        $episode_link,\n        $podcast_name,\n        $episode_title,\n        $author_name,\n        $episode_description,\n        $publication_date,\n        $episode_duration\n    ) {\n        $item = [];\n        $item['title'] = htmlspecialchars_decode($podcast_name . ': ' . $episode_title);\n        $item['author'] = $author_name;\n        $item['timestamp'] = $publication_date;\n        $item['uri'] = $episode_link;\n        $item['content'] = '<a href=\"' . $episode_link . '\">' . $podcast_name . ': ' . $episode_title\n            . '</a><br />Duration: ' . $episode_duration\n            . '<br />Description:<br />' . $episode_description;\n        $this->items[] = $item;\n    }\n\n    private function ivBridgeParseHtmlListing($html)\n    {\n        $limit = 4;\n        $count = 0;\n\n        foreach ($html->find('div.flip-container') as $flipper) {\n            $linkcount = 0;\n            if (!empty($flipper->find('div.modulo-type-banner'))) {\n                // ad\n                continue;\n            }\n\n            if ($count < $limit) {\n                foreach ($flipper->find('div.header-modulo') as $element) {\n                    foreach ($element->find('a') as $link) {\n                        if ($linkcount == 0) {\n                            $episode_link = $link->href;\n                            $episode_title = $link->title;\n                        } elseif ($linkcount == 1) {\n                            $author_link = $link->href;\n                            $author_name = $link->title;\n                        } elseif ($linkcount == 2) {\n                            $podcast_link = $link->href;\n                            $podcast_name = $link->title;\n                        }\n\n                        $linkcount++;\n                    }\n                }\n\n                $episode_description = $flipper->find('button.btn-link', 0)->getAttribute('data-content');\n                $episode_duration = $flipper->find('p.time', 0)->innertext;\n                $publication_date = $flipper->find('li.date', 0)->getAttribute('title');\n\n                // alternative date_parse_from_format\n                // or DateTime::createFromFormat('G:i - d \\d\\e M \\d\\e Y', $publication);\n                // TODO: month name translations, due function doesn't support locale\n\n                $a = strptime($publication_date, '%H:%M - %d de %b. de %Y'); // obsolete function, uses c libraries\n                $publication_date = mktime(0, 0, 0, $a['tm_mon'] + 1, $a['tm_mday'], $a['tm_year'] + 1900);\n\n                $this->ivBridgeAddItem(\n                    $episode_link,\n                    $podcast_name,\n                    $episode_title,\n                    $author_name,\n                    $episode_description,\n                    $publication_date,\n                    $episode_duration\n                );\n                $count++;\n            }\n        }\n    }\n\n    public function collectData()\n    {\n        // store locale, change to spanish\n        $originalLocales = explode(';', setlocale(LC_ALL, 0));\n        setlocale(LC_ALL, 'es_ES.utf8');\n\n        $xml = '';\n        $html = '';\n        $url_feed = '';\n        if ($this->getInput('s')) { /* Search  modes */\n            $this->request = str_replace(' ', '-', $this->getInput('s'));\n            $url_feed = self::URI . urlencode($this->request) . '_sb_f_1.html?o=uploaddate';\n        } else {\n            throwClientException('Not valid mode at IvooxBridge');\n        }\n\n        $dom = getSimpleHTMLDOM($url_feed);\n        $this->ivBridgeParseHtmlListing($dom);\n\n        // restore locale\n\n        foreach ($originalLocales as $localeSetting) {\n            if (strpos($localeSetting, '=') !== false) {\n                [$category, $locale] = explode('=', $localeSetting);\n                if (! defined($category)) {\n                    continue;\n                }\n                $category = constant($category);\n            } else {\n                $category = LC_ALL;\n                $locale = $localeSetting;\n            }\n\n            setlocale($category, $locale);\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/JapanExpoBridge.php",
    "content": "<?php\n\nclass JapanExpoBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Ginko';\n    const NAME = 'Japan Expo Actualités';\n    const URI = 'https://www.japan-expo-paris.com/fr/actualites';\n    const CACHE_TIMEOUT = 14400; // 4h\n    const DESCRIPTION = 'Returns most recent entries from Japan Expo actualités.';\n    const PARAMETERS = [ [\n        'mode' => [\n            'name' => 'Show full contents',\n            'type' => 'checkbox',\n        ]\n    ]];\n\n    public function getIcon()\n    {\n        return 'https://s.japan-expo.com/katana/images/JES073/favicons/paris.png';\n    }\n\n    public function collectData()\n    {\n        $convert_article_images = function ($matches) {\n            if (is_array($matches) && count($matches) > 1) {\n                return '<img src=\"' . $matches[1] . '\" />';\n            }\n        };\n\n        $html = getSimpleHTMLDOM(self::URI);\n        $fullcontent = $this->getInput('mode');\n        $count = 0;\n\n        foreach ($html->find('a._tile2') as $element) {\n            $url = $element->href;\n            $thumbnail = 'https://s.japan-expo.com/katana/images/JES049/paris.png';\n            preg_match('/url\\(([^)]+)\\)/', $element->find('img.rspvimgset', 0)->style, $img_search_result);\n\n            if (count($img_search_result) >= 2) {\n                $thumbnail = trim($img_search_result[1], \"'\");\n            }\n\n            if ($fullcontent) {\n                if ($count >= 5) {\n                    break;\n                }\n\n                $article_html = getSimpleHTMLDOMCached($url);\n                $header = $article_html->find('header.pageHeadBox', 0);\n                $timestamp = strtotime($header->find('time', 0)->datetime);\n                $title_html = $header->find('div.section', 0)->next_sibling();\n                $title = $title_html->plaintext;\n                $headings = $title_html->next_sibling()->outertext;\n                $article = $article_html->find('div.content', 0)->innertext;\n                $article = preg_replace_callback(\n                    '/<img [^>]+ style=\"[^\\(]+\\(\\'([^\\']+)\\'[^>]+>/i',\n                    $convert_article_images,\n                    $article\n                );\n\n                $content = $headings . $article;\n            } else {\n                $date_text = $element->find('span.date', 0)->plaintext;\n                $timestamp = $this->frenchPubDateToTimestamp($date_text);\n                $title = trim($element->find('span._title', 0)->plaintext);\n                $content = '<img src=\"'\n                . $thumbnail\n                . '\"></img><br />'\n                . $date_text\n                . '<br /><a href=\"'\n                . $url\n                . '\">Lire l\\'article</a>';\n            }\n\n            $item = [];\n            $item['uri'] = $url;\n            $item['title'] = $title;\n            $item['timestamp'] = $timestamp;\n            $item['enclosures'] = [$thumbnail];\n            $item['content'] = $content;\n            $this->items[] = $item;\n            $count++;\n        }\n    }\n\n    private function frenchPubDateToTimestamp($date_to_parse)\n    {\n        return strtotime(\n            strtr(\n                strtolower(str_replace('Publié le ', '', $date_to_parse)),\n                [\n                    'janvier' => 'jan',\n                    'février' => 'feb',\n                    'mars' => 'march',\n                    'avril' => 'apr',\n                    'mai' => 'may',\n                    'juin' => 'jun',\n                    'juillet' => 'jul',\n                    'août' => 'aug',\n                    'septembre' => 'sep',\n                    'octobre' => 'oct',\n                    'novembre' => 'nov',\n                    'décembre' => 'dec'\n                ]\n            )\n        );\n    }\n}\n"
  },
  {
    "path": "bridges/JohannesBlickBridge.php",
    "content": "<?php\n\nclass JohannesBlickBridge extends BridgeAbstract\n{\n    const NAME        = 'Johannes Blick';\n    const URI         = 'https://www.st-johannes-baptist.de/index.php/medien-und-downloads/archiv-johannesblick';\n    const DESCRIPTION = 'RSS feed for Johannes Blick';\n    const MAINTAINER  = 'jummo4@yahoo.de';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $html = defaultLinkTo($html, self::URI);\n        foreach ($html->find('ul[class=easyfolderlisting] > li > a') as $index => $a) {\n            $item = []; // Create an empty item\n            $articlePath = $a->href;\n            $item['title'] = $a->innertext;\n            $item['uri'] = $articlePath;\n            $item['content'] = '';\n\n            $this->items[] = $item; // Add item to the list\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/JornalNBridge.php",
    "content": "<?php\n\nclass JornalNBridge extends BridgeAbstract\n{\n    const NAME = 'Jornal N';\n    const URI = 'https://www.jornaln.pt/';\n    const DESCRIPTION = 'Returns news from the Portuguese local newspaper Jornal N';\n    const MAINTAINER = 'rmscoelho';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'News Feed',\n                'type' => 'list',\n                'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT',\n                'values' => [\n                    'Concelhos' => [\n                        'Espinho' => 'espinho',\n                        'Ovar' => 'ovar',\n                        'Santa Maria da Feira' => 'santa-maria-da-feira',\n                    ],\n                    'Cultura' => 'ovar/cultura',\n                    'Desporto' => 'desporto',\n                    'Economia' => 'santa-maria-da-feira/economia',\n                    'Política' => 'santa-maria-da-feira/politica',\n                    'Opinião' => 'santa-maria-da-feira/opiniao',\n                    'Sociedade' => 'santa-maria-da-feira/sociedade',\n                ]\n            ]\n        ]\n    ];\n\n    const PT_MONTH_NAMES = [\n        'janeiro' => '01',\n        'fevereiro' => '02',\n        'março' => '03',\n        'abril' => '04',\n        'maio' => '05',\n        'junho' => '06',\n        'julho' => '07',\n        'agosto' => '08',\n        'setembro' => '09',\n        'outubro' => '10',\n        'novembro' => '11',\n        'dezembro' => '12',\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.jornaln.pt/wp-content/uploads/2023/01/cropped-NovoLogoJornal_Instagram-192x192.png';\n    }\n\n    public function getName()\n    {\n        if ($this->getKey('feed')) {\n            return self::NAME . ' | ' . $this->getKey('feed');\n        }\n        return self::NAME;\n    }\n\n    public function getURI()\n    {\n        return self::URI . $this->getInput('feed');\n    }\n\n    public function collectData()\n    {\n        $url = sprintf(self::URI . '/%s', $this->getInput('feed'));\n        $dom = getSimpleHTMLDOMCached($url);\n        $domSelector = '.elementor-widget-container > .elementor-posts-container';\n        $dom = $dom->find($domSelector, 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('article') as $article) {\n            //Get thumbnail\n            $image = $article->find('.elementor-post__thumbnail img', 0)->src;\n            //Timestamp\n            $date = $article->find('.elementor-post-date', 0)->plaintext;\n            $date = trim($date, \"\\t \");\n            $date = preg_replace('/ de /i', '/', $date);\n            $date = preg_replace('/, /', '/', $date);\n            $date = explode('/', $date);\n            $year = (int) $date[2];\n            $month = (int) $date[1];\n            $day = (int) $date[0];\n            foreach (self::PT_MONTH_NAMES as $key => $item) {\n                if ($key === strtolower($month)) {\n                    $month = (int) $item;\n                }\n            }\n            $timestamp = mktime(0, 0, 0, $month, $day, $year);\n            //Content\n            $content = '<img src=\"' . $image . '\" alt=\"' . $article->find('.elementor-post__title > a', 0)->plaintext . '\" />';\n            $this->items[] = [\n                'title' => $article->find('.elementor-post__title > a', 0)->plaintext,\n                'uri' => $article->find('a', 0)->href,\n                'content' => $content,\n                'timestamp' => $timestamp\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/JustETFBridge.php",
    "content": "<?php\n\nclass JustETFBridge extends BridgeAbstract\n{\n    const NAME = 'justETF';\n    const URI = 'https://www.justetf.com';\n    const DESCRIPTION = 'Currently only supports the news feed';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        'News' => [\n            'full' => [\n                'name' => 'Full Article',\n                'type' => 'checkbox',\n                'title' => 'Enable to load full articles'\n            ]\n        ],\n        'Profile' => [\n            'isin' => [\n                'name' => 'ISIN',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'IE00B4X9L533',\n                'pattern' => '[a-zA-Z]{2}[a-zA-Z0-9]{10}',\n                'title' => 'ISIN, consisting of 2-letter country code, 9-character identifier, check character'\n            ],\n            'strategy' => [\n                'name' => 'Include Strategy',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ],\n            'description' => [\n                'name' => 'Include Description',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ],\n        'global' => [\n            'lang' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'values' => [\n                    'Englisch' => 'en',\n                    'Deutsch'  => 'de',\n                    'Italiano' => 'it'\n                ],\n                'defaultValue' => 'Englisch'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        defaultLinkTo($html, static::URI);\n\n        switch ($this->queriedContext) {\n            case 'News':\n                $this->collectNews($html);\n                break;\n            case 'Profile':\n                $this->collectProfile($html);\n                break;\n        }\n    }\n\n    public function getURI()\n    {\n        $uri = static::URI;\n\n        if ($this->getInput('lang')) {\n            $uri .= '/' . $this->getInput('lang');\n        }\n\n        switch ($this->queriedContext) {\n            case 'News':\n                $uri .= '/news';\n                break;\n            case 'Profile':\n                $uri .= '/etf-profile.html?' . http_build_query([\n                    'isin' => strtoupper($this->getInput('isin'))\n                ]);\n                break;\n        }\n\n        return $uri;\n    }\n\n    public function getName()\n    {\n        $name = static::NAME;\n\n        $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';\n\n        switch ($this->queriedContext) {\n            case 'News':\n                break;\n            case 'Profile':\n                if ($this->getInput('isin')) {\n                    $name .= ' ISIN ' . strtoupper($this->getInput('isin'));\n                }\n        }\n\n        if ($this->getInput('lang')) {\n            $name .= ' (' . strtoupper($this->getInput('lang')) . ')';\n        }\n\n        return $name;\n    }\n\n    #region Common\n\n    /**\n     * Fixes dates depending on the choosen language:\n     *\n     * de : dd.mm.yy\n     * en : dd.mm.yy\n     * it : dd/mm/yy\n     *\n     * Basically strtotime doesn't convert dates correctly due to formats\n     * being hard to interpret. So we use the DateTime object, manually\n     * fixing dates and times (set to 00:00:00.000).\n     *\n     * We don't know the timezone, so just assume +00:00 (or whatever\n     * DateTime chooses)\n     */\n    private function fixDate($date)\n    {\n        switch ($this->getInput('lang')) {\n            case 'en':\n            case 'de':\n                $df = date_create_from_format('d.m.y', $date);\n                break;\n            case 'it':\n                $df = date_create_from_format('d/m/y', $date);\n                break;\n        }\n\n        date_time_set($df, 0, 0);\n\n        return date_format($df, 'U');\n    }\n\n    private function extractImages($article)\n    {\n        // Notice: We can have zero or more images (though it should mostly be 1)\n        $elements = $article->find('img');\n\n        $images = [];\n\n        foreach ($elements as $img) {\n            // Skip the logo (mostly provided part of a hidden div)\n            if (substr($img->src, strrpos($img->src, '/') + 1) === 'logo.png') {\n                continue;\n            }\n\n            $images[] = $img->src;\n        }\n\n        return $images;\n    }\n\n    #endregion\n\n    #region News\n\n    private function collectNews($html)\n    {\n        $articles = $html->find('div.newsTopArticle')\n            or throwServerException('No articles found! Layout might have changed!');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            // Common data\n\n            $item['uri'] = $this->extractNewsUri($article);\n            $item['timestamp'] = $this->extractNewsDate($article);\n            $item['title'] = $this->extractNewsTitle($article);\n\n            if ($this->getInput('full')) {\n                $uri = $this->extractNewsUri($article);\n\n                $html = getSimpleHTMLDOMCached($uri);\n\n                $fullArticle = $html->find('div.article', 0)\n                    or throwServerException('No content found! Layout might have changed!');\n\n                defaultLinkTo($fullArticle, static::URI);\n\n                $item['author'] = $this->extractFullArticleAuthor($fullArticle);\n                $item['content'] = $this->extractFullArticleContent($fullArticle);\n                $item['enclosures'] = $this->extractImages($fullArticle);\n            } else {\n                $item['content'] = $this->extractNewsDescription($article);\n                $item['enclosures'] = $this->extractImages($article);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function extractNewsUri($article)\n    {\n        $element = $article->find('a', 0)\n            or throwServerException('Anchor not found!');\n\n        return $element->href;\n    }\n\n    private function extractNewsDate($article)\n    {\n        $element = $article->find('div.subheadline', 0)\n            or throwServerException('Date not found!');\n\n        $date = trim(explode('|', $element->plaintext)[0]);\n\n        return $this->fixDate($date);\n    }\n\n    private function extractNewsDescription($article)\n    {\n        $element = $article->find('span.newsText', 0)\n            or throwServerException('Description not found!');\n\n        $element->find('a', 0)->onclick = '';\n\n        return $element->innertext;\n    }\n\n    private function extractNewsTitle($article)\n    {\n        $element = $article->find('h3', 0)\n            or throwServerException('Title not found!');\n\n        return $element->plaintext;\n    }\n\n    private function extractFullArticleContent($article)\n    {\n        $element = $article->find('div.article_body', 0)\n            or throwServerException('Article body not found!');\n\n        // Remove teaser image\n        $element->find('img.teaser-img', 0)->outertext = '';\n\n        // Remove self advertisements\n        foreach ($element->find('.call-action') as $adv) {\n            $adv->outertext = '';\n        }\n\n        // Remove tips\n        foreach ($element->find('.panel-edu') as $tip) {\n            $tip->outertext = '';\n        }\n\n        // Remove inline scripts (used for i.e. interactive graphs) as they are\n        // rendered as a long series of strings\n        foreach ($element->find('script') as $script) {\n            $script->outertext = '[Content removed! Visit site to see full contents!]';\n        }\n\n        return $element->innertext;\n    }\n\n    private function extractFullArticleAuthor($article)\n    {\n        $element = $article->find('span[itemprop=name]', 0)\n            or throwServerException('Author not found!');\n\n        return $element->plaintext;\n    }\n\n    #endregion\n\n    #region Profile\n\n    private function collectProfile($html)\n    {\n        $item = [];\n\n        $item['uri'] = $this->getURI();\n        $item['timestamp'] = $this->extractProfileDate($html);\n        $item['title'] = $this->extractProfiletitle($html);\n        $item['author'] = $this->extractProfileAuthor($html);\n        $item['content'] = $this->extractProfileContent($html);\n\n        $this->items[] = $item;\n    }\n\n    private function extractProfileDate($html)\n    {\n        $element = $html->find('div.infobox div.vallabel', 0)\n            or throwServerException('Date not found!');\n\n        $date = trim(explode(\"\\r\\n\", $element->plaintext)[1]);\n\n        return $this->fixDate($date);\n    }\n\n    private function extractProfileTitle($html)\n    {\n        $element = $html->find('span.h1', 0)\n            or throwServerException('Title not found!');\n\n        return $element->plaintext;\n    }\n\n    private function extractProfileContent($html)\n    {\n        // There are a few thins we are interested:\n        // - Investment Strategy\n        // - Description\n        // - Quote\n\n        $strategy = $html->find('div.tab-container div.col-sm-6 p', 0)\n            or throwServerException('Investment Strategy not found!');\n\n        // Description requires a bit of cleanup due to lack of propper identification\n\n        $description = $html->find('div.headline', 5)\n            or throwServerException('Description container not found!');\n\n        $description = $description->parent();\n\n        foreach ($description->find('div') as $div) {\n            $div->outertext = '';\n        }\n\n        $quote = $html->find('div.infobox div.val', 0)\n            or throwServerException('Quote not found!');\n\n        $quote_html = '<strong>Quote</strong><br><p>' . $quote . '</p>';\n        $strategy_html = '';\n        $description_html = '';\n\n        if ($this->getInput('strategy') === true) {\n            $strategy_html = '<strong>Strategy</strong><br><p>' . $strategy . '</p><br>';\n        }\n\n        if ($this->getInput('description') === true) {\n            $description_html = '<strong>Description</strong><br><p>' . $description . '</p><br>';\n        }\n\n        return $strategy_html . $description_html . $quote_html;\n    }\n\n    private function extractProfileAuthor($html)\n    {\n        // Use ISIN + WKN as author\n        // Notice: \"identfier\" is not a typo [sic]!\n        $element = $html->find('span.identfier', 0)\n            or throwServerException('Author not found!');\n\n        return $element->plaintext;\n    }\n\n    #endregion\n}\n"
  },
  {
    "path": "bridges/JustWatchBridge.php",
    "content": "<?php\n\nclass JustWatchBridge extends BridgeAbstract\n{\n    const NAME = 'JustWatch';\n    const URI = 'https://justwatch.com';\n    const DESCRIPTION = 'Returns latest releases on Streaming Platforms.';\n    const MAINTAINER = 'Bocki';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [[\n            'country' => [\n                'name' => 'Country',\n                'defaultValue' => 'us',\n                'type' => 'list',\n                'values' => [\n                    'North America' => [\n                        'Bermuda' => 'bm',\n                        'Canada' => 'ca',\n                        'Mexico' => 'mx',\n                        'United States' => 'us'\n                    ],\n                    'South America' => [\n                        'Argentina' => 'ar',\n                        'Bolivia' => 'bo',\n                        'Brazil' => 'br',\n                        'Chile' => 'cl',\n                        'Colombia' => 'co',\n                        'Ecuador' => 'ec',\n                        'French Guiana' => 'gf',\n                        'Paraguay' => 'py',\n                        'Peru' => 'pe',\n                        'Uruguay' => 'uy',\n                        'Venezuela' => 've'\n                    ],\n                    'Europe' => [\n                        'Albania' => 'al',\n                        'Andorra' => 'ad',\n                        'Austria' => 'at',\n                        'Belgium' => 'be',\n                        'Bosnia Herzegovina' => 'ba',\n                        'Bulgaria' => 'bg',\n                        'Croatia' => 'hr',\n                        'Czech Republic' => 'cz',\n                        'Denmark' => 'dk',\n                        'Estonia' => 'ee',\n                        'Finland' => 'fi',\n                        'France' => 'fr',\n                        'Germany' => 'de',\n                        'Gibraltar' => 'gi',\n                        'Greece' => 'gr',\n                        'Guernsey' => 'gg',\n                        'Hungary' => 'hu',\n                        'Iceland' => 'is',\n                        'Ireland' => 'ie',\n                        'Italy' => 'it',\n                        'Kosovo' => 'xk',\n                        'Liechtenstein' => 'li',\n                        'Lithuania' => 'lt',\n                        'Macedonia' => 'mk',\n                        'Malta' => 'mt',\n                        'Moldova' => 'md',\n                        'Monaco' => 'mc',\n                        'Netherlands' => 'nl',\n                        'Norway' => 'no',\n                        'Poland' => 'pl',\n                        'Portugal' => 'pt',\n                        'Romania' => 'ro',\n                        'Russia' => 'ru',\n                        'San Marino' => 'sm',\n                        'Serbia' => 'rs',\n                        'Slovakia' => 'sk',\n                        'Slovenia' => 'si',\n                        'Spain' => 'es',\n                        'Sweden' => 'se',\n                        'Switzerland' => 'ch',\n                        'Turkey' => 'tr',\n                        'United Kingdom' => 'uk',\n                        'Vatican City' => 'va'\n                    ],\n                    'Asia' => [\n                        'Hong Kong' => 'hk',\n                        'India' => 'in',\n                        'Indonesia' => 'id',\n                        'Japan' => 'jp',\n                        'Lebanon' => 'lb',\n                        'Malaysia' => 'my',\n                        'Pakistan' => 'pk',\n                        'Philippines' => 'ph',\n                        'Singapore' => 'sg',\n                        'South Korea' => 'kr',\n                        'Taiwan' => 'tw',\n                        'Thailand' => 'th'\n                    ],\n                    'Central America' => [\n                        'Costa Rica' => 'cr',\n                        'El Salvador' => 'sv',\n                        'Guatemala' => 'gt',\n                        'Honduras' => 'hn',\n                        'Panama' => 'pa'\n                    ],\n                    'Africa' => [\n                        'Algeria' => 'dz',\n                        'Cape Verde' => 'cv',\n                        'Equatorial Guinea' => 'gq',\n                        'Ghana' => 'gh',\n                        'Ivory Coast' => 'ci',\n                        'Kenya' => 'ke',\n                        'Libya' => 'ly',\n                        'Mauritius' => 'mu',\n                        'Morocco' => 'ma',\n                        'Mozambique' => 'mz',\n                        'Niger' => 'ne',\n                        'Nigeria' => 'ng',\n                        'Senegal' => 'sn',\n                        'Seychelles' => 'sc',\n                        'South Africa' => 'za',\n                        'Tunisia' => 'tn',\n                        'Uganda' => 'ug',\n                        'Zambia' => 'zm'\n                    ],\n                    'Pacific' => [\n                        'Australia' => 'au',\n                        'Fiji' => 'fj',\n                        'French Polynesia' => 'pf',\n                        'New Zealand' => 'nz'\n                    ],\n                    'Middle East' => [\n                        'Bahrain' => 'bh',\n                        'Egypt' => 'eg',\n                        'Iraq' => 'iq',\n                        'Israel' => 'il',\n                        'Jordan' => 'jo',\n                        'Kuwait' => 'kw',\n                        'Oman' => 'om',\n                        'Palestine' => 'ps',\n                        'Qatar' => 'qa',\n                        'Saudi Arabia' => 'sa',\n                        'United Arab Emirates' => 'ae',\n                        'Yemen' => 'ye'\n                    ]\n                ]\n            ],\n            'mediatype' => [\n                'name' => 'Type',\n                'defaultValue' => '0',\n                'type' => 'list',\n                'values' => [\n                    'All' => 0,\n                    'Movies' => 1,\n                    'Series' => 2\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $basehtml = getSimpleHTMLDOM($this->getURI());\n        $basehtml = defaultLinkTo($basehtml, self::URI);\n        $overviewhtml = getSimpleHTMLDOM($basehtml->find('.navbar__button__link', 1)->href);\n        $overviewhtml = defaultLinkTo($overviewhtml, self::URI);\n        $html = getSimpleHTMLDOM($overviewhtml->find('.filter-bar-content-type__item', $this->getInput('mediatype'))->find('a', 0)->href);\n        $html = defaultLinkTo($html, self::URI);\n        $today = $html->find('div.title-timeline', 0);\n        $providers = $today->find('div.provider-timeline');\n\n        foreach ($providers as $provider) {\n            $titles = $html->find('div.horizontal-title-list__item');\n            foreach ($titles as $title) {\n                $item = [];\n                $item['uri'] = $title->find('a', 0)->href;\n\n                $posterImage = $title->find('.title-poster__image > img', 0);\n                $itemTitle = sprintf(\n                    '%s - %s',\n                    $provider->find('picture > img', 0)->alt ?? '',\n                    $posterImage->alt ?? ''\n                );\n                $item['title'] = $itemTitle;\n\n                $imageUrl = $posterImage->attr['src'] ?? '';\n                if (str_starts_with($imageUrl, 'data')) {\n                    $imageUrl = $posterImage->attr['data-src'];\n                }\n\n                $content  = '<b>Provider:</b> ' . $provider->find('picture > img', 0)->alt . '<br>';\n                $content .= '<b>Media:</b> ' . ($posterImage->alt ?? '') . '<br>';\n\n                if (isset($title->find('.title-poster__badge', 0)->plaintext)) {\n                    $content .= '<b>Type:</b> Series<br>';\n                    $content .= '<b>Season:</b> ' . $title->find('.title-poster__badge', 0)->plaintext . '<br>';\n                } else {\n                    $content .= '<b>Type:</b> Movie<br>';\n                }\n\n                $content .= '<b>Poster:</b><br><a href=\"'\n                    . $title->find('a', 0)->href\n                    . '\"><img src=\"'\n                    . $imageUrl\n                    . '\"></a>';\n\n                $item['content'] = $content;\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        return 'https://www.justwatch.com/' . $this->getInput('country');\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('country'))) {\n            return 'JustWatch - ' . $this->getKey('country') . ' - ' . $this->getKey('mediatype');\n        }\n        return parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.justwatch.com/appassets/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/Kanali6Bridge.php",
    "content": "<?php\n\nclass Kanali6Bridge extends XPathAbstract\n{\n    const NAME = 'Kanali6 Latest Podcasts';\n    const DESCRIPTION = 'Returns the latest podcasts';\n    const URI = 'https://kanali6.com.cy/mp3/TOC.html';\n\n    const FEED_SOURCE_URL = 'https://kanali6.com.cy/mp3/TOC.xml';\n    const XPATH_EXPRESSION_ITEM = '//recording[position() <= 50]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './title';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './durationvisual';\n    const XPATH_EXPRESSION_ITEM_URI = './filename';\n    const XPATH_EXPRESSION_ITEM_AUTHOR = './/producersname';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './recfinisheddatetime';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './filename';\n\n    public function getURI()\n    {\n        return self::URI;\n    }\n}\n"
  },
  {
    "path": "bridges/KemonoBridge.php",
    "content": "<?php\n\nclass KemonoBridge extends BridgeAbstract\n{\n    const NAME = 'Kemono';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://kemono.cr/';\n    const DESCRIPTION = 'Returns posts from Kemono and Coomer.';\n    const PARAMETERS = [[\n        'service' => [\n            'name' => 'Content service',\n            'type' => 'list',\n            'defaultValue' => 'patreon',\n            'values' => [\n                'Patreon' => 'patreon',\n                'Pixiv Fanbox' => 'fanbox',\n                'Fantia' => 'fantia',\n                'Boosty' => 'boosty',\n                'Gumroad' => 'gumroad',\n                'SubscribeStar' => 'subscribestar',\n                'DLSite' => 'dlsite',\n\n                'OnlyFans' => 'onlyfans',\n                'Fansly' => 'fansly',\n                'CandFans' => 'candfans',\n            ]\n        ],\n        'user' => [\n            'name' => 'User ID/Name',\n            'exampleValue' => '9069743', # Thomas Joy\n            'required' => true,\n        ],\n        'q' => [\n            'name' => 'Search query',\n            'exampleValue' => 'classic',\n            'required' => false,\n        ],\n        'limit' => self::LIMIT,\n    ]];\n\n    private $author;\n\n    private function isCoomer()\n    {\n        $haystack = $this->getInput('service') ?? '';\n        return str_contains($haystack, 'fans');\n    }\n\n    private function baseURI()\n    {\n        if ($this->isCoomer()) {\n            return 'https://coomer.st/';\n        }\n        return parent::getURI();\n    }\n\n    private function getJson(string $endpoint)\n    {\n        $api = $this->baseURI() . 'api/v1/' . $this->getInput('service');\n        $url = $api . $this->getInput('service') . '/user/' . $this->getInput('user');\n        $header = [ 'Accept: text/css' ]; // Required by API\n\n        $api_response = getContents(\"$api$endpoint\", $header);\n        return Json::decode($api_response);\n    }\n\n    public function collectData()\n    {\n        $user = '/user/' . $this->getInput('user');\n        $profile = $this->getJson(\"$user/profile\");\n        $this->author = ucfirst($profile['name']);\n\n        $json = $this->getJson(\"$user/posts?q=\" . urlencode($this->getInput('q')));\n        $elements = array_slice($json, 0, $this->getInput('limit'));\n\n        foreach ($elements as $element) {\n            $element = $this->getJson($user . '/post/' . $element['id']);\n            $post = $element['post'];\n\n            $item = [\n                'author' => $this->author,\n                'categories' => $post['tags'],\n                'content' => $post['content'],\n                'timestamp' => strtotime($post['published']),\n                'title' => $post['title'],\n                'uid' => $post['id'],\n                'uri' => $this->getURI() . '/post/' . $post['id'],\n            ];\n\n            $item['enclosures'] = [];\n            if (array_key_exists('url', $post['embed'])) {\n                $item['enclosures'][] = $post['embed']['url'];\n            }\n            if (array_key_exists('path', $post['file'])) {\n                $element['attachments'][] = $post['file'];\n            }\n            foreach ($element['attachments'] as $file) {\n                $item['enclosures'][] = $this->baseURI() . $file['path'];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if (isset($this->author)) {\n            $name .= ' - ' . $this->author;\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $service = $this->getInput('service');\n        $user = $this->getInput('user');\n\n        $uri = $this->baseURI() . $service . '/user/' . $user;\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/KernelBugTrackerBridge.php",
    "content": "<?php\n\nclass KernelBugTrackerBridge extends BridgeAbstract\n{\n    const NAME = 'Kernel Bug Tracker';\n    const URI = 'https://bugzilla.kernel.org';\n    const DESCRIPTION = 'DEPRECATED: Use BugzillaBridge instead.\nReturns feeds for bug comments';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        'Bug comments' => [\n            'id' => [\n                'name' => 'Bug tracking ID',\n                'type' => 'number',\n                'required' => true,\n                'title' => 'Insert bug tracking ID',\n                'exampleValue' => 121241\n            ],\n            'limit' => [\n                'name' => 'Number of comments to return',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify number of comments to return',\n                'defaultValue' => -1\n            ],\n            'sorting' => [\n                'name' => 'Sorting',\n                'type' => 'list',\n                'required' => false,\n                'title' => 'Defines the sorting order of the comments returned',\n                'defaultValue' => 'of',\n                'values' => [\n                    'Oldest first' => 'of',\n                    'Latest first' => 'lf'\n                ]\n            ]\n        ]\n    ];\n\n    private $bugid = '';\n    private $bugdesc = '';\n\n    public function getIcon()\n    {\n        return self::URI . '/images/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit');\n        $sorting = $this->getInput('sorting');\n\n        // We use the print preview page for simplicity\n        $html = getSimpleHTMLDOMCached(\n            $this->getURI() . '&format=multiple',\n            86400,\n            null,\n            null,\n            true,\n            true,\n            DEFAULT_TARGET_CHARSET,\n            false, // Do NOT remove line breaks\n            DEFAULT_BR_TEXT,\n            DEFAULT_SPAN_TEXT\n        );\n\n        $html = defaultLinkTo($html, self::URI);\n\n        // Store header information into private members\n        $this->bugid = $html->find('#bugzilla-body', 0)->find('a', 0)->innertext;\n        $this->bugdesc = $html->find('table.bugfields', 0)->find('tr', 0)->find('td', 0)->innertext;\n\n        // Get and limit comments\n        $comments = $html->find('div.bz_comment');\n\n        if ($limit > 0 && count($comments) > $limit) {\n            $comments = array_slice($comments, count($comments) - $limit, $limit);\n        }\n\n        // Order comments\n        switch ($sorting) {\n            case 'lf':\n                $comments = array_reverse($comments, true);\n                // fall-through\n            case 'of':\n                // fall-through\n            default: // Nothing to do, keep original order\n        }\n\n        foreach ($comments as $comment) {\n            $comment = $this->inlineStyles($comment);\n\n            $item = [];\n            $item['uri'] = $this->getURI() . '#' . $comment->id;\n            $item['author'] = $comment->find('span.bz_comment_user', 0)->innertext;\n            $item['title'] = $comment->find('span.bz_comment_number', 0)->find('a', 0)->innertext;\n            $item['timestamp'] = strtotime($comment->find('span.bz_comment_time', 0)->innertext);\n            $item['content'] = $comment->find('pre.bz_comment_text', 0)->innertext;\n\n            // Fix line breaks (they use LF)\n            $item['content'] = str_replace(\"\\n\", '<br>', $item['content']);\n\n            // Fix relative URIs\n            $item['content'] = $item['content'];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Bug comments':\n                return parent::getURI()\n                . '/show_bug.cgi?id='\n                . $this->getInput('id');\n                break;\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Bug comments':\n                return 'Bug '\n                . $this->bugid\n                . ' tracker for '\n                . $this->bugdesc\n                . ' - '\n                . parent::getName();\n                break;\n            default:\n                return parent::getName();\n        }\n    }\n\n    /**\n     * Adds styles as attributes to tags with known classes\n     *\n     * @param object $html A simplehtmldom object\n     * @return object Returns the original object with styles added as\n     * attributes.\n     */\n    private function inlineStyles($html)\n    {\n        foreach ($html->find('.bz_obsolete') as $element) {\n            $element->style = 'text-decoration:line-through;';\n        }\n\n        return $html;\n    }\n}\n"
  },
  {
    "path": "bridges/KhinsiderBridge.php",
    "content": "<?php\n\nclass KhinsiderBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Chouchenos';\n    const NAME = 'Khinsider';\n    const URI = 'https://downloads.khinsider.com/';\n    const CACHE_TIMEOUT = 14400; // 4 h\n    const DESCRIPTION = 'Fetch daily game OST from Khinsider';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $dates = $html->find('.latestSoundtrackHeading');\n        $tables = $html->find('.albumList');\n        // $dates is empty\n        foreach ($dates as $i => $date) {\n            $item = [];\n            $item['uri'] = self::URI;\n            $item['timestamp'] = DateTime::createFromFormat('F jS, Y', $date->plaintext)->setTime(1, 1)->format('U');\n            $item['title'] = sprintf('OST for %s', $date->plaintext);\n            $item['author'] = 'Khinsider';\n            $trs = $tables[$i]->find('tr');\n            $content = '<ul>';\n            foreach ($trs as $tr) {\n                $td = $tr->find('td', 1);\n                if (null !== $td) {\n                    $link = $td->find('a', 0);\n                    $content .= sprintf('<li><a href=\"%s\">%s</a></li>', $link->href, $link->plaintext);\n                }\n            }\n            $content .= '</ul>';\n            $item['content'] = $content;\n            $item['uid'] = $item['timestamp'];\n            $item['categories'] = ['Video games', 'Music', 'OST', 'download'];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/KilledbyGoogleBridge.php",
    "content": "<?php\n\nclass KilledbyGoogleBridge extends BridgeAbstract\n{\n    const NAME = 'Killed by Google';\n    const URI = 'https://killedbygoogle.com';\n    const DESCRIPTION = 'Returns list of recently discontinued Google services, products, devices, and apps.';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $json = getContents(self::URI . '/graveyard.json');\n\n        $this->handleJson($json);\n        $this->orderItems();\n        $this->limitItems();\n    }\n\n    /**\n     * Handle JSON\n     */\n    private function handleJson($json)\n    {\n        $graveyard = json_decode($json, true);\n\n        foreach ($graveyard as $tombstone) {\n            $item = [];\n\n            $openDate = new DateTime($tombstone['dateOpen']);\n            $closeDate = new DateTime($tombstone['dateClose']);\n            $currentDate = new DateTime();\n\n            $yearOpened = $openDate->format('Y');\n            $yearClosed = $closeDate->format('Y');\n\n            if ($closeDate > $currentDate) {\n                continue;\n            }\n\n            $item['title'] = $tombstone['name'] . ' (' . $yearOpened . ' - ' . $yearClosed . ')';\n            $item['uid'] = $tombstone['slug'];\n            $item['uri'] = $tombstone['link'];\n            $item['timestamp'] = strtotime($tombstone['dateClose']);\n\n            $item['content'] = <<<EOD\n<p>{$tombstone['description']}</p><p><a href=\"{$tombstone['link']}\">{$tombstone['link']}</a></p>\nEOD;\n\n            $item['enclosures'][] = 'https://static.killedbygoogle.com/com/tombstone.svg';\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Order items by timestamp\n     */\n    private function orderItems()\n    {\n        $sort = [];\n\n        foreach ($this->items as $key => $item) {\n            $sort[$key] = $item['timestamp'];\n        }\n\n        array_multisort($sort, SORT_DESC, $this->items);\n        $this->items = array_slice($this->items, 0, 15);\n    }\n\n    /**\n     * Limit items to 15\n     */\n    private function limitItems()\n    {\n        $this->items = array_slice($this->items, 0, 15);\n    }\n}\n"
  },
  {
    "path": "bridges/KilledbyMicrosoftBridge.php",
    "content": "<?php\n\nclass KilledbyMicrosoftBridge extends BridgeAbstract\n{\n    const NAME = 'Killed by Microsoft';\n    const URI = 'https://killedbymicrosoft.info';\n    const DESCRIPTION = 'Lists recently discontinued Microsoft products';\n    const MAINTAINER = 'tillcash';\n\n    public function collectData()\n    {\n        // Fetch JSON data\n        $json = getContents('https://killedbymicrosoft.info/graveyard.json');\n\n        // Decode JSON data\n        $discontinuedServices = json_decode($json, true);\n\n        // Sort the array based on dateClose in descending order\n        usort($discontinuedServices, function ($a, $b) {\n            return strtotime($b['dateClose']) - strtotime($a['dateClose']);\n        });\n\n        // Slice the array to limit the number of items processed\n        $discontinuedServices = array_slice($discontinuedServices, 0, 15);\n\n        // Process each item\n        foreach ($discontinuedServices as $service) {\n            // Construct the title\n            $title = $this->formatTitle(\n                $service['name'],\n                $service['dateOpen'],\n                $service['dateClose']\n            );\n\n            // Construct the content\n            $content = sprintf(\n                '<p>%s</p><p>Scheduled closure on %s.</p>',\n                $service['description'],\n                $service['dateClose']\n            );\n\n            // Add the item to the feed\n            $this->items[] = [\n                'title' => $title,\n                'uid' => $service['slug'],\n                'uri' => $service['link'],\n                'content' => $content\n            ];\n        }\n    }\n\n    private function formatTitle($name, $dateOpen, $dateClose)\n    {\n        // Extract years from dateOpen and dateClose\n        $yearOpen = date('Y', strtotime($dateOpen));\n        $yearClose = date('Y', strtotime($dateClose));\n\n        // Format the title\n        return \"{$name} ({$yearOpen} - {$yearClose})\";\n    }\n}\n"
  },
  {
    "path": "bridges/KitsuBridge.php",
    "content": "<?php\n\nclass KitsuBridge extends BridgeAbstract\n{\n    const NAME = 'Kitsu Episode Updates';\n    const URI = 'https://kitsu.io';\n    const DESCRIPTION = 'Lists latest upcoming episodes';\n    //const PARAMETERS = array();\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [\n        'Episodes from all shows' => [],\n        'By show id' => [\n            'id' => [\n                'name' => 'Show id',\n                'type' => 'number',\n                'title' => 'Specify the id of the anime show as provided by the api',\n                'exampleValue' => '43806',\n                'required' => true\n            ]\n        ],\n        'By show name' => [\n            'name' => [\n                'name' => 'Show name',\n                'title' => 'Copy & paste the exact name from show URL',\n                'exampleValue' => 'Chainsaw Man',\n                'required' => true\n            ]\n        ],\n        'By show url path' => [\n            'url_path' => [\n                'name' => 'Show URL path',\n                'title' => 'Copy & paste the exact name from show URL',\n                'exampleValue' => 'chainsaw-man',\n                'required' => true\n            ]\n        ],\n        'global' => [\n            'number_of_items' => [\n                'name' => 'Number of items',\n                'type' => 'number',\n                'title' => 'Specify the number of items in the resulting feed (max 20)',\n                'exampleValue' => 20\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        if ($this->getInput('number_of_items') > 0 && $this->getInput('number_of_items') < 20) {\n            $pageSize = $this->getInput('number_of_items');\n        } else {\n            $pageSize = 20;\n        }\n\n        if ($this->getInput('id') && ctype_digit($this->getInput('id'))) {\n            $urlApi = self::URI . '/api/edge/episodes?filter[mediaType]=Anime&filter[media_id]=' . $this->getInput('id')\n                . '&sort=-airdate&include=media&page[limit]=' . $pageSize;\n        } elseif ($this->getInput('name') || $this->getInput('url_path')) {\n            if ($this->getInput('url_path')) {\n                $urlApiAnime = self::URI . '/api/edge/anime?filter[slug]=' . urlencode($this->getInput('url_path'));\n            } else {\n                $urlApiAnime = self::URI . '/api/edge/anime?filter[text]=' . urlencode($this->getInput('name'));\n            }\n            $animeList = json_decode(getContents($urlApiAnime), true);\n            if ($animeList['meta']['count'] == 0 || !isset($animeList['data'][0]['id'])) {\n                throw new \\Exception('show not found');\n            }\n            $urlApi = self::URI . '/api/edge/episodes?filter[mediaType]=Anime&filter[media_id]=' . $animeList['data'][0]['id']\n                . '&sort=-airdate&include=media&page[limit]=' . $pageSize;\n        } else {\n            $urlApi = self::URI . '/api/edge/episodes?filter[mediaType]=Anime&sort=-airdate&include=media&page[limit]=' . $pageSize;\n        }\n\n        $feedContent = json_decode(getContents($urlApi), true);\n\n        $animeList = [];\n\n        foreach ($feedContent['included'] as $included) {\n            if ($included['type'] === 'anime') {\n                $animeList[(int)$included['id']] = $included['attributes'];\n            }\n        }\n\n        foreach ($feedContent['data'] as $episode) {\n            $item = [];\n\n            $item['title'] = $animeList[(int)$episode['relationships']['media']['data']['id']]['canonicalTitle']\n             . ': Episode ' . $episode['attributes']['number'];\n            $item['content'] = $episode['attributes']['canonicalTitle'];\n            if ($episode['attributes']['description']) {\n                $item['content'] .= '<br/><br/>'\n                 . $episode['attributes']['description'];\n            }\n            $item['content'] .= '<br/><br/>Airdate: ' . $episode['attributes']['airdate'];\n            $item['uri'] = 'https://kitsu.io/anime/' . $animeList[(int)$episode['relationships']['media']['data']['id']]['slug']\n             . '/episodes/' . $episode['attributes']['number'];\n            $item['author'] = $episode['attributes']['canonicalTitle'];\n            $item['timestamp'] = strtotime($episode['attributes']['createdAt']);\n            $item['uid'] = $episode['id'];\n\n            if (is_array($episode['attributes']['thumbnail'])) {\n                $item['enclosures'][] = $episode['attributes']['thumbnail']['original'];\n            }\n\n            $this->items[] = $item;\n        }\n\n        usort($this->items, function ($item1, $item2) {\n            return $item2['timestamp'] <=> $item1['timestamp'];\n        });\n    }\n}\n"
  },
  {
    "path": "bridges/KleinanzeigenBridge.php",
    "content": "<?php\n\nclass KleinanzeigenBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'knrdl';\n    const NAME = 'Kleinanzeigen';\n    const URI = 'https://www.kleinanzeigen.de';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = '(ebay) Kleinanzeigen';\n\n    const PARAMETERS = [\n        'By search' => [\n            'query' => [\n                'name' => 'query',\n                'required' => false,\n                'title' => 'query term',\n            ],\n            'category' => [\n                'name' => 'category',\n                'required' => false,\n                'title' => 'search category, e.g. \"Damenschuhe\" or \"Notebooks\"'\n            ],\n            'location' => [\n                'name' => 'location',\n                'required' => false,\n                'title' => 'e.g. Berlin',\n            ],\n            'radius' => [\n                'name' => 'radius',\n                'required' => false,\n                'type' => 'number',\n                'title' => 'location radius in kilometers',\n                'defaultValue' => 10,\n            ],\n            'minprice' => [\n                'name' => 'minimum price',\n                'required' => false,\n                'type' => 'number',\n                'title' => 'in euros'\n            ],\n            'maxprice' => [\n                'name' => 'maximum price',\n                'required' => false,\n                'type' => 'number',\n                'title' => 'in euros'\n            ],\n            'pages' => [\n                'name' => 'pages',\n                'required' => true,\n                'type' => 'number',\n                'title' => 'how many pages to fetch',\n                'defaultValue' => 2,\n            ]\n        ],\n        'By profile' => [\n            'userid' => [\n                'name' => 'user id',\n                'required' => true,\n                'type' => 'number',\n                'exampleValue' => 12345678\n            ],\n            'pages' => [\n                'name' => 'pages',\n                'required' => true,\n                'type' => 'number',\n                'title' => 'how many pages to fetch',\n                'defaultValue' => 2,\n            ]\n        ],\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.kleinanzeigen.de/favicon.ico';\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By profile':\n                return 'Kleinanzeigen Profil';\n            case 'By search':\n                return 'Kleinanzeigen ' . $this->getInput('query') . ' ' . $this->getInput('category') . ' ' . $this->getInput('location');\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function collectData()\n    {\n        if ($this->queriedContext === 'By profile') {\n            for ($i = 1; $i <= $this->getInput('pages'); $i++) {\n                $html = getSimpleHTMLDOM($this->getURI() . '/s-bestandsliste.html?userId=' . $this->getInput('userid') . '&pageNum=' . $i . '&sortingField=SORTING_DATE');\n                $html = defaultLinkTo($html, $this->getURI());\n\n                $foundItem = false;\n                foreach ($html->find('article.aditem') as $element) {\n                    $this->addItem($element);\n                    $foundItem = true;\n                }\n                if (!$foundItem) {\n                    break;\n                }\n            }\n        }\n\n        if ($this->queriedContext === 'By search') {\n            $categoryId = $this->findCategoryId();\n            for ($page = 1; $page <= $this->getInput('pages'); $page++) {\n                $searchUrl = $this->getURI() . '/s-suchanfrage.html?' . http_build_query([\n                    'keywords' => $this->getInput('query'),\n                    'locationStr' => $this->getInput('location'),\n                    'locationId' => '',\n                    'radius' => $this->getInput('radius') || '0',\n                    'sortingField' => 'SORTING_DATE',\n                    'categoryId' => $categoryId,\n                    'pageNum' => $page,\n                    'maxPrice' => $this->getInput('maxprice'),\n                    'minPrice' => $this->getInput('minprice')\n                ]);\n\n                $html = getSimpleHTMLDOM($searchUrl);\n                $html = defaultLinkTo($html, $this->getURI());\n\n                // end of list if returned page is not the expected one\n                if ($html->find('.pagination-current', 0)->plaintext != $page) {\n                    break;\n                }\n\n                foreach ($html->find('ul#srchrslt-adtable article.aditem') as $element) {\n                    $this->addItem($element);\n                }\n            }\n        }\n    }\n\n    private function addItem($element)\n    {\n        $item = [];\n\n        $item['uid'] = $element->getAttribute('data-adid');\n        $item['uri'] = $element->getAttribute('data-href');\n\n        $item['title'] = $element->find('h2', 0)->plaintext;\n        $item['timestamp'] = $element->find('div.aditem-main--top--right', 0)->plaintext;\n        $imgUrl = str_replace(\n            'rule=$_2.JPG',\n            'rule=$_57.JPG',\n            str_replace(\n                'rule=$_35.JPG',\n                'rule=$_57.JPG',\n                $element->find('img', 0) ? $element->find('img', 0)->getAttribute('src') : ''\n            )\n        ); //enhance img quality\n\n        $item['content'] = '<img src=\"' . $imgUrl . '\"/>' . $element->find('div.aditem-main', 0)->outertext;\n\n        $this->items[] = $item;\n    }\n\n    private function findCategoryId()\n    {\n        if ($this->getInput('category')) {\n            $html = getSimpleHTMLDOM($this->getURI() . '/s-kategorie-baum.html');\n            foreach ($html->find('a[data-val]') as $element) {\n                $catId = (int)$element->getAttribute('data-val');\n                $catName = $element->plaintext;\n                if (str_contains(strtolower($catName), strtolower($this->getInput('category')))) {\n                    return $catId;\n                }\n            }\n        }\n        return 0;\n    }\n}\n"
  },
  {
    "path": "bridges/KoFiBridge.php",
    "content": "<?php\n\nclass KoFiBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'walkero';\n    const NAME = 'Ko-Fi';\n    const URI = 'https://ko-fi.com';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns the newest articles.';\n    const FEED_URI = 'https://ko-fi.com/Feed/PersonalFeed?pageIndex=0&pageId=';\n    const PARAMETERS = [[\n        'pageId' => [\n            'name' => 'Page ID',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'walkero',\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $limit = 0;\n        $html = getSimpleHTMLDOM(self::FEED_URI . $this->getPageId());\n        foreach ($html->find('div.feeditem-unit') as $element) {\n            if ($limit < 10) {\n                $titleWrapper = $element->find('div.content-link-text');\n                if (isset($titleWrapper[0])) {\n                    $item = [];\n                    $item['title'] = $element->find('div.content-link-text div')[0]->plaintext;\n                    $uri = $element->find('div.content-link-text div')[2]->find('a')[0]->onclick;\n                    $uri = trim(str_replace('window.location =', '', $uri));\n                    $uri = trim(str_replace('&#39;', '', $uri));\n                    $uri = trim(str_replace(';', '', $uri));\n                    $item['uri'] = self::URI . $uri;\n\n                    if (isset($element->find('div.fi-post-item-large div.content-link-post img')[0])) {\n                        $item['enclosures'][] = $element->find('div.fi-post-item-large div.content-link-post img')[0]->src;\n                    }\n\n                    $html = getSimpleHTMLDOM($item['uri']);\n                    $feedItemTime = $html->find('div.feeditem-time', 0);\n                    $feedItemTime->find('span', 0)->remove();\n                    $feedItemTime->find('div', 0)->remove();\n                    $item['timestamp'] = strtotime(trim($feedItemTime->plaintext));\n                    $item['content'] = $this->getFullContent($html);\n                    $html->clear();\n\n                    $this->items[] = $item;\n                    $limit++;\n                }\n            }\n        }\n        $html->clear();\n    }\n\n    private function getFullContent($html)\n    {\n        foreach ($html->find('script[type=\"text/javascript\"]') as $script) {\n            if (!empty($script->innertext)) {\n                if (strpos($script->innertext, 'shadowDom.innerHTML += \\'') !== false) {\n                    preg_match_all('/d\\N+/i', $script->innertext, $aMatches);\n                    foreach ($aMatches[0] as $match) {\n                        if (strpos($match, 'article-body') !== false) {\n                            break;\n                        }\n                    }\n                    $fullPostHtml = str_get_html(mb_substr($match, 21, -3));\n                    // Get the first paragraph\n                    return mb_substr($fullPostHtml->innertext, 0, mb_strpos($fullPostHtml->innertext, '</p>') + 4);\n                }\n            }\n        }\n    }\n\n    private function getPageId()\n    {\n        $html = getSimpleHTMLDOM(self::URI . '/' . $this->getInput('pageId'));\n        $reportUrl = $html->find('div.modal-dialog div.mb a.btn')[1]->href;\n        $html->clear();\n        return substr($reportUrl, strpos($reportUrl, '=') + 1);\n    }\n}\n"
  },
  {
    "path": "bridges/KonachanBridge.php",
    "content": "<?php\n\nclass KonachanBridge extends MoebooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Konachan';\n    const URI = 'https://konachan.com/';\n    const DESCRIPTION = 'Returns images from given page';\n}\n"
  },
  {
    "path": "bridges/KoreusBridge.php",
    "content": "<?php\n\nclass KoreusBridge extends FeedExpander\n{\n    const MAINTAINER = 'pit-fgfjiudghdf';\n    const NAME = 'Koreus';\n    const URI = 'https://www.koreus.com/';\n    const DESCRIPTION = 'Returns the newest posts from Koreus (full text)';\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $text = $html->find('p.itemText', 0)->innertext;\n        $item['content'] = utf8_encode($text);\n\n        return $item;\n    }\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://feeds.feedburner.com/Koreus-articles');\n    }\n}\n"
  },
  {
    "path": "bridges/KununuBridge.php",
    "content": "<?php\n\nclass KununuBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Kununu';\n    const URI = 'https://www.kununu.com/';\n    const CACHE_TIMEOUT = 86400; // 24h\n    const DESCRIPTION = 'Returns the latest reviews for a company and site of your choice.';\n\n    const PARAMETERS = [\n        'global' => [\n            'site' => [\n                'name' => 'Site',\n                'type' => 'list',\n                'title' => 'Select your site',\n                'values' => [\n                    'Austria' => 'at',\n                    'Germany' => 'de',\n                    'Switzerland' => 'ch'\n                ],\n                'exampleValue' => 'de',\n            ],\n            'include_ratings' => [\n                'name' => 'Include ratings',\n                'type' => 'checkbox',\n                'title' => 'Activate to include ratings in the feed'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'defaultValue' => 3,\n                'title' => \"Maximum number of items to return in the feed.\\n0 = unlimited\"\n            ]\n        ],\n        [\n            'company' => [\n                'name' => 'Company',\n                'required' => true,\n                'exampleValue' => 'kununu',\n                'title' => 'Insert company name (i.e. Kununu) or URI path (i.e. kununu)'\n            ]\n        ]\n    ];\n\n    private $companyName = '';\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('company')) && !is_null($this->getInput('site'))) {\n            $company = $this->fixCompanyName($this->getInput('company'));\n            $site = $this->getInput('site');\n\n            return sprintf('%s%s/%s', self::URI, $site, $company);\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('company'))) {\n            $company = $this->fixCompanyName($this->getInput('company'));\n            return ($this->companyName ?: $company) . ' - ' . self::NAME;\n        }\n\n        return parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.kununu.com/favicon-196x196.png';\n    }\n\n    public function collectData()\n    {\n        $full = $this->getInput('full');\n\n        // Load page\n        $json = json_decode(getContents($this->getAPI()), true);\n        $this->companyName = $json['common']['name'];\n        $baseURI = $this->getURI() . '/bewertung/';\n\n        $limit = $this->getInput('limit') ?: 0;\n\n        // Go through all articles\n        foreach ($json['reviews'] as $review) {\n            $item = [];\n            $item['author'] = $review['position'] . ' / ' . $review['department'];\n            $item['timestamp'] = $review['createdAt'];\n            $item['title'] = $review['roundedScore'] . ' : ' . $review['title'];\n            $item['uri'] = $baseURI . $review['uuid'];\n            $item['content'] = $this->extractArticleDescription($review);\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    /**\n    * Returns JSON API url\n    */\n    private function getAPI()\n    {\n        $company = $this->fixCompanyName($this->getInput('company'));\n        $site = $this->getInput('site');\n\n        return self::URI . 'middlewares/profiles/' .\n               $site . '/' . $company .\n               '/reviews?reviewType=employees&urlParams=sort=newest&sort=newest&page=1';\n    }\n\n    /*\n    * Returns a fixed version of the provided company name\n    */\n    private function fixCompanyName($company)\n    {\n        $company = trim($company);\n        $company = str_replace(' ', '-', $company);\n        $company = strtolower($company);\n\n        $umlauts = ['/ä/','/ö/','/ü/','/Ä/','/Ö/','/Ü/','/ß/'];\n        $replace = ['ae','oe','ue','Ae','Oe','Ue','ss'];\n\n        return preg_replace($umlauts, $replace, $company);\n    }\n\n    /**\n    * Returns the description from a given article\n    */\n    private function extractArticleDescription($json)\n    {\n        $retVal = '';\n        foreach ($json['texts'] as $text) {\n            $retVal .= '<h4>' . $text['id'] . '</h4><p>' . $text['text'] . '</p>';\n        }\n\n        if ($this->getInput('include_ratings') && !empty($json['ratings'])) {\n            $retVal .= (empty($retVal) ? '' : '<hr>') . '<table>';\n            foreach ($json['ratings'] as $rating) {\n                $retVal .= <<<EOD\n<tr>\n\t<td>{$rating['id']}\n\t<td>{$rating['roundedScore']}\n\t<td>{$rating['text']}\n</tr>\nEOD;\n            }\n            $retVal .= '</table>';\n        }\n\n        return $retVal;\n    }\n}\n"
  },
  {
    "path": "bridges/LWNprevBridge.php",
    "content": "<?php\n\nclass LWNprevBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Pierre Mazière';\n    const NAME = 'LWN Free Weekly Edition';\n    const URI = 'https://lwn.net/';\n    const CACHE_TIMEOUT = 604800; // 1 week\n    const DESCRIPTION = 'LWN Free Weekly Edition available one week late';\n\n    private $editionTimeStamp;\n\n    public function getURI()\n    {\n        return self::URI . 'free/bigpage';\n    }\n\n    private function jumpToNextTag(&$node)\n    {\n        while ($node && $node->nodeType === XML_TEXT_NODE) {\n            $nextNode = $node->nextSibling;\n            if (!$nextNode) {\n                break;\n            }\n            $node = $nextNode;\n        }\n    }\n\n    private function jumpToPreviousTag(&$node)\n    {\n        while ($node && $node->nodeType === XML_TEXT_NODE) {\n            $previousNode = $node->previousSibling;\n            if (!$previousNode) {\n                break;\n            }\n            $node = $previousNode;\n        }\n    }\n\n    public function collectData()\n    {\n        // Because the LWN page is written in loose HTML and not XHTML,\n        // Simple HTML Dom is not accurate enough for the job\n        $content = getContents($this->getURI());\n\n        $contents = explode('<b>Page editor</b>', $content);\n\n        foreach ($contents as $content) {\n            if (strpos($content, '<html>') === false) {\n                $content = <<<EOD\n<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\" \"http://www.w3.org/TR/html4/loose.dtd\">\n<html><head><title>LWN</title></head><body>{$content}</body></html>\nEOD;\n            } else {\n                $content = $content . '</body></html>';\n            }\n\n            libxml_use_internal_errors(true);\n            $html = new DOMDocument();\n            $html->loadHTML($content);\n            libxml_clear_errors();\n\n            $edition = $html->getElementsByTagName('h1');\n            if ($edition->length !== 0) {\n                $text = $edition->item(0)->textContent;\n                $this->editionTimeStamp = strtotime(\n                    substr($text, strpos($text, 'for ') + strlen('for '))\n                );\n            }\n\n            if (strpos($content, 'Cat1HL') === false) {\n                $items = $this->getFeatureContents($html);\n            } elseif (strpos($content, 'Cat3HL') === false) {\n                $items = $this->getBriefItems($html);\n            } else {\n                $items = $this->getAnnouncements($html);\n            }\n\n            $this->items = array_merge($this->items, $items);\n        }\n    }\n\n    private function getArticleContent(&$title)\n    {\n        $link = $title->firstChild;\n        $this->jumpToNextTag($link);\n        $item['uri'] = self::URI;\n        if ($link->nodeName === 'a') {\n            $item['uri'] .= $link->getAttribute('href');\n        }\n\n        $item['timestamp'] = $this->editionTimeStamp;\n\n        $node = $title;\n        $content = '';\n        $contentEnd = false;\n        while (!$contentEnd) {\n            $node = $node->nextSibling;\n            if (\n                !$node || (\n                    $node->nodeType !== XML_TEXT_NODE &&\n                    $node->nodeName === 'h3' || (\n                        !is_null($node->attributes) &&\n                        !is_null($class = $node->attributes->getNamedItem('class')) &&\n                        in_array($class->nodeValue, ['Cat1HL','Cat2HL'])\n                    )\n                )\n            ) {\n                $contentEnd = true;\n            } else {\n                $content .= $node->C14N();\n            }\n        }\n        $item['content'] = $content;\n        return $item;\n    }\n\n    private function getFeatureContents(&$html)\n    {\n        $items = [];\n        foreach ($html->getElementsByTagName('h3') as $title) {\n            if ($title->getAttribute('class') !== 'SummaryHL') {\n                continue;\n            }\n\n            $item = [];\n\n            $author = $title->nextSibling;\n            $this->jumpToNextTag($author);\n            if ($author->getAttribute('class') === 'FeatureByline') {\n                $item['author'] = $author->getElementsByTagName('b')->item(0)->textContent;\n            } else {\n                continue;\n            }\n\n            $item['title'] = $title->textContent;\n\n            $items[] = array_merge($item, $this->getArticleContent($title));\n        }\n        return $items;\n    }\n\n    private function getItemPrefix(&$cat, &$cats)\n    {\n        $cat1 = '';\n        $cat2 = '';\n        $cat3 = '';\n        switch ($cat->getAttribute('class')) {\n            case 'Cat3HL':\n                $cat3 = $cat->textContent;\n                $cat = $cat->previousSibling;\n                $this->jumpToPreviousTag($cat);\n                $cats[2] = $cat3;\n                if ($cat->getAttribute('class') !== 'Cat2HL') {\n                    break;\n                }\n                // fall-through? Looks like a bug\n            case 'Cat2HL':\n                $cat2 = $cat->textContent;\n                $cat = $cat->previousSibling;\n                $this->jumpToPreviousTag($cat);\n                $cats[1] = $cat2;\n                if (empty($cat3)) {\n                    $cats[2] = '';\n                }\n                if ($cat->getAttribute('class') !== 'Cat1HL') {\n                    break;\n                }\n                // fall-through? Looks like a bug\n            case 'Cat1HL':\n                $cat1 = $cat->textContent;\n                $cats[0] = $cat1;\n                if (empty($cat3)) {\n                    $cats[2] = '';\n                }\n                if (empty($cat2)) {\n                    $cats[1] = '';\n                }\n                break;\n            default:\n                break;\n        }\n\n        $prefix = '';\n        if (!empty($cats[0])) {\n            $prefix .= '[' . $cats[0] . ($cats[1] ? '/' . $cats[1] : '') . '] ';\n        }\n        return $prefix;\n    }\n\n    private function getAnnouncements(&$html)\n    {\n        $items = [];\n        $cats = ['','',''];\n\n        foreach ($html->getElementsByTagName('p') as $newsletters) {\n            if ($newsletters->getAttribute('class') !== 'Cat3HL') {\n                continue;\n            }\n\n            $item = [];\n\n            $item['uri'] = self::URI . '#' . count($items);\n\n            $item['timestamp'] = $this->editionTimeStamp;\n\n            $item['author'] = 'LWN';\n\n            $cat = $newsletters->previousSibling;\n            $this->jumpToPreviousTag($cat);\n            $prefix = $this->getItemPrefix($cat, $cats);\n            $item['title'] = $prefix . ' ' . $newsletters->textContent;\n\n            $node = $newsletters;\n            $content = '';\n            $contentEnd = false;\n            while (!$contentEnd) {\n                $node = $node->nextSibling;\n                if (\n                    !$node || (\n                        $node->nodeType !== XML_TEXT_NODE && (\n                            !is_null($node->attributes) &&\n                            !is_null($class = $node->attributes->getNamedItem('class')) &&\n                            in_array($class->nodeValue, ['Cat1HL','Cat2HL','Cat3HL'])\n                        )\n                    )\n                ) {\n                    $contentEnd = true;\n                } else {\n                    $content .= $node->C14N();\n                }\n            }\n            $item['content'] = $content;\n            $items[] = $item;\n        }\n\n        foreach ($html->getElementsByTagName('h2') as $title) {\n            if ($title->getAttribute('class') !== 'SummaryHL') {\n                continue;\n            }\n\n            $item = [];\n\n            $cat = $title->previousSibling;\n            $this->jumpToPreviousTag($cat);\n            $cat = $cat->previousSibling;\n            $this->jumpToPreviousTag($cat);\n            $prefix = $this->getItemPrefix($cat, $cats);\n            $item['title'] = $prefix . ' ' . $title->textContent;\n            $items[] = array_merge($item, $this->getArticleContent($title));\n        }\n\n        return $items;\n    }\n\n    private function getBriefItems(&$html)\n    {\n        $items = [];\n        $cats = ['','',''];\n        foreach ($html->getElementsByTagName('h2') as $title) {\n            if ($title->getAttribute('class') !== 'SummaryHL') {\n                continue;\n            }\n\n            $item = [];\n\n            $cat = $title->previousSibling;\n            $this->jumpToPreviousTag($cat);\n            $cat = $cat->previousSibling;\n            $this->jumpToPreviousTag($cat);\n            $prefix = $this->getItemPrefix($cat, $cats);\n            $item['title'] = $prefix . ' ' . $title->textContent;\n            $items[] = array_merge($item, $this->getArticleContent($title));\n        }\n\n        return $items;\n    }\n}\n"
  },
  {
    "path": "bridges/LaCentraleBridge.php",
    "content": "<?php\n\nclass LaCentraleBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'jacknumber';\n    const NAME = 'La Centrale';\n    const URI = 'https://www.lacentrale.fr/';\n    const DESCRIPTION = 'Returns most recent vehicules ads from LaCentrale';\n\n    const PARAMETERS = [ [\n        'type' => [\n            'name' => 'Type de véhicule',\n            'type' => 'list',\n            'values' => [\n                'Voiture' => 'car',\n                'Camion/Pickup' => 'truck',\n                'Moto' => 'moto',\n                'Scooter' => 'scooter',\n                'Quad' => 'quad',\n                'Caravane/Camping-car' => 'mobileHome'\n            ]\n        ],\n        'brand' => [\n            'name' => 'Marque',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                'ABARTH' => 'ABARTH',\n                'AC' => 'AC',\n                'AIXAM' => 'AIXAM',\n                'ALFA ROMEO' => 'ALFA ROMEO',\n                'ALKE' => 'ALKE',\n                'ALPINA' => 'ALPINA',\n                'ALPINE' => 'ALPINE',\n                'AMC' => 'AMC',\n                'ANAIG' => 'ANAIG',\n                'APRILIA' => 'APRILIA',\n                'ARIEL' => 'ARIEL',\n                'ASTON MARTIN' => 'ASTON MARTIN',\n                'AUDI' => 'AUDI',\n                'AUSTIN HEALEY' => 'AUSTIN HEALEY',\n                'AUSTIN' => 'AUSTIN',\n                'AUTOBIANCHI' => 'AUTOBIANCHI',\n                'AVINTON' => 'AVINTON',\n                'BELLIER' => 'BELLIER',\n                'BENELLI' => 'BENELLI',\n                'BENTLEY' => 'BENTLEY',\n                'BETA' => 'BETA',\n                'BMW' => 'BMW',\n                'BOLLORE' => 'BOLLORE',\n                'BRIXTON' => 'BRIXTON',\n                'BUELL' => 'BUELL',\n                'BUGATTI' => 'BUGATTI',\n                'BUICK' => 'BUICK',\n                'BULLIT' => 'BULLIT',\n                'CADILLAC' => 'CADILLAC',\n                'CASALINI' => 'CASALINI',\n                'CATERHAM' => 'CATERHAM',\n                'CHATENET' => 'CHATENET',\n                'CHEVROLET' => 'CHEVROLET',\n                'CHRYSLER' => 'CHRYSLER',\n                'CHUNLAN' => 'CHUNLAN',\n                'CITROEN' => 'CITROEN',\n                'COURB' => 'COURB',\n                'CR&S' => 'CR&S',\n                'CUPRA' => 'CUPRA',\n                'CYCLONE' => 'CYCLONE',\n                'DACIA' => 'DACIA',\n                'DAELIM' => 'DAELIM',\n                'DAEWOO' => 'DAEWOO',\n                'DAF' => 'DAF',\n                'DAIHATSU' => 'DAIHATSU',\n                'DANGEL' => 'DANGEL',\n                'DATSUN' => 'DATSUN',\n                'DE SOTO' => 'DE SOTO',\n                'DE TOMASO' => 'DE TOMASO',\n                'DERBI' => 'DERBI',\n                'DEVINCI' => 'DEVINCI',\n                'DODGE' => 'DODGE',\n                'DONKERVOORT' => 'DONKERVOORT',\n                'DS' => 'DS',\n                'DUCATI' => 'DUCATI',\n                'DUCATY' => 'DUCATY',\n                'DUE' => 'DUE',\n                'ENFIELD' => 'ENFIELD',\n                'EXCALIBUR' => 'EXCALIBUR',\n                'FACEL VEGA' => 'FACEL VEGA',\n                'FANTIC MOTOR' => 'FANTIC MOTOR',\n                'FERRARI' => 'FERRARI',\n                'FIAT' => 'FIAT',\n                'FISKER' => 'FISKER',\n                'FORD' => 'FORD',\n                'FUSO' => 'FUSO',\n                'GAS GAS' => 'GAS GAS',\n                'GILERA' => 'GILERA',\n                'GMC' => 'GMC',\n                'GOWINN' => 'GOWINN',\n                'GRANDIN' => 'GRANDIN',\n                'HARLEY DAVIDSON' => 'HARLEY DAVIDSON',\n                'HOMMELL' => 'HOMMELL',\n                'HONDA' => 'HONDA',\n                'HUMMER' => 'HUMMER',\n                'HUSABERG' => 'HUSABERG',\n                'HUSQVARNA' => 'HUSQVARNA',\n                'HYOSUNG' => 'HYOSUNG',\n                'HYUNDAI' => 'HYUNDAI',\n                'INDIAN' => 'INDIAN',\n                'INFINITI' => 'INFINITI',\n                'INNOCENTI' => 'INNOCENTI',\n                'ISUZU' => 'ISUZU',\n                'IVECO' => 'IVECO',\n                'JAGUAR' => 'JAGUAR',\n                'JDM SIMPA' => 'JDM SIMPA',\n                'JEEP' => 'JEEP',\n                'JENSEN' => 'JENSEN',\n                'JIAYUAN' => 'JIAYUAN',\n                'KAWASAKI' => 'KAWASAKI',\n                'KEEWAY' => 'KEEWAY',\n                'KIA' => 'KIA',\n                'KSR' => 'KSR',\n                'KTM' => 'KTM',\n                'KYMCO' => 'KYMCO',\n                'LADA' => 'LADA',\n                'LAMBORGHINI' => 'LAMBORGHINI',\n                'LANCIA' => 'LANCIA',\n                'LAND ROVER' => 'LAND ROVER',\n                'LEXUS' => 'LEXUS',\n                'LIGIER' => 'LIGIER',\n                'LINCOLN' => 'LINCOLN',\n                'LONDON TAXI COMPANY' => 'LONDON TAXI COMPANY',\n                'LOTUS' => 'LOTUS',\n                'MAGPOWER' => 'MAGPOWER',\n                'MAN' => 'MAN',\n                'MASAI' => 'MASAI',\n                'MASERATI' => 'MASERATI',\n                'MASH' => 'MASH',\n                'MATRA' => 'MATRA',\n                'MAYBACH' => 'MAYBACH',\n                'MAZDA' => 'MAZDA',\n                'MCLAREN' => 'MCLAREN',\n                'MEGA' => 'MEGA',\n                'MERCEDES' => 'MERCEDES',\n                'MERCEDES-AMG' => 'MERCEDES-AMG',\n                'MERCURY' => 'MERCURY',\n                'MEYERS MANX' => 'MEYERS MANX',\n                'MG' => 'MG',\n                'MIA ELECTRIC' => 'MIA ELECTRIC',\n                'MICROCAR' => 'MICROCAR',\n                'MINAUTO' => 'MINAUTO',\n                'MINI' => 'MINI',\n                'MITSUBISHI' => 'MITSUBISHI',\n                'MORGAN' => 'MORGAN',\n                'MORRIS' => 'MORRIS',\n                'MOTO GUZZI' => 'MOTO GUZZI',\n                'MOTO MORINI' => 'MOTO MORINI',\n                'MOTOBECANE' => 'MOTOBECANE',\n                'MPM MOTORS' => 'MPM MOTORS',\n                'MV AGUSTA' => 'MV AGUSTA',\n                'NISSAN' => 'NISSAN',\n                'NORTON' => 'NORTON',\n                'NSU' => 'NSU',\n                'OLDSMOBILE' => 'OLDSMOBILE',\n                'OPEL' => 'OPEL',\n                'ORCAL' => 'ORCAL',\n                'OSSA' => 'OSSA',\n                'PACKARD' => 'PACKARD',\n                'PANTHER' => 'PANTHER',\n                'PEUGEOT' => 'PEUGEOT',\n                'PGO' => 'PGO',\n                'PIAGGIO' => 'PIAGGIO',\n                'PLYMOUTH' => 'PLYMOUTH',\n                'POLARIS' => 'POLARIS',\n                'PONTIAC' => 'PONTIAC',\n                'PORSCHE' => 'PORSCHE',\n                'REALM' => 'REALM',\n                'REGAL RAPTOR' => 'REGAL RAPTOR',\n                'RENAULT' => 'RENAULT',\n                'RIEJU' => 'RIEJU',\n                'ROLLS ROYCE' => 'ROLLS ROYCE',\n                'ROVER' => 'ROVER',\n                'ROYAL ENFIELD' => 'ROYAL ENFIELD',\n                'SAAB' => 'SAAB',\n                'SANTANA' => 'SANTANA',\n                'SCANIA' => 'SCANIA',\n                'SEAT' => 'SEAT',\n                'SECMA' => 'SECMA',\n                'SHELBY' => 'SHELBY',\n                'SHERCO' => 'SHERCO',\n                'SIMCA' => 'SIMCA',\n                'SKODA' => 'SKODA',\n                'SMART' => 'SMART',\n                'SPYKER' => 'SPYKER',\n                'SSANGYONG' => 'SSANGYONG',\n                'STUDEBAKER' => 'STUDEBAKER',\n                'SUBARU' => 'SUBARU',\n                'SUNBEAM' => 'SUNBEAM',\n                'SUZUKI' => 'SUZUKI',\n                'SWM' => 'SWM',\n                'SYM' => 'SYM',\n                'TALBOT SIMCA' => 'TALBOT SIMCA',\n                'TALBOT' => 'TALBOT',\n                'TEILHOL' => 'TEILHOL',\n                'TESLA' => 'TESLA',\n                'TM' => 'TM',\n                'TNT MOTOR' => 'TNT MOTOR',\n                'TOYOTA' => 'TOYOTA',\n                'TRIUMPH' => 'TRIUMPH',\n                'TVR' => 'TVR',\n                'VAUXHALL' => 'VAUXHALL',\n                'VESPA' => 'VESPA',\n                'VICTORY' => 'VICTORY',\n                'VOLKSWAGEN' => 'VOLKSWAGEN',\n                'VOLVO' => 'VOLVO',\n                'VOXAN' => 'VOXAN',\n                'WIESMANN' => 'WIESMANN',\n                'YAMAHA' => 'YAMAHA',\n                'YCF' => 'YCF',\n                'ZERO' => 'ZERO',\n                'ZONGSHEN' => 'ZONGSHEN'\n            ]\n        ],\n        'model' => [\n            'name' => 'Modèle',\n            'type' => 'text',\n            'title' => 'Get the exact name on LaCentrale'\n        ],\n        'versions' => [\n            'name' => 'Version(s)',\n            'type' => 'text',\n            'title' => 'Get the exact name(s) on LaCentrale. Separate by comma'\n        ],\n        'category' => [\n            'name' => 'Catégorie',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                'Voiture' => [\n                    '4x4, SUV & Crossover' => '47',\n                    'Citadine' => '40',\n                    'Berline' => '41_42',\n                    'Break' => '43',\n                    'Cabriolet' => '46',\n                    'Coupé' => '45',\n                    'Monospace' => '44',\n                    'Bus et minibus' => '82',\n                    'Fourgonnette' => '85',\n                    'Fourgon (< 3,5 tonnes)' => '81',\n                    'Pick-up' => '50',\n                    'Voiture société, commerciale' => '80',\n                    'Sans permis' => '48',\n                    'Camion (> 3,5 tonnes)' => '83',\n                ],\n                'Camion/Pickup' => [\n                    'Camion (> 3,5 tonnes)' => '83',\n                    'Fourgon (< 3,5 tonnes)' => '81',\n                    'Bus et minibus' => '82',\n                    'Fourgonnette' => '85',\n                    'Pick-up' => '50',\n                    'Voiture société, commerciale' => '80'\n                ],\n                'Moto' => [\n                    'Custom' => '60',\n                    'Offroad' => '61',\n                    'Roadster' => '62',\n                    'GT' => '63',\n                    'Mini moto' => '64',\n                    'Mobylette' => '65',\n                    'Supermotard' => '66',\n                    'Trail' => '67',\n                    'Side-car' => '69',\n                    'Sportive' => '68'\n                ],\n                'Caravane/Camping-car' => [\n                    'Caravane' => '423',\n                    'Profilé' => '506',\n                    'Fourgon aménagé' => '507',\n                    'Intégral' => '508',\n                    'Capucine' => '510'\n                ]\n            ]\n        ],\n        'pricemin' => [\n            'name' => 'Prix min',\n            'type' => 'number'\n        ],\n        'pricemax' => [\n            'name' => 'Prix max',\n            'type' => 'number'\n        ],\n        'location' => [\n            'name' => 'CP ou département',\n            'type' => 'number',\n            'title' => 'Only one'\n        ],\n        'distance' => [\n            'name' => 'Rayon de recherche',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                '10 km' => '1',\n                '20 km' => '2',\n                '50 km' => '3',\n                '100 km' => '4',\n                '200 km' => '5'\n            ]\n        ],\n        'region' => [\n            'name' => 'Région',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                'Auvergne-Rhône-Alpes' => 'FR-ARA',\n                'Bourgogne-Franche-Comté' => 'FR-BFC',\n                'Bretagne' => 'FR-BRE',\n                'Centre-Val de Loire' => 'FR-CVL',\n                'Corse' => 'FR-COR',\n                'Grand Est' => 'FR-GES',\n                'Hauts-de-France' => 'FR-HDF',\n                'Île-de-France' => 'FR-IDF',\n                'Normandie' => 'FR-NOR',\n                'Nouvelle-Aquitaine' => 'FR-PAC',\n                'Occitanie' => 'FR-PDL',\n                'Pays de la Loire' => 'FR-OCC',\n                'Provence-Alpes-Côte d\\'Azur' => 'FR-NAQ'\n            ]\n        ],\n        'mileagemin' => [\n            'name' => 'Kilométrage min',\n            'type' => 'number'\n        ],\n        'mileagemax' => [\n            'name' => 'Kilométrage max',\n            'type' => 'number'\n        ],\n        'yearmin' => [\n            'name' => 'Année min',\n            'type' => 'number'\n        ],\n        'yearmax' => [\n            'name' => 'Année max',\n            'type' => 'number'\n        ],\n        'cubiccapacitymin' => [\n            'name' => 'Cylindrée min',\n            'type' => 'number'\n        ],\n        'cubiccapacitymax' => [\n            'name' => 'Cylindrée max',\n            'type' => 'number'\n        ],\n        'fuel' => [\n            'name' => 'Énergie',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                'Diesel' => 'dies',\n                'Essence' => 'ess',\n                'Électrique' => 'elec',\n                'Hybride' => 'hyb',\n                'GPL' => 'gpl',\n                'Bioéthanol' => 'eth',\n                'Autre' => 'alt'\n            ]\n        ],\n        'gearbox' => [\n            'name' => 'Boite de vitesse',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                'Boite automatique' => 'AUTO',\n                'Boite mécanique' => 'MANUAL'\n            ]\n        ],\n        'doors' => [\n            'name' => 'Nombre de portes',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                '2 portes' => '2',\n                '3 portes' => '3',\n                '4 portes' => '4',\n                '5 portes' => '5',\n                '6 portes ou plus' => '6'\n            ]\n        ],\n        'firsthand' => [\n            'name' => 'Première main',\n            'type' => 'checkbox'\n        ],\n        'seller' => [\n            'name' => 'Vendeur',\n            'type' => 'list',\n            'values' => [\n                '' => '',\n                'Particulier' => 'PART',\n                'Professionel' => 'PRO'\n            ]\n        ],\n        'sort' => [\n            'name' => 'Tri',\n            'type' => 'list',\n            'values' => [\n                'Prix (croissant)' => 'priceAsc',\n                'Prix (décroissant)' => 'priceDesc',\n                'Marque (croissant)' => 'makeAsc',\n                'Marque (décroissant)' => 'makeDesc',\n                'Kilométrage (croissant)' => 'mileageAsc',\n                'Kilométrage (décroissant)' => 'mileageDesc',\n                'Année (croissant)' => 'yearAsc',\n                'Année (décroissant)' => 'yearDesc',\n                'Département (croissant)' => 'visitPlaceAsc',\n                'Département (décroissant)' => 'visitPlaceDesc'\n            ]\n        ],\n    ]];\n\n    public function collectData()\n    {\n        if (\n            !empty($this->getInput('distance'))\n            && is_null($this->getInput('location'))\n        ) {\n            throwClientException('You need a place (\"CP ou département\") to search arround.');\n        }\n\n        $params = [\n            'vertical' => $this->getInput('type'),\n            'makesModelsCommercialNames' => $this->getInput('brand') . ':' . $this->getInput('model'),\n            'versions' => $this->getInput('versions'),\n            'categories' => $this->getInput('category'),\n            'priceMin' => $this->getInput('pricemin'),\n            'priceMax' => $this->getInput('pricemax'),\n            'dptCp' => $this->getInput('location'),\n            'distance' => $this->getInput('distance'),\n            'regions' => $this->getInput('region'),\n            'mileageMin' => $this->getInput('mileagemin'),\n            'mileageMax' => $this->getInput('mileagemax'),\n            'yearMin' => $this->getInput('yearmin'),\n            'yearMax' => $this->getInput('yearmax'),\n            'cubicMin' => $this->getInput('cubiccapacitymin'),\n            'cubicMax' => $this->getInput('cubiccapacitymax'),\n            'energies' => $this->getInput('fuel'),\n            'firstHand' => $this->getInput('firsthand') ? 'true' : 'false',\n            'gearbox' => $this->getInput('gearbox'),\n            'doors' => $this->getInput('doors'),\n            'sortBy' => $this->getInput('sort')\n        ];\n        $url = sprintf('%slisting?%s', self::URI, http_build_query($params));\n        $html = getSimpleHTMLDOM($url);\n\n        $elements = $html->find('.adLineContainer');\n        foreach ($elements as $element) {\n            $item = [];\n            $item['uri'] = trim(self::URI, '/') . $element->find('div > a', 0)->href;\n            $item['title'] = $element->find('.searchCard__makeModel', 0)->plaintext;\n            $item['sellerType'] = $element->find('.searchCard__customer', 0)->plaintext;\n            $item['author'] = $item['sellerType'];\n            $item['version'] = $element->find('.searchCard__version', 0)->plaintext;\n            $item['price'] = $element->find('.searchCard__fieldPrice', 0)->plaintext;\n            $item['year'] = $element->find('.searchCard__year', 0)->plaintext;\n            $item['mileage'] = $element->find('.searchCard__mileage', 0)->plaintext;\n            // The image is lazyloaded with ajax\n\n            $item['content'] = '\n\t\t\t<br>Variation : ' . $item['version']\n            . '<br>Prix : ' . $item['price']\n            . '<br>Année : ' . $item['year']\n            . '<br>Kilométrage : ' . $item['mileage']\n            . '<br>Type de vendeur : ' . $item['sellerType'];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/LaTeX3ProjectNewslettersBridge.php",
    "content": "<?php\n\nclass LaTeX3ProjectNewslettersBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'µKöff';\n    const NAME = 'LaTeX3 Project Newsletters';\n    const URI = 'https://www.latex-project.org';\n    const DESCRIPTION = 'Newsletters by the LaTeX3 project team covering topics of interest in the area of\n\t\tLaTeX3/expl3 development. They appear in irregular intervals and are not necessarily tied to individual\n\t\treleases of the software (as the LaTeX3 kernel code is updated rather often).';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(static::URI . '/news/latex3-news/');\n        $newsContainer = $html->find('article tbody', 0);\n\n        foreach ($newsContainer->find('tr') as $row) {\n            $this->items[] = $this->collectArticle($row);\n        }\n    }\n\n    private function collectArticle($element)\n    {\n        $item = [];\n        $item['uri'] = static::URI . $element->find('td', 1)->find('a', 0)->href;\n        $item['title'] = $element->find('td', 1)->find('a', 0)->plaintext;\n        $item['timestamp'] = DateTime::createFromFormat('Y/m/d', $element->find('td', 0)->plaintext)->getTimestamp();\n        $item['content'] = $element->find('td', 2)->plaintext;\n        $item['author'] = 'LaTeX3 Project';\n        return $item;\n    }\n\n    public function getIcon()\n    {\n        return self::URI . '/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/LeBonCoinBridge.php",
    "content": "<?php\n\nclass LeBonCoinBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'jacknumber';\n    const NAME = 'LeBonCoin';\n    const URI = 'https://www.leboncoin.fr/';\n    const DESCRIPTION = 'Returns most recent results from LeBonCoin';\n\n    const PARAMETERS = [\n        [\n            'keywords' => ['name' => 'Mots-Clés'],\n            'region' => [\n                'name' => 'Région',\n                'type' => 'list',\n                'values' => [\n                    'Toute la France' => '',\n                    'Alsace' => '1',\n                    'Aquitaine' => '2',\n                    'Auvergne' => '3',\n                    'Basse Normandie' => '4',\n                    'Bourgogne' => '5',\n                    'Bretagne' => '6',\n                    'Centre' => '7',\n                    'Champagne Ardenne' => '8',\n                    'Corse' => '9',\n                    'Franche Comté' => '10',\n                    'Haute Normandie' => '11',\n                    'Ile de France' => '12',\n                    'Languedoc Roussillon' => '13',\n                    'Limousin' => '14',\n                    'Lorraine' => '15',\n                    'Midi Pyrénées' => '16',\n                    'Nord Pas De Calais' => '17',\n                    'Pays de la Loire' => '18',\n                    'Picardie' => '19',\n                    'Poitou Charentes' => '20',\n                    'Provence Alpes Côte d\\'Azur' => '21',\n                    'Rhône-Alpes' => '22',\n                    'Guadeloupe' => '23',\n                    'Martinique' => '24',\n                    'Guyane' => '25',\n                    'Réunion' => '26'\n                ]\n            ],\n            'department' => [\n                'name' => 'Département',\n                'type' => 'list',\n                'values' => [\n                    '' => '',\n                    'Ain' => '1',\n                    'Aisne' => '2',\n                    'Allier' => '3',\n                    'Alpes-de-Haute-Provence' => '4',\n                    'Hautes-Alpes' => '5',\n                    'Alpes-Maritimes' => '6',\n                    'Ardèche' => '7',\n                    'Ardennes' => '8',\n                    'Ariège' => '9',\n                    'Aube' => '10',\n                    'Aude' => '11',\n                    'Aveyron' => '12',\n                    'Bouches-du-Rhône' => '13',\n                    'Calvados' => '14',\n                    'Cantal' => '15',\n                    'Charente' => '16',\n                    'Charente-Maritime' => '17',\n                    'Cher' => '18',\n                    'Corrèze' => '19',\n                    'Corse-du-Sud' => '2A',\n                    'Haute-Corse' => '2B',\n                    'Côte-d\\'Or' => '21',\n                    'Côtes-d\\'Armor' => '22',\n                    'Creuse' => '23',\n                    'Dordogne' => '24',\n                    'Doubs' => '25',\n                    'Drôme' => '26',\n                    'Eure' => '27',\n                    'Eure-et-Loir' => '28',\n                    'Finistère' => '29',\n                    'Gard' => '30',\n                    'Haute-Garonne' => '31',\n                    'Gers' => '32',\n                    'Gironde' => '33',\n                    'Hérault' => '34',\n                    'Ille-et-Vilaine' => '35',\n                    'Indre' => '36',\n                    'Indre-et-Loire' => '37',\n                    'Isère' => '38',\n                    'Jura' => '39',\n                    'Landes' => '40',\n                    'Loir-et-Cher' => '41',\n                    'Loire' => '42',\n                    'Haute-Loire' => '43',\n                    'Loire-Atlantique' => '44',\n                    'Loiret' => '45',\n                    'Lot' => '46',\n                    'Lot-et-Garonne' => '47',\n                    'Lozère' => '48',\n                    'Maine-et-Loire' => '49',\n                    'Manche' => '50',\n                    'Marne' => '51',\n                    'Haute-Marne' => '52',\n                    'Mayenne' => '53',\n                    'Meurthe-et-Moselle' => '54',\n                    'Meuse' => '55',\n                    'Morbihan' => '56',\n                    'Moselle' => '57',\n                    'Nièvre' => '58',\n                    'Nord' => '59',\n                    'Oise' => '60',\n                    'Orne' => '61',\n                    'Pas-de-Calais' => '62',\n                    'Puy-de-Dôme' => '63',\n                    'Pyrénées-Atlantiques' => '64',\n                    'Hautes-Pyrénées' => '65',\n                    'Pyrénées-Orientales' => '66',\n                    'Bas-Rhin' => '67',\n                    'Haut-Rhin' => '68',\n                    'Rhône' => '69',\n                    'Haute-Saône' => '70',\n                    'Saône-et-Loire' => '71',\n                    'Sarthe' => '72',\n                    'Savoie' => '73',\n                    'Haute-Savoie' => '74',\n                    'Paris' => '75',\n                    'Seine-Maritime' => '76',\n                    'Seine-et-Marne' => '77',\n                    'Yvelines' => '78',\n                    'Deux-Sèvres' => '79',\n                    'Somme' => '80',\n                    'Tarn' => '81',\n                    'Tarn-et-Garonne' => '82',\n                    'Var' => '83',\n                    'Vaucluse' => '84',\n                    'Vendée' => '85',\n                    'Vienne' => '86',\n                    'Haute-Vienne' => '87',\n                    'Vosges' => '88',\n                    'Yonne' => '89',\n                    'Territoire de Belfort' => '90',\n                    'Essonne' => '91',\n                    'Hauts-de-Seine' => '92',\n                    'Seine-Saint-Denis' => '93',\n                    'Val-de-Marne' => '94',\n                    'Val-d\\'Oise' => '95'\n                ]\n            ],\n            'cities' => [\n                'name' => 'Villes',\n                'title' => 'Codes postaux séparés par des virgules'\n            ],\n            'category' => [\n                'name' => 'Catégorie',\n                'type' => 'list',\n                'values' => [\n                    'Toutes catégories' => '',\n                    'EMPLOI' => [\n                        'Emploi et recrutement' => '71',\n                        'Offres d\\'emploi et jobs' => '33'\n                    ],\n                    'VÉHICULES' => [\n                        'Tous' => '1',\n                        'Voitures' => '2',\n                        'Motos' => '3',\n                        'Caravaning' => '4',\n                        'Utilitaires' => '5',\n                        'Equipement Auto' => '6',\n                        'Equipement Moto' => '44',\n                        'Equipement Caravaning' => '50',\n                        'Nautisme' => '7',\n                        'Equipement Nautisme' => '51'\n                    ],\n                    'IMMOBILIER' => [\n                        'Tous' => '8',\n                        'Ventes immobilières' => '9',\n                        'Locations' => '10',\n                        'Colocations' => '11',\n                        'Bureaux & Commerces' => '13'\n                    ],\n                    'VACANCES' => [\n                        'Tous' => '66',\n                        'Locations & Gîtes' => '12',\n                        'Chambres d\\'hôtes' => '67',\n                        'Campings' => '68',\n                        'Hôtels' => '69',\n                        'Hébergements insolites' => '70'\n                    ],\n                    'MULTIMÉDIA' => [\n                        'Tous' => '14',\n                        'Informatique' => '15',\n                        'Consoles & Jeux vidéo' => '43',\n                        'Image & Son' => '16',\n                        'Téléphonie' => '17'\n                    ],\n                    'LOISIRS' => [\n                        'Tous' => '24',\n                        'DVD / Films' => '25',\n                        'CD / Musique' => '26',\n                        'Livres' => '27',\n                        'Animaux' => '28',\n                        'Vélos' => '55',\n                        'Sports & Hobbies' => '29',\n                        'Instruments de musique' => '30',\n                        'Collection' => '40',\n                        'Jeux & Jouets' => '41',\n                        'Vins & Gastronomie' => '48'\n                    ],\n                    'MATÉRIEL PROFESSIONNEL' => [\n                        'Tous' => '56',\n                        'Matériel Agricole' => '57',\n                        'Transport - Manutention' => '58',\n                        'BTP - Chantier Gros-oeuvre' => '59',\n                        'Outillage - Matériaux 2nd-oeuvre' => '60',\n                        'Équipements Industriels' => '32',\n                        'Restauration - Hôtellerie' => '61',\n                        'Fournitures de Bureau' => '62',\n                        'Commerces & Marchés' => '63',\n                        'Matériel Médical' => '64'\n                    ],\n                    'SERVICES' => [\n                        'Tous' => '31',\n                        'Prestations de services' => '34',\n                        'Billetterie' => '35',\n                        'Événements' => '49',\n                        'Cours particuliers' => '36',\n                        'Covoiturage' => '65'\n                    ],\n                    'MAISON' => [\n                        'Tous' => '18',\n                        'Ameublement' => '19',\n                        'Électroménager' => '20',\n                        'Arts de la table' => '45',\n                        'Décoration' => '39',\n                        'Linge de maison' => '46',\n                        'Bricolage' => '21',\n                        'Jardinage' => '52',\n                        'Vêtements' => '22',\n                        'Chaussures' => '53',\n                        'Accessoires & Bagagerie' => '47',\n                        'Montres & Bijoux' => '42',\n                        'Équipement bébé' => '23',\n                        'Vêtements bébé' => '54',\n                    ],\n                    'AUTRES' => '37'\n                ]\n            ],\n            'pricemin' => [\n                'name' => 'Prix min',\n                'type' => 'number'\n            ],\n            'pricemax' => [\n                'name' => 'Prix max',\n                'type' => 'number'\n            ],\n            'estate' => [\n                'name' => 'Type de bien',\n                'type' => 'list',\n                'values' => [\n                    '' => '',\n                    'Maison' => '1',\n                    'Appartement' => '2',\n                    'Terrain' => '3',\n                    'Parking' => '4',\n                    'Autre' => '5'\n                ]\n            ],\n            'roomsmin' => [\n                'name' => 'Pièces min',\n                'type' => 'number'\n            ],\n            'roomsmax' => [\n                'name' => 'Pièces max',\n                'type' => 'number'\n            ],\n            'squaremin' => [\n                'name' => 'Surface min',\n                'type' => 'number'\n            ],\n            'squaremax' => [\n                'name' => 'Surface max',\n                'type' => 'number'\n            ],\n            'mileagemin' => [\n                'name' => 'Kilométrage min',\n                'type' => 'number'\n            ],\n            'mileagemax' => [\n                'name' => 'Kilométrage max',\n                'type' => 'number'\n            ],\n            'yearmin' => [\n                'name' => 'Année min',\n                'type' => 'number'\n            ],\n            'yearmax' => [\n                'name' => 'Année max',\n                'type' => 'number'\n            ],\n            'cubiccapacitymin' => [\n                'name' => 'Cylindrée min',\n                'type' => 'number'\n            ],\n            'cubiccapacitymax' => [\n                'name' => 'Cylindrée max',\n                'type' => 'number'\n            ],\n            'fuel' => [\n                'name' => 'Énergie',\n                'type' => 'list',\n                'values' => [\n                    '' => '',\n                    'Essence' => '1',\n                    'Diesel' => '2',\n                    'GPL' => '3',\n                    'Électrique' => '4',\n                    'Hybride' => '6',\n                    'Autre' => '5'\n                ]\n            ],\n            'owner' => [\n                'name' => 'Vendeur',\n                'type' => 'list',\n                'values' => [\n                    'Tous' => '',\n                    'Particuliers' => 'private',\n                    'Professionnels' => 'pro'\n                ]\n            ]\n        ]\n    ];\n\n    public static $LBC_API_KEY = 'ba0c2dad52b3ec';\n\n    private function getRange($field, $range_min, $range_max)\n    {\n        if (\n            !is_null($range_min)\n            && !is_null($range_max)\n            && $range_min > $range_max\n        ) {\n            throwClientException('Min-' . $field . ' must be lower than max-' . $field . '.');\n        }\n\n        if (\n            !is_null($range_min)\n            && is_null($range_max)\n        ) {\n            throwClientException('Max-' . $field . ' is needed when min-' . $field . ' is setted (range).');\n        }\n\n        return [\n            'min' => $range_min,\n            'max' => $range_max\n        ];\n    }\n\n    public function collectData()\n    {\n        $url = 'https://api.leboncoin.fr/api/adfinder/v1/search';\n        $data = $this->buildRequestJson();\n\n        $header = [\n            'User-Agent: LBC;Android;10;SAMSUNG;phone;0aaaaaaaaaaaaaaa;wifi;8.24.3.8;152437;0',\n            'Content-Type: application/json',\n            'X-LBC-CC: 7',\n            'Accept: application/json,application/hal+json',\n            'Content-Length: ' . strlen($data),\n            'api_key: ' . self::$LBC_API_KEY\n        ];\n\n        $opts = [\n            CURLOPT_CUSTOMREQUEST => 'POST',\n            CURLOPT_POSTFIELDS => $data\n\n        ];\n\n        $content = getContents($url, $header, $opts);\n\n        $json = json_decode($content);\n\n        if ($json->total === 0) {\n            return;\n        }\n\n        foreach ($json->ads as $element) {\n            $item['title'] = $element->subject;\n            $item['content'] = $element->body;\n            $item['date'] = $element->index_date;\n            $item['timestamp'] = strtotime($element->index_date);\n            $item['uri'] = $element->url;\n            $item['ad_type'] = $element->ad_type;\n            $item['author'] = $element->owner->name;\n\n            if (isset($element->location->city)) {\n                $item['city'] = $element->location->city;\n                $item['content'] .= ' -- ' . $element->location->city;\n            }\n\n            if (isset($element->location->zipcode)) {\n                $item['zipcode'] = $element->location->zipcode;\n            }\n\n            if (isset($element->price)) {\n                $item['price'] = $element->price[0];\n                $item['content'] .= ' -- ' . current($element->price) . '€';\n            }\n\n            if (isset($element->images->urls)) {\n                $item['thumbnail'] = $element->images->thumb_url;\n                $item['enclosures'] = [];\n\n                foreach ($element->images->urls as $image) {\n                    $item['enclosures'][] = $image;\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function buildRequestJson()\n    {\n        $requestJson = new StdClass();\n        $requestJson->owner_type = $this->getInput('owner');\n        $requestJson->filters = new StdClass();\n\n        $requestJson->filters->keywords = [\n            'text' => $this->getInput('keywords')\n        ];\n\n        if ($this->getInput('region') != '') {\n            $requestJson->filters->location['regions'] = [$this->getInput('region')];\n        }\n\n        if ($this->getInput('department') != '') {\n            $requestJson->filters->location['departments'] = [$this->getInput('department')];\n        }\n\n        if ($this->getInput('cities') != '') {\n            $requestJson->filters->location['city_zipcodes'] = [];\n\n            foreach (explode(',', $this->getInput('cities')) as $zipcode) {\n                $requestJson->filters->location['city_zipcodes'][] = [\n                    'zipcode' => trim($zipcode)\n                ];\n            }\n        }\n\n        $requestJson->filters->category = [\n            'id' => $this->getInput('category')\n        ];\n\n        if (\n            $this->getInput('pricemin') != ''\n            || $this->getInput('pricemax') != ''\n        ) {\n            $requestJson->filters->ranges->price = $this->getRange(\n                'price',\n                $this->getInput('pricemin'),\n                $this->getInput('pricemax')\n            );\n        }\n\n        if ($this->getInput('estate') != '') {\n            $requestJson->filters->enums['real_estate_type'] = [$this->getInput('estate')];\n        }\n\n        if (\n            $this->getInput('roomsmin') != ''\n            || $this->getInput('roomsmax') != ''\n        ) {\n            $requestJson->filters->ranges->rooms = $this->getRange(\n                'rooms',\n                $this->getInput('roomsmin'),\n                $this->getInput('roomsmax')\n            );\n        }\n\n        if (\n            $this->getInput('squaremin') != ''\n            || $this->getInput('squaremax') != ''\n        ) {\n            $requestJson->filters->ranges->square = $this->getRange(\n                'square',\n                $this->getInput('squaremin'),\n                $this->getInput('squaremax')\n            );\n        }\n\n        if (\n            $this->getInput('mileagemin') != ''\n            || $this->getInput('mileagemax') != ''\n        ) {\n            $requestJson->filters->ranges->mileage = $this->getRange(\n                'mileage',\n                $this->getInput('mileagemin'),\n                $this->getInput('mileagemax')\n            );\n        }\n\n        if (\n            $this->getInput('yearmin') != ''\n            || $this->getInput('yearmax') != ''\n        ) {\n            $requestJson->filters->ranges->regdate = $this->getRange(\n                'year',\n                $this->getInput('yearmin'),\n                $this->getInput('yearmax')\n            );\n        }\n\n        if (\n            $this->getInput('cubiccapacitymin') != ''\n            || $this->getInput('cubiccapacitymax') != ''\n        ) {\n            $requestJson->filters->ranges->cubic_capacity = $this->getRange(\n                'cubic_capacity',\n                $this->getInput('cubiccapacitymin'),\n                $this->getInput('cubiccapacitymax')\n            );\n        }\n\n        if ($this->getInput('fuel') != '') {\n            $requestJson->filters->enums['fuel'] = [$this->getInput('fuel')];\n        }\n\n        $requestJson->limit = 30;\n\n        return json_encode($requestJson);\n    }\n}\n"
  },
  {
    "path": "bridges/LeMondeInformatiqueBridge.php",
    "content": "<?php\n\nclass LeMondeInformatiqueBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Le Monde Informatique';\n    const URI = 'https://www.lemondeinformatique.fr/';\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::URI . 'rss/rss.xml', 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article_html = getSimpleHTMLDOMCached($item['uri']);\n\n        //Deduce thumbnail URL from article image URL\n        $item['enclosures'] = [\n            str_replace(\n                '/grande/',\n                '/petite/',\n                $article_html->find('.article-image > img, figure > img', 0)->src\n            )\n        ];\n\n        //No response header sets the encoding, explicit conversion is needed or subsequent xml_encode() will fail\n        $content_node = $article_html->find('div.col-primary, div.col-sm-9', 0);\n        $item['content'] = $this->cleanArticle($content_node->innertext);\n        $item['author'] = $article_html->find('div.author-infos', 0)->find('b', 0)->plaintext;\n\n        return $item;\n    }\n\n    private function cleanArticle($article_html)\n    {\n        $article_html = stripWithDelimiters($article_html, '<script', '</script>');\n        $article_html = explode('<p class=\"contact-error', $article_html)[0] . '</div>';\n        return $article_html;\n    }\n}\n"
  },
  {
    "path": "bridges/LeagueOfLegendsNewsBridge.php",
    "content": "<?php\n\nclass LeagueOfLegendsNewsBridge extends BridgeAbstract\n{\n    const NAME = 'League of Legends News';\n    const URI = 'https://www.leagueoflegends.com';\n    const DESCRIPTION = 'Official League of Legends news.';\n    const MAINTAINER = 'KappaPrajd';\n    const PARAMETERS = [\n        [\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'defaultValue' => 'en-us',\n                'values' => [\n                    'English (NA)' => 'en-us',\n                    'English (EUW)' => 'en-gb',\n                    'Deutsch' => 'de-de',\n                    'Español (EUW)' => 'es-es',\n                    'Français' => 'fr-fr',\n                    'Italiano' => 'it-it',\n                    'Polski' => 'pl-pl',\n                    'Ελληνικά' => 'el-gr',\n                    'Română' => 'ro-ro',\n                    'Magyar' => 'hu-hu',\n                    'Čeština' => 'cs-cz',\n                    'Español (LATAM)' => 'es-mx',\n                    'Português' => 'pt-br',\n                    '日本語' => 'ja-jp',\n                    'Русский' => 'ru-ru',\n                    'Türkçe' => 'tr-tr',\n                    'English (OCE)' => 'en-au',\n                    '한국어' => 'ko-kr',\n                    'English (SG)' => 'en-sg',\n                    'English (PH)' => 'en-ph',\n                    'Tiếng Việt' => 'vi-vn',\n                    'ภาษาไทย' => 'th-th',\n                    '繁體中文' => 'zh-tw',\n                    'العربية' => 'ar-ae'\n                ]\n            ],\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'defaultValue' => 'all',\n                'values' => [\n                    'All' => 'all',\n                    'Game updates' => 'game-updates',\n                    'Esports' => 'esports',\n                    'Dev' => 'dev',\n                    'Lore' => 'lore',\n                    'Media' => 'media',\n                    'Merch' => 'merch',\n                    'Community' => 'community',\n                    'Riot Games' => 'riot-games'\n                ]\n            ],\n            'onlyPatchNotes' => [\n                'name' => 'Only patch notes',\n                'type' => 'checkbox',\n                'defaultValue' => false,\n            ],\n        ],\n\n    ];\n\n    public function collectData()\n    {\n        $siteUrl = $this->getSiteUrl();\n        $html = getSimpleHTMLDOM($siteUrl);\n\n        $articles = $html->find('a[data-testid=articlefeaturedcard-component]');\n\n        foreach ($articles as $article) {\n            $title = $article->find('div[data-testid=card-title]', 0)->plaintext;\n            $content = $article->find('div[data-testid=card-description] div div div', 0)->plaintext;\n            $timestamp = $article->find('div[data-testid=card-date] time', 0)->getAttribute('datetime');\n            $href = $article->getAttribute('href');\n\n            $item = [\n                'title' => $title,\n                'content' => $content,\n                'timestamp' => $timestamp,\n                'uri' => $this->getArticleUri($href),\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getSiteUrl()\n    {\n        $lang = $this->getInput('language');\n        $category = $this->getInput('category');\n        $onlyPatchNotes = $this->getInput('onlyPatchNotes');\n\n        $url = self::URI . '/' . $lang . '/news';\n\n        if ($onlyPatchNotes) {\n            return $url . '/tags/patch-notes';\n        } else if ($category === 'all') {\n            return $url;\n        }\n\n        return $url . '/' . $category;\n    }\n\n    private function getArticleUri($href)\n    {\n        $isInternalLink = str_starts_with($href, '/');\n\n        if ($isInternalLink) {\n            return self::URI . $href;\n        }\n\n        return $href;\n    }\n}"
  },
  {
    "path": "bridges/LegifranceJOBridge.php",
    "content": "<?php\n\nclass LegifranceJOBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Pierre Mazière';\n    const NAME = 'Journal Officiel de la République Française';\n    // This uri returns a snippet of js. Should probably be https://www.legifrance.gouv.fr/jorf/jo/\n    const URI = 'https://www.legifrance.gouv.fr/affichJO.do';\n    const DESCRIPTION = 'Returns the laws and decrees officially registered daily in France';\n\n    const PARAMETERS = [];\n\n    private $author;\n    private $timestamp;\n    private $uri;\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $title = $html->find('h2.titleJO', 0);\n\n        //$this->author = trim($title->plaintext);\n        $uri1 = $html->find('h2.titleELI', 0);\n        //$uri = $uri1->plaintext;\n        //$this->uri = trim(substr($uri, strpos($uri, 'https')));\n        $this->timestamp = strtotime(substr($this->uri, strpos($this->uri, 'eli/jo/') + strlen('eli/jo/'), -5));\n\n        foreach ($html->find('h3') as $section) {\n            $subsections = $section->nextSibling()->find('h4');\n            foreach ($subsections as $subsection) {\n                $origins = $subsection->nextSibling()->find('h5');\n                foreach ($origins as $origin) {\n                    $this->items[] = $this->extractItem($section, $subsection, $origin);\n                }\n                if (!empty($origins)) {\n                    continue;\n                }\n                $this->items[] = $this->extractItem($section, $subsection);\n            }\n            if (!empty($subsections)) {\n                continue;\n            }\n            $this->items[] = $this->extractItem($section);\n        }\n    }\n\n    private function extractItem($section, $subsection = null, $origin = null)\n    {\n        $item = [];\n        $item['author'] = $this->author;\n        $item['timestamp'] = $this->timestamp;\n        $item['uri'] = $this->uri . '#' . count($this->items);\n        $item['title'] = $section->plaintext;\n\n        if (!is_null($origin)) {\n            $item['title'] = '[ ' . $item['title'] . ' / ' . $subsection->plaintext . ' ] ' . $origin->plaintext;\n            $data = $origin;\n        } elseif (!is_null($subsection)) {\n            $item['title'] = '[ ' . $item['title'] . ' ] ' . $subsection->plaintext;\n            $data = $subsection;\n        } else {\n            $data = $section;\n        }\n\n        $item['content'] = '';\n        foreach ($data->nextSibling()->find('a') as $content) {\n            $text = $content->plaintext;\n            $href = '';\n            //$href = $content->nextSibling()->getAttribute('resource');\n\n            $item['content'] .= '<p><a href=\"' . $href . '\">' . $text . '</a></p>';\n        }\n        return $item;\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.legifrance.gouv.fr/img/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/LegoIdeasBridge.php",
    "content": "<?php\n\nclass LegoIdeasBridge extends BridgeAbstract\n{\n    const NAME = 'Lego Ideas';\n    const URI = 'https://ideas.lego.com/';\n    const DESCRIPTION = 'Community Supported Lego Builds';\n    const MAINTAINER = 'sal0max';\n    const CACHE_TIMEOUT = 60 * 60 * 2; // 2h\n    const PARAMETERS = [ [\n            'support_value_min' => [\n                'name' => 'Minimum Supporters',\n                'title' => 'The number of people that need to have supported a project at minimum.\nOnce a project reaches 10,000 supporters, it gets reviewed by the lego experts.',\n                'type' => 'number',\n                'defaultValue' => 1000\n            ],\n            'idea_phase' => [\n                'name' => 'Idea Phase',\n                'type' => 'list',\n                'values' => [\n                    'Gathering Support' => 'idea_gathering_support',\n                    'Achieved Support' => 'idea_achieved_support',\n                    'In Review' => 'idea_in_review',\n                    'Approved Ideas' => 'idea_idea_approved',\n                    'Not Approved Ideas' => 'idea_idea_not_approved',\n                    'On Shelves' => 'idea_on_shelves',\n                    'Expired Ideas' => 'idea_expired_ideas',\n                ],\n                'defaultValue' => 'idea_gathering_support'\n            ]\n        ]\n    ];\n\n    public function getURI()\n    {\n        // link to the corresponding page on the website, not the api endpoint\n        return self::URI . 'search/global_search/ideas'\n            . \"?support_value={$this->getInput('support_value_min')}\"\n            . '&support_value=10000'\n            . \"&idea_phase={$this->getInput('idea_phase')}\"\n            . '&sort=most_recent';\n    }\n\n    public function collectData()\n    {\n        $header = [\n            'Content-Type: application/json',\n            'Accept: application/json'\n        ];\n        $opts = [\n            CURLOPT_POST => 1,\n            CURLOPT_POSTFIELDS => $this->getHttpPostData()\n        ];\n        $responseData = getContents($this->getHttpPostURI(), $header, $opts);\n\n        foreach (json_decode($responseData)->results as $project) {\n            preg_match('/datetime=\\\"(\\S+)\\\"/', $project->entity->published_at, $date_matches);\n            $datetime = $date_matches[1];\n            $link     = self::URI . $project->entity->view_url;\n            $title    = $project->entity->title;\n            $desc     = $project->entity->content;\n            $imageUrl = $project->entity->image_url;\n            $creator  = $project->entity->creator->alias;\n            $uuid     = $project->entity->uuid;\n\n            $item = [\n                'uri'       => $link,\n                'title'     => $title,\n                'timestamp' => strtotime($datetime),\n                'author'    => $creator,\n                'content'   => <<<EOD\n<p><img src=\"{$imageUrl}\" alt=\"{$title}\"/></p>\n<p>{$desc}</p>\nEOD\n            ];\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Returns the API endpoint\n     */\n    private function getHttpPostURI()\n    {\n        return self::URI . '/search/global_search/ideas';\n    }\n\n    /**\n     * Returns the API query\n     */\n    private function getHttpPostData()\n    {\n        $phase = $this->getInput('idea_phase');\n        $minSupporters = $this->getInput('support_value_min');\n\n        return <<<EOD\n{ \"filters\": {\n\t \"idea_phase\": [ \"$phase\" ],\n\t \"support_value\": [ $minSupporters, 10000 ]\n},\n\"sort\": [ \"most_recent:desc\" ]\n}\nEOD;\n    }\n}\n"
  },
  {
    "path": "bridges/LesJoiesDuCodeBridge.php",
    "content": "<?php\n\nclass LesJoiesDuCodeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'superbaillot.net';\n    const NAME = 'Les Joies Du Code';\n    const URI = 'https://lesjoiesducode.fr/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'LesJoiesDuCode';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('article.blog-post') as $element) {\n            $item = [];\n            $temp = $element->find('h1 a', 0);\n            $titre = html_entity_decode($temp->innertext);\n            $url = $temp->href;\n\n            $temp = $element->find('div.blog-post-content', 0);\n\n            // retrieve .gif instead of static .jpg\n            $images = $temp->find('p img');\n            foreach ($images as $image) {\n                $img_src = str_replace('.jpg', '.gif', $image->src);\n                $image->src = $img_src;\n            }\n            $content = $temp->innertext;\n\n            $item['content'] = trim($content);\n            $item['uri'] = $url;\n            $item['title'] = trim($titre);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/LfcPlBridge.php",
    "content": "<?php\n\nclass LfcPlBridge extends BridgeAbstract\n{\n    const NAME = 'LFC (lfc.pl)';\n    const DESCRIPTION = 'LFC.pl - największa polska strona o Liverpool FC';\n    const URI = 'https://lfc.pl';\n    const MAINTAINER = 'brtsos';\n    const PARAMETERS = [\n        [\n            'comments' => [\n                'type' => 'list',\n                'name' => 'Include comments',\n                'title' => 'Include comments in the article content',\n                'values' => [\n                    'No' => 'no',\n                    'Yes' => 'yes',\n                ],\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(self::URI . '/Archiwum/' . date('Y') . date('m'));\n\n        $list = $dom->find('#page .list-vertical li');\n        $list = array_reverse($list);\n        $list = array_slice($list, 0, 10);\n\n        foreach ($list as $li) {\n            $link = $li->find('a', 0);\n            $url = self::URI . $link->href;\n\n            $articleDom = getSimpleHTMLDOM($url);\n\n            $description = $this->getContent($articleDom);\n            if (mb_strpos($description, 'Artykuł sponsorowany') !== false) {\n                continue;\n            }\n\n            $image = '<img src=\"' . $this->getImage($articleDom) . '\" alt=\"' . $link->plaintext . '\" />';\n\n            $content = $image . '</br>' . $description;\n\n            $tagsToRemove = ['script', 'iframe', 'input', 'form'];\n            $content = sanitize($content, $tagsToRemove);\n\n            $footerArticle = $articleDom->find('.footer', 0)->find('.item', 0)->find('div', 1);\n            $author = $footerArticle->find('a', 0)->plaintext;\n\n            $dateTime = $footerArticle->find('div', 0)->plaintext;\n            $date = DateTime::createFromFormat('d.m.Y H:i', $dateTime);\n            $timestamp = $date->getTimestamp();\n            $this->items[] = [\n                'title' => $link->plaintext,\n                'uri' => $url,\n                'timestamp' => $timestamp,\n                'content' => $content,\n                'author' => $author,\n            ];\n        }\n    }\n\n    private function getContent($article)\n    {\n        $content = $article->find('.news-body', 0)->innertext;\n        $commentsHtml = $article->find('#comments', 0);\n\n        $comments = '';\n        if ($this->withComment()) {\n            if ($commentsHtml) {\n                $commentsDom = $commentsHtml->find('.comment');\n\n                if (count($commentsDom) > 0) {\n                    $comments = '<h3>Komentarze:</h3>';\n                }\n\n                foreach ($commentsDom as $comment) {\n                    $header = $comment->find('.header', 0)->plaintext;\n                    $commentContent = $comment->find('.content', 0)->plaintext;\n                    $comments .= $header . '<br />' . $commentContent . '<br /><br />';\n                }\n            }\n        }\n\n        return $content . '<br /> <br />' . $comments;\n    }\n\n    private function getImage($article): ?string\n    {\n        $imgElement = $article->find('#news .img', 0);\n        if ($imgElement) {\n            $style = $imgElement->style;\n\n            if (preg_match('/background-image:\\s*url\\(([^)]+)\\)/i', $style, $matches)) {\n                return self::URI . trim($matches[1], \"'\\\"\");\n            }\n\n            return null;\n        }\n\n        return null;\n    }\n\n    private function withComment(): bool\n    {\n        return $this->getInput('comments') === 'yes';\n    }\n}"
  },
  {
    "path": "bridges/LinuxBlogBridge.php",
    "content": "<?php\n\nclass LinuxBlogBridge extends BridgeAbstract\n{\n    const NAME = 'LinuxBlog.io';\n    const URI = 'https://linuxblog.io';\n    const DESCRIPTION = 'Retrieve recent articles';\n    const MAINTAINER = 'tillcash';\n    const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours\n    const MAX_ARTICLES = 5;\n\n    public function collectData()\n    {\n        $count = 0;\n        $dom = getSimpleHTMLDOM(self::URI);\n        $articles = $dom->find('ul.display-posts-listing li.listing-item');\n\n        if (!$articles) {\n            throwServerException('Failed to retrieve articles');\n        }\n\n        foreach ($articles as $article) {\n            if ($count >= self::MAX_ARTICLES) {\n                break;\n            }\n\n            $element = $article->find('a.title', 0);\n\n            if (!$element || empty($element->plaintext) || empty($element->href)) {\n                continue;\n            }\n\n            $timestamp = null;\n            $url = $element->href;\n            $date = $article->find('span.date', 0);\n\n            if ($date && $date->plaintext) {\n                $timestamp = strtotime($date->plaintext . ' 00:00:00 GMT');\n            }\n\n            $this->items[] = [\n                'content'    => $this->constructContent($url),\n                'timestamp'  => $timestamp,\n                'title'      => trim($element->plaintext),\n                'uid'        => $url,\n                'uri'        => $url,\n            ];\n\n            $count++;\n        }\n    }\n\n    private function constructContent($url)\n    {\n        $dom = getSimpleHTMLDOMCached($url);\n        $article = $dom->find('section.entry.fix', 0);\n\n        if (!$article) {\n            return 'Content Not Found';\n        }\n\n        return $article->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/ListverseBridge.php",
    "content": "<?php\n\nclass ListverseBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'Listverse';\n    const URI = 'https://listverse.com/';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'RSS feed for Listverse';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://listverse.com/feed/', 15);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOM($item['uri']);\n        $article = $dom->find('#articlecontentonly', 0);\n        $item['content'] = $article;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/LogicMastersBridge.php",
    "content": "<?php\n\nclass LogicMastersBridge extends XPathAbstract\n{\n    const NAME = 'Logic Masters Deutschland e.V.';\n    const URI = 'https://logic-masters.de/';\n    const DESCRIPTION = 'Aktuelles';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://logic-masters.de/';\n    //const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"SHORTCUT ICON\"]/@href';\n    const XPATH_EXPRESSION_ITEM = '//div[@class=\"aktuelles_eintrag\"]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './div[@class=\"aktuelles_titel\"]';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './p';\n    //const XPATH_EXPRESSION_ITEM_URI = './a/@href';\n    //const XPATH_EXPRESSION_ITEM_AUTHOR = './/';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './div[@class=\"aktuelles_datum\"]';\n    //const XPATH_EXPRESSION_ITEM_ENCLOSURES = './';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n\n    protected function formatItemTimestamp($value)\n    {\n        $formatter = new IntlDateFormatter('de', IntlDateFormatter::LONG, IntlDateFormatter::NONE);\n        return $formatter->parse($value);\n    }\n}"
  },
  {
    "path": "bridges/LolibooruBridge.php",
    "content": "<?php\n\nclass LolibooruBridge extends MoebooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Lolibooru';\n    const URI = 'https://lolibooru.moe/';\n    const DESCRIPTION = 'Returns images from given page and tags';\n}\n"
  },
  {
    "path": "bridges/LuftfahrtBundesAmtBridge.php",
    "content": "<?php\n\nclass LuftfahrtBundesAmtBridge extends XPathAbstract\n{\n    const NAME = 'Luftfahrt-Bundesamt';\n    const URI = 'https://www.lba.de/DE/Home/Nachrichten/nachrichten_node.html';\n    const DESCRIPTION = 'alle Nachrichten: Liste aller Meldungen';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://www.lba.de/DE/Home/Nachrichten/nachrichten_node.html';\n    const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"shortcut icon\"]/@href';\n    const XPATH_EXPRESSION_ITEM = '//table/tbody/tr';\n    const XPATH_EXPRESSION_ITEM_TITLE = './td[2]/a/text()';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './td[2]/a/text()';\n    const XPATH_EXPRESSION_ITEM_URI = './td[2]/a/@href';\n    //const XPATH_EXPRESSION_ITEM_AUTHOR = './/';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './td[1]';\n    //const XPATH_EXPRESSION_ITEM_ENCLOSURES = './';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n\n    protected function provideFeedIcon(\\DOMXPath $xpath)\n    {\n        return parent::provideFeedIcon($xpath) . '?__blob=normal&v=3';\n    }\n\n    protected function formatItemTimestamp($value)\n    {\n        $value = trim($value);\n        if (strpos($value, 'Uhr') !== false) {\n            $value = str_replace(' Uhr', '', $value);\n            $dti = DateTimeImmutable::createFromFormat('d.m.Y G:i', $value);\n        } else {\n            $dti = DateTimeImmutable::createFromFormat('d.m.Y', $value);\n            $dti = $dti->setTime(0, 0);\n        }\n        return $dti->getTimestamp();\n    }\n\n    // remove jsession part\n    protected function formatItemUri($value)\n    {\n        $parts = explode(';', $value);\n        return $parts[0];\n    }\n}\n\n"
  },
  {
    "path": "bridges/LuftsportSHBridge.php",
    "content": "<?php\n\nclass LuftsportSHBridge extends XPathAbstract\n{\n    const NAME = 'Luftsportverband Schleswig-Holstein';\n    const URI = 'https://www.luftsport-sh.de/start.html';\n    const DESCRIPTION = 'Aktuelles vom Luftsportverband Schleswig-Holstein e.V.';\n    const MAINTAINER = 'hleskien';\n\n    const FEED_SOURCE_URL = 'https://www.luftsport-sh.de/start.html';\n    const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"icon\" and @sizes=\"16x16\"]/@href';\n    const XPATH_EXPRESSION_ITEM = '//div[contains(@class, \"mod_newslist\")]/div';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/*[@itemprop=\"name\"]/a/text()';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/div[@itemprop=\"description\"]/p/text()';\n    const XPATH_EXPRESSION_ITEM_URI = './h3/a/@href';\n    //const XPATH_EXPRESSION_ITEM_AUTHOR = './/';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/time/@datetime';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@src';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = './/';\n\n    protected function formatItemTimestamp($value)\n    {\n        $dti = DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $value);\n        return $dti->getTimestamp();\n    }\n}\n"
  },
  {
    "path": "bridges/MaalaimalarBridge.php",
    "content": "<?php\n\nclass MaalaimalarBridge extends BridgeAbstract\n{\n    const NAME = 'Maalaimalar';\n    const URI = 'https://www.maalaimalar.com';\n    const DESCRIPTION = 'Retrieve news from maalaimalar.com';\n    const CACHE_TIMEOUT = 60 * 5; // 5 minutes\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'name' => 'topic',\n                'type' => 'list',\n                'values' => [\n                    'news' => [\n                        'tamilnadu' => '/news/tamilnadu',\n                        'puducherry' => '/news/puducherry',\n                        'india' => '/news/national',\n                        'world' => '/news/world',\n                    ],\n                    'district' => [\n                        'chennai' => '/news/district/chennai',\n                        'ariyalur' => '/news/district/ariyalur',\n                        'chengalpattu' => '/news/district/chengalpattu',\n                        'coimbatore' => '/news/district/coimbatore',\n                        'cuddalore' => '/news/district/cuddalore',\n                        'dharmapuri' => '/news/district/dharmapuri',\n                        'dindugal' => '/news/district/dindugal',\n                        'erode' => '/news/district/erode',\n                        'kaanchepuram' => '/news/district/kaanchepuram',\n                        'kallakurichi' => '/news/district/kallakurichi',\n                        'kanyakumari' => '/news/district/kanyakumari',\n                        'karur' => '/news/district/karur',\n                        'krishnagiri' => '/news/district/krishnagiri',\n                        'madurai' => '/news/district/madurai',\n                        'mayiladuthurai' => '/news/district/mayiladuthurai',\n                        'nagapattinam' => '/news/district/nagapattinam',\n                        'namakal' => '/news/district/namakal',\n                        'nilgiris' => '/news/district/nilgiris',\n                        'perambalur' => '/news/district/perambalur',\n                        'pudukottai' => '/news/district/pudukottai',\n                        'ramanathapuram' => '/news/district/ramanathapuram',\n                        'ranipettai' => '/news/district/ranipettai',\n                        'salem' => '/news/district/salem',\n                        'sivagangai' => '/news/district/sivagangai',\n                        'tanjore' => '/news/district/tanjore',\n                        'theni' => '/news/district/theni',\n                        'thenkasi' => '/news/district/thenkasi',\n                        'thiruchirapalli' => '/news/district/thiruchirapalli',\n                        'thirunelveli' => '/news/district/thirunelveli',\n                        'thirupathur' => '/news/district/thirupathur',\n                        'thiruvarur' => '/news/district/thiruvarur',\n                        'thoothukudi' => '/news/district/thoothukudi',\n                        'tirupur' => '/news/district/tirupur',\n                        'tiruvallur' => '/news/district/tiruvallur',\n                        'tiruvannamalai' => '/news/district/tiruvannamalai',\n                        'vellore' => '/news/district/vellore',\n                        'villupuram' => '/news/district/villupuram',\n                        'virudhunagar' => '/news/district/virudhunagar',\n                    ],\n                    'cinema' => [\n                        'news' => '/cinema/cinemanews',\n                        'gossip' => '/cinema/gossip',\n                    ],\n                ],\n            ],\n        ],\n    ];\n\n    public function getName()\n    {\n        $topic = $this->getKey('topic');\n        return self::NAME . ($topic ? ' - ' . ucfirst($topic) : '');\n    }\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(self::URI . $this->getInput('topic'));\n        $articles = $dom->find('div.mb-20.infinite-card-wrapper.white-section');\n\n        foreach ($articles as $article) {\n            $titleElement = $article->find('h2.title a', 0);\n            if (!$titleElement) {\n                continue;\n            }\n\n            $dateElement = $article->find('time.h-date span', 0);\n            $date = $dateElement ? $dateElement->{'data-datestring'} . 'UTC' : '';\n\n            $content = $this->constructContent($article);\n\n            $this->items[] = [\n                'content'   => $content,\n                'timestamp' => $date,\n                'title'     => $titleElement->plaintext,\n                'uid'       => $titleElement->href,\n                'uri'       => self::URI . $titleElement->href,\n            ];\n        }\n    }\n\n    private function constructContent($article)\n    {\n        $content = '';\n        $imageElement = $article->find('div.ignore-autoplay img', 0);\n        if ($imageElement && isset($imageElement->{'data-src'})) {\n            $url = str_replace('500x300_', '', $imageElement->{'data-src'});\n\n            if (filter_var($url, FILTER_VALIDATE_URL)) {\n                $content = sprintf('<p><img src=\"%s\"></p>', htmlspecialchars($url, ENT_QUOTES, 'UTF-8'));\n            }\n        }\n\n        $storyElement = $article->find('div.story-content', 0);\n        if ($storyElement) {\n            $content .= $storyElement->innertext;\n        }\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/MagellantvBridge.php",
    "content": "<?php\n\nclass MagellantvBridge extends BridgeAbstract\n{\n    const NAME = 'Magellantv articles';\n    const URI = 'https://www.magellantv.com/articles';\n    const DESCRIPTION = 'Articles of the documentery streaming service Magellantv';\n    const MAINTAINER = 'Vincentvd';\n    const CACHE_TIMEOUT = 60; // 15 minutes\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'type' => 'list',\n                'name' => 'Article topic',\n                'values' => [\n                    'All topics' => 'all',\n                    'Ancient history' => 'ancient-history',\n                    'Art & culture' => 'art-culture',\n                    'Biography' => 'biography',\n                    'Current history' => 'current-history',\n                    'Early modern' => 'early-modern',\n                    'Earth' => 'earth',\n                    'Mind & body' => 'mind-body',\n                    'Nature' => 'nature',\n                    'Science & tech' => 'science-tech',\n                    'Short takes' => 'short-takes',\n                    'Space' => 'space',\n                    'Travel & adventure' => 'travel-adventure',\n                    'True crime' => 'true-crime',\n                    'War & military' => 'war-military'\n                ],\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.magellantv.com/favicon-32x32.png';\n    }\n\n    private function retrieveTags($article)\n    {\n        // Retrieve all tags from an article and store in array\n        $article_tags_list = $article->find('div.articleCategory_article-category-tag__uEAXz > a');\n        $tags = [];\n        foreach ($article_tags_list as $tag) {\n            array_push($tags, $tag->plaintext);\n        }\n\n        return $tags;\n    }\n\n    public function collectData()\n    {\n        // Determine URL based on topic\n        $topic = $this->getInput('topic');\n        if ($topic == 'all') {\n            $url = 'https://www.magellantv.com/articles';\n        } else {\n            $url = sprintf('https://www.magellantv.com/articles/category/%s', $topic);\n        }\n        $dom = getSimpleHTMLDOM($url);\n\n        // Check whether items exists\n        $article_list = $dom->find('div.articlePreview_preview-card__mLMOm');\n        if (count($article_list) == 0) {\n            throw new Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n\n        // Loop over each article and store article information\n        foreach ($article_list as $article) {\n            $article = defaultLinkTo($article, $this->getURI());\n            $meta_information = $article->find('div.articlePreview_article-metas__kD1i7', 0);\n            $title = $article->find('div.articlePreview_article-title___Ci5V > h2 > a', 0);\n            $tags_list = $this->retrieveTags($article);\n\n            $item = [\n                'title' => $title->plaintext,\n                'uri' => $title->href,\n                'timestamp' => strtotime($meta_information->find('div.articlePreview_article-date__8Jyfn', 0)->plaintext),\n                'author' => $meta_information->find('div.articlePreview_article-author__Ie0_u > span', 1)->plaintext,\n                'categories' => $tags_list\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/MagicTheGatheringBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass MagicTheGatheringBridge extends BridgeAbstract\n{\n    const NAME = 'Magic: The Gathering';\n    const URI = 'https://magic.wizards.com/en/news/';\n    const DESCRIPTION = 'Daily MTG - MTG News, Announcements, and Podcasts';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 86400;\n\n    const PARAMETERS = [\n        [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'title' => 'News categories',\n                'values' => [\n                    'All' => 'archive',\n                    'Annoucements' => 'annoucements',\n                    'Card Image Gallery' => 'card-image-gallery',\n                    'Card Preview' => 'card-preview',\n                    'Feature' => 'feature',\n                    'Magic Story' => 'magic-story',\n                    'Making Magic' => 'making-magic',\n                    'MTG Arena' => 'mtg-arena',\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = static::URI . $this->getInput('category');\n\n        $dom = getSimpleHTMLDOM($url);\n\n        foreach ($dom->find('article') as $article) {\n            $title = $article->find('h3', 0)->innertext;\n            $author = $article->find('a', 2)->innertext;\n            $articleurl = 'https://magic.wizards.com' . $article->find('a', 1)->href;\n\n            $fullarticle = getSimpleHTMLDomCached($articleurl);\n            $articlebody = $fullarticle->find('article', 0);\n            $timestamp = strtotime($articlebody->find('time', 0)->innertext);\n            $content = $articlebody->find('div.article-body', 0)->innertext;\n\n            $this->items[] = [\n                'title' => $title,\n                'author' => $author,\n                'uri' => $articleurl,\n                'content' => $content,\n                'timestamp' => $timestamp,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/Mailman2Bridge.php",
    "content": "<?php\n\nclass Mailman2Bridge extends BridgeAbstract\n{\n    const NAME = 'Mailman2Bridge';\n    const URI = 'https://list.org';\n    const MAINTAINER = 'imagoiq';\n    const CACHE_TIMEOUT = 60 * 30; // 30m\n    const DESCRIPTION = 'Fetch latest messages from Mailman 2 archive (Pipermail)';\n\n    const PARAMETERS = [\n        'Mailman 2' => [\n            'url' => [\n                'name' => 'Enter web archive URL',\n                'title' => <<<\"EOL\"\n                            Specify the URL from the archive page where all the archive are listed by month.\n                            EOL\n                , 'type' => 'text',\n                'exampleValue' => 'https://mailman.nginx.org/pipermail/nginx-announce/',\n                'required' => true\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'title' => 'Maximum number of items to return',\n                'defaultValue' => 5,\n            ],\n        ],\n    ];\n\n    public function collectData()\n    {\n        $mails = [];\n        $url = $this->getInput('url');\n        $limit = $this->getInput('limit');\n\n        $html = defaultLinkTo(getSimpleHTMLDOMCached($url, 1800), $url);\n\n        // Fetch archive urls from the frontpage\n        $archives = [];\n        foreach ($html->find('tr') as $key => $tr) {\n            $archiveUrl = $tr->find('a[href$=\"date.html\"]', 0);\n            $downloadUrl = $tr->find('a[href$=\".txt\"], a[href$=\".txt.gz\"]', 0);\n            $archives[$key] = [\n                'bydate' => $archiveUrl ? $archiveUrl->getAttribute('href') : null,\n                'download' => $downloadUrl ? $downloadUrl->getAttribute('href') : null\n            ];\n        }\n\n        foreach ($archives as $archive) {\n            if (!$archive['bydate']) {\n                continue;\n            }\n\n            // Fetch urls to mails\n            $parent = pathinfo($archive['bydate'], PATHINFO_DIRNAME) . '/';\n            $html = defaultLinkTo(getSimpleHTMLDOMCached($archive['bydate'], 1800), $parent);\n            $links = array_map(function ($val) {\n                return $val->getAttribute('href');\n            }, $html->find('ul', 1)->find('li a[href$=\".html\"]'));\n            $mailUrls = array_reverse($links);\n\n            // Parse mbox\n            $data = getContents($archive['download']);\n            if (str_ends_with($archive['download'], '.gz')) {\n                $data = \\gzdecode($data, (1024 ** 2) * 25); // 25M\n                if ($data === false) {\n                    throw new \\Exception('Failed to gzdecode');\n                }\n            }\n            $mboxParts = preg_split('/^From\\s.+\\d{2}:\\d{2}:\\d{2}\\s\\d{4}$/m', $data);\n            // Drop the first element which is always an empty string\n            array_shift($mboxParts);\n            $mboxMails = array_reverse($mboxParts);\n            foreach ($mboxMails as $index => $content) {\n                // Match Urls with contents from txt files.\n                // Urls cannot be reconstructed from the txt content.\n                $mails[] = [\n                    'url' => $mailUrls[$index],\n                    'content' => $content\n                ];\n            }\n            if (count($mails) > $limit) {\n                break;\n            }\n        }\n\n        $pluck = function ($header, $mail) {\n            // Not necessary to escape the header here\n            $pattern = sprintf('/(?<=%s:).*$/m', $header);\n            if (preg_match($pattern, $mail, $m)) {\n                return trim(\\mb_decode_mimeheader($m[0]));\n            }\n            return null;\n        };\n        foreach (array_slice($mails, 0, $limit) as $mail) {\n            $item = [];\n            $item['uid'] = $pluck('Message-ID', $mail['content']);\n            $item['uri'] = $mail['url'];\n            $item['title'] = $pluck('Subject', $mail['content']);\n            $item['author'] = preg_replace('/\\sat\\s/', '@', $pluck('From', $mail['content']));\n            $item['timestamp'] = $pluck('Date', $mail['content']);\n            $item['content'] = nl2br(self::render($mail['content']));\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Parse mbox mail. Render some useful html.\n     *\n     * Based on https://gist.github.com/jbroadway/2836900\n     */\n    private static function render($text)\n    {\n        $rules = [\n            '/[\\s\\S]*?^Message-ID:[\\s\\S]*?>\\n\\n/m' => '', // Metadata\n            '/-+\\s+next part\\s+-+[\\s\\S]+?(?=^$|\\Z)/m' => '', // next part\n            '/(?<!href=[\\'\"])https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.\n        [a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()!@:%_\\+.,~#?&\\/\\/=]*)/' => '<a href=\"$0\">$0</a>', // links\n            '/(\\w+) <(.*) at (.*)>/' => '<a href=\"mailto:$2@$3\">$1</a>', // emails\n            '/(\\*|\\*\\*|__)(.*?)\\1/' => '<strong>\\2</strong>', // bold\n            // blockquotes\n            '/(.*)\\s+((?:^>.*+\\n)+)/m' => function ($regs) {\n                return sprintf(\n                    '<details><summary>%s</summary><blockquote style=\"font-style:italic;\">%s</blockquote></details>',\n                    $regs[1],\n                    preg_replace('/^>/m', '', $regs[2])\n                );\n            },\n        ];\n\n        $text = \"\\n\" . $text . \"\\n\";\n        foreach ($rules as $regex => $replacement) {\n            if (is_callable($replacement)) {\n                $text = preg_replace_callback($regex, $replacement, $text);\n            } else {\n                $text = preg_replace($regex, $replacement, $text);\n            }\n        }\n        return trim($text);\n    }\n}\n"
  },
  {
    "path": "bridges/MallTvBridge.php",
    "content": "<?php\n\nclass MallTvBridge extends BridgeAbstract\n{\n    const NAME = 'MALL.TV';\n    const URI = 'https://www.mall.tv';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'Return newest videos';\n    const MAINTAINER = 'kolarcz';\n\n    const PARAMETERS = [\n        [\n            'url' => [\n                'name' => 'url to the show',\n                'required' => true,\n                'exampleValue' => 'https://www.mall.tv/zivot-je-hra'\n            ]\n        ]\n    ];\n\n    private function fixChars($text)\n    {\n        return html_entity_decode($text, ENT_QUOTES, 'UTF-8');\n    }\n\n    private function getUploadTimeFromUrl($url)\n    {\n        $html = getSimpleHTMLDOM($url);\n\n        $scriptLdJson = $html->find('script[type=\"application/ld+json\"]', 0)->innertext;\n        if (!preg_match('/[\\'\"]uploadDate[\\'\"]\\s*:\\s*[\\'\"](\\d{4}-\\d{2}-\\d{2})[\\'\"]/', $scriptLdJson, $match)) {\n            throwServerException('Could not get date from MALL.TV detail page');\n        }\n\n        return strtotime($match[1]);\n    }\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n\n        if (!preg_match('/^https:\\/\\/www\\.mall\\.tv\\/[a-z0-9-]+(\\/[a-z0-9-]+)?\\/?$/', $url)) {\n            throwServerException('Invalid url');\n        }\n\n        $html = getSimpleHTMLDOM($url);\n\n        $this->feedUri = $url;\n        $this->feedName = $this->fixChars($html->find('title', 0)->plaintext);\n\n        foreach ($html->find('section.isVideo .video-card') as $element) {\n            $itemTitle = $element->find('.video-card__details-link', 0);\n            $itemThumbnail = $element->find('.video-card__thumbnail', 0);\n            $itemUri = self::URI . $itemTitle->getAttribute('href');\n\n            $item = [\n                'title' => $this->fixChars($itemTitle->plaintext),\n                'uri' => $itemUri,\n                'content' => '<img src=\"' . $itemThumbnail->getAttribute('data-src') . '\" />',\n                'timestamp' => $this->getUploadTimeFromUrl($itemUri)\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        return $this->feedUri ?? parent::getURI();\n    }\n\n    public function getName()\n    {\n        return $this->feedName ?? parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/MangaDexBridge.php",
    "content": "<?php\n\nclass MangaDexBridge extends BridgeAbstract\n{\n    const NAME = 'MangaDex';\n    const URI = 'https://mangadex.org/';\n    const API_ROOT = 'https://api.mangadex.org/';\n    const DESCRIPTION = 'Returns MangaDex items using the API';\n\n    const PARAMETERS = [\n        'global' => [\n            'limit' => [\n                'name' => 'Item Limit',\n                'type' => 'number',\n                'defaultValue' => 10,\n                'required' => true\n            ],\n            'lang' => [\n                'name' => 'Chapter Languages (default=all)',\n                'title' => 'comma-separated, two-letter language codes (example \"en,jp\")',\n                'exampleValue' => 'en,jp',\n                'required' => false\n            ],\n            'images' => [\n                'name' => 'Fetch chapter page images',\n                'type' => 'list',\n                'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.',\n                'defaultValue' => 'no',\n                'values' => [\n                    'None' => 'no',\n                    'Data Saver' => 'saver',\n                    'Full Quality' => 'yes'\n                ]\n            ]\n\n        ],\n        'Title Chapters' => [\n            'url' => [\n                'name' => 'URL to title page',\n                'exampleValue' => 'https://mangadex.org/title/f9c33607-9180-4ba6-b85c-e4b5faee7192/official-test-manga',\n                'required' => true\n            ],\n            'external' => [\n                'name' => 'Allow external feed items',\n                'type' => 'checkbox',\n                'title' => 'Some chapters are inaccessible or only available on an external site. Include these?'\n            ]\n        ],\n        'Search Chapters' => [\n            'chapter' => [\n                'name' => 'Chapter Number (default=all)',\n                'title' => 'The example value finds the newest first chapters',\n                'exampleValue' => 1,\n                'required' => false\n            ],\n            'groups' => [\n                'name' => 'Group UUID (default=all)',\n                'title' => 'This can be found in the MangaDex Group Page URL',\n                'exampleValue' => '00e03853-1b96-4f41-9542-c71b8692033b',\n                'required' => false,\n            ],\n            'uploader' => [\n                'name' => 'User UUID (default=all)',\n                'title' => 'This can be found in the MangaDex User Page URL',\n                'exampleValue' => 'd2ae45e0-b5e2-4e7f-a688-17925c2d7d6b',\n                'required' => false,\n            ],\n            'external' => [\n                'name' => 'Allow external feed items',\n                'type' => 'checkbox',\n                'title' => 'Some chapters are inaccessible or only available on an external site. Include these?'\n            ]\n        ]\n        // Future Manga Contexts:\n        // Manga List (by author or tags): https://api.mangadex.org/swagger.html#/Manga/get-search-manga\n        // Random Manga: https://api.mangadex.org/swagger.html#/Manga/get-manga-random\n        // Future Chapter Contexts:\n        // User Lists https://api.mangadex.org/swagger.html#/Feed/get-list-id-feed\n        //\n        // https://api.mangadex.org/docs/get-covers/\n    ];\n\n    const TITLE_REGEX = '#title/(?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})#';\n\n    protected $feedName = '';\n    protected $feedURI = '';\n\n    protected function buildArrayQuery($name, $array)\n    {\n        $query = '';\n        foreach ($array as $item) {\n            $query .= '&' . $name . '=' . $item;\n        }\n        return $query;\n    }\n\n    protected function getAPI()\n    {\n        $params = [\n            'limit' => $this->getInput('limit')\n        ];\n\n        $array_params = [];\n        if (!empty($this->getInput('lang'))) {\n            $array_params['translatedLanguage[]'] = explode(',', $this->getInput('lang'));\n        }\n\n        switch ($this->queriedContext) {\n            case 'Title Chapters':\n                preg_match(self::TITLE_REGEX, $this->getInput('url'), $matches)\n                or throwClientException('Invalid URL Parameter');\n                $this->feedURI = self::URI . 'title/' . $matches['uuid'];\n                $params['order[readableAt]'] = 'desc';\n                if (!$this->getInput('external')) {\n                    $params['includeFutureUpdates'] = '0';\n                }\n                $array_params['includes[]'] = ['manga', 'scanlation_group', 'user'];\n                $uri = self::API_ROOT . 'manga/' . $matches['uuid'] . '/feed';\n                break;\n            case 'Search Chapters':\n                $params['chapter'] = $this->getInput('chapter');\n                $params['groups[]'] = $this->getInput('groups');\n                $params['uploader'] = $this->getInput('uploader');\n                $params['order[readableAt]'] = 'desc';\n                if (!$this->getInput('external')) {\n                    $params['includeFutureUpdates'] = '0';\n                }\n                $array_params['includes[]'] = ['manga', 'scanlation_group', 'user'];\n                $uri = self::API_ROOT . 'chapter';\n                break;\n            default:\n                throwServerException('Unimplemented Context (getAPI)');\n        }\n\n        // Remove null keys\n        $params = array_filter($params, function ($v) {\n            return !empty($v);\n        });\n\n        $uri .= '?' . http_build_query($params);\n\n        // Arrays are passed as repeated keys to MangaDex\n        // This cannot be handled by http_build_query\n        foreach ($array_params as $name => $array_param) {\n            $uri .= $this->buildArrayQuery($name, $array_param);\n        }\n\n        return $uri;\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Title Chapters':\n                return $this->feedName . ' Chapters';\n            case 'Search Chapters':\n                return 'MangaDex Chapter Search';\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Title Chapters':\n                return $this->feedURI;\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function collectData()\n    {\n        $api_uri = $this->getAPI();\n        $header = [\n            'Content-Type: application/json'\n        ];\n        $content = json_decode(getContents($api_uri, $header), true);\n        if ($content['result'] == 'ok') {\n            $content = $content['data'];\n        } else {\n            throwServerException('Could not retrieve API results');\n        }\n\n        switch ($this->queriedContext) {\n            case 'Title Chapters':\n                $this->getChapters($content);\n                break;\n            case 'Search Chapters':\n                $this->getChapters($content);\n                break;\n            default:\n                throwServerException('Unimplemented Context (collectData)');\n        }\n    }\n\n    protected function getChapters($content)\n    {\n        foreach ($content as $chapter) {\n            $item = [];\n            $item['uid'] = $chapter['id'];\n            $item['uri'] = self::URI . 'chapter/' . $chapter['id'];\n\n            // External chapter\n            if (!$this->getInput('external') && $chapter['attributes']['pages'] == 0) {\n                continue;\n            }\n\n            $item['title'] = '';\n            if (isset($chapter['attributes']['volume'])) {\n                $item['title'] .= 'Volume ' . $chapter['attributes']['volume'] . ' ';\n            }\n            if (isset($chapter['attributes']['chapter'])) {\n                $item['title'] .= 'Chapter ' . $chapter['attributes']['chapter'];\n            }\n            if (!empty($chapter['attributes']['title'])) {\n                $item['title'] .= ' - ' . $chapter['attributes']['title'];\n            }\n            $item['title'] .= ' [' . $chapter['attributes']['translatedLanguage'] . ']';\n\n            $item['timestamp'] = $chapter['attributes']['readableAt'];\n\n            $groups = [];\n            $users = [];\n            foreach ($chapter['relationships'] as $rel) {\n                switch ($rel['type']) {\n                    case 'scanlation_group':\n                        $groups[] = $rel['attributes']['name'];\n                        break;\n                    case 'manga':\n                        if (empty($this->feedName)) {\n                            $this->feedName = reset($rel['attributes']['title']);\n                        }\n                        if ($this->queriedContext !== 'Title Chapters') {\n                            $item['title'] = reset($rel['attributes']['title']) . ' ' . $item['title'];\n                        }\n                        break;\n                    case 'user':\n                        if (isset($item['author'])) {\n                            $users[] = $rel['attributes']['username'];\n                        } else {\n                            $item['author'] = $rel['attributes']['username'];\n                        }\n                        break;\n                }\n            }\n            $item['content'] = 'Groups: ' .\n                             (empty($groups) ? 'No Group' : implode(', ', $groups));\n            if (!empty($users)) {\n                $item['content'] .= '<br>Other Users: ' . implode(', ', $users);\n            }\n\n            // Fetch chapter page images if desired and add to content\n            if ($this->getInput('images') !== 'no') {\n                $api_uri = self::API_ROOT . 'at-home/server/' . $item['uid'];\n                $header = [ 'Content-Type: application/json' ];\n                $pages = json_decode(getContents($api_uri, $header), true);\n                if ($pages['result'] != 'ok') {\n                    throwServerException('Could not retrieve API results');\n                }\n\n                if ($this->getInput('images') == 'saver') {\n                    $page_base = $pages['baseUrl'] . '/data-saver/' . $pages['chapter']['hash'] . '/';\n                    foreach ($pages['chapter']['dataSaver'] as $image) {\n                        $item['content'] .= '<br><img src=\"' . $page_base . $image . '\"/>';\n                    }\n                } else {\n                    $page_base = $pages['baseUrl'] . '/data/' . $pages['chapter']['hash'] . '/';\n                    foreach ($pages['chapter']['data'] as $image) {\n                        $item['content'] .= '<br><img src=\"' . $page_base . $image . '\"/>';\n                    }\n                }\n            }\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/MangaReaderBridge.php",
    "content": "<?php\n\nclass MangaReaderBridge extends BridgeAbstract\n{\n    const NAME = 'MangaReader';\n    const URI = 'https://mangareader.to';\n    const DESCRIPTION = 'Fetches the latest chapters from MangaReader.to.';\n    const MAINTAINER = 'cubethethird';\n    const PARAMETERS = [\n        [\n            'url' => [\n                'name' => 'Manga URL',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'The URL of the manga on MangaReader',\n                'pattern' => '^https:\\/\\/mangareader\\.to\\/[^\\/]+$',\n                'exampleValue' => 'https://mangareader.to/bleach-1623',\n            ],\n            'lang' => [\n                'name' => 'Chapter Language',\n                'title' => 'two-letter language code (example \"en\", \"jp\", \"fr\")',\n                'exampleValue' => 'en',\n                'required' => true,\n                'pattern' => '^[a-z][a-z]$',\n            ]\n        ]\n    ];\n\n    protected $feedName = '';\n\n\n    public function getName()\n    {\n        if (empty($this->feedName)) {\n            return parent::getName();\n        } else {\n            return $this->feedName;\n        }\n    }\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n        $lang = $this->getInput('lang');\n        $dom = getSimpleHTMLDOM($url);\n        $aniDetail = $dom->getElementById('ani_detail');\n        $this->feedName = html_entity_decode($aniDetail->find('h2', 0)->plaintext);\n\n        $chapters = $dom->getElementById($lang . '-chapters');\n\n        foreach ($chapters->getElementsByTagName('li') as $chapter) {\n            $a = $chapter->getElementsByTagName('a')[0];\n            $item = [];\n            $item['title'] = $a->getAttribute('title');\n            $item['uri'] = self::URI . $a->getAttribute('href');\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ManyVidsBridge.php",
    "content": "<?php\n\nclass ManyVidsBridge extends BridgeAbstract\n{\n    const NAME = 'ManyVids';\n    const URI = 'https://www.manyvids.com';\n    const DESCRIPTION = 'Fetches the latest posts from a profile';\n    const MAINTAINER = 'dvikan, subtle4553';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        [\n            'profile' => [\n                'name' => 'Profile',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '678459/Aziani-Studios',\n                'title' => 'id/profile or url',\n            ],\n        ]\n    ];\n\n    private ?simple_html_dom $htmlDom = null;\n    private ?string $parsedProfileInput = null;\n\n    public function collectData()\n    {\n        $profile = $this->getInput('profile');\n        if (!$profile) {\n            throw new \\Exception('No value for \\'profile\\' was provided.');\n        }\n\n        if (preg_match('#^(\\d+/.*)$#', $profile, $m)) {\n            $this->parsedProfileInput = $m[1];\n        } elseif (preg_match('#https://(www.)?manyvids.com/Profile/(\\d+/.*?)/#', $profile, $m)) {\n            $this->parsedProfileInput = $m[2];\n        } else {\n            throw new \\Exception(sprintf('Profile could not be parsed: %s', $profile));\n        }\n\n        $profileUrl = $this->getUri();\n        $url = sprintf('%s?sort=newest', $profileUrl);\n        $opt = [CURLOPT_COOKIE => 'sfwtoggle=false'];\n        $this->htmlDom = getSimpleHTMLDOM($url, [], $opt);\n\n        $elements = $this->htmlDom->find('div[class^=\"ProfileTabGrid_card__\"]');\n\n        foreach ($elements as $element) {\n            $content = '';\n\n            $title = $element->find('span[class^=\"VideoCardUI_videoTitle__\"] > a', 0);\n            if (!$title) {\n                continue;\n            }\n\n            $linkElement = $element->find('a[href^=\"/Video/\"]', 0);\n            if ($linkElement) {\n                $itemUri = self::URI . $linkElement->getAttribute('href');\n            }\n\n            $image = $element->find('img', 0);\n            if ($image) {\n                if (isset($itemUri)) {\n                    $content .= sprintf('<p><a href=\"%s\"><img src=\"%s\"></a></p>', $itemUri, $image->getAttribute('src'));\n                } else {\n                    $content .= sprintf('<p><img src=\"%s\"></p>', $image->getAttribute('src'));\n                }\n            }\n\n            $contentSegments = [];\n\n            $videoLength = $element->find('[class^=\"CardMedia_videoDuration__\"] > span', 0);\n            if ($videoLength) {\n                $contentSegments[] = sprintf('%s', $videoLength->innertext);\n            }\n\n            $price = $element->find('[class^=\"PriceUI_regularPrice__\"], [class^=\"PriceUI_card_price__\"] > p, [class^=\"PriceUI_card_free_text__\"]', 0);\n            $discountedPrice = $element->find('[class^=\"PriceUI_discountedPrice__\"]', 0);\n\n            if ($price && $discountedPrice) {\n                $contentSegments[] = sprintf('<s>%s</s> <strong>%s</strong>', $price->innertext, $discountedPrice->innertext);\n            } elseif ($price && !$discountedPrice) {\n                $contentSegments[] = sprintf('<strong>%s</strong>', $price->innertext);\n            }\n\n            $content .= implode(' • ', $contentSegments);\n\n            $this->items[] = [\n                'title' => $title->innertext,\n                'uri' => isset($itemUri) ? $itemUri : null,\n                'content' => $content,\n            ];\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->htmlDom)) {\n            $profileNameElement = $this->htmlDom->find('[class^=\"ProfileAboutMeUI_stageName__\"]', 0);\n            if (!$profileNameElement) {\n                return parent::getName();\n            }\n\n            $profileNameElementContent = $profileNameElement->innertext;\n            $index = strpos($profileNameElementContent, '<');\n            $profileName = substr($profileNameElementContent, 0, $index);\n\n            return 'ManyVids: ' . $profileName;\n        }\n\n        return parent::getName();\n    }\n\n    public function getUri()\n    {\n        if (!is_null($this->parsedProfileInput)) {\n            return sprintf('%s/Profile/%s/Store/Videos', self::URI, $this->parsedProfileInput);\n        }\n\n        return parent::getUri();\n    }\n}\n"
  },
  {
    "path": "bridges/MarktplaatsBridge.php",
    "content": "<?php\n\nclass MarktplaatsBridge extends BridgeAbstract\n{\n    const NAME = 'Marktplaats';\n    const URI = 'https://marktplaats.nl';\n    const DESCRIPTION = 'Read search queries from marktplaats.nl';\n    const PARAMETERS = [\n        'Search' => [\n            'q' => [\n                'name' => 'query',\n                'type' => 'text',\n                'exampleValue' => 'lamp',\n                'required' => true,\n                'title' => 'The search string for marktplaats',\n            ],\n            'c' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'Select a category' => '',\n                    'Antiek en Kunst' => '1',\n                    'Audio, Tv en Foto' => '31',\n                    'Auto&#x27;s' => '91',\n                    'Auto-onderdelen' => '2600',\n                    'Auto diversen' => '48',\n                    'Boeken' => '201',\n                    'Caravans en Kamperen' => '289',\n                    'Cd&#x27;s en Dvd&#x27;s' => '1744',\n                    'Computers en Software' => '322',\n                    'Contacten en Berichten' => '378',\n                    'Diensten en Vakmensen' => '1098',\n                    'Dieren en Toebehoren' => '395',\n                    'Doe-het-zelf en Verbouw' => '239',\n                    'Fietsen en Brommers' => '445',\n                    'Hobby en Vrije tijd' => '1099',\n                    'Huis en Inrichting' => '504',\n                    'Huizen en Kamers' => '1032',\n                    'Kinderen en Baby&#x27;s' => '565',\n                    'Kleding | Dames' => '621',\n                    'Kleding | Heren' => '1776',\n                    'Motoren' => '678',\n                    'Muziek en Instrumenten' => '728',\n                    'Postzegels en Munten' => '1784',\n                    'Sieraden, Tassen en Uiterlijk' => '1826',\n                    'Spelcomputers en Games' => '356',\n                    'Sport en Fitness' => '784',\n                    'Telecommunicatie' => '820',\n                    'Tickets en Kaartjes' => '1984',\n                    'Tuin en Terras' => '1847',\n                    'Vacatures' => '167',\n                    'Vakantie' => '856',\n                    'Verzamelen' => '895',\n                    'Watersport en Boten' => '976',\n                    'Witgoed en Apparatuur' => '537',\n                    'Zakelijke goederen' => '1085',\n                    'Diversen' => '428',\n                ],\n                'required' => false,\n                'title' => 'The category to search in',\n            ],\n            'z' => [\n                'name' => 'zipcode',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => '1013AA',\n                'title' => 'Zip code for location limited searches',\n            ],\n            'd' => [\n                'name' => 'distance',\n                'type' => 'number',\n                'required' => false,\n                'exampleValue' => '100000',\n                'title' => 'The distance in meters from the zipcode',\n            ],\n            'f' => [\n                'name' => 'priceFrom',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'The minimal price in cents',\n            ],\n            't' => [\n                'name' => 'priceTo',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'The maximal price in cents',\n            ],\n            's' => [\n                'name' => 'showGlobal',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Include result with negative distance',\n            ],\n            'i' => [\n                'name' => 'includeImage',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Include the image at the end of the content',\n            ],\n            'r' => [\n                'name' => 'includeRaw',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Include the raw data behind the content',\n            ],\n            'sc' => [\n                'name' => 'Sub category',\n                'type' => 'number',\n                'required' => false,\n                'exampleValue' => '12345',\n                'title' => 'Sub category has to be given by id as the list is too big to show here. \n                            Only use subcategories that belong to the main category. Both have to be correct',\n            ],\n        ]\n    ];\n    const CACHE_TIMEOUT = 900;\n\n    public function collectData()\n    {\n        $query = '';\n        $excludeGlobal = false;\n        if (!is_null($this->getInput('z')) && !is_null($this->getInput('d'))) {\n            $query = '&postcode=' . $this->getInput('z') . '&distanceMeters=' . $this->getInput('d');\n        }\n        if (!is_null($this->getInput('f'))) {\n            $query .= '&PriceCentsFrom=' . $this->getInput('f');\n        }\n        if (!is_null($this->getInput('t'))) {\n            $query .= '&PriceCentsTo=' . $this->getInput('t');\n        }\n        if (!is_null($this->getInput('s'))) {\n            if (!$this->getInput('s')) {\n                $excludeGlobal = true;\n            }\n        }\n        if (!empty($this->getInput('c'))) {\n            $query .= '&l1CategoryId=' . $this->getInput('c');\n        }\n        if (!is_null($this->getInput('sc'))) {\n            $query .= '&l2CategoryId=' . $this->getInput('sc');\n        }\n        $url = 'https://www.marktplaats.nl/lrp/api/search?query=' . urlencode($this->getInput('q')) . $query;\n        $jsonString = getSimpleHTMLDOM($url);\n        $jsonObj = json_decode($jsonString);\n        foreach ($jsonObj->listings as $listing) {\n            if (!$excludeGlobal || $listing->location->distanceMeters >= 0) {\n                $item = [];\n                $item['uri'] = 'https://marktplaats.nl' . $listing->vipUrl;\n                $item['title'] = $listing->title;\n                $item['timestamp'] = $listing->date;\n                $item['author'] = $listing->sellerInformation->sellerName;\n                $item['content'] = $listing->description;\n                $item['categories'] = $listing->verticals;\n                $item['uid'] = $listing->itemId;\n                if (!is_null($this->getInput('i')) && !empty($listing->imageUrls)) {\n                    $item['enclosures'] = $listing->imageUrls;\n                    if (is_array($listing->imageUrls)) {\n                        foreach ($listing->imageUrls as $imgurl) {\n                            $item['content'] .= \"<br />\\n<img alt='' src='https:\" . $imgurl . \"' />\";\n                        }\n                    } else {\n                        $item['content'] .= \"<br>\\n<img alt='' src='https:\" . $listing->imageUrls . \"' />\";\n                    }\n                }\n                if (!is_null($this->getInput('r'))) {\n                    if ($this->getInput('r')) {\n                        $item['content'] .= \"<br />\\n<br />\\n<br />\\n\" . json_encode($listing) . \"<br />$url\";\n                    }\n                }\n                $item['content'] .= \"<br>\\n<br>\\nPrice: \" . $listing->priceInfo->priceCents / 100;\n                $item['content'] .= '&nbsp;&nbsp;(' . $listing->priceInfo->priceType . ')';\n                if (!empty($listing->location->cityName)) {\n                    $item['content'] .= \"<br><br>\\n\" . $listing->location->cityName;\n                }\n                if (!is_null($this->getInput('r'))) {\n                    if ($this->getInput('r')) {\n                        $item['content'] .= \"<br />\\n<br />\\n<br />\\n\" . json_encode($listing);\n                    }\n                }\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('q'))) {\n            return $this->getInput('q') . ' - Marktplaats';\n        }\n        return parent::getName();\n    }\n\n    /**\n     * Method can be used to scrape the subcategories from marktplaats\n     */\n    private static function scrapeSubCategories()\n    {\n        $main = [];\n        $main['Select a category'] = '';\n        $marktplaatsHTML = file_get_html('https://www.marktplaats.nl');\n        foreach ($marktplaatsHTML->find('select[id=categoryId] option') as $opt) {\n            if (!str_contains($opt->innertext, 'categorie')) {\n                $main[$opt->innertext] = $opt->value;\n                $ids[] = $opt->value;\n            }\n        }\n\n        $result = [];\n        foreach ($ids as $id) {\n            $url = 'https://www.marktplaats.nl/lrp/api/search?l1CategoryId=' . $id;\n            $jsonstring = getContents($url);\n            $jsondata = json_decode((string)$jsonstring);\n            if (isset($jsondata->searchCategoryOptions)) {\n                $categories = $jsondata->searchCategoryOptions;\n                if (isset($jsondata->categoriesById->$id)) {\n                    $maincategory = $jsondata->categoriesById->$id;\n                    $array = [];\n                    foreach ($categories as $categorie) {\n                        $array[$categorie->fullName] = $categorie->id;\n                    }\n                    $result[$maincategory->fullName] = $array;\n                }\n            } else {\n                print($jsonstring);\n            }\n        }\n        $combinedResult = [\n            'main' => $main,\n            'sub' => $result\n        ];\n        return $combinedResult;\n    }\n\n    /**\n     * Helper method to construct the array that could be used for categories\n     *\n     * @param $array\n     * @param $indent\n     * @return void\n     */\n    private static function printArrayAsCode($array, $indent = 0)\n    {\n        foreach ($array as $key => $value) {\n            if (is_array($value)) {\n                echo str_repeat('    ', $indent) . \"'$key' => [\" . PHP_EOL;\n                self::printArrayAsCode($value, $indent + 1);\n                echo str_repeat('    ', $indent) . '],' . PHP_EOL;\n            } else {\n                $value = str_replace('\\'', '\\\\\\'', $value);\n                $key = str_replace('\\'', '\\\\\\'', $key);\n                echo str_repeat('    ', $indent) . \"'$key' => '$value',\" . PHP_EOL;\n            }\n        }\n    }\n\n    private static function printScrapeArray()\n    {\n        $array = (MarktplaatsBridge::scrapeSubCategories());\n\n        echo '$myArray = [' . PHP_EOL;\n        self::printArrayAsCode($array['main'], 1);\n        echo '];' . PHP_EOL;\n\n        echo '$myArray = [' . PHP_EOL;\n        self::printArrayAsCode($array['sub'], 1);\n        echo '];' . PHP_EOL;\n    }\n}\n"
  },
  {
    "path": "bridges/MastodonBridge.php",
    "content": "<?php\n\nclass MastodonBridge extends BridgeAbstract\n{\n    // This script attempts to imitiate the behaviour of a read-only ActivityPub server\n    // to read the outbox.\n\n    // Note: Most PixelFed instances have ActivityPub outbox disabled,\n    // so use the official feed: https://pixelfed.instance/users/username.atom (Posts only)\n\n    const MAINTAINER = 'Austin Huang';\n    const NAME = 'ActivityPub';\n    const CACHE_TIMEOUT = 900; // 15mn\n    const DESCRIPTION = 'Returns recent statuses. Supports Mastodon, Pleroma and Misskey, among others. Access to\n    instances that have Authorized Fetch enabled requires\n    <a href=\"https://rss-bridge.github.io/rss-bridge/Bridge_Specific/ActivityPub_(Mastodon).html\">configuration</a>.';\n    const URI = 'https://mastodon.social';\n\n    // Some Mastodon instances use Secure Mode which requires all requests to be signed.\n    // You do not need this for most instances, but if you want to support every known\n    // instance, then you should configure them.\n    // See also https://docs.joinmastodon.org/spec/security/#http\n    const CONFIGURATION = [\n        'private_key' => [\n            'required' => false,\n        ],\n        'key_id' => [\n            'required' => false,\n        ],\n    ];\n\n    const PARAMETERS = [[\n        'canusername' => [\n            'name' => 'Canonical username',\n            'exampleValue' => '@sebsauvage@framapiaf.org',\n            'required' => true,\n        ],\n        'noregular' => [\n            'name' => 'Without regular statuses',\n            'type' => 'checkbox',\n            'title' => 'Hide regular statuses (i.e. non-boosts, replies, etc.)',\n        ],\n        'norep' => [\n            'name' => 'Without replies',\n            'type' => 'checkbox',\n            'title' => 'Hide replies, as determined by relations (not mentions).'\n        ],\n        'noboost' => [\n            'name' => 'Without boosts',\n            'type' => 'checkbox',\n            'title' => 'Hide boosts. This will reduce loading time as RSS-Bridge fetches the boosted status from other federated instances.'\n        ],\n        'signaturetype' => [\n            'type' => 'list',\n            'name' => 'Signature Type',\n            'title' => 'How to sign requests when fetching from instances.\n                Defaults to \"nosig\" for RSS-Bridge instances that did not set up signatures.',\n            'values' => [\n                'Without Query (Mastodon)' => 'noquery',\n                'With Query (GoToSocial)' => 'query',\n                'Don\\'t sign' => 'nosig',\n            ],\n            'defaultValue' => 'noquery'\n        ],\n    ]];\n\n    public function collectData()\n    {\n        if ($this->getInput('norep') && $this->getInput('noboost') && $this->getInput('noregular')) {\n            throw new \\Exception('replies, boosts, or regular statuses must be allowed');\n        }\n\n        $user = $this->fetchAP($this->getURI());\n        if (!isset($user['outbox'])) {\n            throw new \\Exception('Unable to find the outbox');\n        }\n        $content = $this->fetchAP($user['outbox']);\n        if (is_array($content['first'])) { // mobilizon\n            $content = $content['first'];\n        } else {\n            $content = $this->fetchAP($content['first']);\n        }\n        $items = $content['orderedItems'] ?? $content['items'];\n        foreach ($items as $status) {\n            $item = $this->parseStatus($status);\n            if ($item) {\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    protected function parseStatus($content)\n    {\n        $item = [];\n        switch ($content['type']) {\n            case 'Announce': // boost\n                if ($this->getInput('noboost')) {\n                    return null;\n                }\n                // We fetch the boosted content.\n                try {\n                    $rtContent = $this->fetchAP($content['object']);\n                    if (!$rtContent) {\n                        // Sometimes fetchAP returns null. Someone should figure out why. json_decode failure?\n                        break;\n                    }\n\n                    /** @var string|string[] $attributedTo */\n                    $attributedTo = $rtContent['attributedTo'];\n\n                    if (is_string($attributedTo)) {\n                        $rtUser = $this->loadCacheValue($attributedTo);\n                        if (!$rtUser) {\n                            // We fetch the author, since we cannot always assume the format of the URL.\n                            $user = $this->fetchAP($attributedTo);\n                            preg_match('/https?:\\/\\/([a-z0-9-\\.]{0,})\\//', $attributedTo, $matches);\n                            // We assume that the server name as indicated by the path is the actual server name,\n                            // since using webfinger to delegate domains is not officially supported, and it only\n                            // seems to work in one way.\n                            $rtUser = '@' . $user['preferredUsername'] . '@' . $matches[1];\n                            $this->saveCacheValue($attributedTo, $rtUser);\n                        }\n                        $item['author'] = $rtUser;\n                        $item['title'] = 'Shared a status by ' . $rtUser . ': ';\n                    } else {\n                        // TODO\n                    }\n                    $item = $this->parseObject($rtContent, $item);\n                } catch (HttpException $e) {\n                    $item['title'] = 'Shared an unreachable status: ' . $content['object'];\n                    $item['content'] = $content['object'];\n                    $item['uri'] = $content['object'];\n                }\n                break;\n            case 'Note': // frendica posts\n                if ($this->getInput('norep') && isset($content['inReplyTo'])) {\n                    return null;\n                }\n                if ($this->getInput('noregular') && !isset($content['inReplyTo'])) {\n                    return null;\n                }\n                $item['title'] = '';\n                $item['author'] = $this->getInput('canusername');\n                $item = $this->parseObject($content, $item);\n                break;\n            case 'Create': // posts\n                if ($this->getInput('norep') && isset($content['object']['inReplyTo'])) {\n                    return null;\n                }\n                if ($this->getInput('noregular') && !isset($content['object']['inReplyTo'])) {\n                    return null;\n                }\n                $item['title'] = '';\n                $item['author'] = $this->getInput('canusername');\n                $item = $this->parseObject($content['object'], $item);\n                break;\n            default:\n                return null;\n        }\n        $item['timestamp'] = $content['published'] ?? $item['timestamp'];\n        $item['uid'] = $content['id'];\n        return $item;\n    }\n\n    protected function parseObject($object, $item)\n    {\n        // If object is a link to another object, fetch it\n        if (is_string($object)) {\n            $object = $this->fetchAP($object);\n        }\n\n        $item['content'] = $object['content'] ?? '';\n        $strippedContent = strip_tags(str_replace('<br>', ' ', $item['content']));\n\n        if (isset($object['name'])) {\n            $item['title'] = $object['name'];\n        } elseif (mb_strlen($strippedContent) > 75) {\n            $contentSubstring = mb_substr($strippedContent, 0, mb_strpos(wordwrap($strippedContent, 75), \"\\n\"));\n            $item['title'] .= $contentSubstring . '...';\n        } else {\n            $item['title'] .= $strippedContent;\n        }\n        $item['uri'] = $object['id'];\n        $item['timestamp'] = $object['published'];\n\n        if (!isset($object['attachment'])) {\n            return $item;\n        }\n\n        if (isset($object['attachment']['url'])) {\n            // Normalize attachment (turn single attachment into array)\n            $object['attachment'] = [$object['attachment']];\n        }\n\n        foreach ($object['attachment'] as $attachment) {\n            // Only process REMOTE pictures (prevent xss)\n            $mediaType = $attachment['mediaType'] ?? null;\n            if (\n                $mediaType\n                && preg_match('/^image\\//', $mediaType, $match)\n                && preg_match('/^http(s|):\\/\\//', $attachment['url'], $match)\n            ) {\n                $item['content'] = $item['content'] . '<br /><img ';\n                if (isset($attachment['name'])) {\n                    $item['content'] .= sprintf('alt=\"%s\" ', $attachment['name']);\n                }\n                $item['content'] .= sprintf('src=\"%s\" />', $attachment['url']);\n            }\n        }\n        return $item;\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('canusername')) {\n            return $this->getInput('canusername');\n        }\n        return parent::getName();\n    }\n\n    private function getInstance()\n    {\n        preg_match('/^@[a-zA-Z0-9_]+@(.+)/', $this->getInput('canusername'), $matches);\n        return $matches[1];\n    }\n\n    private function getUsername()\n    {\n        preg_match('/^@([a-zA-Z_0-9_]+)@.+/', $this->getInput('canusername'), $matches);\n        return $matches[1];\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('canusername')) {\n            // We parse webfinger to make sure the URL is correct. This is mostly because\n            // MissKey uses user ID instead of the username in the endpoint, domain delegations,\n            // and also to be compatible with future ActivityPub implementations.\n            $resource = 'acct:' . $this->getUsername() . '@' . $this->getInstance();\n            $webfingerUrl = 'https://' . $this->getInstance() . '/.well-known/webfinger?resource=' . $resource;\n            $webfingerHeader = [\n                'Accept: application/jrd+json'\n            ];\n            $webfinger = json_decode(getContents($webfingerUrl, $webfingerHeader), true);\n            foreach ($webfinger['links'] as $link) {\n                if ($link['type'] === 'application/activity+json') {\n                    return $link['href'];\n                }\n            }\n        }\n\n        return parent::getURI();\n    }\n\n    protected function fetchAP($url)\n    {\n        $d = new DateTime();\n        $d->setTimezone(new DateTimeZone('GMT'));\n        $date = $d->format('D, d M Y H:i:s e');\n\n        // GoToSocial expects the query string to be included when\n        // building the url to sign\n        // @see https://github.com/superseriousbusiness/gotosocial/issues/107#issuecomment-1188289857\n        $regex = [\n            // Include query string when parsing URL\n            'query' => '/https?:\\/\\/([a-z0-9-\\.]{0,})(\\/[^#]+)/',\n\n            // Exclude query string when parsing URL\n            'noquery' => '/https?:\\/\\/([a-z0-9-\\.]{0,})(\\/[^#?]+)/',\n            'nosig' => '/https?:\\/\\/([a-z0-9-\\.]{0,})(\\/[^#?]+)/',\n        ];\n\n        preg_match($regex[$this->getInput('signaturetype')], $url, $matches);\n        $headers = [\n            'Accept: application/activity+json',\n            'Host: ' . $matches[1],\n            'Date: ' . $date\n        ];\n        $privateKey = $this->getOption('private_key');\n        $keyId = $this->getOption('key_id');\n        if ($privateKey && $keyId && $this->getInput('signaturetype') !== 'nosig') {\n            $pkey = openssl_pkey_get_private('file://' . $privateKey);\n            $toSign = '(request-target): get ' . $matches[2] . \"\\nhost: \" . $matches[1] . \"\\ndate: \" . $date;\n            $result = openssl_sign($toSign, $signature, $pkey, 'RSA-SHA256');\n            if ($result) {\n                $sig = sprintf(\n                    'Signature: keyId=\"%s\",headers=\"(request-target) host date\",signature=\"%s\"',\n                    $keyId,\n                    base64_encode($signature)\n                );\n\n                $headers[] = $sig;\n            }\n        }\n        try {\n            return Json::decode(getContents($url, $headers));\n        } catch (\\JsonException $e) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/MediapartBlogsBridge.php",
    "content": "<?php\n\nclass MediapartBlogsBridge extends BridgeAbstract\n{\n    const NAME = 'Mediapart Blogs';\n    const BASE_URI = 'https://blogs.mediapart.fr';\n    const URI = self::BASE_URI . '/blogs';\n    const MAINTAINER = 'somini';\n    const PARAMETERS = [\n        [\n            'slug' => [\n                'name' => 'Blog Slug',\n                'type' => 'text',\n                'title' => 'Blog user name',\n                'required' => true,\n                'exampleValue' => 'jean-vincot',\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://static.mediapart.fr/favicon/favicon-club.ico?v=2';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::BASE_URI . '/' . $this->getInput('slug') . '/blog');\n\n        foreach ($html->find('ul.post-list li') as $element) {\n            $item = [];\n\n            $item_title = $element->find('h3.title a', 0);\n            $item_divs = $element->find('div');\n\n            $item['title'] = $item_title->innertext;\n            $item['uri'] = self::BASE_URI . trim($item_title->href);\n\n            $author = $element->find('.author .subscriber', 0);\n            if ($author) {\n                $item['author'] = $author->innertext;\n            }\n\n            $item['content'] = $item_divs[count($item_divs) - 2] . $item_divs[count($item_divs) - 1];\n            $item['timestamp'] = strtotime($element->find('.author time', 0)->datetime);\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('slug')) {\n            return self::NAME . ' | ' . $this->getInput('slug');\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/MediapartBridge.php",
    "content": "<?php\n\nclass MediapartBridge extends FeedExpander\n{\n    const MAINTAINER = 'killruana';\n    const NAME = 'Mediapart';\n    const URI = 'https://www.mediapart.fr/';\n    const PARAMETERS = [\n        [\n            'single_page_mode' => [\n                'name' => 'Single page article',\n                'type' => 'checkbox',\n                'title' => 'Display long articles on a single page',\n                'defaultValue' => 'checked'\n            ],\n            'mpsessid' => [\n                'name' => 'MPSESSID',\n                'type' => 'text',\n                'title' => 'Value of the session cookie MPSESSID'\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    public function collectData()\n    {\n        $url = self::URI . 'articles/feed';\n        $this->collectExpandableDatas($url);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $itemUrl = $item['uri'];\n\n        // Mediapart provide multiple type of contents.\n        // We only process items relative to the newspaper\n        // See issue #1292 - https://github.com/RSS-Bridge/rss-bridge/issues/1292\n        if (strpos($item['uri'], self::URI . 'journal/') === 0) {\n            // Enable single page mode?\n            if ($this->getInput('single_page_mode') === true) {\n                $item['uri'] .= '?onglet=full';\n            }\n\n            // If a session cookie is defined, get the full article\n            $mpsessid = $this->getInput('mpsessid');\n            if (!empty($mpsessid)) {\n                // Set the session cookie\n                $opt = [];\n                $opt[CURLOPT_COOKIE] = 'MPSESSID=' . $mpsessid;\n\n                $pageUrl = $itemUrl . '?onglet=full';\n                $articlePage = getSimpleHTMLDOM($pageUrl, [], $opt);\n\n                // Extract the article content\n                $content = $articlePage->find('div.content-article', 0)->innertext;\n                $content = sanitize($content);\n                $content = defaultLinkTo($content, static::URI);\n                $item['content'] .= $content;\n            }\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/MicrosoftOfficeUpdatesBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass MicrosoftOfficeUpdatesBridge extends BridgeAbstract\n{\n    public const NAME = 'Microsoft Office Updates';\n    public const URI = 'https://learn.microsoft.com/en-us/officeupdates/';\n    public const DESCRIPTION = 'Returns the latest release notes for Microsoft 365 update channels';\n    public const CACHE_TIMEOUT = 21600; // 6 hours\n    public const MAINTAINER = 'tillcash';\n\n    public const PARAMETERS = [\n        [\n            'channel' => [\n                'name' => 'Update Channel',\n                'type' => 'list',\n                'values' => [\n                    'Current' => 'current-channel',\n                    'Monthly' => 'monthly-enterprise-channel',\n                    'Semi-Annual' => 'semi-annual-enterprise-channel',\n                ],\n            ],\n        ],\n    ];\n\n    public function getIcon()\n    {\n        return 'https://learn.microsoft.com/favicon.ico';\n    }\n\n    public function getName()\n    {\n        $channel = $this->getKey('channel');\n        return self::NAME . ($channel ? ': ' . $channel : '');\n    }\n\n    public function collectData(): void\n    {\n        $path = $this->getInput('channel') ?? 'current-channel';\n        $url = self::URI . $path;\n\n        $dom = getSimpleHTMLDOMCached($url, self::CACHE_TIMEOUT);\n        if (!$dom) {\n            throwServerException('Invalid or empty content received');\n        }\n\n        $dom = defaultLinkTo($dom, self::URI);\n        $versions = $dom->find('h2[id^=\"version-\"]');\n\n        foreach ($versions as $version) {\n            $this->items[] = [\n                'title'   => trim($version->plaintext),\n                'uri'     => $url . '#' . $version->id,\n                'uid'     => $version->id,\n                'content' => $this->collectContent($version),\n            ];\n        }\n    }\n\n    private function collectContent($version): string\n    {\n        $content = '';\n        $sibling = $version->next_sibling();\n\n        while ($sibling) {\n            if ($sibling->tag === 'h2') {\n                break;\n            }\n\n            $content .= $sibling->outertext;\n            $sibling = $sibling->next_sibling();\n        }\n\n        return trim($content);\n    }\n}\n"
  },
  {
    "path": "bridges/MilbooruBridge.php",
    "content": "<?php\n\nclass MilbooruBridge extends Shimmie2Bridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Milbooru';\n    const URI = 'http://sheslostcontrol.net/moe/shimmie/';\n    const DESCRIPTION = 'Returns images from given page';\n}\n"
  },
  {
    "path": "bridges/MinecraftBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass MinecraftBridge extends BridgeAbstract\n{\n    const NAME = 'Minecraft';\n    const URI = 'https://www.minecraft.net';\n    const DESCRIPTION = 'Catch up on the latest Minecraft articles';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'category' => [\n                'type' => 'list',\n                'name' => 'Category',\n                'values' => [\n                    'All' => 'all',\n                    'Deep Dives' => 'minecraft:stockholm/deep-dives',\n                    'News' => 'minecraft:stockholm/news',\n                    'Marketplace' => 'minecraft:stockholm/marketplace',\n                ],\n                'title' => 'Choose article category',\n                'defaultValue' => 'all',\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.minecraft.net/etc.clientlibs/minecraftnet/clientlibs/clientlib-site/resources/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        /* Removing either \"category=News\" or \"newsOnly=false\" causes many articles to not be visible */\n        $json = getContents('https://net-secondary.web.minecraft-services.net/api/v1.0/en-us/search?sortType=Recent&category=News&newsOnly=false');\n\n        $data = json_decode($json);\n        if ($data === null || empty($data->result->results)) {\n            throwServerException('Invalid or empty content');\n        }\n\n        $category = $this->getInput('category');\n\n        foreach ($data->result->results as $article) {\n            if ($category !== 'all' && in_array($category, $article->tags)) {\n                continue;\n            }\n\n            $imageUrl = $article->image;\n\n            /* All posts have this article-page tag. Removing it. */\n            $tags = array_filter($article->tags, function ($value) {\n                return $value !== 'article-page';\n            });\n            $tags = array_map([$this, 'normalizeTags'], $tags);\n\n            $this->items[] = [\n                'title' => trim($article->title),\n                'uid' => parse_url($article->url, PHP_URL_PATH),\n                'uri' => $article->url,\n                'timestamp' => $article->time,\n                'author' => $article->author,\n                'content' => $article->description,\n                'categories' => $tags,\n                'enclosures' => $imageUrl ? [$imageUrl] : [],\n            ];\n        }\n    }\n    /**\n     * For compatibility for tags from before 2026-02-12\n     */\n    private function normalizeTags($tag)\n    {\n        $index = strpos($tag, '/');\n        if ($index !== false) {\n            $tag = substr($tag, $index + 1);\n        }\n        $tag = str_replace('-', ' ', $tag);\n        # Backwards compatibility with old tags\n        return ucwords($tag);\n    }\n}\n"
  },
  {
    "path": "bridges/MistralAIBridge.php",
    "content": "<?php\n\nclass MistralAIBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Mistral AI';\n    const URI = 'https://mistral.ai/';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Returns blog posts from Mistral AI';\n\n    const PARAMETERS = [\n        '' => [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI . 'news/');\n        $limit = $this->getInput('limit');\n\n        $posts = $html->find('article.news-card');\n        for ($i = 0; $i < min($limit, count($posts)); $i++) {\n            $post = $posts[$i];\n            $url = self::URI . $post->find('a', 0)->href;\n            $this->parsePage($url);\n        }\n    }\n\n    private function parsePage($url)\n    {\n        $html = getSimpleHTMLDOMCached($url, 7 * 24 * 60 * 60);\n        $title = $html->find('h1.hero-title', 0)->plaintext;\n        $timestamp_tag = $html->find('i.ti-calendar', 0)->parent;\n        $timestamp = DateTime::createFromFormat('F j, Y', $timestamp_tag->plaintext)->format('U');\n\n        $content = '';\n\n        // Subheader\n        $header = $html->find('p.hero-description', 0);\n        if ($header != null) {\n            $content .= $header->outertext;\n        }\n\n        // Main content\n        $main = $html->find('$article > div.content', 0);\n\n        // Mostly YouTube videos\n        $iframes = $main->find('iframe');\n        foreach ($iframes as $iframe) {\n            $iframe->parent->removeAttribute('style');\n            $iframe->outertext = '<a href=\"' . $iframe->src . '\">' . $iframe->src . '</a>';\n        }\n\n        $main = defaultLinkTo($main, self::URI);\n        $content .= $main;\n        $this->items[] = [\n            'title' => $title,\n            'timestamp' => $timestamp,\n            'content' => $content,\n            'uri' => $url,\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/MixCloudBridge.php",
    "content": "<?php\n\nclass MixCloudBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Alexis CHEMEL';\n    const NAME = 'MixCloud';\n    const URI = 'https://www.mixcloud.com';\n    const API_URI = 'https://api.mixcloud.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns latest musics on user stream';\n\n    const PARAMETERS = [[\n        'u' => [\n            'name' => 'username',\n            'required' => true,\n            'exampleValue' => 'DJJazzyJeff',\n        ]\n    ]];\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return 'MixCloud - ' . $this->getInput('u');\n        }\n\n        return parent::getName();\n    }\n\n    private static function compareDate($stream1, $stream2)\n    {\n        return (strtotime($stream1['timestamp']) < strtotime($stream2['timestamp']) ? 1 : -1);\n    }\n\n    public function collectData()\n    {\n        $user = urlencode($this->getInput('u'));\n        // Get Cloudcasts\n        $mixcloudUri = self::API_URI . $user . '/cloudcasts/';\n        $content = getContents($mixcloudUri);\n        $casts = json_decode($content)->data;\n\n        // Get Listens\n        $mixcloudUri = self::API_URI . $user . '/listens/';\n        $content = getContents($mixcloudUri);\n        $listens = json_decode($content)->data;\n\n        $streams = array_merge($casts, $listens);\n\n        foreach ($streams as $stream) {\n            $item = [];\n\n            $item['uri'] = $stream->url;\n            $item['title'] = $stream->name;\n            $item['content'] = '<img src=\"' . $stream->pictures->thumbnail . '\" />';\n            $item['author'] = $stream->user->name;\n            $item['timestamp'] = $stream->created_time;\n\n            $this->items[] = $item;\n        }\n\n        // Sort items by date\n        usort($this->items, ['MixCloudBridge', 'compareDate']);\n    }\n}\n"
  },
  {
    "path": "bridges/MixologyBridge.php",
    "content": "<?php\n\nclass MixologyBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'swofl';\n    const NAME = 'Mixology';\n    const URI = 'https://mixology.eu';\n    const CACHE_TIMEOUT = 6 * 60 * 60; // 6h\n    const DESCRIPTION = 'Get latest blog posts from Mixology';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $teasers = [];\n        $teaserElements = [];\n\n        $teaserElements[] = $html->find('.aufmacher .views-view-responsive-grid__item-inner', 0);\n        foreach ($html->find('.block-views-blockmixology-frontpage-block-2 .views-col') as $teaser) {\n            $teaserElements[] = $teaser;\n        }\n\n        foreach ($teaserElements as $teaser) {\n            $teasers[] = $this->parseTeaser($teaser);\n        }\n\n        foreach ($teasers as $article) {\n            $this->items[] = $this->parseItem($article);\n        }\n    }\n\n    protected function parseTeaser($teaser)\n    {\n        $result = [];\n\n        $title = $teaser->find('.views-field-title a', 0);\n        $result['title'] = $title->plaintext;\n        $result['uri'] = self::URI . $title->href;\n        $result['enclosures'] = [];\n        $result['enclosures'][] = self::URI . $teaser->find('img', 0)->src;\n        $result['uid'] = hash('sha256', $result['title']);\n\n        $categories = $teaser->find('.views-field-field-kategorie', 0);\n        if ($categories) {\n            $result['categories'] = [];\n            foreach ($categories->find('a') as $category) {\n                $result['categories'][] = $category->innertext;\n            }\n        }\n\n        return $result;\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article = getSimpleHTMLDOMCached($item['uri']);\n\n        $authorLink = $article->find('.beitrag-author a', 0);\n        if (!empty($authorLink)) {\n            $item['author'] = $authorLink->plaintext;\n        }\n\n        $timeElement = $article->find('.beitrag-date time', 0);\n        if (!empty($timeElement)) {\n            $item['timestamp'] = strtotime($timeElement->datetime);\n        }\n\n        $content = '';\n\n        $content .= '<img src=\"' . $item['enclosures'][0] . '\"/>';\n\n        foreach ($article->find('article .wpb_content_element>.wpb_wrapper, article .field--type-text-with-summary>.wp-block-columns>.wp-block-column') as $element) {\n            $content .= $element->innertext;\n        }\n\n        $item['content'] = $content;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ModelKarteiBridge.php",
    "content": "<?php\n\nclass ModelKarteiBridge extends BridgeAbstract\n{\n    const NAME = 'model-kartei.de';\n    const URI = 'https://www.model-kartei.de/';\n    const DESCRIPTION = 'Get the public comp card gallery';\n    const MAINTAINER = 'fulmeek';\n    const PARAMETERS = [[\n        'model_id' => [\n            'name'          => 'Model ID',\n            'required' => true,\n            'exampleValue'  => '614931'\n        ]\n    ]];\n\n    const LIMIT_ITEMS = 10;\n\n    private $feedName = '';\n\n    public function collectData()\n    {\n        $model_id = preg_replace('/[^0-9]/', '', $this->getInput('model_id'));\n        if (empty($model_id)) {\n            throwServerException('Invalid model ID');\n        }\n\n        $html = getSimpleHTMLDOM(self::URI . 'sedcards/model/' . $model_id . '/');\n\n        $objTitle = $html->find('.sTitle', 0);\n        if ($objTitle) {\n            $this->feedName = $objTitle->plaintext;\n        }\n\n        $itemlist = $html->find('#photoList .photoPreview');\n        if (!$itemlist) {\n            throwServerException('No gallery');\n        }\n\n        foreach ($itemlist as $idx => $element) {\n            if ($idx >= self::LIMIT_ITEMS) {\n                break;\n            }\n\n            $item = [];\n\n            $title      = $element->title;\n            $date       = $element->{'data-date'};\n            $author     = $this->feedName;\n            $text       = '';\n\n            $objImage   = $element->find('a.photoLink img', 0);\n            $objLink    = $element->find('a.photoLink', 0);\n\n            if ($objLink) {\n                $page = getSimpleHTMLDOMCached($objLink->href);\n\n                if (empty($title)) {\n                    $objTitle = $page->find('.p-title', 0);\n                    if ($objTitle) {\n                        $title = $objTitle->plaintext;\n                    }\n                }\n                if (empty($date)) {\n                    $objDate = $page->find('.cameraDetails .date', 0);\n                    if ($objDate) {\n                        $date = strtotime($objDate->parent()->plaintext);\n                    }\n                }\n                if (empty($author)) {\n                    $objAuthor = $page->find('.p-publisher a', 0);\n                    if ($objAuthor) {\n                        $author = $objAuthor->plaintext;\n                    }\n                }\n\n                $objFullImage = $page->find('img#gofullscreen', 0);\n                if ($objFullImage) {\n                    $objImage = $objFullImage;\n                }\n\n                $objText = $page->find('.p-desc', 0);\n                if ($objText) {\n                    $text = $objText->plaintext;\n                }\n            }\n\n            $item['title']      = $title;\n            $item['timestamp']  = $date;\n            $item['author']     = $author;\n\n            if ($objImage) {\n                $item['content'] = '<img src=\"' . $objImage->src . '\"/>';\n            }\n            if ($objLink) {\n                $item['uri'] = $objLink->href;\n                if (!empty($item['content'])) {\n                    $item['content'] = '<a href=\"' . $objLink->href . '\" target=\"_blank\">' . $item['content'] . '</a>';\n                }\n            } else {\n                $item['uri'] = 'urn:sha1:' . hash('sha1', $item['content']);\n            }\n            if (!empty($text)) {\n                $item['content'] = '<p>' . $text . '</p>' . $item['content'];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName . ' - ' . self::NAME;\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/ModifyBridge.php",
    "content": "<?php\n\nclass ModifyBridge extends FeedExpander\n{\n    const MAINTAINER = 'Mynacol';\n    const NAME = 'Modify Feed';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Modifies a feed of your choice with regexes';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge';\n\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Feed URL',\n            'type'  => 'text',\n            'exampleValue' => 'https://lorem-rss.herokuapp.com/feed?unit=day',\n            'required' => true,\n        ],\n        'title_pattern' => [\n            'name' => 'Title find pattern (regular expression!)',\n            'type' => 'text',\n            'exampleValue' => 'Unwanted part in title',\n            'required' => false,\n        ],\n        'title_replacement' => [\n            'name' => 'Title replacement for the find pattern',\n            'type' => 'text',\n            'exampleValue' => '${0}',\n            'required' => false,\n        ],\n        'author_pattern' => [\n            'name' => 'Author find pattern (regular expression!)',\n            'type' => 'text',\n            'exampleValue' => '^(author)\\s*|\\s*publisher$',\n            'required' => false,\n        ],\n        'author_replacement' => [\n            'name' => 'Author replacement for the find pattern',\n            'type' => 'text',\n            'exampleValue' => '${1}',\n            'required' => false,\n        ],\n        'content_pattern' => [\n            'name' => 'Content find pattern (regular expression!)',\n            'type' => 'text',\n            'exampleValue' => '(content)\\s+advertisement\\s+(content)',\n            'required' => false,\n        ],\n        'content_replacement' => [\n            'name' => 'Content replacement for the find pattern',\n            'type' => 'text',\n            'exampleValue' => '${1} ${2}',\n            'required' => false,\n        ],\n        'uri_pattern' => [\n            'name' => 'URI/URL find pattern (regular expression!)',\n            'type' => 'text',\n            'exampleValue' => '^https?://(.*)/(.*)$',\n            'required' => false,\n        ],\n        'uri_replacement' => [\n            'name' => 'URI/URL replacement for the find pattern',\n            'type' => 'text',\n            'exampleValue' => 'https://${1}/foo/${2}',\n            'required' => false,\n        ],\n        'enclosure_pattern' => [\n            'name' => 'Enclosure URI/URL find pattern (regular expression!)',\n            'type' => 'text',\n            'exampleValue' => '^https?://(.*)/(.*)$',\n            'required' => false,\n        ],\n        'enclosure_replacement' => [\n            'name' => 'Enclosure URI/URL replacement for the find pattern',\n            'type' => 'text',\n            'exampleValue' => 'https://${1}/foo/${2}',\n            'required' => false,\n        ],\n        'case_insensitive' => [\n            'name' => 'Case-insensitive find patterns',\n            'type' => 'checkbox',\n            'required' => false,\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n        if (!Url::validate($url)) {\n            throw new \\Exception('The url parameter must either refer to http or https protocol.');\n        }\n        $this->collectExpandableDatas($this->getURI());\n    }\n\n    protected function parseItem(array $item)\n    {\n        // Title\n        $pattern = $this->buildPattern($this->getInput('title_pattern'));\n        $replacement = $this->getInput('title_replacement');\n        $res = preg_replace($pattern, $replacement, $item['title']);\n        if ($res !== null) {\n            $item['title'] = $res;\n        }\n\n        // Author\n        $pattern = $this->buildPattern($this->getInput('author_pattern'));\n        $replacement = $this->getInput('author_replacement');\n        $res = preg_replace($pattern, $replacement, $item['author']);\n        if ($res !== null) {\n            $item['author'] = $res;\n        }\n\n        // Content\n        $pattern = $this->buildPattern($this->getInput('content_pattern'));\n        $replacement = $this->getInput('content_replacement');\n        $res = preg_replace($pattern, $replacement, $item['content']);\n        if ($res !== null) {\n            $item['content'] = $res;\n        }\n\n        // URI\n        $pattern = $this->buildPattern($this->getInput('uri_pattern'));\n        $replacement = $this->getInput('uri_replacement');\n        $res = preg_replace($pattern, $replacement, $item['uri']);\n        if ($res !== null) {\n            $item['uri'] = $res;\n        }\n\n        // Enclosures\n        if (array_key_exists('enclosures', $item)) {\n            $pattern = $this->buildPattern($this->getInput('enclosure_pattern'));\n            $replacement = $this->getInput('enclosure_replacement');\n            foreach ($item['enclosures'] as $key => $val) {\n                $res = preg_replace($pattern, $replacement, $val);\n                if ($res !== null) {\n                    $item['enclosures'][$key] = $res;\n                }\n            }\n        }\n        if (array_key_exists('enclosure', $item)) {\n            $pattern = $this->buildPattern($this->getInput('enclosure_pattern'));\n            $replacement = $this->getInput('enclosure_replacement');\n            $res = preg_replace($pattern, $replacement, $item['enclosure']['url']);\n            if ($res !== null) {\n                $item['enclosure']['url'] = $res;\n            }\n        }\n\n        return $item;\n    }\n\n    private function buildPattern($pattern)\n    {\n        if (! str_contains($pattern, '#')) {\n            $delimiter = '#';\n        } elseif (! str_contains($pattern, '/')) {\n            $delimiter = '/';\n        } else {\n            throw new \\Exception('Cannot use both / and # inside filter');\n        }\n\n        $regex = $delimiter . $pattern . $delimiter;\n        if ($this->getInput('case_insensitive')) {\n            $regex .= 'i';\n        }\n        return $regex;\n    }\n\n    public function getURI()\n    {\n        $url = $this->getInput('url');\n        if ($url) {\n            return $url;\n        }\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/ModrinthBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n// Uses the modrinth API documented here: https://docs.modrinth.com/api/\n\nclass ModrinthBridge extends BridgeAbstract\n{\n    const NAME = 'Modrinth';\n    const URI = 'https://modrinth.com/';\n    const DESCRIPTION = 'For new versions of mods, resource packs, etc.';\n    const MAINTAINER = 'xnand';\n\n    const PARAMETERS = [[\n        'name' => [\n            'name' => 'Name',\n            'required' => true,\n            'title' => 'The project name as seen in the URL bar',\n            'exampleValue' => 'sodium'\n        ],\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'Mod' => 'mod',\n                'Resource Pack' => 'resourcepack',\n                'Data Pack' => 'datapack',\n                'Shader' => 'shader',\n                'Modpack' => 'modpack',\n                'Plugin' => 'plugin'\n            ],\n            'defaultValue' => 'mod'\n        ],\n        'loaders' => [\n            'name' => 'Loaders',\n            'title' => 'List of mod loaders, separated by commas',\n            'exampleValue' => 'neoforge, fabric'\n        ],\n        'game_versions' => [\n            'name' => 'Game versions',\n            'title' => 'List of game versions, separated by commas',\n            'exampleValue' => '1.19.1, 1.19.2'\n        ],\n        'featured' => [\n            'name' => 'Featured',\n            'type' => 'list',\n            'values' => [\n                'Unset' => '',\n                'True' => 'true',\n                'False' => 'false'\n            ],\n            'title' => \"Whether to filter for featured or non-featured\\nUnset means no filter\",\n            'defaultValue', ''\n        ]\n    ]];\n\n\n    public function getURI()\n    {\n        $name = $this->getInput('name');\n        $category = $this->getInput('category');\n        $uri = self::URI . $category . '/' . $name . '/versions';\n        if (empty($name)) {\n            $uri = parent::getURI();\n        }\n        return $uri;\n    }\n\n    public function getName()\n    {\n        $name = $this->getInput('name');\n        if (empty($name)) {\n            $name = parent::getName();\n        }\n        return $name;\n    }\n\n    public function collectData()\n    {\n        $apiUrl = 'https://api.modrinth.com/v2/project';\n        $projectName = $this->getInput('name');\n        $url = sprintf('%s/%s/version', $apiUrl, $projectName);\n\n        $queryTable = [\n            'loaders' => $this->parseInputList($this->getInput('loaders')),\n            'game_versions' => $this->parseInputList($this->getInput('game_versions')),\n            'featured' => ($this->getInput('featured')) ? : null\n        ];\n\n        $query = http_build_query($queryTable);\n        if ($query) {\n            $url .= '?' . $query;\n        }\n\n        // They expect a descriptive user agent and may block connections without one\n        // Change as appropriate\n        // https://docs.modrinth.com/api/#user-agents\n        $header = [ 'User-Agent: rss-bridge plugin https://github.com/RSS-Bridge/rss-bridge' ];\n        $data = json_decode(getContents($url, $header));\n\n        foreach ($data as $entry) {\n            $item = [];\n\n            $item['uri'] = self::URI . $this->getInput('category') . '/' . $this->getInput('name') . '/version/' . $entry->version_number;\n            $item['title'] = $entry->name;\n            $item['timestamp'] = $entry->date_published;\n            // Not setting the author as this would take a second request to match the author's user ID\n            $item['author'] = 'Modrinth';\n            $item['content'] = markdownToHtml($entry->changelog);\n            $item['categories'] = array_merge($entry->loaders, $entry->game_versions);\n            $item['uid'] = $entry->id;\n\n            $this->items[] = $item;\n        }\n    }\n\n    // Converts lists like `foo, bar, baz` to `[\"foo\", \"bar\", \"baz\"]`\n    protected function parseInputList($input): ?string\n    {\n        if (empty($input)) {\n            return null;\n        }\n        $items = array_filter(array_map('trim', explode(',', $input)));\n        return $items ? json_encode($items) : null; // return nothing if string is empty\n    }\n}\n"
  },
  {
    "path": "bridges/MoebooruBridge.php",
    "content": "<?php\n\nclass MoebooruBridge extends BridgeAbstract\n{\n    const NAME = 'Moebooru';\n    const URI = 'https://moe.dev.myconan.net/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns images from given page';\n    const MAINTAINER = 'pmaziere';\n\n    const PARAMETERS = [ [\n        'p' => [\n            'name' => 'page',\n            'defaultValue' => 1,\n            'type' => 'number'\n        ],\n        't' => [\n            'name' => 'tags'\n        ]\n    ]];\n\n    protected function getFullURI()\n    {\n        return $this->getURI()\n        . 'post?page='\n        . $this->getInput('p')\n        . '&tags='\n        . urlencode($this->getInput('t'));\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getFullURI());\n\n        $input_json = explode('Post.register(', $html);\n        foreach ($input_json as $element) {\n            $data[] = preg_replace('/}\\)(.*)/', '}', $element);\n        }\n        unset($data[0]);\n\n        foreach ($data as $datai) {\n            $json = json_decode($datai, true);\n            if ($json === null) {\n                continue;\n            }\n            $item = [];\n            $item['uri'] = $this->getURI() . '/post/show/' . $json['id'];\n            $item['postid'] = $json['id'];\n            $item['timestamp'] = $json['created_at'];\n            $item['imageUri'] = $json['file_url'];\n            $item['title'] = $this->getName() . ' | ' . $json['id'];\n            $item['content'] = '<a href=\"'\n            . $item['imageUri']\n            . '\"><img src=\"'\n            . $json['preview_url']\n            . '\" /></a><br>Tags: '\n            . $json['tags'];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/MoinMoinBridge.php",
    "content": "<?php\n\nclass MoinMoinBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'MoinMoin';\n    const URI = 'https://moinmo.in';\n    const DESCRIPTION = 'Generates feeds for pages of a MoinMoin (compatible) wiki';\n    const PARAMETERS = [\n        [\n            'source' => [\n                'name' => 'Source',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert wiki page URI (e.g.: https://moinmo.in/MoinMoin)',\n                'exampleValue' => 'https://moinmo.in/MoinMoin'\n            ],\n            'separator' => [\n                'name' => 'Separator',\n                'type' => 'list',\n                'requied' => true,\n                'title' => 'Defines the separtor for splitting content into feeds',\n                'defaultValue' => 'h2',\n                'values' => [\n                    'Header (h1)' => 'h1',\n                    'Header (h2)' => 'h2',\n                    'Header (h3)' => 'h3',\n                    'List element (li)' => 'li',\n                    'Anchor (a)' => 'a'\n                ]\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Number of items to return (from top)',\n                'defaultValue' => -1\n            ],\n            'content' => [\n                'name' => 'Content',\n                'type' => 'list',\n                'required' => false,\n                'title' => 'Defines how feed contents are build',\n                'defaultValue' => 'separator',\n                'values' => [\n                    'By separator' => 'separator',\n                    'Follow link (only for anchor)' => 'follow',\n                    'None' => 'none'\n                ]\n            ]\n        ]\n    ];\n\n    private $title = '';\n\n    public function collectData()\n    {\n        /* MoinMoin uses a rather unpleasent representation of HTML. Instead of\n         * using tags like <article/>, <navigation/>, <header/>, etc... it uses\n         * <div/>, <span/> and <p/>. Also each line is literaly identified via\n         * IDs. The only way to distinguish content is via headers, though not\n         * in all cases.\n         *\n         * Example (indented for the sake of readability):\n         * ...\n         * <span class=\"anchor\" id=\"line-1\"></span>\n         * <span class=\"anchor\" id=\"line-2\"></span>\n         * <span class=\"anchor\" id=\"line-3\"></span>\n         * <span class=\"anchor\" id=\"line-4\"></span>\n         * <span class=\"anchor\" id=\"line-5\"></span>\n         * <span class=\"anchor\" id=\"line-6\"></span>\n         * <span class=\"anchor\" id=\"line-7\"></span>\n         * <span class=\"anchor\" id=\"line-8\"></span>\n         * <span class=\"anchor\" id=\"line-9\"></span>\n         *   <p class=\"line867\">MoinMoin is a Wiki software implemented in\n         *     <a class=\"interwiki\" href=\"/Python\" title=\"MoinMoin\">Python</a>\n         *   and distributed as Free Software under\n         *     <a class=\"interwiki\" href=\"/GPL\" title=\"MoinMoin\">GNU GPL license</a>.\n         * ...\n         */\n        $html = getSimpleHTMLDOM($this->getInput('source'));\n\n        // Some anchors link to local sites or local IDs (both don't work well\n        // in feeds)\n        $html = $this->fixAnchors($html);\n\n        $this->title = $html->find('title', 0)->innertext . ' | ' . self::NAME;\n\n        // Here we focus on simple author and timestamp information from the given\n        // page. Later we update this information in case the anchor is followed.\n        $author = $this->findAuthor($html);\n        $timestamp = $this->findTimestamp($html);\n\n        $sections = $this->splitSections($html);\n\n        foreach ($sections as $section) {\n            $item = [];\n\n            $item['uri'] = $this->findSectionAnchor($section[0]);\n\n            switch ($this->getInput('content')) {\n                case 'none': // Do not return any content\n                    break;\n                case 'follow': // Follow the anchor\n                    // We can only follow anchors (use default otherwise)\n                    if ($this->getInput('separator') === 'a') {\n                        $content = $this->followAnchor($item['uri']);\n\n                        // Return only actual content\n                        $item['content'] = $content->find('div#page', 0)->innertext;\n\n                        // Each page could have its own author and timestamp\n                        $author = $this->findAuthor($content);\n                        $timestamp = $this->findTimestamp($content);\n\n                        break;\n                    }\n                    // fall-through\n                case 'separator':\n                default: // Use contents from the current page\n                    $item['content'] = $this->cleanArticle($section[2]);\n            }\n\n            if (!is_null($author)) {\n                $item['author'] = $author;\n            }\n            if (!is_null($timestamp)) {\n                $item['timestamp'] = $timestamp;\n            }\n            $item['title'] = strip_tags($section[1]);\n\n            // Skip items with empty title\n            if (empty(trim($item['title']))) {\n                continue;\n            }\n\n            $this->items[] = $item;\n\n            if (\n                $this->getInput('limit') > 0\n                && count($this->items) >= $this->getInput('limit')\n            ) {\n                break;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        return $this->title ?: parent::getName();\n    }\n\n    public function getURI()\n    {\n        return $this->getInput('source') ?: parent::getURI();\n    }\n\n    /**\n     * Splits the html into sections.\n     *\n     * Returns an array with one element per section. Each element consists of:\n     * [0] The entire section\n     * [1] The section title\n     * [2] The section content\n     */\n    private function splitSections($html)\n    {\n        $content = $html->find('div#page', 0)->innertext\n            or throwServerException('Unable to find <div id=\"page\"/>!');\n\n        $sections = [];\n\n        $regex = implode(\n            '',\n            [\n                \"\\<{$this->getInput('separator')}.+?(?=\\>)\\>\",\n                \"(.+?)(?=\\<\\/{$this->getInput('separator')}\\>)\",\n                \"\\<\\/{$this->getInput('separator')}\\>\",\n                \"(.+?)((?=\\<{$this->getInput('separator')})|(?=\\<div\\sid=\\\"pagebottom\\\")){1}\"\n            ]\n        );\n\n        preg_match_all(\n            '/' . $regex . '/m',\n            $content,\n            $sections,\n            PREG_SET_ORDER\n        );\n\n        // Some pages don't use headers, return page as one feed\n        if (count($sections) === 0) {\n            return [\n                [\n                    $content,\n                    $html->find('title', 0)->innertext,\n                    $content\n                ]\n            ];\n        }\n\n        return $sections;\n    }\n\n    /**\n     * Returns the anchor for a given section\n     */\n    private function findSectionAnchor($section)\n    {\n        $html = str_get_html($section);\n\n        // For IDs\n        $anchor = $html->find($this->getInput('separator') . '[id=]', 0);\n        if (!is_null($anchor)) {\n            return $this->getInput('source') . '#' . $anchor->id;\n        }\n\n        // For actual anchors\n        $anchor = $html->find($this->getInput('separator') . '[href=]', 0);\n        if (!is_null($anchor)) {\n            return $anchor->href;\n        }\n\n        // Nothing found\n        return $this->getInput('source');\n    }\n\n    /**\n     * Returns the author\n     *\n     * Notice: Some pages don't provide author information\n     */\n    private function findAuthor($html)\n    {\n        /* Example:\n         * <p id=\"pageinfo\" class=\"info\" dir=\"ltr\" lang=\"en\">MoinMoin: LocalSpellingWords\n         * (last edited 2017-02-16 15:36:31 by <span title=\"??? @ hosted-by.leaseweb.com\n         * [178.162.199.143]\">hosted-by</span>)</p>\n        */\n        $pageinfo = $html->find('[id=\"pageinfo\"]', 0);\n\n        if (is_null($pageinfo)) {\n            return null;\n        } else {\n            $author = $pageinfo->find('[title=]', 0);\n            if (is_null($author)) {\n                return null;\n            } else {\n                return trim(explode('@', $author->title)[0]);\n            }\n        }\n    }\n\n    /**\n     * Returns the time of last edit\n     *\n     * Notice: Some pages don't provide this information\n     */\n    private function findTimestamp($html)\n    {\n        // See example of findAuthor()\n        $pageinfo = $html->find('[id=\"pageinfo\"]', 0);\n\n        if (is_null($pageinfo)) {\n            return null;\n        } else {\n            $timestamp = $pageinfo->innertext;\n            $matches = [];\n            preg_match('/.+?(?=\\().+?(?=\\d)([0-9\\-\\s\\:]+)/m', $pageinfo, $matches);\n            return strtotime($matches[1]);\n        }\n    }\n\n    /**\n     * Returns the original HTML with all anchors fixed (makes relative anchors\n     * absolute)\n     */\n    private function fixAnchors($html, $source = null)\n    {\n        $source = $source ?: $this->getURI();\n\n        foreach ($html->find('a') as $anchor) {\n            switch (substr($anchor->href, 0, 1)) {\n                case 'h': // http or https, no actions required\n                    break;\n                case '/': // some relative path\n                    $anchor->href = $this->findDomain($source) . $anchor->href;\n                    break;\n                case '#': // it's an ID\n                default: // probably something like ? or &, skip empty ones\n                    if (!isset($anchor->href)) {\n                        break;\n                    }\n                    $anchor->href = $source . $anchor->href;\n            }\n        }\n\n        return $html;\n    }\n\n    /**\n     * Loads the full article of a given anchor (if the anchor is from the same\n     * wiki domain)\n     */\n    private function followAnchor($anchor)\n    {\n        if (strrpos($anchor, $this->findDomain($this->getInput('source')) === false)) {\n            return null;\n        }\n\n        $html = getSimpleHTMLDOMCached($anchor);\n        if (!$html) { // Cannot load article\n            return null;\n        }\n\n        return $this->fixAnchors($html, $anchor);\n    }\n\n    /**\n     * Finds the domain for a given URI\n     */\n    private function findDomain($uri)\n    {\n        $matches = [];\n        preg_match('/(http[s]{0,1}:\\/\\/.+?(?=\\/))/', $uri, $matches);\n        return $matches[1];\n    }\n\n    /* This function is a copy from CNETBridge */\n    private function stripWithDelimiters($string, $start, $end)\n    {\n        while (strpos($string, $start) !== false) {\n            $section_to_remove = substr($string, strpos($string, $start));\n            $section_to_remove = substr($section_to_remove, 0, strpos($section_to_remove, $end) + strlen($end));\n            $string = str_replace($section_to_remove, '', $string);\n        }\n\n        return $string;\n    }\n\n    /* This function is based on CNETBridge */\n    private function cleanArticle($article_html)\n    {\n        $article_html = $this->stripWithDelimiters($article_html, '<script', '</script>');\n        return $article_html;\n    }\n}\n"
  },
  {
    "path": "bridges/MondeDiploBridge.php",
    "content": "<?php\n\nclass MondeDiploBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Pitchoule';\n    const NAME = 'Monde Diplomatique';\n    const URI = 'https://www.monde-diplomatique.fr';\n    const CACHE_TIMEOUT = 21600; //6h\n    const DESCRIPTION = 'Returns most recent results from MondeDiplo.';\n\n    private function cleanText($text)\n    {\n        return trim(str_replace(['&nbsp;', '&nbsp'], ' ', $text));\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('div.unarticle') as $article) {\n            $element = $article->parent();\n            $titleElement = $element->find('h3', 0);\n            if (!$titleElement) {\n                continue;\n            }\n            $title = $titleElement->plaintext;\n            $datesAuteursElement = $element->find('div.dates_auteurs', 0);\n            $datesAuteurs = is_null($datesAuteursElement) ? '' : $element->find('div.dates_auteurs', 0)->plaintext;\n            $item = [];\n            $item['uri'] = urljoin(self::URI, $element->href);\n            $item['title'] = $this->getItemTitle($title, $datesAuteurs);\n            $item['content'] = $this->cleanText(str_replace([$title, $datesAuteurs], '', $element->plaintext));\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getItemTitle($title, $datesAuteurs)\n    {\n        $itemTitle = $this->cleanText($title);\n        if (strlen($datesAuteurs) > 0) {\n            $itemTitle .= ' - ' . $this->cleanText($datesAuteurs);\n        }\n        return $itemTitle;\n    }\n}\n"
  },
  {
    "path": "bridges/MotatosBridge.php",
    "content": "<?php\n\nclass MotatosBridge extends BridgeAbstract\n{\n    const NAME = 'Motatos / Matsmart';\n    const URI = 'https://www.motatos.de/neu-im-shop';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'New articles in the Motatos / Matsmart online shop';\n    const MAINTAINER = 'knrdl';\n    const PARAMETERS = [[\n        'region' => [\n            'name' => 'Region',\n            'type' => 'list',\n            'title' => 'Choose country',\n            'values' => [\n                'Austria' => 'at',\n                'Denmark' => 'dk',\n                'Finland' => 'fi',\n                'Germany' => 'de',\n                'Sweden' => 'se',\n            ],\n        ],\n    ]];\n\n    public function getName()\n    {\n        switch ($this->getInput('region')) {\n            case 'at':\n                return 'Motatos';\n            case 'dk':\n                return 'Motatos';\n            case 'de':\n                return 'Motatos';\n            case 'fi':\n                return 'Matsmart';\n            case 'se':\n                return 'Matsmart';\n            default:\n                return self::NAME;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->getInput('region')) {\n            case 'at':\n                return 'https://www.motatos.at/neu-im-shop';\n            case 'dk':\n                return 'https://www.motatos.dk/nye-varer';\n            case 'de':\n                return 'https://www.motatos.de/neu-im-shop';\n            case 'fi':\n                return 'https://www.matsmart.fi/uusimmat';\n            case 'se':\n                return 'https://www.matsmart.se/nyinkommet';\n            default:\n                return self::URI;\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.motatos.de/favicon.ico';\n    }\n\n    private function getApiUrl()\n    {\n        switch ($this->getInput('region')) {\n            case 'at':\n                return 'https://api.findify.io/v4/4359f7b3-17e0-4f74-9fdb-e6606dfed25c/smart-collection/new-arrivals';\n            case 'dk':\n                return 'https://api.findify.io/v4/3709426e-621a-49df-bd61-ac8543452022/smart-collection/new-arrivals';\n            case 'de':\n                return 'https://api.findify.io/v4/2a044754-6cda-4541-b159-39133b75386c/smart-collection/new-arrivals';\n            case 'fi':\n                return 'https://api.findify.io/v4/63946f89-2a82-4839-a412-883b79144f7b/smart-collection/new-arrivals';\n            case 'se':\n                return 'https://api.findify.io/v4/3ae86b36-a1bd-4442-a3d9-2af6845908e6/smart-collection/new-arrivals';\n        }\n    }\n\n    public function collectData()\n    {\n        // motatos uses this api to dynamically load more items on page scroll\n        $json = getContents($this->getApiUrl() . '?t_client=0&user={%22uid%22:%220%22,%22sid%22:%220%22}');\n        $jsonFile = json_decode($json, true);\n\n        foreach ($jsonFile['items'] as $entry) {\n            $item = [];\n            $item['uid'] = $entry['custom_fields']['uuid'][0];\n            $item['uri'] = $entry['product_url'];\n            $item['timestamp'] = $entry['created_at'] / 1000;\n            $item['title'] = $entry['title'];\n            $item['content'] = <<<HTML\n            <h1>{$entry['title']}</h1>\n            <img src=\"{$entry['image_url']}\" />\n            <p>{$entry['price'][0]}€</p>\n            HTML;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/MozillaBugTrackerBridge.php",
    "content": "<?php\n\nclass MozillaBugTrackerBridge extends BridgeAbstract\n{\n    const NAME = 'Mozilla Bug Tracker';\n    const URI = 'https://bugzilla.mozilla.org';\n    const DESCRIPTION = 'DEPRECATED: Use BugzillaBridge instead.\nReturns feeds for bug comments';\n    const MAINTAINER = 'AntoineTurmel';\n    const PARAMETERS = [\n        'Bug comments' => [\n            'id' => [\n                'name' => 'Bug tracking ID',\n                'type' => 'number',\n                'required' => true,\n                'title' => 'Insert bug tracking ID',\n                'exampleValue' => 121241\n            ],\n            'limit' => [\n                'name' => 'Number of comments to return',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify number of comments to return',\n                'defaultValue' => -1\n            ],\n            'sorting' => [\n                'name' => 'Sorting',\n                'type' => 'list',\n                'required' => false,\n                'title' => 'Defines the sorting order of the comments returned',\n                'defaultValue' => 'of',\n                'values' => [\n                    'Oldest first' => 'of',\n                    'Latest first' => 'lf'\n                ]\n            ]\n        ]\n    ];\n\n    private $bugid = '';\n    private $bugdesc = '';\n\n    public function getIcon()\n    {\n        return self::URI . '/extensions/BMO/web/images/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit');\n        $sorting = $this->getInput('sorting');\n\n        // We use the print preview page for simplicity\n        $html = getSimpleHTMLDOMCached(\n            $this->getURI() . '&format=multiple',\n            86400,\n            null,\n            null,\n            true,\n            true,\n            DEFAULT_TARGET_CHARSET,\n            false, // Do NOT remove line breaks\n            DEFAULT_BR_TEXT,\n            DEFAULT_SPAN_TEXT\n        );\n\n        // Fix relative URLs\n        defaultLinkTo($html, self::URI);\n\n        // Store header information into private members\n        $this->bugid = trim($html->find('#field-value-bug_id', 0)->plaintext);\n        $this->bugdesc = $html->find('h1#field-value-short_desc', 0)->plaintext;\n\n        // Get and limit comments\n        $comments = $html->find('div.change-set');\n\n        if ($limit > 0 && count($comments) > $limit) {\n            $comments = array_slice($comments, count($comments) - $limit, $limit);\n        }\n\n        if ($sorting === 'lf') {\n            $comments = array_reverse($comments, true);\n        }\n\n        foreach ($comments as $comment) {\n            $comment = $this->inlineStyles($comment);\n\n            $item = [];\n            $item['uri'] = $comment->find('h3.change-name', 0)->find('a', 0)->href;\n            $item['author'] = $comment->find('td.change-author', 0)->plaintext;\n            $item['title'] = $comment->find('h3.change-name', 0)->plaintext;\n            $item['timestamp'] = strtotime($comment->find('span.rel-time', 0)->title);\n            $item['content'] = '';\n\n            if ($comment->find('.comment-text', 0)) {\n                $item['content'] = $comment->find('.comment-text', 0)->outertext;\n            }\n\n            if ($comment->find('div.activity', 0)) {\n                $item['content'] .= $comment->find('div.activity', 0)->innertext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Bug comments':\n                return parent::getURI()\n                . '/show_bug.cgi?id='\n                . $this->getInput('id');\n                break;\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Bug comments':\n                return $this->bugid\n                . ' - '\n                . $this->bugdesc\n                . ' - '\n                . parent::getName();\n                break;\n            default:\n                return parent::getName();\n        }\n    }\n\n    /**\n     * Adds styles as attributes to tags with known classes\n     *\n     * @param object $html A simplehtmldom object\n     * @return object Returns the original object with styles added as\n     * attributes.\n     */\n    private function inlineStyles($html)\n    {\n        foreach ($html->find('.bz_closed') as $element) {\n            $element->style = 'text-decoration:line-through;';\n        }\n\n        foreach ($html->find('pre') as $element) {\n            $element->style = 'white-space: pre-wrap;';\n        }\n\n        return $html;\n    }\n}\n"
  },
  {
    "path": "bridges/MozillaSecurityBridge.php",
    "content": "<?php\n\nclass MozillaSecurityBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'm0le.net';\n    const NAME = 'Mozilla Security Advisories';\n    const URI = 'https://www.mozilla.org/en-US/security/advisories/';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Mozilla Security Advisories';\n    const WEBROOT = 'https://www.mozilla.org';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $html = defaultLinkTo($html, self::WEBROOT);\n\n        $item = [];\n        $articles = $html->find('div[id=\"main-content\"] h2');\n\n        foreach ($articles as $element) {\n            //Limit total amount of requests\n            if (count($this->items) >= 20) {\n                break;\n            }\n            $item['title'] = $element->innertext;\n            $item['timestamp'] = strtotime($element->innertext);\n            $item['content'] = $element->next_sibling()->innertext;\n            $item['uri'] = self::URI . '?' . $item['timestamp'];\n            $item['uid'] = self::URI . '?' . $item['timestamp'];\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/MsnMondeBridge.php",
    "content": "<?php\n\nclass MsnMondeBridge extends FeedExpander\n{\n    const MAINTAINER = 'kranack';\n    const NAME = 'MSN Actu Monde';\n    const DESCRIPTION = 'Returns the 10 newest posts from MSN Actualités (full text)';\n    const URI = 'https://www.msn.com/fr-fr/actualite';\n    const FEED_URL = 'https://rss.msn.com/fr-fr';\n    const JSON_URL = 'https://assets.msn.com/content/view/v2/Detail/fr-fr/';\n    const LIMIT = 10;\n\n    public function getName()\n    {\n        return 'MSN Actualités';\n    }\n\n    public function getURI()\n    {\n        return self::URI;\n    }\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::FEED_URL, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        if (!preg_match('#fr-fr/actualite.*/ar-(?<id>[\\w]*)\\?#', $item['uri'], $matches)) {\n            return null;\n        }\n\n        $jsonString = getContents(self::JSON_URL . $matches['id']);\n        $json = json_decode($jsonString, true);\n        $item['content'] = $json['body'];\n        if (!empty($json['authors'])) {\n            $item['author'] = reset($json['authors'])['name'];\n        }\n        $item['timestamp'] = $json['createdDateTime'];\n        foreach ($json['tags'] as $tag) {\n            $item['categories'][] = $tag['label'];\n        }\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/MspabooruBridge.php",
    "content": "<?php\n\nclass MspabooruBridge extends GelbooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Mspabooru';\n    const URI = 'https://mspabooru.com/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    protected function buildThumbnailURI($element)\n    {\n        return $this->getURI() . 'thumbnails/' . $element->directory\n        . '/thumbnail_' . $element->image;\n    }\n}\n"
  },
  {
    "path": "bridges/MydealsBridge.php",
    "content": "<?php\n\nclass MydealsBridge extends PepperBridgeAbstract\n{\n    const NAME = 'Mydealz';\n    const URI = 'https://www.mydealz.de/';\n    const DESCRIPTION = 'Zeigt die Deals von mydealz.de';\n    const MAINTAINER = 'sysadminstory';\n    const PARAMETERS = [\n        'Suche nach Stichworten' => [\n            'q' => [\n                'name' => 'Stichworten',\n                'type' => 'text',\n                'exampleValue' => 'lamp',\n                'required' => true\n            ],\n            'hide_expired' => [\n                'name' => 'Abgelaufenes ausblenden',\n                'type' => 'checkbox',\n            ],\n            'hide_local' => [\n                'name' => 'Lokales ausblenden',\n                'type' => 'checkbox',\n                'title' => 'Deals im physischen Geschäft ausblenden',\n            ],\n            'priceFrom' => [\n                'name' => 'Minimaler Preis',\n                'type' => 'text',\n                'title' => 'Minmaler Preis in Euros',\n                'required' => false\n            ],\n            'priceTo' => [\n                'name' => 'Maximaler Preis',\n                'type' => 'text',\n                'title' => 'maximaler Preis in Euro',\n                'required' => false\n            ],\n        ],\n\n        'Deals pro Gruppen' => [\n            'group' => [\n                'name' => 'Gruppen',\n                'type' => 'text',\n                'exampleValue' => 'dsl',\n                'title' => 'Gruppenname in der URL: Der einzugebende Gruppenname steht nach \"https://www.mydealz.de/gruppe/\" und vor einem \"?\".\nBeispiel: Wenn die URL der Gruppe, die im Browser angezeigt wird, :\nhttps://www.mydealz.de/gruppe/dsl?sortBy=temp\nDann geben Sie ein:\ndsl',\n                ],\n            'subgroups' => [\n                'name' => 'Kategorie',\n                'type' => 'text',\n                'exampleValue' => '293',\n                'title' => 'Nummer des Kategorie in der URL: Der einzugebende Kategorienummer steht nach \"groups=\" und vor einem \"&\".\nBeispiel: Wenn die URL der Gruppe, die im Browser angezeigt wird, :\nhttps://www.mydealz.de/gruppe/telefon-internet?groups=153%2C154&sortBy=new&time_frame=0\nDann geben Sie ein:\n153%2C154',\n                ],\n            'order' => [\n                'name' => 'sortieren nach',\n                'type' => 'list',\n                'title' => 'Sortierung der deals',\n                'values' => [\n                    'Vom heißesten zum kältesten Deal' => '-hot',\n                    'Vom jüngsten Deal zum ältesten' => '-new',\n                ]\n            ],\n        ],\n        'Überwachung Diskussion' => [\n            'url' => [\n                'name' => 'URL der Diskussion',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'URL-Diskussion zu überwachen: https://www.mydealz.de/diskussion/title-123',\n                'exampleValue' => 'https://www.mydealz.de/diskussion/anleitung-wie-schreibe-ich-einen-deal-1658317',\n                ],\n            'only_with_url' => [\n                'name' => 'Kommentare ohne URL ausschließen',\n                'type' => 'checkbox',\n                'title' => 'Kommentare, die keine URL enthalten, im Feed ausschließen',\n                'defaultValue' => false,\n                ]\n            ]\n    ];\n\n    public $lang = [\n        'bridge-uri' => self::URI,\n        'bridge-name' => self::NAME,\n        'context-keyword' => 'Suche nach Stichworten',\n        'context-group' => 'Deals pro Gruppen',\n        'context-talk' => 'Überwachung Diskussion',\n        'uri-group' => 'gruppe/',\n        'uri-deal' => 'deals/',\n        'uri-merchant' => 'search/gutscheine?merchant-id=',\n        'image-host' => 'https://static.mydealz.de/',\n        'request-error' => 'Could not request mydeals',\n        'thread-error' => 'Die ID der Diskussion kann nicht ermittelt werden. Überprüfen Sie die eingegebene URL',\n        'currency' => '€',\n        'price' => 'Preis',\n        'shipping' => 'Versand',\n        'origin' => 'Ursprung',\n        'discount' => 'Rabatte',\n        'title-keyword' => 'Suche',\n        'title-group' => 'Gruppe',\n        'title-talk' => 'Überwachung Diskussion',\n        'deal-type' => 'Angebotsart',\n        'localdeal' => 'Lokales Angebot',\n        'context-hot' => '-hot',\n        'context-new' => '-new',\n    ];\n}\n"
  },
  {
    "path": "bridges/N26Bridge.php",
    "content": "<?php\n\nclass N26Bridge extends BridgeAbstract\n{\n    const MAINTAINER = 'quentinus95';\n    const NAME = 'N26 Blog';\n    const URI = 'https://n26.com';\n    const CACHE_TIMEOUT = 1800;\n    const DESCRIPTION = 'Returns recent blog posts from N26.';\n\n    public function collectData()\n    {\n        $limit = 5;\n        $url = 'https://n26.com/en-eu/blog/all';\n        $html = getSimpleHTMLDOM($url);\n\n        $articles = $html->find('div[class=\"bl bm\"]');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $itemUrl = self::URI . $article->find('a', 1)->href;\n            $item['uri'] = $itemUrl;\n\n            $item['title'] = $article->find('a', 1)->plaintext;\n\n            $fullArticle = getSimpleHTMLDOM($item['uri']);\n\n            $createdAt = $fullArticle->find('time', 0);\n            $item['timestamp'] = strtotime($createdAt->plaintext);\n\n            $this->items[] = $item;\n            if (count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://n26.com/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/NACSouthGermanyMediaLibraryBridge.php",
    "content": "<?php\n\nclass NACSouthGermanyMediaLibraryBridge extends BridgeAbstract\n{\n    private const BASE_URI = 'https://www.nak-sued.de';\n\n    const NAME = 'NAK Süd Mediathek';\n    const DESCRIPTION = 'RSS Feed für die Runkfunkbeiträge der NAK Süd auf Bayern 2 und SWR 1.\n         (Technical note: This bridge might not work on certain server instances because of blacklisted IP ranges to the website.)';\n    const URI = self::BASE_URI . '/mediathek';\n    const MAINTAINER = 'R3dError';\n    const CACHE_TIMEOUT = 7200;\n\n    private const BAYERN2_ROOT_URI = self::BASE_URI . '/mediathek/rundfunksendungen-auf-bayern-2/aktuelle-sendungen';\n    private const SWR1_ROOT_URI = self::BASE_URI . '/mediathek/rundfunksendungen-auf-swr1/aktuelle-sendungen';\n\n    private const MONTHS = [\n        'Januar' => 1,\n        'Februar' => 2,\n        'März' => 3,\n        'April' => 4,\n        'Mai' => 5,\n        'Juni' => 6,\n        'Juli' => 7,\n        'August' => 8,\n        'September' => 9,\n        'Oktober' => 10,\n        'November' => 11,\n        'Dezember' => 12,\n    ];\n\n    public function getIcon()\n    {\n        return 'https://nak-sued.de/static/themes/sued/images/logo.png';\n    }\n\n    private static function parseTimestamp($title)\n    {\n        if (preg_match('/([0-9]+)\\.\\s*([^\\s]+)\\s*([0-9]+)/', $title, $matches)) {\n            $day = $matches[1];\n            $month = self::MONTHS[$matches[2]];\n            $year = $matches[3];\n            return $year . '-' . $month . '-' . $day;\n        } else {\n            return '';\n        }\n    }\n\n    private static function collectDataForSWR1($parent, $item)\n    {\n        # Find link\n        $sourceURI = $parent->find('a', 1)->href;\n        $item['enclosures'] = [self::BASE_URI . $sourceURI];\n\n        # Add time to timestamp\n        $item['timestamp'] .= ' 07:27';\n\n        # Find author\n        if (preg_match('/\\((.*?)\\)/', html_entity_decode($item['content']), $matches)) {\n            $item['author'] = $matches[1];\n        }\n\n        return $item;\n    }\n\n    private static function collectDataForBayern2($parent, $item)\n    {\n        # Find link\n        $relativeURICode = $parent->find('a', 0)->onclick;\n        if (preg_match('/window\\.open\\(\\'([^\\']*)\\'/', $relativeURICode, $matches)) {\n            $playerDom = getSimpleHTMLDOMCached(self::BASE_URI . $matches[1]);\n            $sourceURI = $playerDom->find('source', 0)->src;\n            $item['enclosures'] = [self::BASE_URI . $sourceURI];\n        }\n\n        # Add time to timestamp\n        $item['timestamp'] .= ' 06:45';\n\n        return $item;\n    }\n\n    private function collectDataInList($pageURI, $customizeItemCall)\n    {\n        $page = getSimpleHTMLDOM($pageURI);\n\n        foreach ($page->find('div.flex-columns.entry') as $parent) {\n            # Find title\n            $title = trim($parent->find('h2')[0]->innertext);\n\n            # Find content\n            $contentBlock = $parent->find('div')[2];\n            $content = '';\n            foreach ($contentBlock->find('li,p') as $li) {\n                $content .= '<p>' . $li->plaintext . '</p>';\n            }\n\n            $item = [\n                'title' => $title,\n                'content' => $content,\n                'timestamp' => self::parseTimestamp($title),\n            ];\n            $this->items[] = $customizeItemCall($parent, $item);\n        }\n    }\n\n    private function collectDataFromAllPages($rootURI, $customizeItemCall)\n    {\n        $rootPage = getSimpleHTMLDOM($rootURI);\n        $pages = $rootPage->find('div.flex-columns.inner_filter', 0);\n        foreach ($pages->find('a') as $page) {\n            self::collectDataInList($page->href, [$this, $customizeItemCall]);\n        }\n    }\n\n    public function collectData()\n    {\n        # Collect items\n        self::collectDataFromAllPages(self::BAYERN2_ROOT_URI, 'collectDataForBayern2');\n        self::collectDataFromAllPages(self::SWR1_ROOT_URI, 'collectDataForSWR1');\n\n        # Sort items by decreasing timestamp\n        usort($this->items, function ($a, $b) {\n            return strtotime($b['timestamp']) <=> strtotime($a['timestamp']);\n        });\n    }\n}\n"
  },
  {
    "path": "bridges/NFLRUSBridge.php",
    "content": "<?php\n\nclass NFLRUSBridge extends BridgeAbstract\n{\n    const NAME = 'NFLRUS';\n    const URI = 'http://nflrus.ru/';\n    const DESCRIPTION = 'Returns the recent articles published on nflrus.ru';\n    const MAINTAINER = 'Maxim Shpak';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n        $html = defaultLinkTo($html, self::URI);\n\n        $articles = $html->find('.big-post_content-col');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $url = $article->find('.big-post_title.card-title a', 0);\n\n            $item['uri'] = $url->href;\n            $item['title'] = $url->plaintext;\n            $item['content'] = $article->find('div', 0)->innertext;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NHKWorldJapanShowBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass NHKWorldJapanShowBridge extends BridgeAbstract\n{\n    const NAME = 'NHK World-Japan Show';\n    const URI = 'https://www3.nhk.or.jp';\n    const CACHE_TIMEOUT = 14400; // 4h\n    const DESCRIPTION = 'Returns available episodes from NHK World-Japan Shows';\n    const MAINTAINER = 'TReKiE';\n\n    const PARAMETERS = [\n        [\n            'show' => [\n                'name' => 'Name of show',\n                'type' => 'text',\n                'exampleValue' => 'catseye',\n                'required' => true,\n                'title' => 'Enter the name of the show as it appears in the URL, e.g. \"catseye\" for https://www3.nhk.or.jp/nhkworld/en/shows/catseye/'\n            ],\n            'language' => [\n                'name' => 'language',\n                'type' => 'list',\n                'title' => 'Language of the show',\n                'values' => [\n                    'English' => 'en',\n                    'العربية' => 'ar',\n                    'বাংলা' => 'bn',\n                    'မြန်မာဘာသာစကား' => 'my',\n                    '中文（简体）' => 'zh',\n                    '中文（繁體）' => 'zt',\n                    'Français' => 'fr',\n                    'हिन्दी' => 'hi',\n                    'Bahasa Indonesia' => 'id',\n                    '코리언' => 'ko',\n                    'فارسی' => 'fa',\n                    'Português' => 'pt',\n                    'Русский' => 'ru',\n                    'Español' => 'es',\n                    'Kiswahili' => 'sw',\n                    'ภาษาไทย' => 'th',\n                    'Türkçe' => 'tr',\n                    'Українська' => 'uk',\n                    'اردو' => 'ur',\n                    'Tiếng Việt' => 'vi'\n                ],\n                'defaultValue' => 'en'\n            ],\n            'embedoption' => [\n                'name' => 'Embed option',\n                'type' => 'list',\n                'title' => 'Choose to embed the NHK World-Japan video player, a static thumbnail, or no embedding for each episode',\n                'values' => [\n                    'Embed video player' => 'embed',\n                    'Thumbnail' => 'thumb',\n                    'None' => 'none'\n                ],\n                'defaultValue' => 'embed'\n            ]\n        ]\n    ];\n\n    protected static $labels = [\n        'length' => [\n            'ar' => 'المدة:',\n            'bn' => 'দৈর্ঘ্য:',\n            'en' => 'Length:',\n            'my' => 'အချိန်အရှည်:',\n            'zh' => '时长:',\n            'zt' => '時長:',\n            'fr' => 'Durée:',\n            'hi' => 'अवधि:',\n            'id' => 'Durasi:',\n            'ko' => '재생 시간:',\n            'fa' => 'مدت زمان:',\n            'pt' => 'Duração:',\n            'ru' => 'Длительность:',\n            'es' => 'Duración:',\n            'sw' => 'Urefu:',\n            'th' => 'ความยาว:',\n            'tr' => 'Süre:',\n            'uk' => 'Тривалість:',\n            'ur' => 'دورانیہ:',\n            'vi' => 'Thời lượng:'\n        ],\n        'broadcast' => [\n            'ar' => 'بث:',\n            'bn' => 'প্রচার:',\n            'en' => 'Broadcast:',\n            'my' => 'ထုတ်လွှင့်မှု:',\n            'zh' => '播出:',\n            'zt' => '播放:',\n            'fr' => 'Diffusion:',\n            'hi' => 'प्रसारण:',\n            'id' => 'Siaran:',\n            'ko' => '방송:',\n            'fa' => 'پخش:',\n            'pt' => 'Transmissão:',\n            'ru' => 'Трансляция:',\n            'es' => 'Emisión:',\n            'sw' => 'Matangazo:',\n            'th' => 'ออกอากาศ:',\n            'tr' => 'Yayın:',\n            'uk' => 'Трансляція:',\n            'ur' => 'نشریات:',\n            'vi' => 'Phát sóng:'\n        ],\n        'availableuntil' => [\n            'ar' => 'متاح حتى:',\n            'bn' => 'পর্যন্ত উপলব্ধ:',\n            'en' => 'Available until:',\n            'my' => 'ရရှိနိုင်သည်:',\n            'zh' => '可用至:',\n            'zt' => '可用至:',\n            'fr' => 'Disponible jusqu’au:',\n            'hi' => 'उपलब्ध है:',\n            'id' => 'Tersedia hingga:',\n            'ko' => '이용 가능:',\n            'fa' => 'در دسترس تا:',\n            'pt' => 'Disponível até:',\n            'ru' => 'Доступно до:',\n            'es' => 'Disponible hasta:',\n            'sw' => 'Inapatikana hadi:',\n            'th' => 'ใช้งานได้จนถึง:',\n            'tr' => 'Kullanılabilir:',\n            'uk' => 'Доступно до:',\n            'ur' => 'دستیاب ہے:',\n            'vi' => 'Có sẵn đến:'\n        ],\n        'watchdirectly' => [\n            'ar' => 'شاهد مباشرة على مشغل الفيديو',\n            'bn' => 'সরাসরি ভিডিও প্লেয়ারে দেখুন',\n            'en' => 'Watch on direct video player',\n            'my' => 'တိုက်ရိုက်ဗီဒီယိုပလေယာတွင်ကြည့်ပါ',\n            'zh' => '在直接视频播放器上观看',\n            'zt' => '在直接视频播放器上观看',\n            'fr' => 'Regarder sur le lecteur vidéo direct',\n            'hi' => 'प्रत्यक्ष वीडियो प्लेयर पर देखें',\n            'id' => 'Tonton di pemutar video langsung',\n            'ko' => '직접 비디오 플레이어에서 시청하기',\n            'fa' => 'مشاهده در پخش کننده ویدیویی مستقیم',\n            'pt' => 'Assista no reprodutor de vídeo direto',\n            'ru' => 'Смотреть на прямом видеоплеере',\n            'es' => 'Ver en reproductor de video directo',\n            'sw' => 'Tazama kwenye mchezaji wa video moja kwa moja',\n            'th' => 'ดูบนเครื่องเล่นวิดีโอโดยตรง',\n            'tr' => 'Doğrudan video oynatıcıda izle',\n            'uk' => 'Дивитися на прямому відеоплеєрі',\n            'ur' => 'براہ راست ویڈیو پلیئر پر دیکھیں',\n            'vi' => 'Xem trên trình phát video trực tiếp'\n        ],\n        'watchonplayer' => [\n            'ar' => 'شاهد على مشغل NHK World-Japan',\n            'bn' => 'NHK World-Japan প্লেয়ারে দেখুন',\n            'en' => 'Watch on NHK World-Japan player',\n            'my' => 'NHK World-Japan ပလေယာတွင်ကြည့်ပါ',\n            'zh' => '在NHK World-Japan播放器上观看',\n            'zt' => '在NHK World-Japan播放器上观看',\n            'fr' => 'Regarder sur le lecteur NHK World-Japan',\n            'hi' => 'NHK World-Japan प्लेयर पर देखें',\n            'id' => 'Tonton di pemutar NHK World-Japan',\n            'ko' => 'NHK World-Japan 플레이어에서 시청하기',\n            'fa' => 'مشاهده در پخش کننده NHK World-Japan',\n            'pt' => 'Assista no reprodutor NHK World-Japan',\n            'ru' => 'Смотреть на плеере NHK World-Japan',\n            'es' => 'Ver en reproductor de NHK World-Japan',\n            'sw' => 'Tazama kwenye mchezaji wa NHK World-Japan',\n            'th' => 'ดูบนเครื่องเล่น NHK World-Japan',\n            'tr' => 'NHK World-Japan oynatıcısında izle',\n            'uk' => 'Дивитися на плеєрі NHK World-Japan',\n            'ur' => 'NHK World-Japan پلیئر پر دیکھیں',\n            'vi' => 'Xem trên trình phát NHK World-Japan'\n        ]\n    ];\n\n    protected static $rtlLanguages = [\n        'ar','fa','ur'\n    ];\n\n    public function getURI()\n    {\n        if (($this->getInput('show')) && ($this->getInput('language'))) {\n            return self::URI . '/nhkworld/' . $this->getInput('language') . '/shows/' . $this->getInput('show') . '/';\n        }\n\n        return parent::getURI() . '/nhkworld/';\n    }\n\n    public function getName()\n    {\n        if (($this->getInput('show')) && ($this->getInput('language'))) {\n            $html = getSimpleHTMLDOMCached($this->getURI());\n            return html_entity_decode($html->find('meta[property=\"og:title\"]', 0)->content, ENT_QUOTES, 'UTF-8');\n        }\n\n        return parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return 'https://www3.nhk.or.jp/nhkworld/common/site_images/nw_webapp.ico';\n    }\n\n    public function collectData()\n    {\n        $json = getContents('https://api.nhkworld.jp/nwapi/vodesdlist/v7b/program/' . $this->getInput('show') . '/' . $this->getInput('language') . '/all/all.json');\n        $data = json_decode($json, true);\n\n        if (isset($data['data']['episodes']) && is_array($data['data']['episodes'])) {\n            foreach ($data['data']['episodes'] as $program) {\n                $title = $program['sub_title_clean'] ?? '';\n                $author = $program['title_clean'] ?? '';\n                $description = $program['description'] ?? '';\n                $url = $program['url'];\n                $vod_id = $program['vod_id'];\n                $iframeurl = self::URI . '/nhkworld/common/player/tv/vod/world/player/?opid=' . $vod_id;\n                $movielength = $program['movie_lengh'] ?? 'Unknown length';\n                $onair = $program['onair'] ?? round(microtime(true) * 1000);\n                $vod_to = $program['vod_to'] ?? round(microtime(true) * 1000);\n\n                switch ($this->getInput('embedoption')) {\n                    case 'embed':\n                        $embedhtml = '<iframe src=\"' . $iframeurl . '\" width=\"640\" height=\"360\" frameborder=\"0\" allowfullscreen referrerpolicy=\"no-referrer\">';\n                        $embedhtml .= '<img src=\"' . self::URI . $program['image'] . '\" alt=\"Video thumbnail\" width=\"640\" height=\"360\"></iframe><br><br>';\n                        break;\n                    case 'thumb':\n                        $embedhtml = '<img src=\"' . self::URI . $program['image'] . '\" alt=\"Video thumbnail\"><br><br>';\n                        break;\n                    default:\n                        $embedhtml = '';\n                }\n\n                $dt = new DateTime('@' . ($onair / 1000));\n                $dt->setTimezone(new DateTimeZone('UTC'));\n                $broadcastdate = ($this->getInput('language') === 'en') ? $dt->format('F j, Y') : $dt->format('Y-m-d');\n                $voddate = ($this->getInput('language') === 'en') ? date('F j, Y', $vod_to / 1000) : date('Y-m-d', $vod_to / 1000);\n                $spantag = '<span dir=\"' . (in_array($this->getInput('language'), self::$rtlLanguages) ? 'rtl' : 'ltr') . '\">';\n\n                $description = $spantag . $description;\n                $description .= '<br><br>';\n                $description .= $this->getLocaleString('length') . ' ' . $movielength . '<br>';\n                $description .= $this->getLocaleString('broadcast') . ' ' . $broadcastdate . ' UTC <br> ' . $this->getLocaleString('availableuntil') . ' ' . $voddate . '<br><br>';\n\n                $description .= $embedhtml;\n\n                $description .= '<a href=\"' . $iframeurl . '\" referrerpolicy=\"no-referrer\">' . $this->getLocaleString('watchdirectly') . '</a>';\n                $description .= '<br><a href=\"' . self::URI . $url . '\" referrerpolicy=\"no-referrer\">' . $this->getLocaleString('watchonplayer') . '</a>';\n                $description .= '</span>';\n\n                $item = [];\n                $item['uri'] = self::URI . $url;\n                $item['uid'] = self::URI . $url;\n                $item['title'] = $title;\n                $item['author'] = $author;\n                $item['timestamp'] = $onair / 1000;\n                $item['content'] = $description;\n\n                $this->items[] = $item;\n            }\n        } else {\n            throw new \\Exception('Could not find the episodes for this show. Please create a new GitHub issue if this is unexpected.');\n        }\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n        $regex = '/^(https?:\\/\\/)?(www3\\.)?nhk\\.or\\.jp\\/nhkworld\\/(?<language>[a-z]{2})\\/shows\\/(?<show>[a-zA-Z0-9_-]+)\\/?$/';\n\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['language'] = $matches['language'];\n            $params['show'] = $matches['show'];\n            return $params;\n        }\n\n        return null;\n    }\n\n    protected function getLocaleString($string)\n    {\n        $language = $this->getInput('language');\n        if (isset(self::$labels[$string][$language])) {\n            return self::$labels[$string][$language];\n        }\n\n        if (isset(self::$labels[$string]['en'])) {\n            return self::$labels[$string]['en'];\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "bridges/NOSBridge.php",
    "content": "<?php\n\nclass NOSBridge extends BridgeAbstract\n{\n    const NAME = 'NOS Nieuws & Sport';\n    const URI = 'https://www.nos.nl';\n    const DESCRIPTION = 'NOS Nieuws & Sport';\n    const MAINTAINER = 'wouterkoch';\n\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'type' => 'list',\n                'name' => 'Onderwerp',\n                'title' => 'Kies onderwerp',\n                'values' => [\n                    'Laatste nieuws' => 'nieuws/laatste',\n                    'Binnenland' => 'nieuws/binnenland',\n                    'Buitenland' => 'nieuws/buitenland',\n                    'Regionaal nieuws' => 'nieuws/regio',\n                    'Politiek' => 'nieuws/politiek',\n                    'Economie' => 'nieuws/economie',\n                    'Koningshuis' => 'nieuws/koningshuis',\n                    'Tech' => 'nieuws/tech',\n                    'Cultuur en media' => 'nieuws/cultuur-en-media',\n                    'Opmerkelijk' => 'nieuws/opmerkelijk',\n                    'Voetbal' => 'sport/voetbal',\n                    'Formule 1' => 'sport/formule-1',\n                    'Wielrennen' => 'sport/wielrennen',\n                    'Schaatsen' => 'sport/schaatsen',\n                    'Tennis' => 'sport/tennis',\n                ],\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = sprintf('https://www.nos.nl/%s', $this->getInput('topic'));\n        $dom = getSimpleHTMLDOM($url);\n        $dom = $dom->find('main#content > div > section > ul', 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('li') as $article) {\n            $this->items[] = [\n                'title' => $article->find('h2', 0)->plaintext,\n                'uri' => $article->find('a', 0)->href,\n                'content' => $article->find('p', 0)->plaintext,\n                'timestamp' => strtotime($article->find('time', 0)->datetime),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NPRBridge.php",
    "content": "<?php\n\nclass NPRBridge extends FeedExpander\n{\n    const MAINTAINER = 'phantop';\n    const NAME = 'NPR';\n    const URI = 'https://www.npr.org/';\n    const DESCRIPTION = 'Returns the latest articles from NPR';\n    const PARAMETERS = [[\n        'section' => [\n            'name' => 'Site section',\n            'type' => 'list',\n            'defaultValue' => '1002',\n            // Obtained from https://legacy.npr.org/list?date=2024-05-05&id=\n            // With ids: 3002 (Topics), 3004 (Programs), 3006 (Series)\n            // Feeds cleaned up to exclude all that hadn't updated this year\n            'values' => [\n                'All Things Considered' => '2',\n                'Morning Edition' => '3',\n                'Weekend Edition Saturday' => '7',\n                'Weekend Edition Sunday' => '10',\n                'Fresh Air' => '13',\n                'Wait Wait...Don\\'t Tell Me!' => '35',\n                'TED Radio Hour' => '57',\n                'News' => '1001',\n                'Home Page Top Stories' => '1002',\n                'National' => '1003',\n                'World' => '1004',\n                'Business' => '1006',\n                'Science' => '1007',\n                'Culture' => '1008',\n                'Middle East' => '1009',\n                'Education' => '1013',\n                'Politics' => '1014',\n                'Race' => '1015',\n                'Religion' => '1016',\n                'Economy' => '1017',\n                'Your Money' => '1018',\n                'Technology' => '1019',\n                'Media' => '1020',\n                'Research News' => '1024',\n                'Environment' => '1025',\n                'Space' => '1026',\n                'Health Care' => '1027',\n                'On Aging' => '1028',\n                'Mental Health' => '1029',\n                'Children\\'s Health' => '1030',\n                'Global Health' => '1031',\n                'Books' => '1032',\n                'Author Interviews' => '1033',\n                'Book Reviews' => '1034',\n                'Music' => '1039',\n                'Movies' => '1045',\n                'Performing Arts' => '1046',\n                'Art & Design' => '1047',\n                'Pop Culture' => '1048',\n                'Humor & Fun' => '1052',\n                'Food' => '1053',\n                'Sports' => '1055',\n                'Opinion' => '1057',\n                'Analysis' => '1059',\n                'Obituaries' => '1062',\n                'Your Health' => '1066',\n                'Law' => '1070',\n                'Studio Sessions' => '1103',\n                'Music Reviews' => '1104',\n                'Music Interviews' => '1105',\n                'Music News' => '1106',\n                'Music Lists' => '1107',\n                'New Music' => '1108',\n                'Concerts' => '1109',\n                'Music Videos' => '1110',\n                'National Security' => '1122',\n                'Europe' => '1124',\n                'Asia' => '1125',\n                'Africa' => '1126',\n                'The Americas' => '1127',\n                'Health' => '1128',\n                'Energy' => '1131',\n                'Animals' => '1132',\n                'On Disabilities' => '1133',\n                'Fitness & Nutrition' => '1134',\n                'Medical Treatments' => '1135',\n                'History' => '1136',\n                'Movie Interviews' => '1137',\n                'Television' => '1138',\n                'Recipes' => '1139',\n                'Fine Art' => '1141',\n                'Architecture' => '1142',\n                'Photography' => '1143',\n                'Theater' => '1144',\n                'Dance' => '1145',\n                'Strange News' => '1146',\n                'Investigations' => '1150',\n                'Music Quizzes' => '1151',\n                'Book News & Features' => '1161',\n                'TV Reviews' => '1163',\n                'Family' => '1164',\n                'Weather' => '1165',\n                'Perspective' => '1166',\n                'Climate' => '1167',\n                'Press Releases and Statements' => '750003',\n                'Movie Reviews' => '4467349',\n                'Sunday Puzzle' => '4473090',\n                'Simon Says' => '4495795',\n                'StoryCorps' => '4516989',\n                '\\'Not My Job\\'' => '5163715',\n                'Tiny Desk' => '92071316',\n                'Jazz' => '92756586',\n                'Pop Culture Happy Hour' => '93568166',\n                'Planet Money' => '94427042',\n                'The Thistle & Shamrock' => '103063413',\n                'Fresh Air Weekend' => '139029251',\n                'Elections' => '139482413',\n                'Presidential Race' => '139544303',\n                'World Cafe: Sense Of Place' => '142680413',\n                'Jazz Night In America' => '347139849',\n                'Jazz Night In America: The Radio Program' => '347174538',\n                'Planet Money Buys Gold' => '377029766',\n                'Music Features' => '613820055',\n                'Bill Of The Month' => '651784144',\n                'Student Podcast Challenge' => '662609200',\n                'Life Kit' => '676529561',\n                'Picture This' => '787467815',\n                'Gaming' => '820266919',\n                'Games' => '820593993',\n                'Health Reporting in the States' => '914131100',\n                'Untangling Disinformation' => '973275370',\n                'Pride Month' => '1002248299',\n                'Planet Money Summer School' => '1015448333',\n                'What\\'s Making Us Happy' => '1019281468',\n                'Native American Heritage Month' => '1047406725',\n                'Podcast Recommendations' => '1068304478',\n                'Tiny Desk Contest' => '1072544367',\n                'Ukraine invasion — explained' => '1082539802',\n                'Reproductive rights in America' => '1096684820',\n                'My Unsung Hero' => '1134955065',\n                'The NPR news quiz' => '1146192567',\n                'Video Game Reviews' => '1175242824',\n                'Gaming Culture' => '1175243560',\n                'Up First Newsletter' => '1180232252',\n                'Up First' => '1182407811',\n                'Body Electric' => '1199526213',\n                'Interview highlights' => '1200383155',\n                'Middle East Crisis — explained' => '1205445976',\n                'The Sunday Story from Up First' => '1213771050',\n                'Life Kit\\'s guide to emergency preparedness' => '1217925264',\n                'Code Switch: Perspectives' => '1223739304',\n                'How to Thrive as You Age' => '1225474023',\n                'Time Machine: The Throughline History Quiz' => '1233646427',\n                'We, The Voters' => '1241382501',\n                'The Science of Siblings' => '1241438370',\n                'Throughline: Constitutional Amendments' => '1242285011',\n                'NPR Investigations: Off The Mark' => '1245316423',\n                'Campus protests over the Gaza war' => '1248184956',\n                'UAW Goes South' => '1250012704',\n                'Books We Love' => '1251857292',\n                'NPR\\'s Embedded: Supermajority ' => '1254807812',\n                'Throughline: The Middle East Conflict' => '1255058395',\n            ]\n        ]\n    ]];\n\n    public function getIcon()\n    {\n        return 'https://media.npr.org/chrome/favicon/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $url = 'https://feeds.npr.org/' . $this->getInput('section') . '/rss.xml';\n        $this->collectExpandableDatas($url, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $html = defaultLinkTo($html, self::URI);\n        $text = $html->find('#storytext', 0);\n\n        // a bit of a cheat to offer the text-only alternative url\n        $item['comments'] = preg_replace('/www/', 'text', $item['uri']);\n\n        // clean up related articles, duplicate image credit and enlarged versions\n        $ads = 'aside.ad-wrap, span.credit, .bucket.img';\n        $enlarge = '.enlarge-options, .enlarge_measure, .enlarge_html';\n        foreach ($text->find(\"$ads, $enlarge\") as $ad) {\n            $ad->remove();\n        }\n\n        $item['content'] = preg_replace('/(hide|toggle) caption/', '', $text);\n\n        // get tags, program/series names\n        $item['categories'] = [];\n        $tags = '.tag, .program-block > a, .branding__title, article h3.slug';\n        foreach ($html->find($tags) as $tag) {\n            $item['categories'][] = $tag->plaintext;\n        }\n        $item['categories'] = array_unique($item['categories']);\n\n        // fetch audios and transcripts\n        $item['enclosures'] = [];\n        foreach ($html->find('.audio-tool > a') as $audio) {\n            $item['enclosures'][] = $audio->href;\n        }\n        foreach ($html->find('[data-audio]') as $audio) {\n            $json_text = $audio->getAttribute('data-audio');\n            $json = Json::decode(html_entity_decode($json_text), true);\n            $item['enclosures'][] = base64_decode($json['audioUrl']);\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/NYTBridge.php",
    "content": "<?php\n\nclass NYTBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'New York Times';\n    const URI = 'https://www.nytimes.com/';\n    const CACHE_TIMEOUT = 900; // 15 minutes\n    const DESCRIPTION = 'RSS feed for the New York Times';\n\n    public function collectData()\n    {\n        $url = 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml';\n        $this->collectExpandableDatas($url, 40);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article = '';\n\n        try {\n            $articlePage = getSimpleHTMLDOM($item['uri']);\n        } catch (HttpException $e) {\n            // 403 Forbidden, This means we got anti-bot response\n            if ($e->getCode() === 403) {\n                return $item;\n            }\n            throw $e;\n        }\n        // handle subtitle\n        $subtitle = $articlePage->find('p.css-w6ymp8', 0);\n        if ($subtitle != null) {\n            $article .= '<strong>' . $subtitle->plaintext . '</strong>';\n        }\n\n        // figure contain's the main article image\n        $article .= $articlePage->find('figure', 0) . '<br />';\n\n        // section.meteredContent has the actual article\n        foreach ($articlePage->find('section.meteredContent p') as $element) {\n            $article .= '' . $element . '';\n        }\n\n        $item['content'] = $article;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/NasaApodBridge.php",
    "content": "<?php\n\nclass NasaApodBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'NASA APOD';\n    const URI = 'https://apod.nasa.gov/apod/';\n    const CACHE_TIMEOUT = 43200; // 12h\n    const DESCRIPTION = 'Returns the 3 latest NASA APOD pictures and explanations';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI . 'archivepix.html');\n\n        // Start at 1 to skip the \"APOD Full Archive\" on top of the page\n        for ($i = 1; $i < 4; $i++) {\n            $item = [];\n\n            $uri_page = $html->find('a', $i + 3)->href;\n            $uri = self::URI . $uri_page;\n            $item['uri'] = $uri;\n\n            $picture_html = getSimpleHTMLDOM($uri);\n            $picture_html_string = $picture_html->innertext;\n\n            //Extract image and explanation\n            $image_wrapper = $picture_html->find('a', 1);\n            $image_path = $image_wrapper->href;\n            // This image is not present when youtube embed\n            $img_placeholder = $image_wrapper->find('img', 0);\n            $img_alt = $img_placeholder->alt ?? '';\n            $img_style = $img_placeholder->style ?? '';\n            $image_uri = self::URI . $image_path;\n            $new_img_placeholder = \"<img src=\\\"$image_uri\\\" alt=\\\"$img_alt\\\" style=\\\"$img_style\\\">\";\n            $media = \"<a href=\\\"$image_uri\\\">$new_img_placeholder</a>\";\n            $explanation = $picture_html->find('p', 2)->innertext;\n\n            //Extract date from the picture page\n            $date = explode(' ', $picture_html->find('p', 1)->innertext);\n            $item['timestamp'] = strtotime($date[4] . $date[3] . $date[2]);\n\n            //Other informations\n            $item['content'] = $media . '<br />' . $explanation;\n            $item['title'] = $picture_html->find('b', 0)->innertext;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NasestrechaBridge.php",
    "content": "<?php\n\n/**\n *\n * NaseStrecha.cz is a specialized Czech news and advice portal focusing on roofs, construction, and home improvement, offering reliable expert guidance on roofing materials, insulation, and energy-saving techniques nasestrecha.cz . It is run by the team behind the Strechy-Solar-Remeslo trade fair and includes up-to-date news, practical tips, and industry events..\n *\n */\n\nclass NasestrechaBridge extends BridgeAbstract\n{\n    const NAME = 'Nasestrecha';\n    const URI = 'https://www.nasestrecha.cz/';\n    const DESCRIPTION = 'Articles from Nasestrecha.cz news site - Czech Republic / Spolehlivé informace pro Vaší střechu i stavbu';\n    const MAINTAINER = 'pprenghyorg';\n\n    // Only Articles are supported\n    const PARAMETERS = [\n        'Articles, news and reviews from from construction and housing' => [\n        ],\n    ];\n\n    /**\n     * Fetches and processes data based on the selected context.\n     *\n     * This function retrieves the HTML content for the specified context's URI,\n     * resolves relative links within the content, and then delegates the data\n     * extraction to the appropriate method (currently only `collectNews`).\n     */\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        defaultLinkTo($html, static::URI);\n\n        // Router\n        switch ($this->queriedContext) {\n            case 'Articles, news and reviews from from construction and housing':\n                $this->collectNews($html);\n                break;\n        }\n    }\n\n    /**\n     * Returns the icon for the bridge.\n     *\n     * @return string The icon URL.\n     */\n    public function getURI()\n    {\n        $uri = static::URI;\n\n        // URI Router\n        switch ($this->queriedContext) {\n            case 'Articles, news and reviews from from construction and housing':\n                $uri .= 'clanky/';\n                break;\n        }\n\n        return $uri;\n    }\n\n    /**\n     * Returns the name for the bridge.\n     *\n     * @return string The Name.\n     */\n    public function getName()\n    {\n        $name = static::NAME;\n\n        $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : '';\n\n        switch ($this->queriedContext) {\n            case 'Articles, news and reviews from from construction and housing':\n                break;\n        }\n\n        return $name;\n    }\n\n    /**\n     * Parse most used date formats\n     *\n     * Basically strtotime doesn't convert dates correctly due to formats\n     * being hard to interpret. So we use the DateTime object, manually\n     * fixing dates and times (set to 00:00:00.000).\n     *\n     * We don't know the timezone, so just assume +00:00 (or whatever\n     * DateTime chooses)\n     */\n    private function fixDate($date)\n    {\n        $df = $this->parseDateTimeFromString($date);\n\n        return date_format($df, 'U');\n    }\n\n    /**\n     * Extracts the images from the article.\n     *\n     * @param object $article The article object.\n     * @return array An array of image URLs.\n     */\n    private function extractImages($article)\n    {\n        // Notice: We can have zero or more images (though it should mostly be 1)\n        $elements = $article->find('img');\n\n        $images = [];\n\n        foreach ($elements as $img) {\n            $images[] = $img->src;\n        }\n\n        return $images;\n    }\n\n    #region Articles\n\n    /**\n     * Collects uri, timestamp, title, content and images in the news articles from the HTML and transforms to rss.\n     *\n     * @param object $html The HTML object.\n     * @return void\n     */\n    private function collectNews($html)\n    {\n        // Check if page contains articles\n        $articles = $html->find('.post')\n            or throwServerException('No articles found! Layout might have changed!');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $item['uri'] = $this->extractNewsUri($article);\n            $item['timestamp'] = $this->extractNewsDate($article);\n            $item['title'] = $this->extractNewsTitle($article);\n            $item['content'] = $this->extractNewsDescription($article);\n            $item['enclosures'] = $this->extractImages($article);\n\n            // collect sources into rss article\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Extracts the URI of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The URI of the news article.\n     */\n    private function extractNewsUri($article)\n    {\n        // Return URI of the article\n        $element = $article->find('.thumbnail', 0)\n            or throwServerException('Anchor not found!');\n\n        return $element->href;\n    }\n\n    /**\n     * Extracts the date of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The date of the news article.\n     */\n    private function extractNewsDate($article)\n    {\n        // Check if date is set\n        $element = $article->find('div.post__info', 0)->find('span', 0)\n            or throwServerException('Date not found!');\n\n        $date = trim(explode('|', $element->plaintext)[0]);\n\n        // Format date\n        return $this->fixDate($date);\n    }\n\n    /**\n     * Extracts the description of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The description of the news article.\n     */\n    private function extractNewsDescription($article)\n    {\n        // Extract description\n        $element = $article->find('p.post__text', 0)\n            or throwServerException('Description not found!');\n\n        return $element->innertext;\n    }\n\n    /**\n     * Extracts the title of the news article.\n     *\n     * @param object $article The article object.\n     * @return string The title of the news article.\n     */\n    private function extractNewsTitle($article)\n    {\n        // Extract title\n        $element = $article->find('a.post__title', 0)\n            or throwServerException('Title not found!');\n\n        return $element->plaintext;\n    }\n\n    /**\n     * It attempts to recognize the date/time format in a string and create a DateTime object.\n     *\n     * It goes through the list of defined formats and tries to apply them to the input string.\n     * Returns the first successfully parsed DateTime object that matches the entire string.\n     *\n     * @param string $dateString A string potentially containing a date and/or time.\n     * @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null.\n     */\n    private function parseDateTimeFromString(string $dateString): ?DateTime\n    {\n        // List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs!\n        // Order may matter if the formats are ambiguous.\n        // It is recommended to give more specific formats (with time, full year) before more general ones.\n        $possibleFormats = [\n            // Czech formats (day.month.year)\n            'd.m.Y H:i:s',  // 10.04.2025 10:57:47\n            'j.n.Y H:i:s',  // 10.4.2025 10:57:47\n            'd. m. Y H:i:s', // 10. 04. 2025 10:57:47\n            'j. n. Y H:i:s', // 10. 4. 2025 10:57:47\n            'd.m.Y H:i',    // 10.04.2025 10:57\n            'j.n.Y H:i',    // 10.4.2025 10:57\n            'd. m. Y H:i',   // 10. 04. 2025 10:57\n            'j. n. Y H:i',   // 10. 4. 2025 10:57\n            'd.m.Y',        // 10.04.2025\n            'j.n.Y',        // 10.4.2025\n            'd. m. Y',       // 10. 04. 2025\n            'j. n. Y',       // 10. 4. 2025\n\n            // ISO 8601 and international formats (year-month-day)\n            'Y-m-d H:i:s',  // 2025-04-10 10:57:47\n            'Y-m-d H:i',    // 2025-04-10 10:57\n            'Y-m-d',        // 2025-04-10\n            'YmdHis',       // 20250410105747\n            'Ymd',          // 20250410\n\n            // American formats (month/day/year) - beware of ambiguity!\n            'm/d/Y H:i:s',  // 04/10/2025 10:57:47\n            'n/j/Y H:i:s',  // 4/10/2025 10:57:47\n            'm/d/Y H:i',    // 04/10/2025 10:57\n            'n/j/Y H:i',    // 4/10/2025 10:57\n            'm/d/Y',        // 04/10/2025\n            'n/j/Y',        // 4/10/2025\n\n            // Standard formats (including time zone)\n            DateTime::ATOM,             // example. 2025-04-10T10:57:47+02:00\n            DateTime::RFC3339,          // example. 2025-04-10T10:57:47+02:00\n            DateTime::RFC3339_EXTENDED, // example. 2025-04-10T10:57:47.123+02:00\n            DateTime::RFC2822,          // example. Thu, 10 Apr 2025 10:57:47 +0200\n            DateTime::ISO8601,          // example. 2025-04-10T105747+0200\n            'Y-m-d\\TH:i:sP',            // ISO 8601 s 'T' oddělovačem\n            'Y-m-d\\TH:i:s.uP',          // ISO 8601 s mikrosekundami\n\n            // You can add more formats as needed...\n            // e.g. 'd-M-Y' (10-Apr-2025) - requires English locale\n            // e.g. 'j. F Y' (10. abren 2025) - requires Czech locale\n        ];\n\n            // Set locale for parsing month/day names (if using F, M, l, D)\n            // E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8');\n\n        foreach ($possibleFormats as $format) {\n            // We will try to create a DateTime object from the given format\n            $dateTime = DateTime::createFromFormat($format, $dateString);\n\n            // We check that the parsing was successful AND ALSO\n            // that there were no errors or warnings during the parsing.\n            // This is important to ensure that the format matches the ENTIRE string.\n            if ($dateTime !== false) {\n                $errors = DateTime::getLastErrors();\n                if (!($errors)) {\n                    // Success! We found a valid format for the entire string.\n                    return $dateTime;\n                }\n            }\n        }\n\n        // If no format matches or parsing failed\n        return null;\n    }\n\n    #endregion\n}"
  },
  {
    "path": "bridges/NationalGeographicBridge.php",
    "content": "<?php\n\nclass NationalGeographicBridge extends BridgeAbstract\n{\n    const CONTEXT_BY_TOPIC = 'By Topic';\n    const PARAMETER_TOPIC = 'topic';\n    const PARAMETER_FULL_ARTICLE = 'full';\n    const TOPIC_MAGAZINE = 'Magazine';\n    const TOPIC_LATEST_STORIES = 'Latest Stories';\n    const CACHE_TIMEOUT = 900; //15 min\n\n    const NAME = 'National Geographic';\n    const URI = 'https://www.nationalgeographic.com/';\n    const DESCRIPTION = 'Fetches the latest articles from the National Geographic Magazine';\n    const MAINTAINER = 'csisoap';\n    const PARAMETERS = [\n        self::CONTEXT_BY_TOPIC => [\n            self::PARAMETER_TOPIC => [\n                'name' => 'Topic',\n                'type' => 'list',\n                'values' => [\n                    self::TOPIC_MAGAZINE => 'magazine',\n                    self::TOPIC_LATEST_STORIES => 'latest-stories'\n                ],\n                'title' => 'Select your topic',\n                'defaultValue' => 'Magazine'\n            ]\n        ],\n        'global' => [\n            self::PARAMETER_FULL_ARTICLE => [\n                'name' => 'Full Article',\n                'type' => 'checkbox',\n                'title' => 'Enable to load full articles and other infos (takes longer)'\n            ]\n        ]\n    ];\n\n    private $topicName = '';\n    const CONTEXT = 'eyJjb250ZW50VHlwZSI6IlVuaXNvbkh1YiIsInZhcmlhYmxlcyI6eyJsb2NhdG9yIjoiL3BhZ2VzL3\n\t\t\tRvcGljL2xhdGVzdC1zdG9yaWVzIiwicG9ydGZvbGlvIjoibmF0Z2VvIiwicXVlcn\n\t\t\tlUeXBlIjoiTE9DQVRPUiJ9LCJtb2R1bGVJZCI6bnVsbH0';\n    const LATEST_STORIES_ID = [\n        '1df278bb-0e3d-4a67-a0ce-8fae48392822-f2-m1'\n    ];\n    const MAGAZINE_ID = [\n        '94d87d74-f41a-4a32-9acd-b591ba2df288-f2-m1',\n        '94d87d74-f41a-4a32-9acd-b591ba2df288-f5-m2',\n    ];\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_BY_TOPIC:\n                return self::URI . $this->getInput(self::PARAMETER_TOPIC);\n            default:\n                return parent::getURI();\n        }\n    }\n\n    private function getAPIURL($id)\n    {\n        $context = preg_replace('/\\s*/m', '', self::CONTEXT);\n        $url = 'https://www.nationalgeographic.com/proxy/hub?context='\n                        . $context . '&id=' . $id\n                        . '&moduleType=InfiniteFeedModule&_xhr=pageContent';\n        return $url;\n    }\n\n    public function collectData()\n    {\n        $this->topicName = $this->getTopicName($this->getInput(self::PARAMETER_TOPIC));\n        switch ($this->topicName) {\n            case self::TOPIC_MAGAZINE:\n                return $this->collectMagazine();\n            case self::TOPIC_LATEST_STORIES:\n                return $this->collectLatestStories();\n            default:\n                throwServerException('Unknown topic: \"' . $this->topicName . '\"');\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_BY_TOPIC:\n                return static::NAME . ': ' . $this->topicName;\n            default:\n                return parent::getName();\n        }\n    }\n\n    private function getTopicName($topic)\n    {\n        return array_search($topic, static::PARAMETERS[self::CONTEXT_BY_TOPIC][self::PARAMETER_TOPIC]['values']);\n    }\n\n    private function collectMagazine()\n    {\n        $stories = [];\n\n        foreach (self::MAGAZINE_ID as $id) {\n            $uri = $this->getAPIURL($id);\n\n            $json_raw = getContents($uri);\n\n            $json = json_decode($json_raw, true)['tiles'];\n            $stories = array_merge($json, $stories);\n        }\n\n        foreach ($stories as $story) {\n            $this->addStory($story);\n        }\n    }\n\n    private function collectLatestStories()\n    {\n        $stories = [];\n\n        foreach (self::LATEST_STORIES_ID as $id) {\n            $uri = $this->getAPIURL($id);\n\n            $json_raw = getContents($uri);\n\n            $json = json_decode($json_raw, true)['tiles'];\n            $stories = array_merge($stories, $json);\n        }\n\n        foreach ($stories as $story) {\n            $this->addStory($story);\n        }\n    }\n\n    private function addStory($story)\n    {\n        $title = 'Unknown title';\n        $content = '';\n        $story_type = '';\n        $uri = '';\n\n        foreach ($story['ctas'] as $component) {\n            $uri = $component['url'];\n            $story_type = $component['icon'];\n        }\n\n        $item = [];\n        if (isset($story['description'])) {\n            $content = '<p>' . $story['description'] . '</p>';\n        }\n        $title = $story['title'];\n        $item['uri'] = $uri;\n        $item['title'] = $story['title'];\n\n        // if full article is requested!\n        if ($this->getInput(self::PARAMETER_FULL_ARTICLE)) {\n            if ($story_type != 'interactive') {\n                /* Nat Geo doesn't provided much info about interactive page\n                *       and it requires JS to load the interactive.\n                */\n                $article_data = $this->getFullArticle($item['uri']);\n                $item['timestamp'] = $article_data['published_date'];\n                $item['author'] = $article_data['authors'];\n                $item['content'] = $content . $article_data['content'];\n            } else {\n                $item['content'] = $content;\n            }\n        } else {\n            $item['content'] = $content;\n        }\n\n        $image = $story['img'];\n        $item['enclosures'][] = str_replace(' ', '%20', $image['src']);\n\n        foreach ($story['tags'] as $tag) {\n            $item['categories'][] = $tag['name'] ?? $tag;\n        }\n\n        $this->items[] = $item;\n    }\n\n    private function filterArticleData($data)\n    {\n        $article_module = array_filter(\n            $data,\n            function ($item) {\n                if (isset($item['id']) && $item['id'] == 'natgeo-template1-frame-1') {\n                    return true;\n                }\n            }\n        );\n\n        $article_data = array_reduce(\n            $article_module,\n            function (array $carry, array $item) {\n                $module = $item['mods'];\n                return array_merge(\n                    $carry,\n                    array_filter(\n                        $module,\n                        function ($data) {\n                            return $data['id'] == 'natgeo-template1-frame-1-module-1';\n                        }\n                    )\n                );\n            },\n            []\n        );\n\n        return $article_data[0];\n    }\n\n    private function handleImages($image_module, $image_type)\n    {\n        $image_alt = '';\n        $image_credit = '';\n        $image_src = '';\n        $image_caption = '';\n        $caption = '';\n        switch ($image_type) {\n            case 'image':\n            case 'imagegroup':\n                $image = $image_module['image'] ?? null;\n                if (!$image) {\n                    return '';\n                }\n                $image_src = $image['src'];\n                if (isset($image_module['alt'])) {\n                    $image_alt = $image_module['alt'];\n                } elseif (isset($image['altText'])) {\n                    $image_alt = $image['altText'];\n                }\n                if (isset($image['crdt'])) {\n                    $image_credit = $image['crdt'];\n                }\n                $caption = ($image_module['caption'] ?? '');\n                break;\n            case 'photogallery':\n                $image_credit = ($image_module['caption']['credit'] ?? '');\n                $caption = $image_module['caption']['text'];\n                $image_src = $image_module['img']['src'];\n                $image_alt = $image_module['img']['altText'];\n                break;\n            case 'video':\n                $image_credit = ($image_module['credit'] ?? '');\n                $description = ($image_module['description'] ?? '');\n                $caption = $description . ' Video can be watched on the article\\'s page';\n                $image = $image_module['image'];\n                $image_alt = $image['altText'];\n                $image_src = $image['src'];\n        }\n\n        $image_caption = $caption . ' ' . $image_credit\n                    . '. Notes: Some image may have copyrighted on it.';\n        $wrapper = <<<EOD\n<figure>\n<img src=\"{$image_src}\" alt=\"{$image_alt}\">\n<figcaption>$image_caption</figcaption>\n</figure>\nEOD;\n        return $wrapper;\n    }\n\n    private function getFullArticle($uri)\n    {\n        $html = getContents($uri);\n\n        $scriptRegex = '/window\\[\\'__natgeo__\\'\\]=(.*);<\\/script>/';\n\n        preg_match($scriptRegex, $html, $matches, PREG_OFFSET_CAPTURE, 0);\n\n        $json = json_decode($matches[1][0], true);\n\n        if (isset($json['page']['content']['article']['frms'])) {\n            $unfiltered_data = $json['page']['content']['article']['frms'];\n        } else {\n            $unfiltered_data = $json['page']['content']['prismarticle']['frms'];\n        }\n        $filtered_data = $this->filterArticleData($unfiltered_data);\n\n        $article = $filtered_data['edgs'][0];\n\n        $contributors = $article['cntrbGrp'];\n        $authors = [];\n        if (count($contributors) > 0) {\n            $authors = $contributors[0]['contributors'];\n        }\n\n        $authors_name = '';\n        $counter = 0;\n        foreach ($authors as $author) {\n            $counter++;\n            if ($counter == count($authors)) {\n                $authors_name .= $author['displayName'];\n            } else {\n                $authors_name .= $author['displayName'] . ', ';\n            }\n        }\n\n        $published_date = $article['pbDt'] ?? $article['dt'];\n        $article_body = $article['bdy'];\n        $content = '';\n\n        foreach ($article_body as $body) {\n            switch ($body['type']) {\n                case 'p':\n                    $content .= '<p>' . $body['cntnt']['mrkup'] . '</p>';\n                    break;\n                case 'h2':\n                    $content .= '<h2>' . $body['cntnt']['mrkup'] . '</h2>';\n                    break;\n                case 'inline':\n                    $module = $body['cntnt'];\n                    if (empty($module)) {\n                        continue 2;\n                    }\n                    switch ($module['cmsType']) {\n                        case 'image':\n                            $content .= $this->handleImages($module, $module['cmsType']);\n                            break;\n                        case 'imagegroup':\n                            $images = $module['images'];\n                            foreach ($images as $image) {\n                                $content .= $this->handleImages($image, $module['cmsType']);\n                            }\n                            break;\n                        case 'editorsNote':\n                            $content .= $module['note'];\n                            break;\n                        case 'listicle':\n                            $content .= '<h2>' . ($module['title'] ?? '(no title)') . '</h2>';\n                            if (isset($module['image'])) {\n                                $content .= $this->handleImages($module['image'], $module['image']['cmsType']);\n                            }\n                            $content .= '<p>' . ($module['text'] ?? '') . '</p>';\n                            break;\n                        case 'photogallery':\n                            $gallery = $body['cntnt']['media'];\n                            foreach ($gallery as $image) {\n                                $content .= $this->handleImages($image, $module['cmsType']);\n                            }\n                            break;\n                        case 'video':\n                            $content .= $this->handleImages($module, $module['cmsType']);\n                            break;\n                        case 'pullquote':\n                            $quote = $module['quote'];\n                            $author_name = '';\n                            $authors = ($module['byLineProps']['authors'] ?? []);\n                            foreach ($authors as $author) {\n                                $author_desc = ($author['authorDesc'] ?? '');\n                                $author_name .= $author['displayName'] . ', ' . $author_desc;\n                            }\n                            $content .= <<<EOD\n<figure>\n<blockquote>\n<p>$quote</p>\n</blockquote>\n<figcaption>$author_name</figcaption>\n</figure>\nEOD;\n                            break;\n                    }\n                    break;\n                case 'ul':\n                    $content .= $body['cntnt']['mrkup'] . '<hr>';\n                    break;\n            }\n        }\n\n        return [\n            'content' => $content,\n            'published_date' => $published_date,\n            'authors' => $authors_name\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/NautiljonBridge.php",
    "content": "<?php\n\nclass NautiljonBridge extends BridgeAbstract\n{\n    const NAME = 'Nautiljon';\n    const URI = 'https://www.nautiljon.com';\n    const DESCRIPTION = 'Actualités et Brèves de Nautiljon.';\n    const MAINTAINER = 'papjul';\n\n    const PARAMETERS = [\n        [\n            'type' => [\n                'type' => 'list',\n                'name' => 'Type',\n                'title' => 'Choisir le type',\n                'values' => [\n                    'Actualités' => 'actualite',\n                    'Brèves' => 'breves',\n                ],\n            ]\n        ]\n    ];\n\n    private function formatDate($fright)\n    {\n        preg_match('#^(.*)</a>(.*)<a(.*)$#', $fright, $matches);\n        if ($matches) {\n            $frenchFormat = trim($matches[2]);\n            $englishFormat = str_replace(['aujourd\\'hui', 'hier', 'à', 'le', '-'], ['today', 'yesterday', '', '', ''], $frenchFormat);\n            $englishFormat = preg_replace('#([0-9]{2})/([0-9]{2})/([0-9]{4})#', '$2/$1/$3', $englishFormat);\n            return strtotime($englishFormat);\n        } else {\n            return null;\n        }\n    }\n\n    public function collectData()\n    {\n        $url = sprintf('https://www.nautiljon.com/%s/', $this->getInput('type'));\n        $dom = getSimpleHTMLDOM($url);\n\n        foreach ($dom->find('div.une_actu') as $article) {\n            $fright = $article->find('span.fright', 0);\n            $this->items[] = [\n                'title' => $article->find('h3 a', 0)->plaintext,\n                'uri' => self::URI . $article->find('h3 a', 0)->href,\n                'content' => $article->find('p', 0)->plaintext,\n                'author' => $fright->find('a', 0)->plaintext,\n                'categories' => [($fright->find('a')[1])->plaintext],\n                'enclosures' => [self::URI . $article->find('a img', 0)->src],\n                'timestamp' => $this->formatDate($article->find('span.fright', 0)),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NewOnNetflixBridge.php",
    "content": "<?php\n\nclass NewOnNetflixBridge extends BridgeAbstract\n{\n    const NAME = 'NewOnNetflix removals';\n    const URI = 'https://www.newonnetflix.info';\n    const DESCRIPTION = 'Upcoming removals from Netflix (NewOnNetflix already provides additions as RSS)';\n    const MAINTAINER = 'jdesgats';\n    const PARAMETERS = [[\n        'country' => [\n            'name' => 'Country',\n            'type' => 'list',\n            'values' => [\n                'Australia/New Zealand' => 'anz',\n                'Canada' => 'can',\n                'United Kingdom' => 'uk',\n                'United States' => 'usa',\n            ],\n            'defaultValue' => 'uk',\n        ]\n    ]];\n    const CACHE_TIMEOUT = 3600 * 24;\n\n    public function collectData()\n    {\n        $baseURI = 'https://' . $this->getInput('country') . '.newonnetflix.info';\n        $html = getSimpleHTMLDOMCached($baseURI . '/lastchance', self::CACHE_TIMEOUT);\n\n        foreach ($html->find('article.oldpost') as $element) {\n            $title = $element->find('a.infopop[title]', 0);\n            $img = $element->find('img[lazy_src]', 0);\n            $date = $element->find('span[title]', 0);\n\n            // format sholud be 'dd/mm/yy - dd/mm/yy'\n            // (the added date might be \"unknown\")\n            $fromTo = [];\n            if (preg_match('/^\\s*(.*?)\\s*-\\s*(.*?)\\s*$/', $date->title, $fromTo)) {\n                $from = $fromTo[1];\n                $to = $fromTo[2];\n            } else {\n                $from = 'unknown';\n                $to = 'unknown';\n            }\n            $summary = <<<EOD\n\t\t\t\t<img src=\"{$img->lazy_src}\" loading=\"lazy\">\n\t\t\t\t<div>{$title->title}</div>\n\t\t\t\t<div><strong>Added on:</strong>$from</div>\n\t\t\t\t<div><strong>Removed on:</strong>$to</div>\nEOD;\n\n            $item = [];\n            $item['uri'] = $baseURI . $title->href;\n            $item['title'] = $to . ' - ' . $title->plaintext;\n            $item['content'] = $summary;\n            // some movies are added and removed multiple times\n            $item['uid'] = $title->href . '-' . $to;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NewgroundsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass NewgroundsBridge extends BridgeAbstract\n{\n    const NAME = 'Newgrounds';\n    const URI = 'https://www.newgrounds.com';\n    const DESCRIPTION = 'Get the latest art from a given user';\n    const MAINTAINER = 'KamaleiZestri';\n    const PARAMETERS = [\n        'User' => [\n            'username' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'TomFulp'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $username = $this->getInput('username');\n        if (!preg_match('/^\\w+$/', $username)) {\n            throw new \\Exception('Illegal username');\n        }\n\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $posts = $html->find('.item-portalitem-art-medium');\n\n        foreach ($posts as $post) {\n            $item = [];\n\n            $item['author'] = $username;\n            $item['uri'] = $post->href;\n\n            $titleOrRestricted = $post->find('h4')[0]->innertext;\n\n            // Newgrounds doesn't show public previews for NSFW content.\n            if ($titleOrRestricted === 'Restricted Content: Sign in to view!') {\n                $item['title'] = 'NSFW: ' . $item['uri'];\n                $item['content'] = <<<EOD\n<a href=\"{$item['uri']}\">\n{$item['title']}\n</a>\nEOD;\n            } else {\n                $item['title'] = $titleOrRestricted;\n                $item['content'] = <<<EOD\n<a href=\"{$item['uri']}\">\n<img\n    style=\"align:top; width:270px; border:1px solid black;\"\n    alt=\"{$item['title']}\"\n    src=\"{$post->find('img')[0]->src}\"\n    title=\"{$item['title']}\" />\n</a>\nEOD;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('username')) {\n            return sprintf('%s - %s', $this->getInput('username'), self::NAME);\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('username')) {\n            return sprintf('https://%s.newgrounds.com/art', $this->getInput('username'));\n        }\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/NextInkBridge.php",
    "content": "<?php\n\nclass NextInkBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Next.Ink';\n    const URI = 'https://www.next.ink/';\n    const DESCRIPTION = 'Returns the newest articles.';\n\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed',\n            'type' => 'list',\n            'values' => [\n                'Publications' => [\n                    'Toutes nos publications' => 'news',\n                    'Droit' => 'news:3',\n                    'Économie' => 'news:4',\n                    'Flock' => 'news:13',\n                    'Hardware' => 'news:9',\n                    'IA et algorithmes' => 'news:6',\n                    'Internet' => 'news:7',\n                    'Logiciel' => 'news:8',\n                    'Next' => 'news:14',\n                    'Réseaux sociaux' => 'news:5',\n                    'Sciences et escpace' => 'news:10',\n                    'Sécurité' => 'news:12',\n                    'Société numérique' => 'news:11',\n                ],\n                'Flux Gratuit' => [\n                    'Publications en accès libre' => 'free',\n                ],\n            ],\n            'title' => <<<EOT\n                To obtain individual #LeBrief articles in your feed reader, generate two feeds:\n                1. \"Publications\" with \"Hide brief\": Everything except #LeBrief\n                2. \"Flux Gratuit\" with \"Only Brief\": Individual #LeBrief articles\n                There may be a lot of #LeBrief entries at once, increase limit of \"Flux Gratuit\" to 20.\n                EOT,\n        ],\n        'filter_premium' => [\n            'name' => 'Premium',\n            'type' => 'list',\n            'values' => [\n                'No filter' => '0',\n                'Hide Premium' => '1',\n                'Only Premium' => '2'\n            ],\n            'title' => 'Note: \"Flux Gratuit\" already excludes Premium articles.',\n        ],\n        'filter_brief' => [\n            'name' => 'Brief',\n            'type' => 'list',\n            'values' => [\n                'No filter' => '0',\n                'Hide Brief' => '1',\n                'Only Brief' => '2'\n            ],\n            'title' => 'Note: \"Publications\" has only one #LeBrief entry each day.',\n        ],\n        'limit' => self::LIMIT,\n    ]];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit') ?? 10;\n\n        $feed = explode(':', $this->getInput('feed'));\n        $category = '';\n        if (count($feed) > 1) {\n            $category = $feed[1];\n        }\n        $feed = $feed[0];\n\n        if ($feed === 'news') {\n            // Scrap HTML listing to build list of articles\n            $url = self::URI;\n            if ($category !== '') {\n                $url = $url . '?category=' . $category;\n            }\n            $this->collectArticlesFromHtmlListing($url, $limit);\n        } else if ($feed === 'free') {\n            // Expand Free RSS feed\n            $url = self::URI . 'feed/free';\n            $this->collectExpandableDatas($url, $limit);\n        }\n    }\n\n    protected function collectArticlesFromHtmlListing($url, $limit)\n    {\n        $html = getSimpleHTMLDOM($url);\n        $html = convertLazyLoading($html);\n        foreach ($html->find('.block-article') as $article) {\n            $author = $article->find('.author', 0);\n            $subtitle = $article->find('h3', 0);\n            $item = [\n                'uri' => trim($article->find('a', 0)->href),\n                'title' => trim($article->find('h2', 0)->plaintext),\n                'author' => is_object($author) ? trim($author->plaintext) : '',\n                'enclosures' => [ $article->find('img', 0)->src ],\n                'content' => is_object($subtitle) ? trim($subtitle->plaintext) : '',\n            ];\n            $item = $this->parseItem($item);\n            if ($item !== null) {\n                $this->items[] = $item;\n                if (--$limit == 0) {\n                    break;\n                }\n            }\n        }\n    }\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $html = convertLazyLoading($html);\n\n        if (!is_object($html)) {\n            $item['content'] = $item['content']\n                . '<p><em>Failed to request Next.ink: ' . $item['uri'] . '</em></p>';\n            return $item;\n        }\n\n        // Filter premium and brief articles?\n        $paywall_selector = 'div#paywall';\n        $brief_selector = 'div.brief-article';\n        foreach (\n            [\n            'filter_premium' => $paywall_selector,\n            'filter_brief' => $brief_selector,\n            ] as $param_name => $selector\n        ) {\n            $param_val = intval($this->getInput($param_name));\n            if ($param_val != 0) {\n                $element_present = is_object($html->find($selector, 0));\n                $element_wanted = ($param_val == 2);\n                if ($element_present != $element_wanted) {\n                    return null; //Filter article\n                }\n            }\n        }\n\n        $article_content = $html->find('div.article-contenu, ' . $brief_selector, 0);\n        if (is_object($article_content)) {\n            // Clean article content\n            foreach (\n                [\n                    'h1',\n                    'div.author',\n                    'p.brief-categories',\n                    'div.thumbnail-mobile',\n                    'div#share-bottom',\n                    'div.author-info',\n                    'div.other-article',\n                    'script',\n                ] as $item_to_remove\n            ) {\n                foreach ($article_content->find($item_to_remove) as $dom_node) {\n                    $dom_node->outertext = '';\n                }\n            }\n            // Image\n            $postimg = $article_content->find('div.thumbnail', 0);\n            if (empty($item['enclosures']) && is_object($postimg)) {\n                $postimg = $postimg->find('img', 0);\n                if (!empty($postimg->src)) {\n                    $item['enclosures'] = [ $postimg->src ];\n                }\n            }\n            // Timestamp\n            $published_time = $html->find('meta[property=article:published_time]', 0);\n            if (!isset($item['timestamp']) && is_object($published_time)) {\n                $item['timestamp'] = strtotime($published_time->content);\n            }\n            // Paywall\n            $paywall = $article_content->find($paywall_selector, 0);\n            if (is_object($paywall) && is_object($paywall->find('h3', 0))) {\n                $paywall->outertext = '<p><em>' . $paywall->find('h3', 0)->innertext . '</em></p>';\n            }\n            // Content\n            $item['content'] = $article_content->outertext;\n        } else {\n            $item['content'] = $item['content'] . '<p><em>Failed to retrieve full article content</em></p>';\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/NextgovBridge.php",
    "content": "<?php\n\nclass NextgovBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Nextgov';\n    const URI = 'https://www.nextgov.com/';\n    const DESCRIPTION = 'USA Federal technology news, best practices, and web 2.0 tools.';\n\n    const PARAMETERS = [ [\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'All' => 'all',\n                'Technology News' => 'technology-news',\n                'CIO Briefing' => 'cio-briefing',\n                'Emerging Tech' => 'emerging-tech',\n                'Cybersecurity' => 'cybersecurity',\n                'IT Modernization' => 'it-modernization',\n                'Policy' => 'policy',\n                'Ideas' => 'ideas',\n            ]\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = self::URI . 'rss/' . $this->getInput('category') . '/';\n        $limit = 10;\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article_thumbnail = 'https://cdn.nextgov.com/nextgov/images/logo.png';\n        $item['content'] = '<p><b>' . $item['content'] . '</b></p>';\n\n//        $namespaces = $newsItem->getNamespaces(true);\n//        if (isset($namespaces['media'])) {\n//            $media = $newsItem->children($namespaces['media']);\n//            if (isset($media->content)) {\n//                $attributes = $media->content->attributes();\n//                $item['content'] = '<p><img src=\"' . $attributes['url'] . '\"></p>' . $item['content'];\n//                $article_thumbnail = str_replace(\n//                    'large.jpg',\n//                    'small.jpg',\n//                    strval($attributes['url'])\n//                );\n//            }\n//        }\n\n        $item['enclosures'] = [$article_thumbnail];\n        $item['content'] .= $this->extractContent($item['uri']);\n        return $item;\n    }\n\n    private function extractContent($url)\n    {\n        $article = getSimpleHTMLDOMCached($url);\n\n        if (!is_object($article)) {\n            return 'Could not request Nextgov: ' . $url;\n        }\n\n        $contents = $article->find('div.wysiwyg', 0);\n        $contents = $contents->innertext;\n        $contents = stripWithDelimiters($contents, '<div class=\"ad-container\">', '</div>');\n        $contents = stripWithDelimiters($contents, '<div', '</div>'); //ad outer div\n        return trim(stripWithDelimiters($contents, '<script', '</script>'));\n    }\n}\n"
  },
  {
    "path": "bridges/NiceMatinBridge.php",
    "content": "<?php\n\nclass NiceMatinBridge extends FeedExpander\n{\n    const MAINTAINER = 'pit-fgfjiudghdf';\n    const NAME = 'NiceMatin';\n    const URI = 'https://www.nicematin.com/';\n    const DESCRIPTION = 'Returns the 10 newest posts from NiceMatin (full text)';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::URI . 'derniere-minute/rss', 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['content'] = $this->extractContent($item['uri']);\n        return $item;\n    }\n\n    private function extractContent($url)\n    {\n        $html = getSimpleHTMLDOMCached($url);\n        if (!$html) {\n            return 'Could not acquire content from url: ' . $url . '!';\n        }\n\n        $content = $html->find('article', 0);\n        if (!$content) {\n            return 'Could not find \\'section\\'!';\n        }\n\n        $text = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $content->innertext);\n        $text = strip_tags($text, '<p><a><img>');\n        return $text;\n    }\n}\n"
  },
  {
    "path": "bridges/NikonDownloadCenterBridge.php",
    "content": "<?php\n\nclass NikonDownloadCenterBridge extends BridgeAbstract\n{\n    const NAME = 'Nikon Download Center – What\\'s New';\n    const URI = 'https://downloadcenter.nikonimglib.com/';\n    const DESCRIPTION = 'Firmware updates and new software from Nikon.';\n    const MAINTAINER = 'sal0max';\n    const CACHE_TIMEOUT = 60 * 60 * 2; // 2 hours\n\n    public function getURI()\n    {\n        $year = date('Y');\n        return self::URI . 'en/update/index/' . $year . '.html';\n    }\n\n    public function getIcon()\n    {\n        return self::URI . 'favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('dd>ul>li') as $element) {\n            $date        = $element->find('.date', 0)->plaintext;\n            $productType = $element->find('.icon>img', 0)->alt;\n            $desc        = $element->find('p>a', 0)->plaintext;\n            $link        = urljoin(self::URI, $element->find('p>a', 0)->href);\n\n            $item = [\n                'title'     => $desc,\n                'uri'       => $link,\n                'timestamp' => strtotime($date),\n                'content'   => <<<EOD\n<p>\n New/updated {$productType}:<br>\n <strong><a href=\"{$link}\">{$desc}</a></strong>\n</p>\n<p>\n {$date}\n</p>\nEOD\n            ];\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NineGagBridge.php",
    "content": "<?php\n\nclass NineGagBridge extends BridgeAbstract\n{\n    const NAME = '9gag';\n    const URI = 'https://9gag.com/';\n    const DESCRIPTION = 'Returns latest quotes from 9gag.';\n    const MAINTAINER = 'ZeNairolf';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        'Popular' => [\n            'd' => [\n                'name' => 'Section',\n                'type' => 'list',\n                'values' => [\n                    'Hot' => 'hot',\n                    'Trending' => 'trending',\n                    'Fresh' => 'fresh',\n                ],\n            ],\n            'video' => [\n                'name' => 'Filter Video',\n                'type' => 'list',\n                'values' => [\n                    'NotFiltred' => 'none',\n                    'VideoFiltred' => 'without',\n                    'VideoOnly' => 'only',\n                ],\n            ],\n            'p' => [\n                'name' => 'Pages',\n                'type' => 'number',\n                'defaultValue' => 3,\n            ],\n        ],\n        'Sections' => [\n            'g' => [\n                'name' => 'Section',\n                'type' => 'list',\n                'values' => [\n                    'Among Us' => 'among-us',\n                    'Animals' => 'animals',\n                    'Anime & Manga' => 'anime-manga',\n                    'Anime Waifu' => 'animewaifu',\n                    'Anime Wallpaper' => 'animewallpaper',\n                    'Apex Legends' => 'apexlegends',\n                    'Ask 9GAG' => 'ask9gag',\n                    'Awesome' => 'awesome',\n                    'Car' => 'car',\n                    'Comic & Webtoon' => 'comic-webtoon',\n                    'Coronavirus ' => 'coronavirus',\n                    'Cosplay' => 'cosplay',\n                    'Countryballs' => 'countryballs',\n                    'Cozy & Comfy' => 'home-living',\n                    'Crappy Design' => 'crappydesign',\n                    'Cryptocurrency ' => 'cryptocurrency',\n                    'Cyberpunk 2077' => 'cyberpunk2077',\n                    'Dark Humor' => 'darkhumor',\n                    'Drawing, DIY & Crafts' => 'drawing-diy-crafts',\n                    'Fashion & Beauty' => 'rate-my-outfit',\n                    'Food & Drinks' => 'food-drinks',\n                    'Football' => 'football',\n                    'Fortnite' => 'fortnite',\n                    'Funny' => 'funny',\n                    'Game of Thrones' => 'got',\n                    'Gaming' => 'gaming',\n                    'GIF' => 'gif',\n                    'Girl' => 'girl',\n                    'Girl Celebrity' => 'girlcelebrity',\n                    'Guy' => 'guy',\n                    'History' => 'history',\n                    'Horror' => 'horror',\n                    'K-Pop' => 'kpop',\n                    'Latest News' => 'timely',\n                    'League of Legends' => 'leagueoflegends',\n                    'LEGO' => 'lego',\n                    'Marvel & DC' => 'superhero',\n                    'Meme' => 'meme',\n                    'Movie & TV' => 'movie-tv',\n                    'Music' => 'music',\n                    'NBA' => 'basketball',\n                    'Overwatch' => 'overwatch',\n                    'PC Master Race' => 'pcmr',\n                    'Pokémon' => 'pokemon',\n                    'Politics ' => 'politics',\n                    'PUBG' => 'pubg',\n                    'Random ' => 'random',\n                    'Relationship' => 'relationship',\n                    'Satisfying' => 'satisfying',\n                    'Savage' => 'savage',\n                    'Science & Tech' => 'science-tech',\n                    'Sport ' => 'sport',\n                    'Star Wars' => 'starwars',\n                    'Teens Can Relate' => 'school',\n                    'Travel & Photography' => 'travel-photography',\n                    'Video' => 'video',\n                    'Wallpaper' => 'wallpaper',\n                    'Warhammer' => 'warhammer',\n                    'Wholesome' => 'wholesome',\n                    'WTF' => 'wtf',\n                ],\n            ],\n            't' => [\n                'name' => 'Type',\n                'type' => 'list',\n                'values' => [\n                    'Hot' => 'hot',\n                    'Fresh' => 'fresh',\n                ],\n            ],\n            'video' => [\n                'name' => 'Filter Video',\n                'type' => 'list',\n                'values' => [\n                    'NotFiltred' => 'none',\n                    'VideoFiltred' => 'without',\n                    'VideoOnly' => 'only',\n                ],\n            ],\n            'p' => [\n                'name' => 'Pages',\n                'type' => 'number',\n                'defaultValue' => 3,\n            ],\n        ],\n    ];\n\n    const MIN_NBR_PAGE = 1;\n    const MAX_NBR_PAGE = 6;\n\n    protected $p = null;\n\n    public function collectData()\n    {\n        $url = sprintf(\n            '%sv1/group-posts/group/%s/type/%s?',\n            self::URI,\n            $this->getGroup(),\n            $this->getType()\n        );\n        $cursor = 'c=10';\n        $posts = [];\n        for ($i = 0; $i < $this->getPages(); ++$i) {\n            $content = getContents($url . $cursor);\n            $json = json_decode($content, true);\n            $posts = array_merge($posts, $json['data']['posts']);\n            $cursor = $json['data']['nextCursor'];\n        }\n\n        foreach ($posts as $post) {\n            $AvoidElement = false;\n            switch ($this->getInput('video')) {\n                case 'without':\n                    if ($post['type'] === 'Animated') {\n                        $AvoidElement = true;\n                    }\n                    break;\n                case 'only':\n                    echo $post['type'];\n                    if ($post['type'] !== 'Animated') {\n                        $AvoidElement = true;\n                    }\n                    break;\n                case 'none':\n                default:\n                    break;\n            }\n\n            if (!$AvoidElement) {\n                $item['uri'] = preg_replace('/^http:/i', 'https:', $post['url']);\n                $item['title'] = $post['title'];\n                $item['content'] = self::getContent($post);\n                $item['categories'] = self::getCategories($post);\n                $item['timestamp'] = self::getTimestamp($post);\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('d')) {\n            $name = sprintf('%s - %s', '9GAG', $this->getKey('d'));\n        } elseif ($this->getInput('g')) {\n            $name = sprintf('%s - %s', '9GAG', $this->getKey('g'));\n            if ($this->getInput('t')) {\n                $name = sprintf('%s [%s]', $name, $this->getKey('t'));\n            }\n        }\n        if (!empty($name)) {\n            return $name;\n        }\n\n        return self::NAME;\n    }\n\n    public function getURI()\n    {\n        $uri = $this->getInput('g');\n        if ($uri === 'default') {\n            $uri = $this->getInput('t');\n        }\n\n        return self::URI . $uri;\n    }\n\n    protected function getGroup()\n    {\n        if ($this->getInput('d')) {\n            return 'default';\n        }\n\n        return $this->getInput('g');\n    }\n\n    protected function getType()\n    {\n        if ($this->getInput('d')) {\n            return $this->getInput('d');\n        }\n\n        return $this->getInput('t');\n    }\n\n    protected function getPages()\n    {\n        if ($this->p === null) {\n            $value = (int) $this->getInput('p');\n            $value = ($value < self::MIN_NBR_PAGE) ? self::MIN_NBR_PAGE : $value;\n            $value = ($value > self::MAX_NBR_PAGE) ? self::MAX_NBR_PAGE : $value;\n\n            $this->p = $value;\n        }\n\n        return $this->p;\n    }\n\n    protected static function getContent($post)\n    {\n        if ($post['type'] === 'Animated') {\n            $content = self::getAnimated($post);\n        } elseif ($post['type'] === 'Article') {\n            $content = self::getArticle($post);\n        } else {\n            $content = self::getPhoto($post);\n        }\n\n        return $content;\n    }\n\n    protected static function getPhoto($post)\n    {\n        $image = $post['images']['image460'];\n        $photo = '<picture>';\n        $photo .= sprintf(\n            '<source srcset=\"%s\" type=\"image/webp\">',\n            $image['webpUrl']\n        );\n        $photo .= sprintf(\n            '<img src=\"%s\" alt=\"%s\" %s>',\n            $image['url'],\n            $post['title'],\n            'width=\"500\"'\n        );\n        $photo .= '</picture>';\n\n        return $photo;\n    }\n\n    protected static function getAnimated($post)\n    {\n        $poster = $post['images']['image460']['url'];\n        $sources = $post['images'];\n        $video = sprintf(\n            '<video poster=\"%s\" %s>',\n            $poster,\n            'preload=\"auto\" loop controls style=\"min-height: 300px\" width=\"500\"'\n        );\n        $video .= sprintf(\n            '<source src=\"%s\" type=\"video/webm\">',\n            $sources['image460sv']['vp9Url']\n        );\n        $video .= sprintf(\n            '<source src=\"%s\" type=\"video/mp4\">',\n            $sources['image460sv']['h265Url']\n        );\n        $video .= sprintf(\n            '<source src=\"%s\" type=\"video/mp4\">',\n            $sources['image460svwm']['url']\n        );\n        $video .= '</video>';\n\n        return $video;\n    }\n\n    protected static function getArticle($post)\n    {\n        $blocks = $post['article']['blocks'];\n        $medias = $post['article']['medias'];\n        $contents = [];\n        foreach ($blocks as $block) {\n            if ('Media' === $block['type']) {\n                $mediaId = $block['mediaId'];\n                $contents[] = self::getContent($medias[$mediaId]);\n            } elseif ('RichText' === $block['type']) {\n                $contents[] = self::getRichText($block['content']);\n            }\n        }\n\n        $content = join('</div><div>', $contents);\n        $content = sprintf(\n            '<%1$s>%2$s</%1$s>',\n            'div',\n            $content\n        );\n\n        return $content;\n    }\n\n    protected static function getRichText($text = '')\n    {\n        $text = trim($text);\n\n        if (preg_match('/^>\\s(?<text>.*)/', $text, $matches)) {\n            $text = sprintf(\n                '<%1$s>%2$s</%1$s>',\n                'blockquote',\n                $matches['text']\n            );\n        } else {\n            $text = sprintf(\n                '<%1$s>%2$s</%1$s>',\n                'p',\n                $text\n            );\n        }\n\n        return $text;\n    }\n\n    protected static function getCategories($post)\n    {\n        $params = self::PARAMETERS;\n        $sections = $params['Sections']['g']['values'];\n\n        if (isset($post['sections'])) {\n            $postSections = $post['sections'];\n        } elseif (isset($post['postSection'])) {\n            $postSections = [$post['postSection']];\n        } else {\n            $postSections = [];\n        }\n\n        foreach ($postSections as $key => $section) {\n            $postSections[$key] = array_search($section, $sections);\n        }\n\n        return $postSections;\n    }\n\n    protected static function getTimestamp($post)\n    {\n        $url = $post['images']['image460']['url'];\n        $headers = get_headers($url, true);\n        $date = $headers['Date'];\n        $time = strtotime($date);\n\n        return $time;\n    }\n}\n"
  },
  {
    "path": "bridges/NintendoBridge.php",
    "content": "<?php\n\nclass NintendoBridge extends XPathAbstract\n{\n    const NAME = 'Nintendo Software Updates';\n    const URI = 'https://www.nintendo.co.uk/Support/Welcome-to-Nintendo-Support-11593.html';\n    const DESCRIPTION = self::NAME;\n    const MAINTAINER = 'Niehztog';\n    const PARAMETERS = [\n        '' => [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'All' => 'all',\n                    'Mario Kart 8 Deluxe' => 'mk8d',\n                    'Splatoon 2' => 's2',\n                    'Super Mario 3D All-Stars' => 'sm3as',\n                    'Super Mario 3D World + Bowser’s Fury' => 'sm3wbf',\n                    'Super Mario Bros. Wonder' => 'smbw',\n                    'Super Mario Maker 2' => 'smm2',\n                    'Super Mario Odyssey' => 'smo',\n                    'Super Smash Bros. Ultimate' => 'ssbu',\n                    'Switch Firmware' => 'sf',\n                    'The Legend of Zelda: Link’s Awakening' => 'tlozla',\n                    'The Legend of Zelda: Skyward Sword HD' => 'tlozss',\n                    'The Legend of Zelda: Tears of the Kingdom' => 'tloztotk',\n                    'Xenoblade Chronicles 2' => 'xc2',\n                ],\n                'defaultValue' => 'mk8d',\n                'title' => 'Select category'\n            ],\n            'country' => [\n                'name' => 'Country',\n                'type' => 'list',\n                'values' => [\n                    'België' => 'be/nl',\n                    'Belgique' => 'be/fr',\n                    'Deutschland' => 'de',\n                    'España' => 'es',\n                    'France' => 'fr',\n                    'Italia' => 'it',\n                    'Nederland' => 'nl',\n                    'Österreich' => 'at',\n                    'Portugal' => 'pt',\n                    'Schweiz' => 'ch/de',\n                    'Suisse' => 'ch/fr',\n                    'Svizzera' => 'ch/it',\n                    'UK & Ireland' => 'co.uk',\n                    'South Africa' => 'co.za'\n                ],\n                'defaultValue' => 'co.uk',\n                'title' => 'Select your country'\n            ]\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600;\n\n    const FEED_SOURCE_URL = [\n        'mk8d' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Mario-Kart-8-Deluxe-1482895.html',\n        's2' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Splatoon-2-1482897.html',\n        'sm3as' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-3D-All-Stars-1844226.html',\n        'sm3wbf' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-3D-World-Bowser-s-Fury-1920668.html',\n        'smbw' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-Bros-Wonder-2485410.html',\n        'smm2' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-Maker-2-1586745.html',\n        'smo' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-Odyssey-1482901.html',\n        'ssbu' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Smash-Bros-Ultimate-1484130.html',\n        'sf' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/System-Updates/Nintendo-Switch-System-Updates-and-Change-History-1445507.html',\n        'tlozla' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-The-Legend-of-Zelda-Link-s-Awakening-1666739.html',\n        'tlozss' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-The-Legend-of-Zelda-Skyward-Sword-HD-2022801.html',\n        'tloztotk' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-The-Legend-of-Zelda-Tears-of-the-Kingdom-2388231.html',\n        'xc2' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/Xenoblade-Chronicles-2-Update-History-1482911.html',\n    ];\n    const XPATH_EXPRESSION_ITEM = '//div[@class=\"col-xs-12 content\"]/div[starts-with(@id,\"v\") and @class=\"collapse\"]';\n    const XPATH_EXPRESSION_ITEM_FIRMWARE = '//div[@id=\"latest\" and @class=\"collapse\" and @rel=\"1\"]';\n    const XPATH_EXPRESSION_ITEM_TITLE = '(.//h2[1] | .//strong[1])[1]/node()';\n    const XPATH_EXPRESSION_ITEM_CONTENT = '.';\n    const XPATH_EXPRESSION_ITEM_URI = '//link[@rel=\"canonical\"]/@href';\n\n    //const XPATH_EXPRESSION_ITEM_AUTHOR = '';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP_PART = 'substring-after(//a[@class=\"collapse_link collapsed\" and @data-target=\"#{{id_here}}\"]/text(), \"{{label_here}}\")';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = 'substring(' . self::XPATH_EXPRESSION_ITEM_TIMESTAMP_PART . ', 1, string-length('\n        . self::XPATH_EXPRESSION_ITEM_TIMESTAMP_PART . ') - 1)';\n\n    //const XPATH_EXPRESSION_ITEM_ENCLOSURES = '';\n    //const XPATH_EXPRESSION_ITEM_CATEGORIES = '';\n    const SETTING_FIX_ENCODING = false;\n    const SETTING_USE_RAW_ITEM_CONTENT = true;\n\n    private const GAME_COUNTRY_DATE_SUBSTRING_PART = [\n        'mk8d' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'ubblicata il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada no dia ',\n            'en' => 'eleased ',\n        ],\n        's2' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'ubblicata il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n        'sm3as' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'ubliée le ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n        'sm3wbf' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada no dia ',\n            'en' => 'eleased ',\n        ],\n        'smbw' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n        'smm2' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'ubliée le ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada no dia ',\n            'en' => 'eleased ',\n        ],\n        'smo' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada no dia ',\n            'en' => 'eleased ',\n        ],\n        'ssbu' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada no dia ',\n            'en' => 'eleased ',\n        ],\n        'sf' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'ise en ligne le ',\n            'it' => 'ubblicata il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada no dia ',\n            'en' => 'istributed ',\n        ],\n        'tlozla' => [\n            'de' => 'eröffentlicht ',\n            'es' => 'ublicada el ',\n            'fr' => 'atée du ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgegeven op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n        'tlozss' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'ubblicata l\\'',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n        'tloztotk' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'ubliée le ',\n            'it' => 'ubblicata il ',\n            'nl' => 'erschenen op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n        'xc2' => [\n            'de' => 'eröffentlicht am ',\n            'es' => 'isponible desde el ',\n            'fr' => 'atée du ',\n            'it' => 'istribuita il ',\n            'nl' => 'itgebracht op ',\n            'pt' => 'ançada a ',\n            'en' => 'eleased ',\n        ],\n    ];\n\n    private const GAME_COUNTRY_DATE_FORMAT = [\n        'mk8d' => [\n            'de' => 'd.m.y',\n            'es' => 'd-m-y',\n            'fr' => 'd/m/Y',\n            'it' => 'd/m/y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/y',\n            'en' => 'd/m/y',\n        ],\n        's2' => [\n            'de' => 'd.m.Y',\n            'es' => 'd-m-Y',\n            'fr' => 'd/m/y',\n            'it' => 'd/m/y',\n            'nl' => 'd/m/y',\n            'pt' => 'd/m/y',\n            'en' => 'd F Y',\n        ],\n        'sm3as' => [\n            'de' => 'j. m Y',\n            'es' => 'j \\d\\e m \\d\\e Y',\n            'fr' => 'j m Y',\n            'it' => 'j m Y',\n            'nl' => 'j m Y',\n            'pt' => 'j \\d\\e m \\d\\e Y',\n            'en' => 'j F Y',\n        ],\n        'sm3wbf' => [\n            'de' => 'd.m.y',\n            'es' => 'd-m-y',\n            'fr' => 'd/m/y',\n            'it' => 'd/m/y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/y',\n            'en' => 'F j, Y',\n        ],\n        'smbw' => [\n            'de' => 'd. m Y',\n            'es' => 'j \\d\\e m \\d\\e Y',\n            'fr' => 'd/m/Y',\n            'it' => 'j m Y',\n            'nl' => 'd m Y',\n            'pt' => 'j \\d\\e m \\d\\e Y',\n            'en' => 'j F Y',\n        ],\n        'smm2' => [\n            'de' => 'd.m.Y',\n            'es' => 'd-m-Y',\n            'fr' => 'd/m/Y',\n            'it' => 'd/m/Y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/y',\n            'en' => 'd/m/y',\n        ],\n        'smo' => [\n            'de' => 'd.m.Y',\n            'es' => 'd-m-Y',\n            'fr' => 'd/m/Y',\n            'it' => 'd/m/y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/y',\n            'en' => 'd/m/y',\n        ],\n        'ssbu' => [\n            'de' => 'd. m Y',\n            'es' => 'j \\d\\e m \\d\\e Y',\n            'fr' => 'j m Y',\n            'it' => 'j m Y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/Y',\n            'en' => 'j F Y',\n        ],\n        'sf' => [\n            'de' => 'd.m.Y',\n            'es' => 'd-m-y',\n            'fr' => 'd/m/Y',\n            'it' => 'd/m/Y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/Y',\n            'en' => 'd/m/Y',\n        ],\n        'tlozla' => [\n            'de' => 'd. m Y',\n            'es' => 'j m \\d\\e Y',\n            'fr' => 'd/m/y',\n            'it' => 'j m Y',\n            'nl' => 'd m Y',\n            'pt' => 'j \\d\\e m \\d\\e Y',\n            'en' => 'j F y',\n        ],\n        'tlozss' => [\n            'de' => 'd. m Y',\n            'es' => 'j \\d\\e m \\d\\e Y',\n            'fr' => 'd/m/y',\n            'it' => 'j m Y',\n            'nl' => 'd m Y',\n            'pt' => 'j \\d\\e m \\d\\e Y',\n            'en' => 'j F Y',\n        ],\n        'tloztotk' => [\n            'de' => 'd. m Y',\n            'es' => 'j \\d\\e m \\d\\e Y',\n            'fr' => 'j m Y',\n            'it' => 'j m Y',\n            'nl' => 'd m Y',\n            'pt' => 'j \\d\\e m \\d\\e Y',\n            'en' => 'j F Y',\n        ],\n        'xc2' => [\n            'de' => 'd.m.y',\n            'es' => 'd-m-y',\n            'fr' => 'd/m/Y',\n            'it' => 'd/m/y',\n            'nl' => 'd m Y',\n            'pt' => 'd/m/y',\n            'en' => 'd/m/y',\n        ],\n    ];\n\n    private const FOREIGN_MONTH_NAMES = [\n        'nl' => ['01' => 'januari', '02' => 'februari', '03' => 'maart', '04' => 'april', '05' => 'mei', '06' => 'juni', '07' => 'juli', '08' => 'augustus',\n            '09' => 'september', '10' => 'oktober', '11' => 'november', '12' => 'december'],\n        'fr' => ['01' => 'janvier', '02' => 'février', '03' => 'mars', '04' => 'avril', '05' => 'mai', '06' => 'juin', '07' => 'juillet', '08' => 'août',\n            '09' => 'septembre', '10' => 'octobre', '11' => 'novembre', '12' => 'décembre'],\n        'de' => ['01' => 'Januar', '02' => 'Februar', '03' => 'März', '04' => 'April', '05' => 'Mai', '06' => 'Juni', '07' => 'Juli', '08' => 'August',\n            '09' => 'September', '10' => 'Oktober', '11' => 'November', '12' => 'Dezember'],\n        'es' => ['01' => 'enero', '02' => 'febrero', '03' => 'marzo', '04' => 'abril', '05' => 'mayo', '06' => 'junio', '07' => 'julio', '08' => 'agosto',\n            '09' => 'septiembre', '10' => 'octubre', '11' => 'noviembre', '12' => 'diciembre'],\n        'it' => ['01' => 'gennaio', '02' => 'febbraio', '03' => 'marzo', '04' => 'aprile', '05' => 'maggio', '06' => 'giugno', '07' => 'luglio', '08' => 'agosto',\n            '09' => 'settembre', '10' => 'ottobre', '11' => 'novembre', '12' => 'dicembre'],\n        'pt' => ['01' => 'janeiro', '02' => 'fevereiro', '03' => 'março', '04' => 'abril', '05' => 'maio', '06' => 'junho', '07' => 'julho', '08' => 'agosto',\n            '09' => 'setembro', '10' => 'outubro', '11' => 'novembro', '12' => 'dezembro'],\n    ];\n    const LANGUAGE_REWRITE = ['co.uk' => 'en', 'co.za' => 'en', 'at' => 'de'];\n\n    private string $lastId = '';\n    private ?string $currentCategory = '';\n\n    private function getCurrentCategory()\n    {\n        if (empty($this->currentCategory)) {\n            $category = $this->getInput('category');\n            $this->currentCategory = empty($category) ? self::PARAMETERS['']['category']['defaultValue'] : $category;\n        }\n        return $this->currentCategory;\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.nintendo.co.uk/favicon.ico';\n    }\n\n    public function getURI()\n    {\n        $category = $this->getInput('category');\n        if ('all' === $category) {\n            return self::URI;\n        } else {\n            return $this->getSourceUrl();\n        }\n    }\n\n    protected function provideFeedTitle(\\DOMXPath $xpath)\n    {\n        $category = $this->getInput('category');\n        $categoryName = array_search($category, self::PARAMETERS['']['category']['values']);\n        return 'all' === $category ? self::NAME : $categoryName . ' Software-Updates';\n    }\n\n    protected function getSourceUrl()\n    {\n        $country = $this->getInput('country') ?? '';\n        $category = $this->getCurrentCategory();\n        return str_replace(self::PARAMETERS['']['country']['defaultValue'], $country, self::FEED_SOURCE_URL[$category]);\n    }\n\n    protected function getExpressionItem()\n    {\n        $category = $this->getCurrentCategory();\n        return 'sf' === $category ? self::XPATH_EXPRESSION_ITEM_FIRMWARE : self::XPATH_EXPRESSION_ITEM;\n    }\n\n    protected function getExpressionItemTimestamp()\n    {\n        if (empty($this->lastId)) {\n            return null;\n        }\n        $country = $this->getInput('country');\n        $category = $this->getCurrentCategory();\n        $language = $this->getLanguageFromCountry($country);\n        return str_replace(\n            ['{{id_here}}', '{{label_here}}'],\n            [$this->lastId, static::GAME_COUNTRY_DATE_SUBSTRING_PART[$category][$language]],\n            static::XPATH_EXPRESSION_ITEM_TIMESTAMP\n        );\n    }\n\n    protected function getExpressionItemCategories()\n    {\n        $category = $this->getCurrentCategory();\n        $categoryName = array_search($category, self::PARAMETERS['']['category']['values']);\n        return 'string(\"' . $categoryName . '\")';\n    }\n\n    public function collectData()\n    {\n        $category = $this->getCurrentCategory();\n        if ('all' === $category) {\n            $allItems = [];\n            foreach (self::PARAMETERS['']['category']['values'] as $catKey) {\n                if ('all' === $catKey) {\n                    continue;\n                }\n                $this->currentCategory = $catKey;\n                $this->items = [];\n                parent::collectData();\n                $allItems = [...$allItems, ...$this->items];\n            }\n            $this->currentCategory = 'all';\n            $this->items = $allItems;\n        } else {\n            parent::collectData();\n        }\n    }\n\n    protected function formatItemTitle($value)\n    {\n        if (false !== strpos($value, ' (')) {\n            $value = substr($value, 0, strpos($value, ' ('));\n        }\n        if ('all' === $this->getInput('category')) {\n            $category = $this->getCurrentCategory();\n            $categoryName = array_search($category, self::PARAMETERS['']['category']['values']);\n            return $categoryName . ' ' . $value;\n        }\n        return $value;\n    }\n\n    protected function formatItemContent($value)\n    {\n        $result = preg_match('~<div class=\"collapse\" id=\"([a-z0-9]+)\" rel=\"1\">(.*)</div>~', $value, $matches);\n        if (1 === $result) {\n            $this->lastId = $matches[1];\n            return trim($matches[2]);\n        }\n        return $value;\n    }\n\n    protected function formatItemTimestamp($value)\n    {\n        $country = $this->getInput('country');\n        $category = $this->getCurrentCategory();\n        $language = $this->getLanguageFromCountry($country);\n\n        $aMonthNames = self::FOREIGN_MONTH_NAMES[$language] ?? null;\n        if (null !== $aMonthNames) {\n            $value = str_replace(array_values($aMonthNames), array_keys($aMonthNames), $value);\n        }\n        $value = str_replace('­', '-', $value);\n        $value = str_replace('--', '-', $value);\n\n        $date = \\DateTime::createFromFormat(self::GAME_COUNTRY_DATE_FORMAT[$category][$language], $value);\n        if (false === $date) {\n            $date = new \\DateTime('now');\n        }\n        return $date->getTimestamp();\n    }\n\n    protected function generateItemId(array $item)\n    {\n        return $this->getCurrentCategory() . '-' . $this->lastId;\n    }\n\n    private function getLanguageFromCountry($country)\n    {\n        return (strpos($country, '/') !== false) ? substr($country, strpos($country, '/') + 1) : (self::LANGUAGE_REWRITE[$country] ?? $country);\n    }\n}\n"
  },
  {
    "path": "bridges/NordbayernBridge.php",
    "content": "<?php\n\nclass NordbayernBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'schabi.org';\n    const NAME = 'Nordbayern';\n    const CACHE_TIMEOUT = 3600;\n    const URI = 'https://www.nordbayern.de';\n    const DESCRIPTION = 'Bridge for Bavarian regional news site nordbayern.de';\n    const PARAMETERS = [ [\n        'region' => [\n            'name' => 'region',\n            'type' => 'list',\n            'exampleValue' => 'Nürnberg',\n            'title' => 'Select a region',\n            'values' => [\n                'Ansbach' => 'ansbach',\n                'Bamberg' => 'bamberg',\n                'Bayreuth' => 'bayreuth',\n                'Erlangen' => 'erlangen',\n                'Forchheim' => 'forchheim',\n                'Fürth' => 'fuerth',\n                'Gunzenhausen' => 'gunzenhausen',\n                'Herzogenaurach' => 'herzogenaurach',\n                'Höchstadt' => 'hoechstadt',\n                'Neumarkt' => 'neumarkt',\n                'Neustadt/Aisch-Bad Windsheim' => 'neustadt-aisch-bad-windsheim',\n                'Nürnberg' => 'nuernberg',\n                'Nürnberger Land' => 'nuernberger-land',\n                'Regensburg' => 'regensburg',\n                'Roth' => 'roth',\n                'Schwabach' => 'schwabach',\n                'Weißenburg' => 'weissenburg'\n            ]\n        ],\n        'policeReports' => [\n            'name' => 'Police Reports',\n            'type' => 'checkbox',\n            'exampleValue' => 'checked',\n            'title' => 'Include Police Reports',\n        ],\n        'hideNNPlus' => [\n            'name' => 'Hide NN+ articles',\n            'type' => 'checkbox',\n            'exampleValue' => 'unchecked',\n            'title' => 'Hide all paywall articles on NN'\n        ],\n        'hideDPA' => [\n        'name' => 'Hide dpa articles',\n        'type' => 'checkbox',\n        'exampleValue' => 'unchecked',\n        'title' => 'Hide external articles from dpa'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $region = $this->getInput('region');\n        if ($region === 'rothenburg-o-d-t') {\n            $region = 'rothenburg-ob-der-tauber';\n        }\n        $url = self::URI . '/region/' . $region;\n        $listSite = getSimpleHTMLDOM($url);\n\n        $this->handleNewsblock($listSite);\n    }\n\n\n    private function getValidImage($picture)\n    {\n        $img = $picture->find('img', 0);\n        if ($img) {\n            $imgUrl = $img->src;\n            if (!preg_match('#/logo-.*\\.png#', $imgUrl)) {\n                return '<br><img src=\"' . $imgUrl . '\">';\n            }\n        }\n        return '';\n    }\n\n    private function getUseFullContent($rawContent)\n    {\n        $content = '';\n        foreach ($rawContent->children as $element) {\n            if (\n                ($element->tag === 'p' || $element->tag === 'h3') &&\n                $element->class !== 'article__teaser'\n            ) {\n                $content .= $element;\n            } elseif ($element->tag === 'main') {\n                $content .= $this->getUseFullContent($element->find('article', 0));\n            } elseif ($element->tag === 'header') {\n                $content .= $this->getUseFullContent($element);\n            } elseif (\n                $element->tag === 'div' &&\n                !str_contains($element->class, 'article__infobox') &&\n                !str_contains($element->class, 'authorinfo')\n            ) {\n                $content .= $this->getUseFullContent($element);\n            } elseif (\n                $element->tag === 'section' &&\n                (str_contains($element->class, 'article__richtext') ||\n                    str_contains($element->class, 'article__context'))\n            ) {\n                $content .= $this->getUseFullContent($element);\n            } elseif ($element->tag === 'picture') {\n                $content .= $this->getValidImage($element);\n            } elseif ($element->tag === 'ul') {\n                $content .= $element;\n            }\n        }\n        return $content;\n    }\n\n    private function getTeaser($content)\n    {\n        $teaser = $content->find('p[class=article__teaser]', 0);\n        if ($teaser === null) {\n            return '';\n        }\n        $teaser = $teaser->plaintext;\n        $teaser = preg_replace('/[ ]{2,}/', ' ', $teaser);\n        $teaser = '<p class=\"article__teaser\">' . $teaser . '</p>';\n        return $teaser;\n    }\n\n    private function getArticle($link)\n    {\n        $item = [];\n        $article = getSimpleHTMLDOM($link);\n        defaultLinkTo($article, self::URI);\n        $content = $article->find('article[id=article]', 0);\n        $item['uri'] = $link;\n\n        $author = $article->find('.article__author', 1);\n        if ($author !== null) {\n            $item['author'] = trim($author->plaintext);\n        }\n\n        $createdAt = $article->find('[class=article__release]', 0);\n        if ($createdAt) {\n            $item['timestamp'] = strtotime(str_replace('Uhr', '', $createdAt->plaintext));\n        }\n\n        if ($article->find('h2', 0) === null) {\n            $item['title'] = $article->find('h3', 0)->innertext;\n        } else {\n            $item['title'] = $article->find('h2', 0)->innertext;\n        }\n        $item['content'] = '';\n\n        if ($article->find('section[class*=article__richtext]', 0) === null) {\n            $content = $article->find('div[class*=modul__teaser]', 0)\n                           ->find('p', 0);\n            $item['content'] .= $content;\n        } else {\n            $content = $article->find('article', 0);\n            // change order of article teaser in order to show it on top\n            // of the title image. If we didn't do this some rss programs\n            // would show the subtitle of the title image as teaser instead\n            // of the actuall article teaser.\n            $item['content'] .= $this->getTeaser($content);\n            $item['content'] .= $this->getUseFullContent($content);\n        }\n\n        $categories = $article->find('[class=themen]', 0);\n        if ($categories) {\n            $item['categories'] = [];\n            foreach ($categories->find('a') as $category) {\n                $item['categories'][] = $category->innertext;\n            }\n        }\n\n        $article->clear();\n        return $item;\n    }\n\n    private function handleNewsblock($listSite)\n    {\n        $main = $listSite->find('main', 0);\n        foreach ($main->find('article') as $article) {\n            $url = $article->find('a', 0)->href;\n            $url = urljoin(self::URI, $url);\n            // exclude nn+ articles if desired\n            if (\n                $this->getInput('hideNNPlus') &&\n                str_contains($url, 'www.nn.de')\n            ) {\n                continue;\n            }\n\n            $item = $this->getArticle($url);\n\n            // exclude police reports if desired\n            if (\n                !$this->getInput('policeReports') &&\n                str_contains($item['content'], 'Hier geht es zu allen aktuellen Polizeimeldungen.')\n            ) {\n                continue;\n            }\n\n            // exclude dpa articles\n            if (\n                $this->getInput('hideDPA') &&\n                str_contains($item['author'], 'dpa')\n            ) {\n                continue;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NotAlwaysBridge.php",
    "content": "<?php\n\nclass NotAlwaysBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mozes';\n    const NAME = 'Not Always family';\n    const URI = 'https://notalwaysright.com/';\n    const DESCRIPTION = 'Returns the latest stories';\n    const CACHE_TIMEOUT = 1800; // 30 minutes\n\n    const PARAMETERS = [ [\n                'filter' => [\n                        'type' => 'list',\n                        'name' => 'Filter',\n                        'values' => [\n                                'All' => '',\n                                'Right' => 'right',\n                                'Working' => 'working',\n                                'Romantic' => 'romantic',\n                                'Related' => 'related',\n                                'Learning' => 'learning',\n                                'Hopeless' => 'hopeless',\n                                'Healthy' => 'healthy',\n                                'Legal' => 'legal',\n                                'Friendly' => 'friendly',\n                                'Unfiltered' => 'unfiltered'\n                        ]\n                ]\n        ]];\n\n    public function getIcon()\n    {\n        return self::URI . 'favicon_nar.png';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        foreach ($html->find('.post') as $post) {\n            #print_r($post);\n            $item = [];\n            $item['uri'] = $post->find('h1', 0)->find('a', 0)->href;\n            $postHeader = $post->find('.post_header', 0);\n            $storyContent = $post->find('.storycontent', 0);\n            $item['content'] = $postHeader . '<br/><br/>' . $storyContent;\n            $item['title'] = $post->find('h1', 0)->find('a', 0)->innertext;\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('filter'))) {\n            return $this->getInput('filter') . ' - NotAlways';\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('filter'))) {\n            return self::URI . $this->getInput('filter') . '/';\n        }\n\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/NovayaGazetaEuropeBridge.php",
    "content": "<?php\n\nclass NovayaGazetaEuropeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Novaya Gazeta Europe';\n    const URI = 'https://novayagazeta.eu';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Returns articles from Novaya Gazeta Europe';\n\n    const PARAMETERS = [\n        '' => [\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'defaultValue' => 'ru',\n                'values' => [\n                    'Russian' => 'ru',\n                    'English' => 'en',\n                ]\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Maximum number of items to return',\n                'defaultValue' => 20\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = 'https://novayagazeta.eu/api/v1/get/main';\n        if ($this->getInput('language') != 'ru') {\n            $url .= '?lang=' . $this->getInput('language');\n        }\n\n        $json = getContents($url);\n        $data = json_decode($json);\n\n        foreach ($data->records as $record) {\n            if (!isset($record->blocks)) {\n                continue;\n            }\n            foreach ($record->blocks as $block) {\n                if (!property_exists($block, 'date')) {\n                    continue;\n                }\n                $title = strip_tags($block->title);\n                if (!empty($block->subtitle)) {\n                    $title .= '. ' . strip_tags($block->subtitle);\n                }\n                $item = [\n                    'uri' => self::URI . '/articles/' . $block->slug,\n                    'block' => $block,\n                    'title' => $title,\n                    'author' => join(', ', array_map(function ($author) {\n                        return $author->name;\n                    }, $block->authors)),\n                    'timestamp' => $block->date / 1000,\n                    'categories' => $block->tags\n                ];\n                $this->items[] = $item;\n            }\n        }\n        usort($this->items, function ($item1, $item2) {\n            return $item2['timestamp'] <=> $item1['timestamp'];\n        });\n        if ($this->getInput('limit') !== null) {\n            $this->items = array_slice($this->items, 0, $this->getInput('limit'));\n        }\n        foreach ($this->items as &$item) {\n            $block = $item['block'];\n            $body = '';\n            if (property_exists($block, 'body') && $block->body !== null) {\n                $body = self::convertBody($block);\n            } else {\n                $record_json = getContents(\"https://novayagazeta.eu/api/v1/get/record?slug={$block->slug}\");\n                $record_data = json_decode($record_json);\n                $body = self::convertBody($record_data->record);\n            }\n            $item['content'] = $body;\n            unset($item['block']);\n        }\n    }\n\n    private static function convertBody($data)\n    {\n        $body = '';\n        if ($data->previewUrl !== null && !$data->isPreviewHidden) {\n            $body .= '<figure><img src=\"' . $data->previewUrl . '\"/>';\n            if ($data->previewCaption !== null) {\n                $body .= '<figcaption>' . $data->previewCaption . '</figcaption>';\n            }\n            $body .= '</figure>';\n        }\n        if ($data->lead !== null) {\n            $body .= \"<p><b>{$data->lead}</b></p>\";\n        }\n        if (!empty($data->body)) {\n            foreach ($data->body as $datum) {\n                $body .= self::convertElement($datum);\n            }\n        }\n        return $body;\n    }\n\n    private static function convertElement($datum)\n    {\n        switch ($datum->type) {\n            case 'text':\n                return $datum->data;\n            case 'image/single':\n                $alt = strip_tags($datum->data);\n                $res = \"<figure><img src=\\\"{$datum->previewUrl}\\\" alt=\\\"{$alt}\\\" />\";\n                if ($datum->data !== null) {\n                    $res .= \"<figcaption>{$datum->data}</figcaption>\";\n                }\n                $res .= '</figure>';\n                return $res;\n            case 'text/quote':\n                return \"<figure><blockquote>{$datum->data}</blockquote></figure><br>\";\n            case 'embed/native':\n                if (isset($datum->link)) {\n                    $desc = $datum->link;\n                    if (isset($datum->caption)) {\n                        $desc = $datum->caption;\n                    }\n                    return sprintf('<p><a href=\"%s\">%s</a></p>', $datum->link, $desc);\n                }\n                return '';\n            case 'text/framed':\n                $res = '';\n                if (property_exists($datum, 'typeDisplay')) {\n                    $res .= \"<p><b>{$datum->typeDisplay}</b></p>\";\n                }\n                $res .= \"<p>{$datum->data}</p>\";\n                if (\n                    property_exists($datum, 'attachment')\n                    && property_exists($datum->attachment, 'type')\n                ) {\n                    $res .= self::convertElement($datum->attachment);\n                }\n                return $res;\n            default:\n                return '';\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/NovelUpdatesBridge.php",
    "content": "<?php\n\nclass NovelUpdatesBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'albirew';\n    const NAME = 'Novel Updates';\n    const URI = 'https://www.novelupdates.com';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns releases from Novel Updates';\n    const PARAMETERS = [ [\n        'n' => [\n            'name' => 'Novel name as found in the url',\n            'exampleValue' => 'spirit-realm',\n            'required' => true\n        ]\n    ]];\n\n    private $seriesTitle = '';\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('n'))) {\n            return static::URI . '/series/' . $this->getInput('n') . '/';\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $fullhtml = getSimpleHTMLDOM($this->getURI());\n\n        $this->seriesTitle = $fullhtml->find('div.seriestitlenu', 0)->plaintext;\n        $html = $fullhtml->find('table#myTable tbody', 0);\n        foreach ($html->find('tr') as $element) {\n            $item = [];\n            $item['title'] = $element->find('td', 2)->find('span', 0)->plaintext;\n            $item['author'] = $element->find('a', 0)->plaintext;\n            $item['timestamp'] = strtotime($element->find('td', 0)->plaintext);\n            $item['content'] = $this->seriesTitle\n                . ' - '\n                . $item['title']\n                . ' - by '\n                . $item['author']\n                . '<br>'\n                . $fullhtml->find('div.seriesimg', 0)->innertext;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!empty($this->seriesTitle)) {\n            return $this->seriesTitle . ' - ' . static::NAME;\n        }\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/NpciBridge.php",
    "content": "<?php\n\nclass NpciBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'captn3m0';\n    const NAME = 'NCPI Circulars';\n    const URI = 'https://npci.org.in';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'Returns circulars from National Payments Corporation of India)';\n\n    const URL_SUFFIX = [\n        'cts' => 'circulars',\n        'upi' => 'circular',\n        'rupay' => 'circulars',\n        'nach' => 'circulars',\n        'imps' => 'circular',\n        'netc-fastag' => 'circulars',\n        '99' => 'circular',\n        'nfs' => 'circulars',\n        'aeps' => 'circulars',\n        'bhim-aadhaar' => 'circular',\n        'e-rupi' => 'circular',\n        'Bharat QR' => 'circulars',\n        'bharat-billpay' => 'circulars',\n    ];\n\n    const PARAMETERS = [[\n        'product' => [\n            'name' => 'product',\n            'type' => 'list',\n            'values' => [\n                'CTS' => 'cts',\n                'UPI' => 'upi',\n                'RuPay' => 'rupay',\n                'NACH' => 'nach',\n                'IMPS' => 'imps',\n                'NETC FASTag' => 'netc-fastag',\n                '*99#' => '99',\n                'NFS' => 'nfs',\n                'AePS' => 'aeps',\n                'BHIM Aadhaar' => 'bhim-aadhaar',\n                'e-RUPI' => 'e-rupi',\n                'Bharat BillPay' => 'bharat-billpay'\n            ]\n        ]\n    ]];\n\n    public function getName()\n    {\n        if ($this->getInput('product')) {\n            return 'NPCI Circulars: ' . $this->getKey('product');\n        }\n        return 'NPCI Circulars';\n    }\n\n    public function getURI()\n    {\n        $product = $this->getInput('product');\n        return $product ? sprintf('%s/what-we-do/%s/%s', self::URI, $product, self::URL_SUFFIX[$product]) : self::URI;\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI());\n        $year = date('Y');\n        $elements = $html->find(\"div[id=year$year] .pdf-item\");\n\n        foreach ($elements as $element) {\n            $title = $element->find('p', 0)->innertext;\n\n            $link = $element->find('a', 0);\n\n            $uri = null;\n\n            if ($link) {\n                $pdfLink = $link->getAttribute('href');\n                $uri = self::URI . str_replace(' ', '+', $pdfLink);\n            }\n\n            $item = [\n                'uri' => $uri,\n                'title' => $title,\n                'content' => $title,\n                'uid' => sha1($pdfLink),\n                'enclosures' => [\n                    $uri\n                ]\n            ];\n\n            $this->items[] = $item;\n        }\n\n        $this->items = array_slice($this->items, 0, 15);\n    }\n}\n"
  },
  {
    "path": "bridges/NurembergerNachrichtenBridge.php",
    "content": "<?php\n\nclass NurembergerNachrichtenBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'schabi.org';\n    const NAME = 'Nürnberger Nachrichten';\n    const CACHE_TIMEOUT = 3600;\n    const URI = 'https://www.nn.de';\n    const DESCRIPTION = 'Bridge for NurembergerNachrichten news site nn.de';\n    const PARAMETERS = [ [\n        'region' => [\n            'name' => 'region',\n            'type' => 'list',\n            'exampleValue' => 'Nürnberg',\n            'title' => 'Select a region',\n            'values' => [\n                'Ansbach' => 'ansbach',\n                'Erlangen' => 'erlangen',\n                'Erlangen-Höchstadt' => 'erlangen-hoechstadt',\n                'Forchheim' => 'forchheim',\n                'Fürth' => 'fuerth',\n                'Gunzenhausen' => 'gunzenhausen',\n                'Neumarkt' => 'neumarkt',\n                'Neustadt/Aisch-Bad Windsheim' => 'neustadt-aisch-bad-windsheim',\n                'Nürnberg' => 'nuernberg',\n                'Nürnberger Land' => 'nuernberger-land',\n                'Pegnitz' => 'pegnitz',\n                'Roth' => 'roth',\n                'Schwabach' => 'schwabach',\n                'Weißenburg' => 'weissenburg'\n            ]\n        ],\n        'hideNNPlus' => [\n            'name' => 'Hide NN+ articles',\n            'type' => 'checkbox',\n            'exampleValue' => 'unchecked',\n            'title' => 'Hide all paywall articles on NN'\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $region = $this->getInput('region');\n        if (\n            $region === 'neustadt-aisch-bad-windsheim' ||\n            $region === 'erlangen-hoechstadt' ||\n            $region === ''\n        ) {\n            $region = 'region/' . $region;\n        }\n        $url = self::URI . '/' . $region;\n        $listSite = getSimpleHTMLDOM($url);\n\n        $this->handleNewsblock($listSite);\n    }\n\n    private function handleNewsblock($listSite)\n    {\n        $main = $listSite->find('main', 0);\n        foreach ($main->find('article') as $article) {\n            $url = $article->find('a', 0)->href;\n            $url = urljoin(self::URI, $url);\n\n            $articleContent = getSimpleHTMLDOMCached($url, 86400 * 7);\n\n            // exclude nn+ articles if desired\n            if (\n                $this->getInput('hideNNPlus') &&\n                $articleContent->find('span[class=icon-nnplus]')\n            ) {\n                continue;\n            }\n\n            $item = $this->parseArticle($articleContent, $url);\n            $articleContent->clear();\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function parseArticle($article, $link)\n    {\n        $item = [];\n        defaultLinkTo($article, self::URI);\n\n        $item['uri'] = $link;\n\n        $author = $article->find('.article__author', 1);\n        if ($author !== null) {\n            $item['author'] = trim($author->plaintext);\n        }\n\n        $createdAt = $article->find('[class=article__release]', 0);\n        if ($createdAt) {\n            $item['timestamp'] = strtotime(str_replace('Uhr', '', $createdAt->plaintext));\n        }\n\n        if ($article->find('h2', 0) === null) {\n            $item['title'] = $article->find('h3', 0)->innertext;\n        } else {\n            $item['title'] = $article->find('h2', 0)->innertext;\n        }\n        $item['content'] = '';\n\n        if ($article->find('section[class*=article__richtext]', 0) === null) {\n            $content = $article->find('div[class*=modul__teaser]', 0)->find('p', 0);\n            $item['content'] .= $content;\n        } else {\n            $content = $article->find('article', 0);\n            // change order of article teaser in order to show it on top\n            // of the title image. If we didn't do this some rss programs\n            // would show the subtitle of the title image as teaser instead\n            // of the actuall article teaser.\n            $item['content'] .= $this->getTeaser($content);\n            $item['content'] .= $this->getUseFullContent($content);\n        }\n\n        return $item;\n    }\n\n    private function getTeaser($content)\n    {\n        $teaser = $content->find('p[class=article__teaser]', 0);\n        if ($teaser === null) {\n            return '';\n        }\n        $teaser = $teaser->plaintext;\n        $teaser = preg_replace('/[ ]{2,}/', ' ', $teaser);\n        $teaser = '<p class=\"article__teaser\">' . $teaser . '</p>';\n        return $teaser;\n    }\n\n    private function getUseFullContent($rawContent)\n    {\n        $content = '';\n        foreach ($rawContent->children as $element) {\n            if (\n                ($element->tag === 'p' || $element->tag === 'h3') &&\n                $element->class !== 'article__teaser'\n            ) {\n                $content .= $element;\n            } elseif ($element->tag === 'main') {\n                $content .= $this->getUseFullContent($element->find('article', 0));\n            } elseif ($element->tag === 'header') {\n                $content .= $this->getUseFullContent($element);\n            } elseif (\n                $element->tag === 'div' &&\n                !str_contains($element->class, 'article__infobox') &&\n                !str_contains($element->class, 'authorinfo')\n            ) {\n                $content .= $this->getUseFullContent($element);\n            } elseif (\n                $element->tag === 'section' &&\n                (str_contains($element->class, 'article__richtext') ||\n                    str_contains($element->class, 'article__context'))\n            ) {\n                $content .= $this->getUseFullContent($element);\n            } elseif ($element->tag === 'picture') {\n                $content .= $this->getValidImage($element);\n            } elseif ($element->tag === 'ul') {\n                $content .= $element;\n            }\n        }\n        return $content;\n    }\n\n    private function getValidImage($picture)\n    {\n        $img = $picture->find('img', 0);\n        if ($img) {\n            $imgUrl = $img->src;\n            if (!preg_match('#/logo-.*\\.png#', $imgUrl)) {\n                return '<br><img src=\"' . $imgUrl . '\">';\n            }\n        }\n        return '';\n    }\n}\n"
  },
  {
    "path": "bridges/NvidiaDriverBridge.php",
    "content": "<?php\n\nclass NvidiaDriverBridge extends FeedExpander\n{\n    const NAME = 'NVIDIA Driver Releases';\n    const URI = 'https://www.nvidia.com/Download/processFind.aspx';\n    const DESCRIPTION = 'Fetch the latest NVIDIA driver updates';\n    const MAINTAINER = 'tillcash';\n\n    const PARAMETERS = [\n        'Windows' => [\n            'wwhql' => [\n                'name' => 'Driver Type',\n                'type' => 'list',\n                'values' => [\n                    'All' => '',\n                    'Certified' => '1',\n                    'Studio' => '4',\n                ],\n                'defaultValue' => '1',\n            ],\n        ],\n        'Linux' => [\n            'lwhql' => [\n                'name' => 'Driver Type',\n                'type' => 'list',\n                'values' => [\n                    'All' => '',\n                    'Beta' => '0',\n                    'Branch' => '5',\n                    'Certified' => '1',\n                ],\n                'defaultValue' => '1',\n            ],\n        ],\n        'FreeBSD' => [\n            'fwhql' => [\n                'name' => 'Driver Type',\n                'type' => 'list',\n                'values' => [\n                    'All' => '',\n                    'Beta' => '0',\n                    'Branch' => '5',\n                    'Certified' => '1',\n                ],\n                'defaultValue' => '1',\n            ],\n        ],\n    ];\n\n    private $operatingSystem = '';\n\n    public function collectData()\n    {\n        $parameters = [\n            'lid'   => 1, // en-us\n            'psid'  => 129, // GeForce\n        ];\n\n        switch ($this->queriedContext) {\n            case 'Windows':\n                $whql = $this->getInput('wwhql');\n                $parameters['osid'] = 57;\n                $parameters['dtcid'] = 1; // Windows Driver DCH\n                $parameters['whql'] = $whql;\n                $this->operatingSystem = 'Windows';\n                break;\n            case 'Linux':\n                $whql = $this->getInput('lwhql');\n                $parameters['osid'] = 12;\n                $parameters['whql'] = $whql;\n                $this->operatingSystem = 'Linux';\n                break;\n            case 'FreeBSD':\n                $whql = $this->getInput('fwhql');\n                $parameters['osid'] = 22;\n                $parameters['whql'] = $whql;\n                $this->operatingSystem = 'FreeBSD';\n                break;\n        }\n\n        $url = 'https://www.nvidia.com/Download/processFind.aspx?' . http_build_query($parameters);\n        $dom = getSimpleHTMLDOM($url);\n\n        foreach ($dom->find('tr#driverList') as $element) {\n            $id = str_replace('img_', '', $element->find('img', 0)->id);\n\n            $this->items[] = [\n                'timestamp' => $element->find('td.gridItem', 3)->plaintext,\n                'title'     => sprintf('NVIDIA Driver %s', $element->find('td.gridItem', 2)->plaintext),\n                'uri'       => 'https://www.nvidia.com/Download/driverResults.aspx/' . $id,\n                'content'   => $dom->find('tr#tr_' . $id . ' span', 0)->innertext,\n            ];\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.nvidia.com/favicon.ico';\n    }\n\n    public function getName()\n    {\n        $version = $this->getKey('whql') ?? '';\n        return sprintf('NVIDIA %s %s Driver Releases', $this->operatingSystem, $version);\n    }\n}\n"
  },
  {
    "path": "bridges/NyaaTorrentsBridge.php",
    "content": "<?php\n\nclass NyaaTorrentsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio & Jisagi';\n    const NAME = 'NyaaTorrents';\n    const URI = 'https://nyaa.si/';\n    const DESCRIPTION = 'Returns the newest torrents, with optional search criteria.';\n    const PARAMETERS = [\n        [\n            'f' => [\n                'name' => 'Filter',\n                'type' => 'list',\n                'values' => [\n                    'No filter' => '0',\n                    'No remakes' => '1',\n                    'Trusted only' => '2'\n                ]\n            ],\n            'c' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'values' => [\n                    'All categories' => '0_0',\n                    'Anime' => '1_0',\n                    'Anime - AMV' => '1_1',\n                    'Anime - English' => '1_2',\n                    'Anime - Non-English' => '1_3',\n                    'Anime - Raw' => '1_4',\n                    'Audio' => '2_0',\n                    'Audio - Lossless' => '2_1',\n                    'Audio - Lossy' => '2_2',\n                    'Literature' => '3_0',\n                    'Literature - English' => '3_1',\n                    'Literature - Non-English' => '3_2',\n                    'Literature - Raw' => '3_3',\n                    'Live Action' => '4_0',\n                    'Live Action - English' => '4_1',\n                    'Live Action - Idol/PV' => '4_2',\n                    'Live Action - Non-English' => '4_3',\n                    'Live Action - Raw' => '4_4',\n                    'Pictures' => '5_0',\n                    'Pictures - Graphics' => '5_1',\n                    'Pictures - Photos' => '5_2',\n                    'Software' => '6_0',\n                    'Software - Apps' => '6_1',\n                    'Software - Games' => '6_2',\n                ]\n            ],\n            'q' => [\n                'name' => 'Keyword',\n                'description' => 'Keyword(s)',\n                'type' => 'text'\n            ],\n            'u' => [\n                'name' => 'User',\n                'description' => 'User',\n                'type' => 'text'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $feedParser = new FeedParser();\n        $feed = $feedParser->parseFeed(getContents($this->getURI()));\n\n        foreach ($feed['items'] as $item) {\n            $item['enclosures'] = [$item['uri']];\n            $item['uri'] = str_replace('.torrent', '', $item['uri']);\n            $item['uri'] = str_replace('/download/', '/view/', $item['uri']);\n            $item['id'] = str_replace('https://nyaa.si/view/', '', $item['uri']);\n            $dom = getSimpleHTMLDOMCached($item['uri']);\n            if ($dom) {\n                $description = $dom->find('#torrent-description', 0)->innertext ?? '';\n                $item['content'] = markdownToHtml(html_entity_decode($description));\n\n                $magnet = $dom->find('div.panel-footer.clearfix > a', 1)->href;\n                // can't put raw magnet link in enclosure, this gives information on\n                // magnet contents and works a way to sent magnet value\n                $magnet = 'https://torrent.parts/#' . html_entity_decode($magnet);\n                array_push($item['enclosures'], $magnet);\n            }\n            $this->items[] = $item;\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        $name .= $this->getInput('u') ? ' - ' . $this->getInput('u') : '';\n        $name .= $this->getInput('q') ? ' - ' . $this->getInput('q') : '';\n        $name .= $this->getInput('c') ? ' (' . $this->getKey('c') . ')' : '';\n        return $name;\n    }\n\n    public function getIcon()\n    {\n        return self::URI . 'static/favicon.png';\n    }\n\n    public function getURI()\n    {\n        $params = [\n            'f' => $this->getInput('f'),\n            'c' => $this->getInput('c'),\n            'q' => $this->getInput('q'),\n            'u' => $this->getInput('u'),\n        ];\n        return self::URI . '?page=rss&s=id&o=desc&' . http_build_query($params);\n    }\n}\n"
  },
  {
    "path": "bridges/OLXBridge.php",
    "content": "<?php\n\nclass OLXBridge extends BridgeAbstract\n{\n    const NAME = 'OLX';\n    const DESCRIPTION = <<<'EOF'\nReturns the search results from the OLX auctioning platforms\n(Bulgaria, Kazakhstan, Poland, Portugal, Romania, Ukraine and Uzbekistan only)\nEOF;\n\n    const URI = 'https://www.olx.com';\n    const MAINTAINER = 'wrobelda';\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Search URL',\n            'title' => 'Copy the URL from your browser\\'s address bar after searching for your items and paste it here',\n            'pattern' => '^(https:\\/\\/)?(www.)?olx\\.(bg|kz|pl|pt|ro|ua|uz).*$',\n            'exampleValue' => 'https://www.olx.pl/d/oferty/q-cebula/',\n            'required' => true,\n        ],\n        'includePostsWithoutPricetag' => [\n            'type' => 'checkbox',\n            'name' => 'Include posts without price tag'\n        ],\n        'includeFeaturedPosts' => [\n            'type' => 'checkbox',\n            'name' => 'Include featured posts'\n        ],\n        'shippingOfferedOnly' => [\n            'type' => 'checkbox',\n            'name' => 'Only posts with shipping offered'\n        ]\n    ]];\n\n    private function getHostname()\n    {\n        $scheme = parse_url($this->getInput('url'), PHP_URL_SCHEME);\n        $host = parse_url($this->getInput('url'), PHP_URL_HOST);\n\n        return $scheme . '://' . $host;\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('url')) {\n            # make sure we order by the most recently listed offers\n            $uri = trim(preg_replace('/([?&])search%5Border%5D=[^&]+(&|$)/', '$1', $this->getInput('url')), '?&/');\n            $uri = preg_replace('/([?&])view=[^&]+(&|$)/', '', $uri);\n            $uri .= (parse_url($uri, PHP_URL_QUERY) ? '&' : '?') . 'search%5Border%5D=created_at:desc';\n\n            return $uri;\n        } else {\n            return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        $url = $this->getInput('url');\n        if (!$url) {\n            return parent::getName();\n        }\n\n        $parsedUrl = Url::fromString($url);\n        $paths = explode('/', $parsedUrl->getPath());\n\n        $query = array_reduce($paths, function ($q, $p) {\n            if (preg_match('/^q-(.+)$/i', $p, $matches)) {\n                $q[] = str_replace('-', ' ', urldecode($matches[1]));\n            }\n\n            return $q;\n        });\n\n        if ($query) {\n            return $query[0];\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $html = defaultLinkTo($html, $this->getHostname());\n\n        $isoLang = $html->find('meta[http-equiv=Content-Language]', 0)->content;\n\n        # the second grid, if any, has extended results from additional categories, outside of original search scope\n        $listing_grid = $html->find(\"div[data-testid='listing-grid']\", 0);\n\n        $results = $listing_grid->find(\"div[data-cy='l-card']\");\n\n        foreach ($results as $post) {\n            $item = [];\n\n            if (!$this->getInput('includeFeaturedPosts') && $post->find('div[data-testid=\"adCard-featured\"]', 0)) {\n                continue;\n            }\n\n            $price = $post->find('p[data-testid=\"ad-price\"]', 0)->plaintext ?? '';\n            if (!$this->getInput('includePostsWithoutPricetag') && !$price) {\n                continue;\n            }\n\n            $negotiable = $post->find('p[data-testid=\"ad-price\"] span.css-e2218f', 0)->plaintext ?? false;\n            if ($negotiable) {\n                $price = trim(str_replace($negotiable, '', $price));\n                $negotiable = '(' . $negotiable . ')';\n            }\n\n            if ($post->find('h4', 0)->plaintext != '') {\n                $item['uri'] = $post->find('a', 0)->href;\n                $item['title'] = $post->find('h4', 0)->plaintext;\n            }\n\n            # ignore the date component, as it is too convoluted — use the deep-crawled one; see below\n            $locationAndDate = $post->find('p[data-testid=\"location-date\"]', 0)->plaintext;\n            $locationAndDateArray = explode(' - ', $locationAndDate, 2);\n            $location = trim($locationAndDateArray[0]);\n\n            # OLX only shows 5 results before images get lazy-loaded, so we have to deep-crawl *almost* all the results.\n            # Given that, do deep-crawl *all* the results, which allows to aso obtain the ID, the simplified location\n            # and date strings, as well as the detailed description.\n            $articleHTMLContent = getSimpleHTMLDOMCached($item['uri']);\n            $articleHTMLContent = defaultLinkTo($articleHTMLContent, $this->getHostname());\n\n            $shippingOffered = $articleHTMLContent->find('img[alt=\"Safety Badge\"]', 0)->src ?? false;\n            if ($this->getInput('shippingOfferedOnly') && !$shippingOffered) {\n                continue;\n            }\n\n            # Extract a clean ID without resorting to the convoluted CSS class or sibling selectors. Should be always present.\n            $refreshLink = $articleHTMLContent->find('a[data-testid=refresh-link]', 0)->href ?? false;\n            if ($refreshLink) {\n                parse_str(parse_url($refreshLink, PHP_URL_QUERY), $refreshQuery);\n                $item['uid'] = $refreshQuery['ad-id'];\n            } else {\n                # may be an imported offer from a sibling auto-moto classifieds platform\n                $item['uid'] = $articleHTMLContent->find('span[id=ad_id]', 0)->plaintext;\n            }\n\n            $img = $articleHTMLContent->find('meta[property=\"og:image\"]', 0)->content ?? false;\n            if ($img) {\n                $item['enclosures'] = [$img . '#.image'];\n            }\n\n            $isoDate = $articleHTMLContent->find('meta[property=\"og:updated_time\"]', 0)->content ?? false;\n            if ($isoDate) {\n                $item['timestamp'] = strtotime($isoDate);\n            } else {\n                $date = $articleHTMLContent->find('span[data-cy=\"ad-posted-at\"]', 0)->plaintext;\n                # Relative, today\n                if (preg_match('/^.*\\s(\\d\\d:\\d\\d)$/i', $date, $matches)) {\n                    $item['timestamp'] = strtotime($matches[1]);\n                } else {\n                    # full, localized date\n                    $formatter = new IntlDateFormatter($isoLang, IntlDateFormatter::SHORT, IntlDateFormatter::NONE);\n                    $item['timestamp'] = $formatter->parse($date);\n                }\n            }\n\n            $descriptionHtml = $articleHTMLContent->find('div[data-cy=\"ad_description\"] div', 0)->innertext ?? false;\n            if (!$descriptionHtml) {\n                $descriptionHtml = $articleHTMLContent->find('div[id=\"description\"] div[data-read-more]', 0)->innertext ?? false;\n            }\n\n            $item['categories'] = [];\n            $breadcrumbs = $articleHTMLContent->find('li[data-testid=\"breadcrumb-item\"]');\n            foreach ($breadcrumbs as $breadcrumb) {\n                $category = $breadcrumb->find('a[href!=\"/\"]', 0) ?? false;\n\n                if ($category) {\n                    $item['categories'][] = $category->plaintext;\n                }\n            }\n\n            $parameters = $articleHTMLContent->find('div.parametersArea li');\n            foreach ($parameters as $parameter) {\n                $category = $parameter->find('a', 0)->plaintext ?? false;\n\n                if ($category = empty($category) ? false : trim($category)) {\n                    if ($category == 'Tak') {\n                        $category = $parameter->find('span', 0)->plaintext ?? '';\n                    } elseif ($category == 'Nie') {\n                        continue;\n                    }\n\n                    $item['categories'][] = $category;\n                }\n            }\n\n            $item['content'] = <<<CONTENT\n<table>\n    <tbody>\n      <tr>\n        <td>\n          <p>$location</p>\n          <p><span style=\"font-weight:bold\">$price</span> $negotiable <span><img src=\"$shippingOffered\"</img></span></p>\n        </td>\n      </tr>\n      <tr>\n        <td>$descriptionHtml</td>\n      </tr>\n    </tbody>\n</table>\nCONTENT;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/OMonlineBridge.php",
    "content": "<?php\n\nclass OMonlineBridge extends BridgeAbstract\n{\n    const NAME        = 'OM Online';\n    const URI         = 'https://www.om-online.de';\n    const DESCRIPTION = 'RSS feed for OM Online';\n    const MAINTAINER  = 'jummo4@yahoo.de';\n    const PARAMETERS = [\n                           [\n                               'ort' => [\n                               'name' => 'Ortsname',\n                               'title' => 'Für die Anzeige von Beitragen nur aus einem Ort oder mehreren Orten \n                               geben einen Orstnamen ein. Mehrere Ortsnamen müssen mit / getrennt eingeben werden, \n                               z.B. Vechta/Cloppenburg. Groß- und Kleinschreibung beachten!'\n                               ]\n                           ]\n                       ];\n\n    public function collectData()\n    {\n        if (!empty($this->getInput('ort'))) {\n            $url = sprintf('%s/ort/%s', self::URI, $this->getInput('ort'));\n        } else {\n            $url = sprintf('%s', self::URI);\n        }\n\n        $html = getSimpleHTMLDOM($url);\n\n        $html = defaultLinkTo($html, $url);\n\n        foreach ($html->find('div.molecule-teaser > a ') as $index => $a) {\n            $item = [];\n\n            $articlePath = $a->href;\n\n            $articlePageHtml = getSimpleHTMLDOMCached($articlePath, self::CACHE_TIMEOUT);\n\n            $articlePageHtml = defaultLinkTo($articlePageHtml, self::URI);\n\n            $contents = $articlePageHtml->find('div.molecule-article', 0);\n\n            $item['uri'] = $articlePath;\n            $item['title'] = $contents->find('h1', 0)->innertext;\n\n            $contents->find('div.col-12 col-md-10 offset-0 offset-md-1', 0);\n\n            $item['content'] = $contents->innertext;\n            $item['timestamp'] = $this->extractDate2($a->plaintext);\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    private function extractDate2($text)\n    {\n        $dateRegex = '/^([0-9]{4}\\/[0-9]{1,2}\\/[0-9]{1,2})/';\n\n        $text = trim($text);\n\n        if (preg_match($dateRegex, $text, $matches)) {\n            return $matches[1];\n        }\n\n        return '';\n    }\n}\n"
  },
  {
    "path": "bridges/OglafBridge.php",
    "content": "<?php\n\nclass OglafBridge extends FeedExpander\n{\n    const NAME = 'Oglaf';\n    const URI = 'https://www.oglaf.com/';\n    const DESCRIPTION = 'Fetch the entire comic image';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'limit' => [\n                'name' => 'limit (max 20)',\n                'type' => 'number',\n                'defaultValue' => 10,\n                'required' => true,\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = self::URI . 'feeds/rss/';\n        $limit = min(20, $this->getInput('limit'));\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem($item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $comicImage = $html->find('img[id=\"strip\"]', 0);\n        $alt = $comicImage->getAttribute('alt');\n        $title = $comicImage->getAttribute('title');\n        $item['content'] = $comicImage . sprintf('<h3>Alt: %s</h3><h3>Title: %s</h3>', $alt, $title);\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/OllamaBridge.php",
    "content": "<?php\n\nclass OllamaBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Ollama Blog';\n    const URI = 'https://ollama.com';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'Returns latest blog posts from Ollama';\n\n    const PARAMETERS = [\n        '' => [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI . '/blog/');\n        $limit = $this->getInput('limit');\n\n        $posts = $html->find('main > section > a.group');\n        for ($i = 0; $i < min(count($posts), $limit); $i++) {\n            $post = $posts[$i];\n            $title = $post->find('h2', 0)->plaintext;\n            $date_text = $post->find('h3[datetime]', 0)->getAttribute('datetime');\n            $timestamp = (new DateTime(mb_substr($date_text, 0, 19)))->format('U');\n            $uri = self::URI . $post->getAttribute('href');\n            $this->items[] = [\n                'uri' => $uri,\n                'title' => $title,\n                'timestamp' => $timestamp,\n                'content' => $this->parsePage($uri),\n                'uid' => $uri\n            ];\n        }\n    }\n\n    private function parsePage($uri)\n    {\n        $html = getSimpleHTMLDOMCached(\n            $uri,\n            86400,\n            [],\n            [],\n            true,\n            true,\n            DEFAULT_TARGET_CHARSET,\n            false // Do not strip \\n from <code> blocks\n        );\n        $contents = $html->find('main > article > section.prose', 0);\n        $contents = defaultLinkTo($contents, self::URI);\n        return $contents->innertext;\n    }\n}\n"
  },
  {
    "path": "bridges/OnVaSortirBridge.php",
    "content": "<?php\n\nclass OnVaSortirBridge extends FeedExpander\n{\n    const MAINTAINER = 'AntoineTurmel';\n    const NAME = 'OnVaSortir';\n    const URI = 'https://www.onvasortir.com';\n    const DESCRIPTION = 'Returns the newest events from OnVaSortir (full text)';\n    const PARAMETERS = [\n            [\n            'city' => [\n                'name' => 'City',\n                'type' => 'list',\n                'values' => [\n                    'Agen' => 'Agen',\n                    'Ajaccio' => 'Ajaccio',\n                    'Albi' => 'Albi',\n                    'Amiens' => 'Amiens',\n                    'Angers' => 'Angers',\n                    'Angoulême' => 'Angouleme',\n                    'Annecy' => 'annecy',\n                    'Aurillac' => 'aurillac',\n                    'Auxerre' => 'auxerre',\n                    'Avignon' => 'avignon',\n                    'Béziers' => 'Beziers',\n                    'Bastia' => 'Bastia',\n                    'Beauvais' => 'Beauvais',\n                    'Belfort' => 'Belfort',\n                    'Bergerac' => 'bergerac',\n                    'Besançon' => 'Besancon',\n                    'Biarritz' => 'Biarritz',\n                    'Blois' => 'Blois',\n                    'Bordeaux' => 'bordeaux',\n                    'Bourg-en-Bresse' => 'bourg-en-bresse',\n                    'Bourges' => 'Bourges',\n                    'Brest' => 'Brest',\n                    'Brive' => 'brive-la-gaillarde',\n                    'Bruxelles' => 'bruxelles',\n                    'Caen' => 'Caen',\n                    'Calais' => 'Calais',\n                    'Carcassonne' => 'Carcassonne',\n                    'Châteauroux' => 'Chateauroux',\n                    'Chalon-sur-saone' => 'chalon-sur-saone',\n                    'Chambéry' => 'chambery',\n                    'Chantilly' => 'chantilly',\n                    'Charleroi' => 'charleroi',\n                    'Charleville-Mézières' => 'Charleville-Mezieres',\n                    'Chartres' => 'Chartres',\n                    'Cherbourg' => 'Cherbourg',\n                    'Cholet' => 'cholet',\n                    'Clermont-Ferrand' => 'Clermont-Ferrand',\n                    'Compiègne' => 'compiegne',\n                    'Dieppe' => 'dieppe',\n                    'Dijon' => 'Dijon',\n                    'Dunkerque' => 'Dunkerque',\n                    'Evreux' => 'evreux',\n                    'Fréjus' => 'frejus',\n                    'Gap' => 'gap',\n                    'Genève' => 'geneve',\n                    'Grenoble' => 'Grenoble',\n                    'La Roche sur Yon' => 'La-Roche-sur-Yon',\n                    'La Rochelle' => 'La-Rochelle',\n                    'Lausanne' => 'lausanne',\n                    'Laval' => 'Laval',\n                    'Le Havre' => 'le-havre',\n                    'Le Mans' => 'le-mans',\n                    'Liège' => 'liege',\n                    'Lille' => 'lille',\n                    'Limoges' => 'Limoges',\n                    'Lorient' => 'Lorient',\n                    'Luxembourg' => 'Luxembourg',\n                    'Lyon' => 'lyon',\n                    'Marseille' => 'marseille',\n                    'Metz' => 'Metz',\n                    'Mons' => 'Mons',\n                    'Mont de Marsan' => 'mont-de-marsan',\n                    'Montauban' => 'Montauban',\n                    'Montluçon' => 'montlucon',\n                    'Montpellier' => 'montpellier',\n                    'Mulhouse' => 'Mulhouse',\n                    'Nîmes' => 'nimes',\n                    'Namur' => 'Namur',\n                    'Nancy' => 'Nancy',\n                    'Nantes' => 'nantes',\n                    'Nevers' => 'nevers',\n                    'Nice' => 'nice',\n                    'Niort' => 'niort',\n                    'Orléans' => 'orleans',\n                    'Périgueux' => 'perigueux',\n                    'Paris' => 'paris',\n                    'Pau' => 'Pau',\n                    'Perpignan' => 'Perpignan',\n                    'Poitiers' => 'Poitiers',\n                    'Quimper' => 'Quimper',\n                    'Reims' => 'Reims',\n                    'Rennes' => 'Rennes',\n                    'Roanne' => 'roanne',\n                    'Rodez' => 'rodez',\n                    'Rouen' => 'Rouen',\n                    'Saint-Brieuc' => 'Saint-Brieuc',\n                    'Saint-Etienne' => 'saint-etienne',\n                    'Saint-Malo' => 'saint-malo',\n                    'Saint-Nazaire' => 'saint-nazaire',\n                    'Saint-Quentin' => 'saint-quentin',\n                    'Saintes' => 'saintes',\n                    'Strasbourg' => 'Strasbourg',\n                    'Tarbes' => 'Tarbes',\n                    'Toulon' => 'Toulon',\n                    'Toulouse' => 'Toulouse',\n                    'Tours' => 'Tours',\n                    'Troyes' => 'troyes',\n                    'Valence' => 'valence',\n                    'Vannes' => 'vannes',\n                    'Zurich' => 'zurich',\n                ]\n            ]\n            ]\n    ];\n\n    public function collectData()\n    {\n        $url = 'https://' . $this->getInput('city') . '.onvasortir.com/rss.php';\n        $this->collectExpandableDatas($url);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOMCached($item['uri']);\n        $text = $dom->find('div.corpsMax', 0)->innertext;\n        $item['content'] = utf8_encode($text);\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/OneFortuneADayBridge.php",
    "content": "<?php\n\nclass OneFortuneADayBridge extends BridgeAbstract\n{\n    const NAME = 'One Fortune a Day';\n    const URI = 'https://github.com/fulmeek';\n    const DESCRIPTION = 'Get a fortune quote every single day.';\n    const MAINTAINER = 'fulmeek';\n    const PARAMETERS = [[\n        'time' => [\n            'name'      => 'Time in UTC',\n            'type'      => 'list',\n            'values'    => [\n                '0:00'  => 0,\n                '1:00'  => 1,\n                '2:00'  => 2,\n                '3:00'  => 3,\n                '4:00'  => 4,\n                '5:00'  => 5,\n                '6:00'  => 6,\n                '7:00'  => 7,\n                '8:00'  => 8,\n                '9:00'  => 9,\n                '10:00' => 10,\n                '11:00' => 11,\n                '12:00' => 12,\n                '13:00' => 13,\n                '14:00' => 14,\n                '15:00' => 15,\n                '16:00' => 16,\n                '17:00' => 17,\n                '18:00' => 18,\n                '19:00' => 19,\n                '20:00' => 20,\n                '21:00' => 21,\n                '22:00' => 22,\n                '23:00' => 23,\n            ],\n            'defaultValue' => 5\n        ],\n        'lucky' => [\n            'name' => 'Lucky number (optional)',\n            'type' => 'text'\n        ]\n    ]];\n\n    const LIMIT_ITEMS = 7;\n    const DAY_SECS = 86400;\n\n    public function getDescription()\n    {\n        return self::DESCRIPTION . '<br/>Set a lucky number to get your personal quotes, like ' . mt_rand();\n    }\n\n    public function collectData()\n    {\n        $time = gmmktime((int)$this->getInput('time'), 0, 0);\n        if ($time > time()) {\n            $time -= self::DAY_SECS;\n        }\n\n        for ($i = self::LIMIT_ITEMS; $i > 0; --$i) {\n            $seed = gmdate('Ymd', $time) . $this->getInput('lucky');\n            $quote = $this->getQuote($seed);\n\n            $item['title']      = strftime('%A, %x', $time);\n            $item['content']    = htmlentities($quote, ENT_QUOTES, 'UTF-8');\n            $item['timestamp']  = $time;\n            $item['uid']        = hash('sha1', $seed);\n\n            $this->items[] = $item;\n\n            $time -= self::DAY_SECS;\n        }\n    }\n\n    private function getQuote($seed)\n    {\n        $quotes = explode(\n            '//',\n            <<<QUOTES\nPeople are naturally attracted to you.\n//You learn from your mistakes... You will learn a lot today.\n//If you have something good in your life, don't let it go!\n//What ever you're goal is in life, embrace it visualize it, and for it will be\nyours.\n//Your shoes will make you happy today.\n//You cannot love life until you live the life you love.\n//Be on the lookout for coming events; They cast their shadows beforehand.\n//Land is always on the mind of a flying bird.\n//The man or woman you desire feels the same about you.\n//Meeting adversity well is the source of your strength.\n//A dream you have will come true.\n//Our deeds determine us, as much as we determine our deeds.\n//Never give up. You're not a failure if you don't give up.\n//You will become great if you believe in yourself.\n//There is no greater pleasure than seeing your loved ones prosper.\n//You will marry your lover.\n//A very attractive person has a message for you.\n//You already know the answer to the questions lingering inside your head.\n//It is now, and in this world, that we must live.\n//You must try, or hate yourself for not trying.\n//You can make your own happiness.\n//The greatest risk is not taking one.\n//The love of your life is stepping into your planet this summer.\n//Love can last a lifetime, if you want it to.\n//Adversity is the parent of virtue.\n//Serious trouble will bypass you.\n//A short stranger will soon enter your life with blessings to share.\n//Now is the time to try something new.\n//Wealth awaits you very soon.\n//If you feel you are right, stand firmly by your convictions.\n//If winter comes, can spring be far behind?\n//Keep your eye out for someone special.\n//You are very talented in many ways.\n//A stranger, is a friend you have not spoken to yet.\n//A new voyage will fill your life with untold memories.\n//You will travel to many exotic places in your lifetime.\n//Your ability for accomplishment will follow with success.\n//Nothing astonishes men so much as common sense and plain dealing.\n//Its amazing how much good you can do if you dont care who gets the credit.\n//Everyone agrees. You are the best.\n//LIFE CONSIST NOT IN HOLDING GOOD CARDS, BUT IN PLAYING THOSE YOU HOLD WELL.\n//Jealousy doesn't open doors, it closes them!\n//It's better to be alone sometimes.\n//When fear hurts you, conquer it and defeat it!\n//Let the deeds speak.\n//You will be called in to fulfill a position of high honor and responsibility.\n//The man on the top of the mountain did not fall there.\n//You will conquer obstacles to achieve success.\n//Joys are often the shadows, cast by sorrows.\n//Fortune favors the brave.\n//An upward movement initiated in time can counteract fate.\n//A journey of a thousand miles begins with a single step.\n//Sometimes you just need to lay on the floor.\n//Never give up. Always find a reason to keep trying.\n//If you have something worth fighting for, then fight for it.\n//Stop wishing. Start doing.\n//Accept your past without regrets. Handle your present with confidence. Face\nyour future without fear.\n//Stay true to those who would do the same for you.\n//Ask yourself if what you are doing today is getting you closer to where you\nwant to be tomorrow.\n//Happiness is an activity.\n//Help is always needed but not always appreciated. Stay true to your heart and\nhelp those in need weather they appreciate it or not.\n//Hone your competitive instincts.\n//Finish your work on hand don't be greedy.\n//For success today, look first to yourself.\n//Your fortune is as sweet as a cookie.\n//Integrity is the essence of everything successful.\n//If you're happy, you're successful.\n//You will always be surrounded by true friends\n//Believing that you are beautiful will make you appear beautiful to others\naround you.\n//Happinees comes from a good life.\n//Before trying to please others think of what makes you happy.\n//When hungry, order more Chinese food.\n//Your golden opportunity is coming shortly.\n//For hate is never conquered by hate. Hate is conquered by love .\n//You will make many changes before settling down happily.\n//A man is born to live and not prepare to live.\n//You cannot become rich except by enriching others.\n//Don't pursue happiness - create it.\n//You will be successful in love.\n//All your fingers can't be of the same length.\n//Wise sayings often fall on barren ground, but a kind word is never thrown away.\n//A lifetime of happiness is in store for you.\n//It is very possible that you will achieve greatness in your lifetime.\n//Be tactful; overlook your own opportunity.\n//You are the controller of your destiny.\n//Everything happens for a reson.\n//How can you have a beutiful ending without making beautiful mistakes.\n//You can open doors with your charm and patience.\n//Welcome the change coming into your life.\n//There will be a happy romance for you shortly.\n//Your fondest dream will come true within this year.\n//You have a deep interest in all that is artistic.\n//Your emotional nature is strong and sensitive.\n//A letter of great importance may reach you any day now.\n//Good health will be yours for a long time.\n//You will become better acquainted with a coworker.\n//To be old and wise, you must first be young and stupid.\n//Failure is only the opportunity to begin again more intelligently.\n//Integrity is doing the right thing, even if nobody is watching.\n//Conquer your fears or they will conquer you.\n//You are a lover of words; One day you will write a book.\n//In this life it is not what we take up, but what we give up, that makes us\nrich.\n//Fear can keep us up all night long, but faith makes one fine pillow.\n//Seek out the significance of your problem at this time. Try to understand.\n//Never upset the driver of the car you're in; they're the master of your\ndestiny until you get home.\n//He who slithers among the ground is not always a foe.\n//You learn from your mistakes, you will learn a lot today.\n//You only need look to your own reflection for inspiration. Because you are\nBeautiful!\n//You are not judged by your efforts you put in; you are judged on your\nperformance.\n//Rivers need springs.\n//Good news from afar may bring you a welcome visitor.\n//When all else seems to fail, smile for today and just love someone.\n//Patience is a virtue, unless its against a brick wall.\n//When you look down, all you see is dirt, so keep looking up.\n//If you are afraid to shake the dice, you will never throw a six.\n//Even if the person who appears most wrong, is also quite often right.\n//A single conversation with a wise man is better than ten years of study.\n//Happiness is often a rebound from hard work.\n//The world may be your oyster, but that doesn't mean you'll get it's pearl.\n//Your life will be filled with magical moments.\n//You're true love will show himself to you under the moonlight.\n//Do not follow where the path may lead. Go where there is no path...and leave a\ntrail\n//Do not fear what you don't know\n//The object of your desire comes closer.\n//You have a flair for adding a fanciful dimension to any story.\n//If you wish to know the mind of a man, listen to his words\n//The most useless energy is trying to change what and who God so carefully\ncreated.\n//Do not be covered in sadness or be fooled in happiness they both must exist\n//You will have unexpected great good luck.\n//You will have a pleasant surprise\n//All progress occurs because people dare to be different.\n//Your ability for accomplishment will be followed by success.\n//The world is always ready to receive talent with open arms.\n//Things may come to those who wait, but only the things left by those who\nhustle.\n//We can't help everyone. But everyone can help someone.\n//Every day is a new day. But tomorrow is never promised.\n//Express yourself: Don't hold back!\n//It is not necessary to show others you have change; the change will be obvious.\n//You have a deep appreciation of the arts and music.\n//If your desires are not extravagant, they will be rewarded.\n//You try hard, never to fail. You don't, never to win.\n//Never give up on someone that you don't go a day without thinking about.\n//It never pays to kick a skunk.\n//In case of fire, keep calm, pay bill and run.\n//Next full moon brings an enchanting evening.\n//Not all closed eye is sleeping nor open eye is seeing.\n//Impossible is a word only to be found in the dictionary of fools.\n//You will soon witness a miracle.\n//The time is alway right to do what is right.\n//Love is as necessary to human beings as food and shelter.\n//You will make heads turn.\n//You are extremely loved. Don't worry :)\n//If you are never patient, you will never get anything done. If you believe you\ncan do it, you will be rewarded with success.\n//You will soon embark on a business venture.\n//You believe in the goodness of man kind.\n//You will have a long and wealthy life.\n//You will take a pleasant journey to a place far away.\n//You are a person of culture.\n//Keep it simple. The more you say, the less people remember.\n//Life is like a dogsled team. If you ain't the lead dog, the scenery never\nchanges.\n//Prosperity makes friends and adversity tries them.\n//Nothing seems impossible to you.\n//Patience is bitter, but its fruit is sweet.\n//The only certainty is that nothing is certain.\n//Success is the sum of my unique visions realized by the sweat of perseverance.\n//When you expect your opponent to yield, you also should avoid hurting him.\n//Human evolution: “wider freeway but narrower viewpoints.\n//Intelligence is the door to freedom and alert attention is the mother of\nintelligence.\n//Back away from individuals who are impulsive.\n//Enjoyed the meal? Buy one to go too.\n//You believe in the goodness of mankind.\n//A big fortune will descend upon you this year.\n//Now these three remain, faith, hope, and love. The greatest of these is love.\n//For success today look first to yourself.\n//Determination is the wake-up call to the human will.\n//There are no limitations to the mind except those we aknowledge.\n//A merry heart does good like a medicine.\n//Whenever possible, keep it simple.\n//Your dearest wish will come true.\n//Poverty is no disgrace.\n//If you don’t do it excellently, don’t do it at all.\n//You have an unusual equipment for success, use it properly.\n//Emotion is energy in motion.\n//You will soon be honored by someone you respect.\n//Punctuality is the politeness of kings and the duty of gentle people\neverywhere.\n//Your happiness is intertwined with your outlook on life.\n//Elegant surroundings will soon be yours.\n//If you feel you are right, stand firmly by your convictions.\n//Your smile brings happiness to everyone you meet.\n//Instead of worrying and agonizing, move ahead constructively.\n//Do you believe? Endurance and persistence will be rewarded.\n//A new business venture is on the horizon.\n//Never underestimate the power of the human touch.\n//Hold on to the past but eventually, let the times go and keep the memories\ninto the present.\n//Truth is an unpopular subject. Because it is unquestionably correct.\n//The most important thing in communication is to hear what isn’t being said.\n//You are broad minded and socially active.\n//Your dearest dream is coming true. God looks after you especially.\n//You will recieve some high prize or award.\n//Your present question marks are going to succeed.\n//You have a fine capacity for the enjoyment of life.\n//You will live long and enjoy life.\n//An admirer is concealing his/her affection for you.\n//A wish is what makes life happen when you dream of rose petals.\n//Love can turn cottage into a golden palace.\n//Lend your money and lose your freind.\n//You will kiss your crush ohhh lalahh\n//You will be rewarded for being a good listener in the next week.\n//If you never give up on love, It will never give up on you.\n//Unleash your life force.\n//Your wish will come true.\n//There is a prospect of a thrilling time ahead for you.\n//No distance is too far, if two hearts are tied together.\n//Land is always in the mind of the flying birds.\n//Try? No! Do or do not, there is no try.\n//Do not worry, you will have great peace.\n//It's about time you asked that special someone on a date.\n//You create your own stage ... the audience is waiting.\n//It is never too late. Just as it is never too early.\n//Discover the power within yourself.\n//Good things take time.\n//Stop thinking about the road not taken and pave over the one you did.\n//Put your unhappiness aside. Life is beautiful, be happy.\n//You can still love what you can not have in life.\n//Make a wise choice everyday.\n//Circumstance does not make the man; it reveals him to himself.\n//The man who waits till tomorrow, misses the opportunities of today.\n//Life does not get better by chance. It gets better by change.\n//If you never expect anything you can never be disappointed.\n//People in your surroundings will be more cooperative than usual.\n//True wisdom is found in happiness.\n//Ones always regrets what could have done. Remember for next time.\n//Follow your bliss and the Universe will open doors where there were once only\nwalls.\n//Find a peaceful place where you can make plans for the future.\n//All the water in the world can't sink a ship unless it gets inside.\n//The earth is a school learn in it.\n//In music, one must think with his heart and feel with his brain.\n//If you speak honestly, everyone will listen.\n//Ganerosity will repay itself sooner than you imagine.\n//good things take time\n//Do what is right, not what you should.\n//To effect the quality of the day is no small achievement.\n//Simplicity and clearity should be the theme in your dress.\n//Virtuous find joy while Wrongdoers find grieve in their actions.\n//Not all closed eye is sleeping, nor open eye is seeing.\n//Bread today is better than cake tomorrow.\n//In evrything there is a piece of truth.But a piece.\n//A feeling is an idea with roots.\n//Man is born to live and not prepare to live\n//It's all right to have butterflies in your stomach. Just get them to fly in\nformation.\n//If you don t give something, you will not get anything\n//The harder you try to not be like your parents, the more likely you will\nbecome them\n//Someday everything will all make perfect sense\n//you will think for yourself when you stop letting others think for you\n//Everything will be ok. Don't obsess. Time will prove you right, you must stay\nwhere you are.\n//Let's finish this up now, someone is waiting for you on that\n//The finest men like the finest steels have been tempered in the hottest\nfurnace.\n//A dream you have will come true\n//The worst of friends may become the best of enemies, but you will always find\nyourself hanging on.\n//I think, you ate your fortune while you were eating your cookie\n//If u love someone keep fighting for them\n//Do what you want, when you want, and you will be rewarded\n//Let your fantasies unwind...\n//The cooler you think you are the dumber you look\n//Expect great things and great things will come\n//The Wheel of Good Fortune is finally turning in your direction!\n//Don't lead if you won't lead.\n//You will always be successful in your professional career\n//Share your hapiness with others today.\n//It's up to you to clearify.\n//Your future will be happy and productive.\n//Seize every second of your life and savor it.\n//Those who walk in other's tracks leave no footprints.\n//Failure is the mother of all success.\n//Difficulty at the beginning useually means ease at the end.\n//Do not seek so much to find the answer as much as to understand the question\nbetter.\n//Your way of doing what other people do their way is what makes you special.\n//A beautiful, smart, and loving person will be coming into your life.\n//Friendship is an ocean that you cannot see bottom.\n//Your life does not get better by chance, it gets better by change.\n//Our duty,as men and women,is to proceed as if limits to our ability did not\nexist.\n//A pleasant expeience is ahead:don't pass it by.\n//Our perception and attitude toward any situation will determine the outcome\n//They say you are stubborn; you call it persistence.\n//Two small jumps are sometimes better than one big leap.\n//A new wardrobe brings great joy and change to your life.\n//The cure for grief is motion.\n//It's a good thing that life is not as serious as it seems to the waiter\n//I hear and I forget. I see and I remember. I do and I understand.\n//I have a dream....Time to go to bed.\n//Ideas you believe are absurd ultimately lead to success!\n//A human being is a deciding being.\n//Today is an ideal time to water your parsonal garden.\n//Some men dream of fortunes, others dream of cookies.\n//Things are never quite the way they seem.\n//the project on your mind will soon gain momentum\n//YOUR FAILURES WILL LEAD YOU TO YOUR SUCCESS.\n//IN ORDER TO GET THE RAINBOW, YOU MUST ENDURE THE RAIN.\n//Beauty is simply beauty. originality is magical.\n//Your dream will come true when you least expect it.\n//Let not your hand be stretched out to receive and shut when you should repay.\n//Don't worry, half the people you know are below average.\n//Vision is the art of seeing what is invisible to others.\n//You don't need talent to gain experience.\n//A focused mind is one of the most powerful forces in the universe.\n//Today you shed your last tear. Tomorrow fortune knocks at your door.\n//Be patient! The Great Wall didn't got build in one day.\n//Think you can. Think you can't. Either way, you'll be right.\n//Wisdom is on her way to you.\n//Digital circuits are made from analog parts.\n//If you eat a box of fortune cookies, anything is possible.\n//The best is yet to come.\n//I'm with you.\n//Be direct,usually one can accomplish more that way.\n//A single kind work will keep one warm for years.\n//Ask a friend to join you on your next voyage.\n//In God we trust.\n//Love is free. Lust will cost you everything you have.\n//Stop searching forever, happiness is just next to you.\n//You don't need the answers to all of life's questions. Just ask your father\nwhat to do.\n//Jealousy is a useless emotion.\n//You are not a ghost.\n//There is someone rather annoying in your life that you need to listen to.\n//You will plant the smallest seed and it will become the greatest and most\nmighty tree in the world.\n//The dream you've been dreaming all your life isn't worth it. Find a new dream,\nand once you're sure you've found it, fight for it.\n//See if you can learn anything from the children.\n//It's Never Too Late For Good Things To Happen!\n//A clear conscience is usually the sign of a bad memory.\n//Aim high, time flies.\n//One is not sleeping, does not mean they are awake.\n//A great pleasure in life is doing what others say you can't.\n//Isn't there something else you should be working on right now?\n//Your father still loves and is in always with you. Remember that.\n//Before you can be reborn you must die.\n//It better to be the hammer than the nail.\n//You are admired by everyone for your talent and ability.\n//Save the whales. Collect the whole set.\n//You will soon discover a major truth about the one you love most.\n//Your life will prosper only if you acknowledge your faults and work to reduce\nthem.\n//Pray to God, but row towards shore.\n//You will soon witness a miracle.\n//The early bird gets the worm, but the second mouse gets the cheese\n//Help, I'm being held prisoner in a Chinese cookie factory.\n//Alas! The onion you are eating is someone else’s water lily.\n//You are a persoon with a good sense of justice, now it's time to act like it.\n//You create enthusiasm around you.\n//There are big changes ahead for you. They will be good ones!\n//You will have many happy days soon.\n//Out of confusion comes new patterns.\n//If you love someone enough and they break your heart, you can't stop yourself\nfrom still loving them again even after all that pain.\n//Look right...Now look left...Now look forward (do this really fast) do you\nfeel any different? good you should feel dizzy.\n//Live like you are on the bottom, even if you are on the top.\n//You will soon emerge victorious from the maze you've been traveling in.\n//Do not judge a book by it's color.\n//Everything will come your way.\n//There is a time to be practical now.\n//Bend the rod while it is still hot.\n//Darkness is only succesful when there is no light. Don't forget about light!\n//Acting is not lying. It is findind someone hiding inside you and letting that\nperson run free.\n//You will be forced to face fear, but if you do not run, fear will be afraid of\nyou.\n//You are thinking about doing something. Don't do it, it won't help anything.\n//Your worst enemy has a crush on you!\n//Love Conquers all.\n//The phrase is follow your dreams. Not dream period.\n//stop nagging to your partner and take it day by day.\n//Do not think that me or my brothers have supreme control over what will happen\nto you.\n//Bad luck and misfortune will follow you all your days.\n//Remember the fate of the early Worm.\n//Begin your life anew with strength, grace and wonder.\n//Be a good friend and a fair enemy.\n//What goes around comes around.\n//Bad luck and misfortune will infest your pathetic soul for all eternity.\n//The best prophet of the future is the past\n//Movies have pause buttons, friends do not\n//Use the force.\n//Trust your intuition.\n//Encourage your peers.\n//Let your imagination wander.\n//Your pain is the breaking of the shell that encloses your understanding.\n//Patience is key, a wait short or long will have its reward.\n//Tell them before it's too late...\n//A bird in the hand is worth three in the bush!!\n//Be assertive when decisive action is needed.\n//To determine whether someone is beautiful is not by looking at his/her\nappearance, but his/her heart.\n//Hope brings about a better future\n//While you have this day, fill it with life. While you're in this moment, give\nit your own special meaning and purpose and joy.\n//Even though it will often be difficult and complicated, you know you have what\nit takes to get it done.\n//You can choose, right now and in every moment, to put your powerful and\neffective abilities to purposeful use. There is always something you can do, no\nmatter what the situation may be, that will move your life forward.\n//IT IS NOT GOOD TO BE A USER BLESSINGS COME FROM BEING A GIVER NOT A TAKER.\n//Cookie says, You crack me up\n//You will prosper in the field of wacky inventions.\n//Your tongue is your ambassador.\n//The cure for grief is movement.\n//Love Is At Your Hands Be Glad And Hold On To It.\n//You are often asked if it is in yet.\n//Life to you is a bold and dashing responsibility.\n//Patience is a key to joy.\n//A bargain is something you don't need at a price you can't resist.\n//Today is going to be a disasterous day, be prepared!\n//Stay to your inner-self, you will benefit in many ways.\n//Rarely do great beauty and great virtue dwell together as they do in you.\n//You are talented in many ways.\n//You are the master of every situation.\n//Your problem just got bigger. Think, what have you done.\n//If your cookie still in one piece, buy lotto.\n//Go with the flow will make your transition ever so much easier.\n//Tomorrow Morning,Take a Left Turn As Soon As You Leave Home\n//A metaphor could save your life.\n//Don't wait for your ship to come in, swim out to it\n//There are lessons to be learned by listening to others.\n//If you want the rainbow, you have to tolerate the rain.\n//Volition, Strength, Languages, Freedom and Power rests in you.\n//TOO MANY PEOPLE VOLUNTEER TO CARRY THE STOOL WHEN ITS TIME TO MOVE THE PIANO\n//It takes more than a good memory to have good memories.\n//You are what you are; understand yourself before you react\n//Word to the wise: Don't play leapfrog with a unicorn.........\n//Forgive your enemies, but never forget them.\n//Everything will now come your way\n//Don't worry about the stock market. Invest in family.\n//Your fortune is as sweet as a cookie.\n//It is much easier to look for the bad, than it is to find the good\n//If a person who has caused you pain and suffering has brought you, reconsider\nthat person's value in your life\n//You are worth loving, you are also worth the effort it takes to love you\n//Never trouble trouble till trouble troubles you.\n//Get off to a new start - come out of your shell.\n//Life is a dancefloor,you are the DJ!\n//Cooperate with those who have both know how and integrith.\n//Minor aches today are likely to pay off handsomely tomorrow.\n//You are about to become $8.95 poorer. ($6.95 if you had the buffet)\n//Your mouth may be moving, but nobody is listening.\n//Focus in on the color yellow tomorrow for good luck!\n//The problem with resisting temptation is that it may never come again.\n//All your sorrows will vanish.\n//About time I got out of that cookie.\n//Love will lead the way.\n//The ads revenge is massive success\n//It is best to act with confidence, no matter how little right you have to it.\n//Soon, a visitor shall delight you.\n//What breaks in a moment may take years to mend.\n//Someone stole your fortune and replaced it with this one. Your luck sucks.\nHave a good day!\n//Take control of your life rather than letting things happen just like that!\n//You will be rewarded for your patience and understanding.\n//You will achieve all your desires and pleasures.\n//Never miss a chance to keep your mouth shut.\n//Nothing Shows A Man's Character More Than What He Laughs At.\n//Never regret anything that made you smile.\n//Love Takes Pratice.\n//Don't take yourself so seriously, no one else does.\n//You've got what it takes, but it will take everything you've got!\n//At this very moment you can change the rest of your life.\n//Become who you are.\n//All comes at the proper time to him who knows how to wait.\n//The energy is within you. Money is Coming!\n//The quotes that you do not understand, are not meant for you.\n//You have an important new business development shaping up.\n//if love someone a lot tell it before it's too late\n//Birds are entangled by their feet and men by their tongues.\n//Benefit by doing things that others give up on.\n//Rest has a peaceful effect on your physical and emotional health.\n//One of the best ways to persuade others is with your ears--by listening to\nthem.\n//Plan your work and work your plan.\n//Over self-confidence is equal to being blind.\n//Those who bring sunshine to the lives of others cannot keep it from themselves.\n//Love or money, or neither?\n//Before the beginning of great brilliance, there must be chaos.\n//Old friends make best friends.\n//Stop searching forever. Happiness is just next to you.\n//Accept something that you cannot change, and you will feel better.\n//Kiss is not a kiss without the heart.\n//Enhance your karma by engaging in various charitable activities.\n//You will have good luck and overcome many hardships.\n//You never hesitate to tackle the most difficult problems.\n//Hope is like food. You will starve without it.\n//WHEN FIRE AND WATER GO TO WAR WATER ALWAYS WINS.\n//An angry man opens his mouth and shuts up his eyes.\n//Make the system work for you, not the other way around.\n//You will be hungry soon, order takeout now.\n//Be prepared for extra energy.\n//An unexpected relationship will become permanent.\n//The love of your life is sitting across from you.\n//Better be the head of a chicken than the tail of an ox.\n//To forgive others one more time is to create one more blessing for yourself.\n//Enjoy yourself while you can.\n//The ultimate test of a relationship is to disagree but to hold hands.\n//Excellence is the difference between what I do and what I am capable of.\n//Do not let what you do not have prevent you from using what you do have.\n//What ends on hope does not end at all.\n//People enjoy having you around. Appreciate this.\n//You are admired for your adventuous ways.\n//It's never crowded along the extra mile\n//You are blessed, today is the day to bless others.\n//The Greatest War Sometimes Isn't On The Battlefield But Against Oneself.\n//People in your background will be more co-operative than usual.\n//A good way to stay healthy is to eat more Chinese food.\n//Anyone who dares to be, can never be weak.\n//Affirm it, visualize it, believe it, and it`will actualize itself.\n//The measure of time to your next goal is the measure of your discipline.\n//Help, I'm prisoner in a Chinese bakery!!!\n//Take a minute and let it ride, then take a minute to let it breeze.\n//We are here to love each other, serve each other and uplift each other.\n//If everybody is a worm you should be a glow worm\n//To affirm is to make firm.\n//Remember this: duct tape can fix anything, so don't worry about messing things\nup.\n//You broke my cookie!\n//Failure is not defeat until you stop trying.\n//The days that make us happy make us wise.\n//Men do not fail... they give up trying.\n//Time may fly by. But Memories don't.\n//You will win success in whatever you adopt.\n//You will outdistance all your competitors.\n//You have a great capability to break cookies - use it wisely!\n//AT TIMES IT IS BETTER TO KNOW WHEN EXIT THAN ENTER\n//Money will come to you when you are doing the right thing.\n//When you get something for nothing, you just haven't been billed for it yet.\n//You will discover your hidden talents.\n//You'll advance for with your abilities.\n//When you can't naturally feel upbeat it can sometimes help you to act as if\nyou did.\n//You will overcome difficult times.\n//Your problem just became your stepping stone. Catch the moment.\n//I am a fortune. You just broke my little house. Where will i live now?\n//The majority of the word can't is can.\n//The secret of getting ahead is getting started.\n//Be most affectionate today.\n//Change your thoughts and you change the world.\n//Sing and rejoice, fortune is smiling on you.\n//All the preparation you've done will finally be paying off!\n//A truly great person never puts away the simplicity of a child.\n//Customer service is like taking a bath you have to keep doing it.\n//The expanse of your intelligence is a void no universe could ever fill.\n//Those grapes you cannot taste are always sour.\n//An unexpected aquaintance will resurface.\n//If you want the rainbow, then you have to tolerate the rain.\n//You don't get harmony when everyone sings the same note.\n//The race is not always to the swift, but to those who keep on running.\n//The early bird gets the worm, but the second mouse gets the cheese.\n//The best things in life aren't things.\n//Don't bother looking for fault. The reward for finding it is low.\n//Everything has beauty but not everyone sees it.\n//Nothing is as good or bad as it appears.\n//Never cut what you can untie.\n//Meet your opponent half way. You need the exercise.\n//Laughter is the shortest distance between two people.\n//We cannot change the direction of the wind, but we can adjust our sails.\n//We could learn a lot from crayons: Some of are sharp, some are pretty, some\nhave weird names, and all are different colors. But they all have to learn to\nlive in the same box.\n//Use your instincts now.\n//If you take a single step to your journey, you'll succeed; it's not best to\nfail.\n//In the eyes of lovers, everything is beautiful.\n//Warning, do not eat your fortune.\n//Demonstrate refinement in everything you do.\n//Impossible standards just make life difficult.\n//A different world cannot be build by indifferent people.\n//Q. What is H2O? A. Caring, 2 parts Hug and 1 part Open-mind.\n//All troubles you have can pass away very quickly.\n//Integrity is the essense of everything successful.\n//For true love? Send real roses preserved in 24kt gold!\n//Sometimes the object of the journey is not the end, but the journey itself.\n//Fear is just excitement in need of an attitude adjustment.\n//The food here taste so good, even a cave man likes it.\n//Perhaps you've been focusing too much on spending.\n//Happiness isn't something you remember, it's something you experience.\n//Oops... Wrong cookie.\n//The dream is within you.\n//Love is on its way.\n//Be direct, usually one can accomplish more that way.\n//Use your talents. That's what they are intended for.\n//The troubles you have now will pass away quickly.\n//See the light at the end of the tunnel.\n//Your dream will come true when you least expect it.\n//Don't 'face' reality, let it be the place from which you leap.\n//Fortune smiles upon you today.\n//Believing is doing.\n//Your dynamic eyes have attracted a secret admirer.\n//You know where you are going and how to get there.\n//Go confidently in the direction of your dreams.\n//Your ability to pick a winner will bring you success.\n//Humor usually works at the moment of awkwardness.\n//A good time to finish up old tasks.\n//Stop procrastinating - starting tomorrow\n//Enthusiastic leadership gets you a promotion when you least expect it.\n//You love Chinese food.\n//You are far more influential than you think.\n//Adjust finances, make budgets, to improve your standing.\n//Happiness is not the absence of conflict, but the ability to cope with it.\n//An understanding heart warms all that are graced with it's presense.\n//Your co-workers take pleasure in your great sense of creativity.\n//You are one of the people who goes places in life.\n//Others enjoy your company.\n//When in doubt, let your instincts guide you.\n//A cheerful message is on its way to you.\n//A pleasant surprise is in store for you tonight.\n//you cant go down the right path with out first discovering the path to go down\n//To courageously shoulder the responsibility of one's mistake is character.\n//The joyful energy of the day will have a positive affect on you.\n//You have a strong desire for a home and your family interests come first.\n//Dogs have owners, cats have staff.\n//Be patient: in time, even an egg will walk.\n//You are not a person who can be ignored.\n//You always know the right times to be assertive or to simply wait.\n//Reading to the mind is what exercise is to the body.\n//Eat something you never tried before.\n//Your life becomes more and more of an adventure!\n//You need to live authentically, and you can't ignore that.\n//Make all you can, save all you can, give all you can.\n//A well-aimed spear is worth three.\n//To build a better world, start in your community.\n//When you can't naturally feel upbeat, it can sometimes help to act a if you\ndid.\n//May you have great luck.\n//A kind word will keep someone warm for years.\n//Nothing in the world is accomplished without passion.\n//Human invented language to satisfy the need to complain.\n//Accept what comes to you each day.\n//A small lucky package is on its way to you soon.\n//In human endeavor, chance favors the prepared mind.\n//Do not upset the penguin today.\n//Don't cry.\n//The best way to give credit is to give it away.\n//Anything you do, do it well. The last thing you want is to be sorry for what\nyou didn't do.\n//It takes more then good memory to have good memories.\n//Grant yourself a wish this year only you can do it.\n//love thy neighbour, just don't get caught\n//You will be selected for a promotion because of your accomplishments.\n//There are many new opportunities that are being presented to you.\n//You will inherit a large sum of money.\n//You will recieve a gift from someone that cares about you.\n//You are not illiterate.\n//Love because it is the only true adventure.\n//You are contemplating some action which will bring credit upon you\n//Keep true to the dreams of your youth.\n//Treasure what you have.\n//The greatest precept is continual awareness.\n//A new friend helps you break out of an old routine.\n//I have a dream.... Time to go to bed.\n//Your skill will accomplish what the force of many cannot.\n//You will soon be surrounded by good friends and laughter.\n//The best is yet to come.\n//It is better to be the hammer then the anvil.\n//He who climbs a ladder must begin at the first step.\n//Action speaks nothing, without the Motive.\n//Give yourself some peace and quiet for at least a few hours.\n//Live each day well and wisely\n//Old dreams never die they just get filed away.\n//You can fix it with a little extra energy and a positive attitude.\n//Life is a verb\n//A man without aim is like a clock without hands, as useless if it turns as if\nit stands.\n//Many folks are about as happy as they make up their minds to be.\n//It's kind of fun to do the impossible\n//Wow! A secret message from you teeth!\n//You should be able to make money and hold on to it.\n//The human spirit is stronger than anything that can happen to it.\n//Your succeess will astonish everyone.\n//It is better to have a hen tomorrow than an egg today.\n//Judge each day not by the harvest you reap but by the seeds you plant.\n//You like Chinese food.\n//Your hard work will get payoff today.\n//Today is the tomorrow we worried about yesterday\n//There are no shortcuts to any place worth going\n//No matter what your past has been, you have a spotless future.\n//Your secret desire to completely change your life will manifest.\n//Soon you will be sitting on top of the world.\n//You are never selfish with your advice or your help.\n//A thrilling time is in store for you.\n//It's tough to be fascinating.\n//Soon life will become more interesting\n//Luck sometimes visits a fool, but it never sits down with him.\n//Keep your plans secret for now.\n//Aren't you glad you just had a great meal?\n//Traveling this year will bring your life into greater perspective.\n//Only talent people get help from others.\n//Constant grinding can turn an iron nod into a needle.\n//You will be successful in your work\n//you will spend old age in confort and material wealth\n//When you're about to turn your heart into a stone remember: you do not walk\nalone.\n//I am a bad luck person since I was born\n//You are vigorous in words and action.\n//The one who snores will always fall asleep first.\n//An alien of some sort will be appearing to you shortly!\n//Rest is a good thing, but boredom is its brother.\n//Do not be overly judgemental of your loved one's intentions or actions.\n//Think of how you can assist on a problem, not who to blame.\n//The life of every woman or man - the heart of it - is pure and holy joy.\n//Take it easy\n//Trust your intuition. The universe is guiding your life.\n//Use your head, but live in your heart.\n//Don't find fault, find a remedy\n//It may be those who do most, dream most\n//Your passions sweep you away.\n//Listen to yourself more often\n//Think of mother's exhortations more.\n//The gambler is like the fisherman both have beginners luck.\n//You are given the chance to take part in an exciting adventure.\n//The simplest answer is to act.\n//You will always be surrounded by true friends.\n//Keep your feet on the ground even though friends flatter you.\n//You are the man of righteousness and integrity.\n//He who seeks will find.\n//The smart thing to do is to begin trusting your intuitions.\n//Your many hidden talents will become obvious to those around you.\n//Pick a path with heart.\n//The human spirit is stronger then anything that can happen to it.\n//It takes more than good memory to have good memories.\n//Face facts with dignity.\n//Be calm when confronting an emergency crisis.\n//Do you believe? Endurance and persistence will be rewarded.\n//A new wardrobe brings great joy and change in your life.\n//Everyone agrees you are the best.\n//A new outlook brightens your image and brings new friends.\n//Everything will now come your way.\n//You will be called to fill a position of high honor and responsibility.\n//The eyes believe themselves; the ears believe other people.\n//Good beginning is half done.\n//Some pursue happiness; you create it.\n//It's the worst of times, you need to summon your optimism.\n//You are cautious in showing your true self to others.\n//Your ability to accomplish tasks will follow with success.\n//We all have extraordinary coded within us, waiting to be released.\n//You will have a bright future.\n//Compassion is a way of being.\n//You will always have good luck in your personal affairs.\n//The pleasure of what we enjoy is lost by wanting more\n//Did you remember to order your take out also?\n//Perhaps you've been focusing too much on that one thing..\n//Right now there's an energy pushing you in a new direction.\n//Everybody feels lucky for having you as a friend.\n//When the moment comes, take the top one.\n//Sometimes travel to new places leads to great transformation.\n//There is always a way - if you are committed.\n//Life is too short to waste time hating anyone.\n//All the world may not love a lover but they will be watching him.\n//Don't just spend time, invest it.\n//Life always gets harder near the summit.\n//Take the chance while you still have the choice.\n//It is much easier to be cirtical than to be correct.\n//Enjoy life! It is better to be happy than wise.\n//To make the cart go, you must grease the wheels.\n//You are contemplating some action which will bring credit upon you.\n//Before you wonder Am I doing things right, ask Am I doing the right things?\n//You may be disappointed if you fail, but you are doomed if you don't try.\n//You will always get what you want through your charm and personality.\n//The big issues are work, career, or status right now.\n//Your emotional currents are flowing powerfully now.\n//Any decision you have to make tomorrow is a good decsion.\n//Consume less. Share more. Enjoy life.\n//The secret of staying young is good health and lying about your age.\n//Spring has sprung. Life is blooming.\n//Go ask your mom.\n//The possibility of a career change is near.\n//The important thing is to never stop questioning.\n//Compassion will cure more then condemnation.\n//Excuses are easy to manufacture, and hard to sell.\n//Put your mind into planning today. Look into the future.\n//Listen to life, and you will hear the voice of life crying, Be!\n//Broke is only temporaryl poor is a state of mind.\n//Here we go. Moo Shu Cereal for breakfast with duck sauce.\n//Teamwork: the fuel that allows common people attain uncommon results.\n//Hard words break no bones, fine words butter no parsnips.\n//We cannot direct the wind but we can adjust the sails.\n//You are offered the dream of a lifetime. Say yes!\n//Working out the kinks today will make for a better tomorrow.\n//You have a curious smile and a mysterious nature.\n//Questions provide the key to unlocking our unlimited potential.\n//You will enjoy razon-sharp spiritual vision today.\n//The wise are aware of their treasure, while fools follow their vanity\n//Well-arranged time is the surest sign of a well-arranged mind.\n//Never bring unhappy feelings into your home.\n//This is really a lovely day. Congratulations!\n//Bad luck and ill misfortune will infest your pathetic soul for all eternity.\n//A golden egg of opportunity falls into your lap this month.\n//You are very grateful for the small pleasures of life.\n//today you should be a passenger. Stay close to a driver for a day.\n//For hate is never conquered by hate. Hate is conquered by love.\n//Service is the rent we pay for the privilege of living on this planet.\n//Good clothes open many doors. Go shopping.\n//The leader seeks to communicate his vision to his followers.\n//Great works are performed not by strength, but by perseverance.\n//People who are late are often happier than those who have to wait for them\n//Present your best ideas today to an eager and welcoming audience.\n//Friends long absent are coming back to you.\n//The time is right to make new friends.\n//Life to you is a dashing and bold adventure\n//You may be hungry soon: order a takeout now.\n//Do not hesitate to look for help, an extra hand should always be welcomed.\n//How can you have a beautiful ending without making beautiful mistakes?\n//Humor is an affirmation of dignity\n//He who climbs a ladder must begin at the first step\n//What's vice today may be virtue tomorow.\n//You have an unusually magnetic personality.\n//You will travel to many places.\n//Accept yourself\n//Be a generous friend and a fair enemy\n//Never quit!\n//Old friends, old wines and old gold are best\n//If your desires are not extravagant, they will be granted\n//Every Friend Joys in your Success\n//You should be able to undertake and complete anything\n//You will enjoy good health, you will be surrounded by luxury\n//You are a person of strong sense of duty\n//Dream lofty dreams, and as you dream, so shall you become.\n//You have a quiet and unobtrusive nature.\n//Great thoughts come from the heart.\n//You love peace\n//Judge not according to the appearance.\n//One who admires you greatly is hidden before your eyes.\n//Traveling more often is important for your health and happiness.\n//You will be sharing great news with all people you love\n//You have a reputation for being straightforward and honest.\n//You are always welcome in any gathering.\n//You will be traveling and coming into a fortune.\n//Open up your heart - it can always be closed again.\n//Being happy is not always being perfect.\n//Next time you have the opportunity, go on a rollercoaster.\n//Try everything once, even the things you don't think you will like.\n//Life is too short to hold grudges.\n//Dream your dream and your dream will dream of you.\n//Being alone and being lonely are two different things.\n//Don't worry about things in the past, there is nothing you can do about them\nnow. Don't worry about things that are happening now, make the best of a bad\nsituation. Don't worry about things in the future, they may never happen.\n//Tomorrow, take a moment to do something just for yourself.\n//Someone close to you is waiting for you to call.\n//A virtual fortune cookie will not satisfy your hunger like that of a home made\none.\n//Smile. Tomorrow is another day.\n//You can never been certain of success, but you can be certain of failure if\nyou never try.\n//It takes ten times as many muscles to frown as it does to smile.\n//Shoot for the moon! If you miss you will still be amongst the stars.\n//Keep your eyes open. You never know what you might see.\n//Tell them what you really think. Otherwise, nothing will change.\n//Let your heart make your decisions - it does not get as confused as your head.\n//Working hard will make you live a happy life.\n//A pleasant surprise is waiting for you.\nQUOTES\n        );\n\n        $i = round(fmod(hexdec(hash('crc32', $seed)), count($quotes)), 0);\n        return trim(str_replace([\"\\r\\n\", \"\\n\", \"\\r\"], ' ', $quotes[$i]));\n    }\n}\n"
  },
  {
    "path": "bridges/OpenCVEBridge.php",
    "content": "<?php\n\nclass OpenCVEBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'OpenCVE';\n    const URI = 'https://opencve.io';\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n    const DESCRIPTION = 'A feed for search results from OpenCVE';\n\n    const PARAMETERS = [\n        '' => [\n            'instance' => [\n                'name' => 'OpenCVE Instance',\n                'required' => true,\n                'defaultValue' => 'https://app.opencve.io',\n                'exampleValue' => 'https://app.opencve.io'\n            ],\n            'login' => [\n                'name' => 'Login',\n                'type' => 'text',\n                'required' => true\n            ],\n            'password' => [\n                'name' => 'Password',\n                'type' => 'text',\n                'required' => true\n            ],\n            'pages' => [\n                'name' => 'Number of pages',\n                'type' => 'number',\n                'required' => false,\n                'exampleValue' => 1,\n                'defaultValue' => 1\n            ],\n            'filter' => [\n                'name' => 'Filter',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => 'search:jenkins;product:gitlab,cvss:critical',\n                'title' => 'Syntax: param1:value1,param2:value2;param1query2:param2query2. See https://docs.opencve.io/api/cve/ for parameters'\n            ],\n            'upd_timestamp' => [\n                'name' => 'Use updated_at instead of created_at as timestamp',\n                'type' => 'checkbox'\n            ],\n            'trunc_summary' => [\n                'name' => 'Truncate summary for header',\n                'type' => 'number',\n                'defaultValue' => 100\n            ],\n            'fetch_contents' => [\n                'name' => 'Fetch detailed contents for CVEs',\n                'defaultValue' => 'checked',\n                'type' => 'checkbox'\n            ]\n        ]\n    ];\n\n    const CSS = '\n      <style>\n        .cvss-na-color {\n          background-color: #d2d6de;\n          color: #000;\n        }\n        .cvss-low-color {\n          background-color: #0073b7;\n          color: #fff;\n        }\n        .cvss-medium-color {\n          background-color: #f39c12;\n          color: #fff;\n        }\n        .cvss-high-color {\n          background-color: #dd4b39;\n          color: #fff;\n        }\n        .cvss-crit-color {\n          background-color: #972b1e;\n          color: #fff;\n        }\n        .label {\n          padding: .2em .6em .3em;\n          font-size: 75%;\n          font-weight: 700;\n          line-height: 1;\n          text-align: center;\n          white-space: nowrap;\n          border-radius: .25em;\n        }\n        .labels-row {\n           display: flex;\n           flex-direction: row;\n           align-items: center;\n           white-space: nowrap;\n           overflow: hidden;\n           margin-bottom: 6px;\n        }\n        .labels-row div {\n           margin-right: 6px;\n        }\n        .cvss-table table {\n           border-collapse: collapse;\n           width: 100%;\n           margin-bottom: 12px;\n        }\n        .cvss-table td, th {\n           border: 1px solid #dddddd;\n           text-align: left;\n           padding: 8px;\n        }\n    </style>';\n\n    public function collectData()\n    {\n        $creds = $this->getInput('login') . ':' . $this->getInput('password');\n        $authHeader = 'Authorization: Basic ' . base64_encode($creds);\n        $instance = $this->getInput('instance');\n\n        $queries = [];\n        $filter = $this->getInput('filter');\n        $filterValues = [];\n        if ($filter && mb_strlen($filter) > 0) {\n            $filterValues = explode(';', $filter);\n        } else {\n            $queries[''] = [];\n        }\n        foreach ($filterValues as $filterValue) {\n            $params = explode(',', $filterValue);\n            $queryName = $filterValue;\n            $query = [];\n            foreach ($params as $param) {\n                [$key, $value] = explode(':', $param);\n                if ($key == 'title') {\n                    $queryName = $value;\n                } else {\n                    $query[$key] = $value;\n                }\n            }\n            $queries[$queryName] = $query;\n        }\n\n        $fetchedIds = [];\n\n        foreach ($queries as $queryName => $query) {\n            for ($i = 1; $i <= $this->getInput('pages'); $i++) {\n                $queryPaginated = array_merge($query, ['page' => $i]);\n                $url = $instance . '/api/cve?' . http_build_query($queryPaginated);\n\n                $response = getContents($url, [$authHeader]);\n\n                $titlePrefix = '';\n                if (count($queries) > 1) {\n                    $titlePrefix = '[' . $queryName . '] ';\n                }\n\n                foreach (json_decode($response)->results as $cveItem) {\n                    if (array_key_exists($cveItem->cve_id, $fetchedIds)) {\n                        continue;\n                    }\n                    $fetchedIds[$cveItem->cve_id] = true;\n                    $item = [\n                        'uri' => $instance . '/cve/' . $cveItem->cve_id,\n                        'uid' => $cveItem->cve_id,\n                    ];\n                    if ($this->getInput('upd_timestamp') == 1) {\n                        $item['timestamp'] = strtotime($cveItem->updated_at);\n                    } else {\n                        $item['timestamp'] = strtotime($cveItem->created_at);\n                    }\n                    if ($this->getInput('fetch_contents')) {\n                        [$content, $title] = $this->fetchContents(\n                            $cveItem,\n                            $titlePrefix,\n                            $instance,\n                            $authHeader\n                        );\n                        $item['content'] = $content;\n                        $item['title'] = $title;\n                    } else {\n                        $item['content'] = $cveItem->description . $this->getLinks($cveItem->cve_id);\n                        $item['title'] = $this->getTitle($titlePrefix, $cveItem);\n                    }\n                    $this->items[] = $item;\n                }\n            }\n        }\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] - $a['timestamp'];\n        });\n    }\n\n    private function getTitle($titlePrefix, $cveItem)\n    {\n        $summary = $cveItem->description;\n        $limit = $this->getInput('limit');\n        if ($limit && mb_strlen($summary) > 100) {\n            $summary = mb_substr($summary, 0, $limit) + '...';\n        }\n        return $titlePrefix . $cveItem->cve_id . '. ' . $summary;\n    }\n\n    private function fetchContents($cveItem, $titlePrefix, $instance, $authHeader)\n    {\n        $url = $instance . '/api/cve/' . $cveItem->cve_id;\n\n        $response = getContents($url, [$authHeader]);\n        $datum = json_decode($response);\n\n        $title = $this->getTitleFromDatum($datum, $titlePrefix);\n\n        $result = self::CSS;\n        $result .= '<h1>' . $cveItem->cve_id . '</h1>';\n        $result .= $this->getCVSSLabels($datum);\n        $result .= '<p>' . $datum->description . '</p>';\n        $result .= <<<EOD\n            <h3>Information:</h3>\n            <p>\n              <ul>\n                <li><b>Created At</b>: {$datum->created_at}\n                <li><b>Updated At</b>: {$datum->updated_at}\n              </ul>\n            </p>\n            EOD;\n\n        if (isset($datum->metrics->cvssV4_0->data->vector)) {\n            $result .= $this->cvssV4VectorToTable($datum->metrics->cvssV4_0->data->vector);\n        }\n\n        if (isset($datum->metrics->cvssV3_1->data->vector)) {\n            $result .= $this->cvssV3VectorToTable($datum->metrics->cvssV3_1->data->vector);\n        }\n\n        if (isset($datum->metrics->cvssV3_0->data->vector)) {\n            $result .= $this->cvssV3VectorToTable($datum->metrics->cvssV3_0->data->vector);\n        }\n\n        if (isset($datum->metrics->cvssV2_0->data->vector)) {\n            $result .= $this->cvssV2VectorToTable($datum->metrics->cvssV2_0->data->vector);\n        }\n\n        $result .= $this->getLinks($datum->cve_id);\n        $result .= $this->getVendors($datum);\n\n        return [$result, $title];\n    }\n\n    private function getTitleFromDatum($datum, $titlePrefix)\n    {\n        $title = $titlePrefix;\n        if (isset($datum->metrics->cvssV4_0->data->score)) {\n            $title .= \"[v4: {$datum->metrics->cvssV4_0->data->score}] \";\n        }\n        if (isset($datum->metrics->cvssV3_1->data->score)) {\n            $title .= \"[v3.1: {$datum->metrics->cvssV3_1->data->score}] \";\n        }\n        if (isset($datum->metrics->cvssV3_0->data->score)) {\n            $title .= \"[v3: {$datum->metrics->cvssV3_0->data->score}] \";\n        }\n        if (isset($datum->metrics->cvssV2_0->data->score)) {\n            $title .= \"[v2: {$datum->metrics->cvssV2_0->data->score}] \";\n        }\n        $title .= $datum->cve_id . '. ';\n        $titlePostfix = $datum->description;\n        $limit = $this->getInput('limit');\n        if ($limit && mb_strlen($titlePostfix) > 100) {\n            $titlePostfix = mb_substr($titlePostfix, 0, $limit) + '...';\n        }\n        $title .= $titlePostfix;\n        return $title;\n    }\n\n    private function getCVSSLabels($datum)\n    {\n        $cvss4 = '';\n        $cvss31 = '';\n        $cvss3 = '';\n        $cvss2 = '';\n        if (isset($datum->metrics->cvssV4_0->data->score)) {\n            $cvss4 = $this->formatCVSSLabel($datum->metrics->cvssV4_0->data->score, '4.0', 9, 7, 4);\n        }\n        if (isset($datum->metrics->cvssV3_1->data->score)) {\n            $cvss31 = $this->formatCVSSLabel($datum->metrics->cvssV3_1->data->score, '3.1', 9, 7, 4);\n        }\n        if (isset($datum->metrics->cvssV3_0->data->score)) {\n            $cvss3 = $this->formatCVSSLabel($datum->metrics->cvssV3_0->data->score, '3.0', 9, 7, 4);\n        }\n        if (isset($datum->metrics->cvssV2_0->data->score)) {\n            $cvss2 = $this->formatCVSSLabel($datum->metrics->cvssV2_0->data->score, '2.0', 99, 7, 4);\n        }\n\n        return '<div class=\"labels-row\">' . $cvss4 . $cvss31 . $cvss3 . $cvss2 . '</div>';\n    }\n\n    private function formatCVSSLabel($score, $version, $critical_thr, $high_thr, $medium_thr)\n    {\n        $text = 'n/a';\n        $class = 'cvss-na-color';\n        if ($score) {\n            $importance = '';\n            if ($score >= $critical_thr) {\n                $importance = 'CRITICAL';\n                $class = 'cvss-crit-color';\n            } else if ($score >= $high_thr) {\n                $importance = 'HIGH';\n                $class = 'cvss-high-color';\n            } else if ($score >= $medium_thr) {\n                $importance = 'MEDIUM';\n                $class = 'cvss-medium-color';\n            } else {\n                $importance = 'LOW';\n                $class = 'cvss-low-color';\n            }\n            $text = sprintf('[%s] %.1f', $importance, $score);\n        }\n        $item = \"<div>CVSS {$version}: </div><div class=\\\"label {$class}\\\">{$text}</div>\";\n        return $item;\n    }\n\n    private function getLinks($id)\n    {\n        return <<<EOD\n            <h3>Links</h3>\n            <p>\n              <ul>\n                <li>NVD Link: <a href=\"https://nvd.nist.gov/vuln/detail/{$id}\">{$id}</a>\n                <li>MITRE Link: <a href=\"https://cve.mitre.org/cgi-bin/cvename.cgi?name={$id}\">{$id}</a>\n                <li>CVE.ORG Link: <a href=\"https://www.cve.org/CVERecord?id={$id}\">{$id}</a>\n              </ul>\n            </p>\n            EOD;\n    }\n\n    private function cvssV3VectorToTable($cvssVector)\n    {\n        $vectorComponents = [];\n        $parts = explode('/', $cvssVector);\n\n        if (!preg_match('/^CVSS:3\\.[01]/', $parts[0])) {\n            return 'Error: Not a valid CVSS v3.0 or v3.1 vector';\n        }\n\n        for ($i = 1; $i < count($parts); $i++) {\n            $component = explode(':', $parts[$i]);\n            if (count($component) == 2) {\n                $vectorComponents[$component[0]] = $component[1];\n            }\n        }\n\n        $readableNames = [\n            'AV' => ['N' => 'Network', 'A' => 'Adjacent', 'L' => 'Local', 'P' => 'Physical'],\n            'AC' => ['L' => 'Low', 'H' => 'High'],\n            'PR' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'UI' => ['N' => 'None', 'R' => 'Required'],\n            'S'  => ['U' => 'Unchanged', 'C' => 'Changed'],\n            'C'  => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'I'  => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'A'  => ['N' => 'None', 'L' => 'Low', 'H' => 'High']\n        ];\n\n        $data = new stdClass();\n        $data->attackVector = isset($readableNames['AV'][$vectorComponents['AV']]) ? $readableNames['AV'][$vectorComponents['AV']] : 'Unknown';\n        $data->attackComplexity = isset($readableNames['AC'][$vectorComponents['AC']]) ? $readableNames['AC'][$vectorComponents['AC']] : 'Unknown';\n        $data->privilegesRequired = isset($readableNames['PR'][$vectorComponents['PR']]) ? $readableNames['PR'][$vectorComponents['PR']] : 'Unknown';\n        $data->userInteraction = isset($readableNames['UI'][$vectorComponents['UI']]) ? $readableNames['UI'][$vectorComponents['UI']] : 'Unknown';\n        $data->scope = isset($readableNames['S'][$vectorComponents['S']]) ? $readableNames['S'][$vectorComponents['S']] : 'Unknown';\n        $data->confidentialityImpact = isset($readableNames['C'][$vectorComponents['C']]) ? $readableNames['C'][$vectorComponents['C']] : 'Unknown';\n        $data->integrityImpact = isset($readableNames['I'][$vectorComponents['I']]) ? $readableNames['I'][$vectorComponents['I']] : 'Unknown';\n        $data->availabilityImpact = isset($readableNames['A'][$vectorComponents['A']]) ? $readableNames['A'][$vectorComponents['A']] : 'Unknown';\n\n        $html = '<div class=\"cvss-table\">\n              <h3>CVSS v3 details</h3>\n              <table>\n                <tr>\n                  <td>Attack vector</td><td>' . $data->attackVector . '</td>\n                  <td>Confidentiality Impact</td><td>' . $data->confidentialityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Attack complexity</td><td>' . $data->attackComplexity . '</td>\n                  <td>Integrity Impact</td><td>' . $data->integrityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Privileges Required</td><td>' . $data->privilegesRequired . '</td>\n                  <td>Availability Impact</td><td>' . $data->availabilityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>User Interaction</td><td>' . $data->userInteraction . '</td>\n                  <td>Scope</td><td>' . $data->scope . '</td>\n                </tr>\n              </table>\n            </div>';\n\n        return $html;\n    }\n\n    private function cvssV2VectorToTable($cvssVector)\n    {\n        $vectorComponents = [];\n        $parts = explode('/', $cvssVector);\n\n        foreach ($parts as $part) {\n            $component = explode(':', $part);\n            if (count($component) == 2) {\n                $vectorComponents[$component[0]] = $component[1];\n            }\n        }\n\n        $readableNames = [\n            'AV' => ['L' => 'Local', 'A' => 'Adjacent Network', 'N' => 'Network'],\n            'AC' => ['H' => 'High', 'M' => 'Medium', 'L' => 'Low'],\n            'Au' => ['M' => 'Multiple', 'S' => 'Single', 'N' => 'None'],\n            'C'  => ['N' => 'None', 'P' => 'Partial', 'C' => 'Complete'],\n            'I'  => ['N' => 'None', 'P' => 'Partial', 'C' => 'Complete'],\n            'A'  => ['N' => 'None', 'P' => 'Partial', 'C' => 'Complete']\n        ];\n\n        $metricValues = [\n            'AV' => ['L' => 0.395, 'A' => 0.646, 'N' => 1.0],\n            'AC' => ['H' => 0.35, 'M' => 0.61, 'L' => 0.71],\n            'Au' => ['M' => 0.45, 'S' => 0.56, 'N' => 0.704],\n            'C'  => ['N' => 0, 'P' => 0.275, 'C' => 0.660],\n            'I'  => ['N' => 0, 'P' => 0.275, 'C' => 0.660],\n            'A'  => ['N' => 0, 'P' => 0.275, 'C' => 0.660]\n        ];\n\n        $confImpact = isset($metricValues['C'][$vectorComponents['C']]) ? $metricValues['C'][$vectorComponents['C']] : 0;\n        $integImpact = isset($metricValues['I'][$vectorComponents['I']]) ? $metricValues['I'][$vectorComponents['I']] : 0;\n        $availImpact = isset($metricValues['A'][$vectorComponents['A']]) ? $metricValues['A'][$vectorComponents['A']] : 0;\n\n        $impact = 10.41 * (1 - (1 - $confImpact) * (1 - $integImpact) * (1 - $availImpact));\n\n        $av = isset($metricValues['AV'][$vectorComponents['AV']]) ? $metricValues['AV'][$vectorComponents['AV']] : 0;\n        $ac = isset($metricValues['AC'][$vectorComponents['AC']]) ? $metricValues['AC'][$vectorComponents['AC']] : 0;\n        $au = isset($metricValues['Au'][$vectorComponents['Au']]) ? $metricValues['Au'][$vectorComponents['Au']] : 0;\n\n        $exploitability = 20 * $av * $ac * $au;\n\n        $impact = round($impact, 1);\n        $exploitability = round($exploitability, 1);\n\n        $data = new stdClass();\n        $data->accessVector = isset($readableNames['AV'][$vectorComponents['AV']]) ? $readableNames['AV'][$vectorComponents['AV']] : 'Unknown';\n        $data->accessComplexity = isset($readableNames['AC'][$vectorComponents['AC']]) ? $readableNames['AC'][$vectorComponents['AC']] : 'Unknown';\n        $data->authentication = isset($readableNames['Au'][$vectorComponents['Au']]) ? $readableNames['Au'][$vectorComponents['Au']] : 'Unknown';\n        $data->confidentialityImpact = isset($readableNames['C'][$vectorComponents['C']]) ? $readableNames['C'][$vectorComponents['C']] : 'Unknown';\n        $data->integrityImpact = isset($readableNames['I'][$vectorComponents['I']]) ? $readableNames['I'][$vectorComponents['I']] : 'Unknown';\n        $data->availabilityImpact = isset($readableNames['A'][$vectorComponents['A']]) ? $readableNames['A'][$vectorComponents['A']] : 'Unknown';\n\n        $v2 = new stdClass();\n        $v2->impactScore = $impact;\n        $v2->exploitabilityScore = $exploitability;\n\n        $html = '<div class=\"cvss-table\">\n              <h3>CVSS v2 details</h3>\n              <table>\n                <tr>\n                  <td>Impact score</td><td>' . $v2->impactScore . '</td>\n                  <td>Exploitability score</td><td>' . $v2->exploitabilityScore . '</td>\n                </tr>\n                <tr>\n                  <td>Access Vector</td><td>' . $data->accessVector . '</td>\n                  <td>Confidentiality Impact</td><td>' . $data->confidentialityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Access Complexity</td><td>' . $data->accessComplexity . '</td>\n                  <td>Integrity Impact</td><td>' . $data->integrityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Authentication</td><td>' . $data->authentication . '</td>\n                  <td>Availability Impact</td><td>' . $data->availabilityImpact . '</td>\n                </tr>\n              </table>\n            </div>';\n\n        return $html;\n    }\n\n    private function cvssV4VectorToTable($cvssVector)\n    {\n        $vectorComponents = [];\n        $parts = explode('/', $cvssVector);\n\n        if (!preg_match('/^CVSS:4\\.0/', $parts[0])) {\n            return 'Error: Not a valid CVSS v4.0 vector';\n        }\n\n        for ($i = 1; $i < count($parts); $i++) {\n            $component = explode(':', $parts[$i]);\n            if (count($component) == 2) {\n                $vectorComponents[$component[0]] = $component[1];\n            }\n        }\n\n        $readableNames = [\n            'AV' => ['N' => 'Network', 'A' => 'Adjacent', 'L' => 'Local', 'P' => 'Physical'],\n            'AC' => ['L' => 'Low', 'H' => 'High'],\n            'AT' => ['N' => 'None', 'P' => 'Present'],\n            'PR' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'UI' => ['N' => 'None', 'P' => 'Passive', 'A' => 'Active'],\n            'VC' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'VI' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'VA' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'SC' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'SI' => ['N' => 'None', 'L' => 'Low', 'H' => 'High'],\n            'SA' => ['N' => 'None', 'L' => 'Low', 'H' => 'High']\n        ];\n\n        $data = new stdClass();\n        $data->attackVector = isset($readableNames['AV'][$vectorComponents['AV']]) ? $readableNames['AV'][$vectorComponents['AV']] : 'Unknown';\n        $data->attackComplexity = isset($readableNames['AC'][$vectorComponents['AC']]) ? $readableNames['AC'][$vectorComponents['AC']] : 'Unknown';\n        $data->privilegesRequired = isset($readableNames['PR'][$vectorComponents['PR']]) ? $readableNames['PR'][$vectorComponents['PR']] : 'Unknown';\n        $data->attackRequirements = isset($readableNames['AT'][$vectorComponents['AT']]) ? $readableNames['AT'][$vectorComponents['AT']] : 'Unknown';\n        $data->userInteraction = isset($readableNames['UI'][$vectorComponents['UI']]) ? $readableNames['UI'][$vectorComponents['UI']] : 'Unknown';\n        $data->confidentialityImpact = isset($readableNames['VC'][$vectorComponents['VC']]) ? $readableNames['VC'][$vectorComponents['VC']] : 'Unknown';\n        $data->integrityImpact = isset($readableNames['VI'][$vectorComponents['VI']]) ? $readableNames['VI'][$vectorComponents['VI']] : 'Unknown';\n        $data->availabilityImpact = isset($readableNames['VA'][$vectorComponents['VA']]) ? $readableNames['VA'][$vectorComponents['VA']] : 'Unknown';\n        $data->confidentialityImpactS = isset($readableNames['SC'][$vectorComponents['SC']]) ? $readableNames['SC'][$vectorComponents['SC']] : 'Unknown';\n        $data->integrityImpactS = isset($readableNames['SI'][$vectorComponents['SI']]) ? $readableNames['SI'][$vectorComponents['SI']] : 'Unknown';\n        $data->availabilityImpactS = isset($readableNames['SA'][$vectorComponents['SA']]) ? $readableNames['SA'][$vectorComponents['SA']] : 'Unknown';\n\n        $html = '<div class=\"cvss-table\">\n              <h3>CVSS v4.0 details</h3>\n              <table>\n                <tr>\n                  <td>Attack vector</td><td>' . $data->attackVector . '</td>\n                  <td>Vulnerable System Confidentiality Impact</td><td>' . $data->confidentialityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Attack complexity</td><td>' . $data->attackComplexity . '</td>\n                  <td>Vulnerable System Integrity Impact</td><td>' . $data->integrityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Privileges Required</td><td>' . $data->privilegesRequired . '</td>\n                  <td>Vulnerable System Availability Impact</td><td>' . $data->availabilityImpact . '</td>\n                </tr>\n                <tr>\n                  <td>Attack Requirements</td><td>' . $data->attackRequirements . '</td>\n                  <td>Subsequent System Confidentiality Impact</td><td>' . $data->confidentialityImpactS . '</td>\n                </tr>\n                <tr>\n                  <td>User Interaction</td><td>' . $data->userInteraction . '</td>\n                  <td>Subsequent System Integrity Impact</td><td>' . $data->integrityImpactS . '</td>\n                </tr>\n                <tr>\n                  <td></td><td></td>\n                  <td>Subsequent System Avaliablity Impact</td><td>' . $data->availabilityImpactS . '</td>\n                </tr>\n              </table>\n            </div>';\n\n        return $html;\n    }\n\n\n    private function getVendors($datum)\n    {\n        if (count((array)$datum->vendors) == 0) {\n            return '';\n        }\n\n        $vendor_data = [];\n        foreach ($datum->vendors as $vendor_str) {\n            $pieces = explode('$PRODUCT$', $vendor_str);\n            if (count($pieces) == 1) {\n                $vendor = $pieces[0];\n                if (!array_key_exists($vendor, $vendor_data)) {\n                    $vendor_data[$vendor] = [];\n                }\n            } else {\n                $vendor = $pieces[0];\n                $product = $pieces[1];\n                if (!array_key_exists($vendor, $vendor_data)) {\n                    $vendor_data[$vendor] = [];\n                }\n                array_push($vendor_data[$vendor], $product);\n            }\n        }\n\n        $res = '<h3>Affected products</h3><p><ul>';\n        foreach ($vendor_data as $vendor => $products) {\n            $res .= \"<li>{$vendor}\";\n            if (count($products) > 0) {\n                $res .= '<ul>';\n                foreach ($products as $product) {\n                    $res .= '<li>' . $product . '</li>';\n                }\n                $res .= '</ul>';\n            }\n            $res .= '</li>';\n        }\n        $res .= '</ul></p>';\n        return $res;\n    }\n}\n"
  },
  {
    "path": "bridges/OpenwhydBridge.php",
    "content": "<?php\n\nclass OpenwhydBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'kranack';\n    const NAME = 'Openwhyd';\n    const URI = 'https://openwhyd.org';\n    const CACHE_TIMEOUT = 600; // 10min\n    const DESCRIPTION = 'Returns 10 newest music from user profile';\n\n    const PARAMETERS = [ [\n        'u' => [\n            'name' => 'username/id',\n            'exampleValue' => '5247f0267e91c862b2b052d0',\n            'required' => true\n        ]\n    ]];\n\n    private $userName = '';\n\n    public function getIcon()\n    {\n        return self::URI . '/images/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $html = '';\n        if (strlen(preg_replace('/[^0-9a-f]/', '', $this->getInput('u'))) == 24) {\n            // is input the userid ?\n            $html = getSimpleHTMLDOM(\n                self::URI . '/u/' . preg_replace('/[^0-9a-f]/', '', $this->getInput('u'))\n            );\n        } else { // input may be the username\n            $html = getSimpleHTMLDOM(\n                self::URI . '/search?q=' . urlencode($this->getInput('u'))\n            );\n\n            for ($j = 0; $j < 5; $j++) {\n                if (strtolower($html->find('div.user', $j)->find('a', 0)->plaintext) == strtolower($this->getInput('u'))) {\n                    $html = getSimpleHTMLDOM(\n                        self::URI . $html->find('div.user', $j)->find('a', 0)->getAttribute('href')\n                    );\n                    break;\n                }\n            }\n        }\n        $this->userName = $html->find('div#profileTop', 0)->find('h1', 0)->plaintext;\n\n        for ($i = 0; $i < 10; $i++) {\n            $track = $html->find('div.post', $i);\n            $item = [];\n            $item['author'] = $track->find('h2', 0)->plaintext;\n            $item['title'] = $track->find('h2', 0)->plaintext;\n            $item['content'] = $track->find('a.thumb', 0) . '<br/>' . $track->find('h2', 0)->plaintext;\n            $item['id'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href');\n            $item['uri'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href');\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        return (!empty($this->userName) ? $this->userName . ' - ' : '') . 'Openwhyd';\n    }\n}\n"
  },
  {
    "path": "bridges/OpenwrtSecurityBridge.php",
    "content": "<?php\n\nclass OpenwrtSecurityBridge extends BridgeAbstract\n{\n    const NAME = 'OpenWrt Security Advisories';\n    const URI = 'https://openwrt.org/advisory/start';\n    const DESCRIPTION = 'Security Advisories published by openwrt.org';\n    const MAINTAINER = 'mschwld';\n    const CACHE_TIMEOUT = 3600;\n    const WEBROOT = 'https://openwrt.org';\n\n    public function collectData()\n    {\n        $item = [];\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $advisories = $html->find('div[class=plugin_nspages]', 0);\n\n        foreach ($advisories->find('a[class=wikilink1]') as $element) {\n            $item = [];\n\n            $row = $element->innertext;\n\n            $item['title'] = substr($row, 0, strpos($row, ' - '));\n            $item['timestamp'] = $this->getDate($element->href);\n            $item['uri'] = self::WEBROOT . $element->href;\n            $item['uid'] = self::WEBROOT . $element->href;\n            $item['content'] = substr($row, strpos($row, ' - ') + 3);\n            $item['author'] = 'OpenWrt Project';\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getDate($href)\n    {\n        $date = substr($href, -12);\n        return $date;\n    }\n}\n"
  },
  {
    "path": "bridges/OtrkeyFinderBridge.php",
    "content": "<?php\n\nclass OtrkeyFinderBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mibe';\n    const NAME = 'OtrkeyFinder';\n    const URI = 'https://otrkeyfinder.com';\n    const URI_TEMPLATE = 'https://otrkeyfinder.com/en/?search=%s&order=&page=%d';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns the newest .otrkey files matching the search criteria.';\n    const PARAMETERS = [\n        [\n            'searchterm' => [\n                'name' => 'Search term',\n                'exampleValue' => 'Tatort',\n                'title' => 'The search term is case-insensitive',\n            ],\n            'station' => [\n                'name' => 'Station name',\n                'exampleValue' => 'ARD',\n            ],\n            'type' => [\n                'name' => 'Media type',\n                'type' => 'list',\n                'values' => [\n                    'any' => '',\n                    'Detail' => [\n                        'HD' => 'HD.avi',\n                        'AC3' => 'HD.ac3',\n                        'HD &amp; AC3' => 'HD.',\n                        'HQ' => 'HQ.avi',\n                        'AVI' => 'g.avi',   // 'g.' to exclude HD.avi and HQ.avi (filename always contains 'mpg.')\n                        'MP4' => '.mp4',\n                    ],\n                ],\n            ],\n            'minTime' => [\n                'name' => 'Min. running time',\n                'type' => 'number',\n                'title' => 'The minimum running time in minutes. The resolution is 5 minutes.',\n                'exampleValue' => '90',\n                'defaultValue' => '0',\n            ],\n            'maxTime' => [\n                'name' => 'Max. running time',\n                'type' => 'number',\n                'title' => 'The maximum running time in minutes. The resolution is 5 minutes.',\n                'exampleValue' => '120',\n                'defaultValue' => '0',\n            ],\n            'pages' => [\n                'name' => 'Number of pages',\n                'type' => 'number',\n                'title' => 'Specifies the number of pages to fetch. Increase this value if you get an empty feed.',\n                'exampleValue' => '5',\n                'defaultValue' => '5',\n            ],\n        ]\n    ];\n    // Example: Terminator_20.04.13_02-25_sf2_100_TVOON_DE.mpg.avi.otrkey\n    // The first group is the running time in minutes\n    const FILENAME_REGEX = '/_(\\d+)_TVOON_DE\\.mpg\\..+\\.otrkey/';\n    // year.month.day_hour-minute with leading zeros\n    const TIME_REGEX = '/\\d{2}\\.\\d{2}\\.\\d{2}_\\d{2}-\\d{2}/';\n    const CONTENT_TEMPLATE = '<ul>%s</ul>';\n    const MIRROR_TEMPLATE = '<li><a href=\"https://otrkeyfinder.com%s\">%s</a></li>';\n\n    public function collectData()\n    {\n        $pages = $this->getInput('pages');\n\n        for ($page = 1; $page <= $pages; $page++) {\n            $uri = $this->buildUri($page);\n\n            $html = getSimpleHTMLDOMCached($uri, self::CACHE_TIMEOUT);\n\n            $keys = $html->find('div.otrkey');\n\n            foreach ($keys as $key) {\n                $temp = $this->buildItem($key);\n\n                if ($temp != null) {\n                    $this->items[] = $temp;\n                }\n            }\n\n            // Sleep for 0.5 seconds to don't hammer the server.\n            usleep(500000);\n        }\n    }\n\n    private function buildUri($page)\n    {\n        $searchterm = $this->getInput('searchterm');\n        $station = $this->getInput('station');\n        $type = $this->getInput('type');\n\n        // Combine all three parts to a search query by separating them with white space\n        $search = implode(' ', [$searchterm, $station, $type]);\n        $search = trim($search);\n        $search = urlencode($search);\n\n        return sprintf(self::URI_TEMPLATE, $search, $page);\n    }\n\n    private function buildItem(simple_html_dom_node $node)\n    {\n        $file = $this->getFilename($node);\n\n        if ($file == null) {\n            return null;\n        }\n\n        $minTime = $this->getInput('minTime');\n        $maxTime = $this->getInput('maxTime');\n\n        // Do we need to check the running time?\n        if ($minTime != 0 || $maxTime != 0) {\n            if ($maxTime > 0 && $maxTime < $minTime) {\n                throwClientException('The minimum running time must be less than the maximum running time.');\n            }\n\n            preg_match(self::FILENAME_REGEX, $file, $matches);\n\n            if (!isset($matches[1])) {\n                return null;\n            }\n\n            $time = (int)$matches[1];\n\n            // Check for minimum running time\n            if ($minTime > 0 && $minTime > $time) {\n                return null;\n            }\n\n            // Check for maximum running time\n            if ($maxTime > 0 && $maxTime < $time) {\n                return null;\n            }\n        }\n\n        $item = [];\n        $item['title'] = $file;\n\n        // The URI_TEMPLATE for querying the site can be reused here\n        $item['uri'] = sprintf(self::URI_TEMPLATE, $file, 1);\n\n        $content = $this->buildContent($node);\n\n        if ($content != null) {\n            $item['content'] = $content;\n        }\n\n        if (preg_match(self::TIME_REGEX, $file, $matches) === 1) {\n            $item['timestamp'] = DateTime::createFromFormat(\n                'y.m.d_H-i',\n                $matches[0],\n                new DateTimeZone('Europe/Berlin')\n            )->getTimestamp();\n        }\n\n        return $item;\n    }\n\n    private function getFilename(simple_html_dom_node $node)\n    {\n        $file = $node->find('.file', 0);\n\n        if ($file == null) {\n            return null;\n        }\n\n        // Sometimes there is HTML in the filename - we don't want that.\n        // To filter that out, enumerate to the node which contains the text only.\n        foreach ($file->nodes as $node) {\n            if ($node->nodetype == HDOM_TYPE_TEXT) {\n                return trim($node->innertext);\n            }\n        }\n\n        return null;\n    }\n\n    private function buildContent(simple_html_dom_node $node)\n    {\n        $mirrors = $node->find('div.mirror');\n        $list = '';\n\n        // Build list of available mirrors\n        foreach ($mirrors as $mirror) {\n            $anchor = $mirror->find('a', 0);\n            $list .= sprintf(self::MIRROR_TEMPLATE, $anchor->href, $anchor->innertext);\n        }\n\n        return sprintf(self::CONTENT_TEMPLATE, $list);\n    }\n}\n"
  },
  {
    "path": "bridges/OvertakeBridge.php",
    "content": "<?php\n\nclass OvertakeBridge extends FeedExpander\n{\n    const NAME = 'Overtake News';\n    const URI = 'https://www.overtake.gg/';\n    const DESCRIPTION = 'Get the latest (sim)racing news from Overtake.';\n    const MAINTAINER = 't0stiman';\n    const DONATION_URI = 'https://ko-fi.com/tostiman';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://www.overtake.gg/ams/index.rss', 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOMCached($item['uri']);\n\n        $coverImage = $articlePage->find('img.js-articleCoverImage', 0);\n        #relative url -> absolute url\n        $coverImage = str_replace('src=\"/', 'src=\"' . $this->getURI() . '/', $coverImage);\n        $article = $articlePage->find('article.articleBody-main > div.bbWrapper', 0);\n        $item['content'] = str_get_html($coverImage . $article);\n\n        //convert iframes to links. meant for embedded videos.\n        foreach ($item['content']->find('iframe') as $found) {\n            $iframeUrl = $found->getAttribute('src');\n\n            if ($iframeUrl) {\n                $found->outertext = '<a href=\"' . $iframeUrl . '\">' . $iframeUrl . '</a>';\n            }\n        }\n\n        $item['categories'] = [];\n        foreach ($articlePage->find('a.tagItem') as $tag) {\n            array_push($item['categories'], $tag->innertext);\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/PanneauPocketBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass PanneauPocketBridge extends BridgeAbstract\n{\n    const NAME = 'Panneau Pocket';\n    const URI = 'https://app.panneaupocket.com';\n    const DESCRIPTION = 'Fetches the latest infos from Panneau Pocket';\n    const MAINTAINER = 'floviolleau';\n    const PARAMETERS = [\n        [\n            'city' => [\n                'name' => 'City slug',\n                'exampleValue' => '508884409-hadol-88220',\n                'required' => true,\n            ],\n        ],\n    ];\n    const CACHE_TIMEOUT = 7200; // 2h\n\n    private $cityName = '';\n\n    public function getName()\n    {\n        return $this->cityName !== '' ? $this->cityName : self::NAME;\n    }\n\n    public function collectData()\n    {\n        $citySlug = $this->getInput('city');\n        $cityUrl = self::URI . '/ville/' . $citySlug;\n\n        if (!filter_var($cityUrl, FILTER_VALIDATE_URL)) {\n            throwServerException('Invalid city slug: ' . $citySlug);\n        }\n\n        $dom = getSimpleHTMLDOM($cityUrl);\n\n        $this->cityName = $this->extractCityName($dom);\n\n        $notices = $dom->find('div.sign-carousel--item');\n        if (!is_array($notices)) {\n            throwServerException('Invalid or empty content');\n        }\n\n        foreach ($notices as $notice) {\n            $a = $notice->find('button.dropdown-item', 0);\n            $url = $a->href ?? '';\n\n            if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {\n                continue;\n            }\n\n            $title = $notice->find('.sign-preview__content .title', 0);\n            $content = $notice->find('.sign-preview__content .content', 0);\n            $date = $notice->find('span.date', 0);\n\n            $this->items[] = [\n                'uid' => $url,\n                'uri' => $url,\n                'title' => $title ? trim($title->plaintext) : '',\n                'timestamp' => $date ? $this->extractDate($date->plaintext) : '',\n                'content' => $content ? sanitize($content->innertext) : '',\n            ];\n        }\n    }\n\n    private function extractCityName($dom)\n    {\n        $city = $dom->find('.sign-preview__title .infos .city', 0);\n        if (!$city) {\n            return '';\n        }\n\n        $cityName = trim($city->plaintext);\n        if ($cityName === '') {\n            return '';\n        }\n\n        $postcode = $dom->find('.sign-preview__title .infos .postcode', 0);\n        if ($postcode) {\n            $postcodeValue = trim($postcode->plaintext);\n            if ($postcodeValue !== '') {\n                return $cityName . ' - ' . $postcodeValue;\n            }\n        }\n\n        return $cityName;\n    }\n\n    private function extractDate($text)\n    {\n        $text = trim($text);\n\n        if (!preg_match('~(\\d{2})/(\\d{2})/(\\d{4})$~', $text, $match)) {\n            return '';\n        }\n\n        [, $day, $month, $year] = $match;\n\n        if (!checkdate((int)$month, (int)$day, (int)$year)) {\n            return '';\n        }\n\n        return mktime(0, 0, 0, (int)$month, (int)$day, (int)$year);\n    }\n}\n"
  },
  {
    "path": "bridges/ParksOnTheAirBridge.php",
    "content": "<?php\n\nclass ParksOnTheAirBridge extends BridgeAbstract\n{\n    const MAINTAINER = 's0lesurviv0r';\n    const NAME = 'Parks On The Air Spots';\n    const URI = 'https://pota.app/#';\n    const API_URI = 'https://api.pota.app/spot/activator';\n    const CACHE_TIMEOUT = 60; // 1m\n    const DESCRIPTION = 'Parks On The Air Activator Spots';\n\n    public function collectData()\n    {\n        $header = ['Content-type:application/json'];\n        $opts = [CURLOPT_HTTPGET => 1];\n        $json = getContents(self::API_URI, $header, $opts);\n\n        $spots = json_decode($json, true);\n\n        foreach ($spots as $spot) {\n            $title = $spot['activator'] . ' @ ' . $spot['reference'] . ' ' .\n                $spot['frequency'] . ' kHz';\n            $park_link = self::URI . '/park/' . $spot['reference'];\n\n            $content = <<<EOL\n<a href=\"{$park_link}\">\n{$spot['reference']}, {$spot['name']}</a><br />\nLocation: {$spot['locationDesc']}<br />\nFrequency: {$spot['frequency']} kHz<br />\nSpotter: {$spot['spotter']}<br />\nComments: {$spot['comments']}\nEOL;\n\n            $this->items[] = [\n                'uri' => $park_link,\n                'title' => $title,\n                'content' => $content,\n                'timestamp' => $spot['spotTime']\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ParlerBridge.php",
    "content": "<?php\n\nfinal class ParlerBridge extends BridgeAbstract\n{\n    const NAME = 'Parler.com';\n    const URI = 'https://parler.com';\n    const DESCRIPTION = 'Fetches the latest posts from a parler user';\n    const MAINTAINER = 'dvikan';\n    const CACHE_TIMEOUT = 60 * 15; // 15m\n    const PARAMETERS = [\n        [\n            'user' => [\n                'name' => 'User',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'NigelFarage',\n            ],\n            'limit' => self::LIMIT,\n        ]\n    ];\n\n    public function collectData()\n    {\n        $user = trim($this->getInput('user'));\n        if (preg_match('#^https?://parler\\.com/(\\w+)#i', $user, $m)) {\n            $user = $m[1];\n        }\n        $json = getContents(sprintf('https://api.parler.com/v0/public/user/%s/feed/?page=1&limit=20&media_only=0', $user));\n        $response = Json::decode($json, false);\n        $data = $response->data ?? null;\n        if (!$data) {\n            throw new \\Exception('The returned data is empty');\n        }\n        foreach ($data as $post) {\n            $item = [\n                'title'     => $post->body,\n                'uri'       => sprintf('https://parler.com/feed/%s', $post->postuuid),\n                'author'    => $post->user->username,\n                'uid'       => $post->postuuid,\n                'content'   => $post->body,\n            ];\n            $date = $post->date_created;\n            $createdAt = date_create($date);\n            if ($createdAt) {\n                $item['timestamp'] = $createdAt->getTimestamp();\n            }\n            if (isset($post->image)) {\n                $item['content'] .= sprintf('<img loading=\"lazy\" src=\"%s\">', $post->image);\n            }\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ParuVenduImmoBridge.php",
    "content": "<?php\n\nclass ParuVenduImmoBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'polo2ro';\n    const NAME = 'Paru Vendu Immobilier';\n    const URI = 'https://www.paruvendu.fr';\n    const CACHE_TIMEOUT = 10800; // 3h\n    const DESCRIPTION = 'Returns the ads from the first page of search result.';\n\n    const PARAMETERS = [ [\n        'minarea' => [\n            'name' => 'Minimal surface m²',\n            'type' => 'number'\n        ],\n        'maxprice' => [\n            'name' => 'Max price',\n            'type' => 'number'\n        ],\n        'pa' => [\n            'name' => 'Country code',\n            'exampleValue' => 'FR'\n        ],\n        'lo' => [\n            'name' => 'department numbers or postal codes, comma-separated'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $elements = $html->find('#bloc_liste > div.ergov3-annonce a');\n\n        foreach ($elements as $element) {\n            if (!$element->title) {\n                continue;\n            }\n\n            $img = '';\n            foreach ($element->find('span.img img') as $img) {\n                if ($img->original) {\n                    $img = '<img src=\"' . $img->original . '\" />';\n                }\n            }\n\n            $description = $element->find('p', 0);\n            if ($description) {\n                $desc = str_replace(\"voir l'annonce\", '', $description->innertext);\n            } else {\n                $desc = '';\n            }\n\n            $priceElement = $element->find('div.ergov3-priceannonce', 0);\n            if ($priceElement) {\n                $price = $priceElement->innertext;\n            } else {\n                $price = '';\n            }\n\n            [$href] = explode('#', $element->href);\n\n            $item = [];\n            $item['uri'] = self::URI . $href;\n            $item['title'] = $element->title;\n            $item['content'] = $img . $desc . $price;\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $appartment = '&tbApp=1&tbDup=1&tbChb=1&tbLof=1&tbAtl=1&tbPla=1';\n        $maison = '&tbMai=1&tbVil=1&tbCha=1&tbPro=1&tbHot=1&tbMou=1&tbFer=1';\n        $link = self::URI\n        . '/immobilier/annonceimmofo/liste/listeAnnonces?tt=1'\n        . $appartment\n        . $maison;\n\n        if ($this->getInput('minarea')) {\n            $link .= '&sur0=' . urlencode($this->getInput('minarea'));\n        }\n\n        if ($this->getInput('maxprice')) {\n            $link .= '&px1=' . urlencode($this->getInput('maxprice'));\n        }\n\n        if ($this->getInput('pa')) {\n            $link .= '&pa=' . urlencode($this->getInput('pa'));\n        }\n\n        if ($this->getInput('lo')) {\n            $link .= '&lo=' . urlencode($this->getInput('lo'));\n        }\n        return $link;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('minarea'))) {\n            $request = '';\n            $minarea = $this->getInput('minarea');\n            if (!empty($minarea)) {\n                $request .= ' ' . $minarea . ' m2';\n            }\n            $location = $this->getInput('lo');\n            if (!empty($location)) {\n                $request .= ' In: ' . $location;\n            }\n            return 'Paru Vendu Immobilier' . $request;\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/PatreonBridge.php",
    "content": "<?php\n\nclass PatreonBridge extends BridgeAbstract\n{\n    const NAME = 'Patreon';\n    const URI = 'https://www.patreon.com/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns posts by creators on Patreon';\n    const MAINTAINER = 'Roliga, mruac';\n    const PARAMETERS = [[\n        'creator' => [\n            'name' => 'Creator',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'user?u=13425451',\n            'title' => 'Creator name as seen in their page URL'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOMCached($url);\n        $regex = '#/api/campaigns/([0-9]+)#';\n        if (preg_match($regex, $html->save(), $matches) > 0) {\n            $campaign_id = $matches[1];\n        } else {\n            throwServerException('Could not find campaign ID');\n        }\n\n        $query = [\n            'include' => implode(',', [\n                'user',\n                'attachments',\n                'user_defined_tags',\n                //'campaign',\n                'poll.choices',\n                //'poll.current_user_responses.user',\n                //'poll.current_user_responses.choice',\n                //'poll.current_user_responses.poll',\n                //'access_rules.tier.null',\n                'images.null',\n                'audio.null',\n                // 'user.null',\n                'attachments.null',\n                'audio_preview.null',\n                'poll.choices.null'\n                // 'poll.current_user_responses.null'\n            ]),\n            'fields' => [\n                'post' => implode(',', [\n                    //'change_visibility_at',\n                    //'comment_count',\n                    'content',\n                    //'current_user_can_delete',\n                    //'current_user_can_view',\n                    //'current_user_has_liked',\n                    'embed',\n                    'image',\n                    //'is_paid',\n                    //'like_count',\n                    //'min_cents_pledged_to_view',\n                    //'patreon_url',\n                    //'patron_count',\n                    //'pledge_url',\n                    // 'post_file',\n                    // 'post_metadata',\n                    'post_type',\n                    'published_at',\n                    'teaser_text',\n                    //'thumbnail_url',\n                    'title',\n                    //'upgrade_url',\n                    'url',\n                    //'was_posted_by_campaign_owner'\n                    // 'content_teaser_text',\n                    // 'current_user_can_report',\n                    'thumbnail',\n                    // 'video_preview'\n                ]),\n                'user' => implode(',', [\n                    //'image_url',\n                    'full_name',\n                    //'url'\n                ]),\n                'media' => implode(',', [\n                    'id',\n                    'image_urls',\n                    'download_url',\n                    'metadata',\n                    'file_name',\n                    'mimetype',\n                    'size_bytes'\n                ])\n            ],\n            'filter' => [\n                'contains_exclusive_posts' => true,\n                'is_draft' => false,\n                'campaign_id' => $campaign_id\n            ],\n            'sort' => '-published_at'\n        ];\n        $posts = $this->apiGet('posts', $query);\n\n        foreach ($posts->data as $post) {\n            $item = [\n                'uri' => $post->attributes->url,\n                'title' => $post->attributes->title,\n                'timestamp' => $post->attributes->published_at,\n                'content' => '',\n                'uid' => 'patreon.com/' . $post->id\n            ];\n\n            $user = $this->findInclude(\n                $posts,\n                'user',\n                $post->relationships->user->data->id\n            )->attributes;\n            $item['author'] = $user->full_name;\n\n            //image, video, audio, link (featured post content)\n            switch ($post->attributes->post_type) {\n                case 'audio_file':\n                    //check if download_url is null before assigning $audio\n                    $id = $post->relationships->audio->data->id ?? null;\n                    if (isset($id)) {\n                        $audio = $this->findInclude($posts, 'media', $id)->attributes ?? null;\n                    }\n                    if (!isset($audio->download_url)) { //if not unlocked\n                        $id = $post->relationships->audio_preview->data->id ?? null;\n                        if (isset($id)) {\n                            $audio = $this->findInclude($posts, 'media', $id)->attributes ?? null;\n                        }\n                    }\n                    $thumbnail = $post->attributes->thumbnail->large ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null;\n                    $audio_filename = $audio->file_name ?? $item['title'];\n                    $download_url = $audio->download_url ?? $item['uri'];\n                    $item['content'] .= \"<p><a href\\\"{$download_url}\\\"><img src=\\\"{$thumbnail}\\\"><br/>🎧 {$audio_filename}</a><br/>\";\n                    if ($download_url !== $item['uri']) {\n                        $item['enclosures'][] = $download_url;\n                        $item['content'] .= \"<audio controls src=\\\"{$download_url}\\\"></audio>\";\n                    }\n                    $item['content'] .= '</p>';\n                    break;\n\n                case 'video_embed':\n                    $thumbnail = $post->attributes->thumbnail->large ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null;\n                    $item['content'] .= \"<p><a href=\\\"{$item['uri']}\\\">🎬 {$item['title']}<br><img src=\\\"{$thumbnail}\\\"></a></p>\";\n                    break;\n\n                case 'video_external_file':\n                    $thumbnail = $post->attributes->thumbnail->large ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null;\n                    $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null;\n                    $item['content'] .= \"<p><a href=\\\"{$item['uri']}\\\">🎬 {$item['title']}<br><img src=\\\"{$thumbnail}\\\"></a></p>\";\n                    break;\n\n                case 'image_file':\n                    $item['content'] .= '<p>';\n                    foreach ($post->relationships->images->data as $key => $image) {\n                        $image = $this->findInclude($posts, 'media', $image->id)->attributes;\n                        $image_fullres = $image->download_url ?? $image->image_urls->url ?? $image->image_urls->original ?? null;\n                        $filename = $image->file_name ?? '';\n                        $image_url = $image->image_urls->url ?? $image->image_urls->original ?? null;\n                        $item['enclosures'][] = $image_fullres;\n                        $item['content'] .= \"<a href=\\\"{$image_fullres}\\\">{$filename}<br/><img src=\\\"{$image_url}\\\"></a><br/><br/>\";\n                    }\n                    $item['content'] .= '</p>';\n                    break;\n\n                case 'link':\n                    //make it locked safe\n                    if (isset($post->attributes->embed)) {\n                        $embed = $post->attributes->embed;\n                        $thumbnail = $post->attributes->image->large_url ?? $post->attributes->image->thumb_url ?? $post->attributes->image->url;\n                        $item['content'] .= '<p><table>';\n                        $item['content'] .= \"<tr><td><a href=\\\"{$embed->url}\\\"><img src=\\\"{$thumbnail}\\\"></a></td></tr>\";\n                        $item['content'] .= \"<tr><td><b>{$embed->subject}</b></td></tr>\";\n                        $item['content'] .= \"<tr><td>{$embed->description}</td></tr>\";\n                        $item['content'] .= '</table></p><hr/>';\n                    }\n                    break;\n            }\n\n            //content of the post\n            if (isset($post->attributes->content)) {\n                $item['content'] .= $post->attributes->content;\n            } elseif (isset($post->attributes->teaser_text)) {\n                $item['content'] .= '<p>'\n                    . $post->attributes->teaser_text;\n                if (strlen($post->attributes->teaser_text) === 140) {\n                    $item['content'] .= '…';\n                }\n                $item['content'] .= '</p>';\n            }\n\n            //post tags\n            if (isset($post->relationships->user_defined_tags)) {\n                $item['categories'] = [];\n                foreach ($post->relationships->user_defined_tags->data as $tag) {\n                    $attrs = $this->findInclude($posts, 'post_tag', $tag->id)->attributes;\n                    $item['categories'][] = $attrs->value;\n                }\n            }\n\n            //poll\n            if (isset($post->relationships->poll->data)) {\n                $poll = $this->findInclude($posts, 'poll', $post->relationships->poll->data->id);\n                $item['content'] .= \"<p><table><tr><th><b>Poll: {$poll->attributes->question_text}</b></th></tr>\";\n                foreach ($poll->relationships->choices->data as $key => $poll_option) {\n                    $poll_option = $this->findInclude($posts, 'poll_choice', $poll_option->id);\n                    $poll_option_text = $poll_option->attributes->text_content ?? null;\n                    if (isset($poll_option_text)) {\n                        $item['content'] .= \"<tr><td><a href=\\\"{$item['uri']}\\\">{$poll_option_text}</a></td></tr>\";\n                    }\n                }\n                $item['content'] .= '</table></p>';\n            }\n\n\n            //post attachments\n            if (\n                isset($post->relationships->attachments->data) &&\n                count($post->relationships->attachments->data) > 0\n            ) {\n                $item['enclosures'] = [];\n                $item['content'] .= '<hr><p><b>Attachments:</b><ul>';\n                foreach ($post->relationships->attachments->data as $attachment) {\n                    $attrs = $this->findInclude($posts, 'attachment', $attachment->id)->attributes;\n                    $filename = $attrs->name;\n                    $n = strrpos($filename, '.');\n                    $ext = ($n === false) ? '' : substr($filename, $n);\n                    $item['enclosures'][] = $attrs->url . '#' . $ext;\n                    $item['content'] .= '<li><a href=\"' . $attrs->url . '\">' . $filename . '</a></li>';\n                }\n                $item['content'] .= '</ul></p>';\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /*\n     * Searches the \"included\" array in an API response and returns the result for the first match.\n     * A result will include attributes containing further details of the included object\n     * (e.g. an audio object), and an optional relationships object that links to more \"included\"\n     * objects. (e.g. a poll object with related poll_choice(s))\n     */\n    private function findInclude($data, $type, $id)\n    {\n        foreach ($data->included as $include) {\n            if ($include->type === $type && $include->id === $id) {\n                return $include;\n            }\n        }\n    }\n\n    private function apiGet($endpoint, $query_data = [])\n    {\n        $query_data['json-api-version'] = 1.0;\n        $query_data['json-api-use-default-includes'] = 0;\n\n        $url = 'https://www.patreon.com/api/'\n            . $endpoint\n            . '?'\n            . http_build_query($query_data);\n\n        /*\n         * Accept-Language header and the CURL cipher list are for bypassing the\n         * Cloudflare anti-bot protection on the Patreon API. If this ever breaks,\n         * here are some other project that also deal with this:\n         * https://github.com/mikf/gallery-dl/issues/342\n         * https://github.com/daemionfox/patreon-feed/issues/7\n         * https://www.patreondevelopers.com/t/api-returning-cloudflare-challenge/2025\n         * https://github.com/splitbrain/patreon-rss/issues/4\n         */\n        $header = [\n            'Accept-Language: en-US',\n            'Content-Type: application/json'\n        ];\n        $opts = [\n            CURLOPT_SSL_CIPHER_LIST => implode(':', [\n                'DEFAULT',\n                '!DHE-RSA-CHACHA20-POLY1305'\n            ])\n        ];\n\n        $data = json_decode(getContents($url, $header, $opts));\n\n        return $data;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('creator'))) {\n            $html = getSimpleHTMLDOMCached($this->getURI());\n            if ($html) {\n                preg_match('#\"name\": \"(.*)\"#', $html->save(), $matches);\n                return 'Patreon posts from ' . stripcslashes($matches[1]);\n            } else {\n                return $this->getInput('creator') . 'posts from Patreon';\n            }\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('creator'))) {\n            return self::URI . $this->getInput('creator');\n        }\n\n        return parent::getURI();\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Matches e.g. https://www.patreon.com/SomeCreator\n        $regex = '/^(https?:\\/\\/)?(www\\.)?patreon\\.com\\/([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['creator'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/PaulGrahamBridge.php",
    "content": "<?php\n\nclass PaulGrahamBridge extends BridgeAbstract\n{\n    const NAME = 'Paul Graham Essays';\n    const URI = 'https://www.paulgraham.com/articles.html';\n    const DESCRIPTION = 'Returns the latest Paul Graham essays in display order';\n    const MAINTAINER = 'Claire (for Stéphane)';\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n    // Navigate to the right TD\n    // /html/body/table/tbody/tr/td[3]\n        $tables = $html->find('body table');\n        if (!isset($tables[0])) {\n            return;\n        }\n\n        $tds = $tables[0]->find('td');\n        if (!isset($tds[2])) {\n            return;\n        }\n\n        $contentTd = $tds[2];\n\n        // Find all inner tables (each one holds a single essay link)\n        $essayTables = $contentTd->find('table');\n        if (!isset($essayTables[1])) {\n            return;\n        }\n\n        $essayTable = $essayTables[1];\n\n    // /html/body/table/tbody/tr/td[3]/table[2]/tbody/tr[2]/td/font/a\n\n        $links = $essayTable->find('font');\n\n        $essayLinks = [];\n        foreach ($links as $t) {\n            $link = $t->find('a', 0);\n            if (!$link) {\n                continue;\n            }\n\n            $href = trim($link->href);\n            $title = trim($link->plaintext);\n\n            if (empty($href) || strpos($href, 'http') === 0 || !preg_match('/\\.html$/', $href)) {\n                continue;\n            }\n\n            $essayLinks[] = [\n                'title' => $title,\n                'url' => 'https://www.paulgraham.com/' . $href,\n            ];\n        }\n\n        // Only fetch the first 10 (in display order)\n        $essayLinks = array_slice($essayLinks, 0, 10);\n\n        foreach ($essayLinks as $essay) {\n            $item = [\n                'uri' => $essay['url'],\n                'title' => $essay['title'],\n                'uid' => $essay['url'],\n                'content' => '',\n            ];\n\n            $essayHtml = getSimpleHTMLDOMCached($essay['url']);\n            if ($essayHtml) {\n                $essayTables = $essayHtml->find('body table');\n                if (isset($essayTables[0])) {\n                    $essayTds = $essayTables[0]->find('td');\n                    if (isset($essayTds[2])) {\n                        $mainContent = $essayTds[2]->innertext;\n                        $mainDom = str_get_html($mainContent);\n\n                        // Strip unwanted layout elements\n                        foreach ($mainDom->find('map, img, script') as $el) {\n                            $el->outertext = '';\n                        }\n\n                        $item['content'] = $mainDom->save();\n                    }\n                }\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n\n"
  },
  {
    "path": "bridges/PcGamerBridge.php",
    "content": "<?php\n\nclass PcGamerBridge extends BridgeAbstract\n{\n    const NAME = 'PC Gamer';\n    const URI = 'https://www.pcgamer.com/';\n    const DESCRIPTION = 'PC Gamer is your source for exclusive reviews, demos, \n\t\tupdates and news on all your favorite PC gaming franchises.';\n    const MAINTAINER = 'IceWreck, mdemoss';\n\n    const PARAMETERS = [\n        [\n            'limit' => self::LIMIT,\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI(), 300);\n        $stories = $html->find('a.article-link');\n        $limit = $this->getInput('limit') ?? 10;\n        foreach (array_slice($stories, 0, $limit) as $element) {\n            $item = [];\n            $item['uri'] = $element->href;\n            $articleHtml = getSimpleHTMLDOMCached($item['uri']);\n\n            // Relying on meta tags ought to be more reliable.\n            $item['title'] = $articleHtml->find('meta[property=og:title]', 0)->content;\n            $item['content'] = html_entity_decode($articleHtml->find('meta[name=description]', 0)->content);\n\n            // TODO: parsely-author is no longer available, but it is in the application/ld+json\n            $item['author'] = $articleHtml->find('a[rel=author]', 0)->innertext;\n\n            $imageUrl = $articleHtml->find('meta[property=og:image]', 0);\n            if ($imageUrl) {\n                $item['enclosures'][] = $imageUrl->content;\n            }\n\n            /*\n            Tags in mrf:tags are semicolon-delimited and each begins with a label and a ':'\n            Example:\n                \"region:US;articleType:News;channel:Gaming software;\"\n            Find the tag, replace ; with \\n, remove the label prefixes, then explode by newline.\n            */\n            $item['categories'] = array_unique(\n                explode(\n                    PHP_EOL,\n                    preg_replace(\n                        '/^[^:]+:/m',\n                        '',\n                        preg_replace(\n                            '/;/',\n                            PHP_EOL,\n                            $articleHtml->find('meta[property=mrf:tags]', 0)->content\n                        )\n                    )\n                )\n            );\n\n            $item['timestamp'] = strtotime($articleHtml->find('meta[name=pub_date]', 0)->content);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PepperBridgeAbstract.php",
    "content": "<?php\n\nclass PepperBridgeAbstract extends BridgeAbstract\n{\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case $this->i8n('context-keyword'):\n                return $this->collectDataKeywords();\n                break;\n            case $this->i8n('context-group'):\n                return $this->collectDataGroup();\n                break;\n            case $this->i8n('context-talk'):\n                return $this->collectDataTalk();\n                break;\n        }\n    }\n\n    /**\n     * Get the Deal data from the choosen group in the choosed order\n     */\n    protected function collectDataGroup()\n    {\n        $url = $this->getGroupURI();\n        $this->collectDeals($url);\n    }\n\n    /**\n     * Get the Deal data from the choosen keywords and parameters\n     */\n    protected function collectDataKeywords()\n    {\n        /* Even if the original website uses POST with the search page, GET works too */\n        $url = $this->getSearchURI();\n        $this->collectDeals($url);\n    }\n\n    /**\n     * Get the Deal data using the given URL\n     */\n    protected function collectDeals($url)\n    {\n        $html = getSimpleHTMLDOM($url);\n        $list = $html->find('article[id][class*=thread--deal]]');\n\n        // Deal Description CSS Selector\n        $selectorDescription = implode(\n            ' ', /* Notice this is a space! */\n            [\n                'overflow--wrap-break'\n            ]\n        );\n\n        // If there is no results, we don't parse the content because it display some random deals\n        $noresult = $html->find('div[id=content-list]', 0)->find('h2', 0);\n        if ($noresult !== null) {\n            $this->items = [];\n        } else {\n            foreach ($list as $deal) {\n                // Get the JSON Data stored as vue\n                $jsonDealData = $this->getDealJsonData($deal);\n                // DEPRECATED : website does not show this info in the deal list anymore\n                // $dealMeta = Json::decode($deal->find('div[class=js-vue3]', 1)->getAttribute('data-vue3'));\n\n                $item = [];\n                $item['uri'] = $this->getDealURI($jsonDealData);\n                $item['title'] = $this->getTitle($jsonDealData);\n                $item['author'] = $this->getDealAuthor($jsonDealData);\n\n                $item['content'] = '<table><tr><td><a href=\"'\n                    . $item['uri']\n                    . '\">'\n                    . $this->getImage($deal)\n                    . '</td><td>'\n                    . $this->getHTMLTitle($jsonDealData)\n                    . $this->getPrice($jsonDealData)\n                    . $this->getDiscount($jsonDealData)\n                    /*\n                     * DEPRECATED : the list does not show this info anymore\n                     * . $this->getShipsFrom($dealMeta)\n                     */\n                    . $this->getShippingCost($jsonDealData)\n                    . $this->getSource($jsonDealData)\n                    . $this->getDealLocation($jsonDealData)\n                    . $deal->find('div[class*=' . $selectorDescription . ']', 0)->innertext\n                    . '</td><td>'\n                    . $this->getTemperature($jsonDealData)\n                    . '</td></table>';\n\n                $item['timestamp'] = $this->getPublishedDate($jsonDealData);\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    /**\n     * Get the Talk lastest comments\n     */\n    protected function collectDataTalk()\n    {\n        $threadURL = $this->getInput('url');\n        $onlyWithUrl = $this->getInput('only_with_url');\n\n        // Get Thread ID from url passed in parameter\n        $threadSearch = preg_match('/-([0-9]{1,20})$/', $threadURL, $matches);\n\n        // Show an error message if we can't find the thread ID in the URL sent by the user\n        if ($threadSearch !== 1) {\n            throwClientException($this->i8n('thread-error'));\n        }\n        $threadID = $matches[1];\n\n        $url = $this->i8n('bridge-uri') . 'graphql';\n\n        // Get Cookies header to do the query\n        $cookiesHeaderValue = $this->getCookiesHeaderValue($url);\n\n        // GraphQL String\n        // This was extracted from https://www.dealabs.com/assets/js/modern/common_211b99.js\n        // This string was extracted during a Website visit, and minified using this neat tool :\n        // https://codepen.io/dangodev/pen/Baoqmoy\n        $graphqlString = <<<'HEREDOC'\nquery comments($filter:CommentFilter!,$limit:Int,$page:Int){comments(filter:$filter,limit:$limit,page:$page){\nitems{...commentFields}pagination{...paginationFields}}}fragment commentFields on Comment{commentId threadId url \npreparedHtmlContent user{...userMediumAvatarFields...userNameFields...userPersonaFields bestBadge{...badgeFields}}\nreactionCounts{type count}deletable currentUserReaction{type}reported reportable source status createdAt updatedAt \nignored popular deletedBy{username}notes{content createdAt user{username}}lastEdit{reason timeAgo userId}}fragment \nuserMediumAvatarFields on User{userId isDeletedOrPendingDeletion imageUrls(slot:\"default\",variations:\n[\"user_small_avatar\"])}fragment userNameFields on User{userId username isUserProfileHidden isDeletedOrPendingDeletion}\nfragment userPersonaFields on User{persona{type text}}fragment badgeFields on Badge{badgeId level{...badgeLevelFields}}\nfragment badgeLevelFields on BadgeLevel{key name description}fragment paginationFields on Pagination{count current last\n next previous size order}\nHEREDOC;\n\n        // Construct the JSON object to send to the Website\n        $queryArray = [\n            'query' => $graphqlString,\n            'variables' => [\n                'filter' => [\n                    'threadId' => [\n                        'eq' => $threadID,\n                    ],\n                    'order' => [\n                        'direction' => 'Descending',\n                    ],\n\n                ],\n                'page' => 1,\n            ],\n        ];\n        $queryJSON = json_encode($queryArray);\n\n        // HTTP headers\n        $header = [\n            'Content-Type: application/json',\n            'Accept: application/json, text/plain, */*',\n            'X-Pepper-Txn: threads.show',\n            'X-Request-Type: application/vnd.pepper.v1+json',\n            'X-Requested-With: XMLHttpRequest',\n            \"Cookie: $cookiesHeaderValue\",\n        ];\n        // CURL Options\n        $opts = [\n            CURLOPT_POST => 1,\n            CURLOPT_POSTFIELDS => $queryJSON\n        ];\n        $json = getContents($url, $header, $opts);\n        $objects = json_decode($json);\n        foreach ($objects->data->comments->items as $comment) {\n            $item = [];\n            $item['uri'] = $comment->url;\n            $item['title'] = $comment->user->username . ' - ' . $comment->createdAt;\n            $item['author'] = $comment->user->username;\n            $item['content'] = $comment->preparedHtmlContent;\n            $item['uid'] = $comment->commentId;\n            // Timestamp handling needs a new parsing function\n            if ($onlyWithUrl == true) {\n                // Only parse the comment if it is not empry\n                if ($item['content'] != '') {\n                    // Count Links and Quote Links\n                    $content = str_get_html($item['content']);\n                    $countLinks = count($content->find('a[href]'));\n                    $countQuoteLinks = count($content->find('a[href][class=userHtml-quote-source]'));\n                    // Only add element if there are Links and more links tant Quote links\n                    if ($countLinks > 0 && $countLinks > $countQuoteLinks) {\n                        $this->items[] = $item;\n                    }\n                }\n            } else {\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function getCookiesHeaderValue($url)\n    {\n        $response = getContents($url, [], [], true);\n        $setCookieHeaders = $response->getHeader('set-cookie', true);\n        $cookies = array_map(fn($c): string => explode(';', $c)[0], $setCookieHeaders);\n\n        return implode('; ', $cookies);\n    }\n\n    /**\n     * Check if the string $str contains any of the string of the array $arr\n     * @return boolean true if the string matched anything otherwise false\n     */\n    private function contains($str, array $arr)\n    {\n        foreach ($arr as $a) {\n            if (stripos($str, $a) !== false) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    /**\n     * Get the Price from a Deal if it exists\n     * @return string String of the deal price\n     */\n    private function getPrice($jsonDealData)\n    {\n        if ($jsonDealData['props']['thread']['discountType'] == null) {\n            $price = $jsonDealData['props']['thread']['price'];\n                return '<div>' . $this->i8n('price') . ' : '\n                . $price . ' ' . $this->i8n('currency') . '</div>';\n        } else {\n            return '';\n        }\n    }\n\n    /**\n     * Get the Publish Date from a Deal if it exists\n     * @return integer Timestamp of the published date of the deal\n     */\n    private function getPublishedDate($jsonDealData)\n    {\n        return $jsonDealData['props']['thread']['publishedAt'];\n    }\n\n    /**\n     * Get the Deal Author from a Deal if it exists\n     * @return String Author of the deal\n     */\n    private function getDealAuthor($jsonDealData)\n    {\n        return $jsonDealData['props']['thread']['user']['username'];\n    }\n\n    /**\n     * Get the Title from a Deal if it exists\n     * @return string String of the deal title\n     */\n    private function getTitle($jsonDealData)\n    {\n        $title = $jsonDealData['props']['thread']['title'];\n        return $title;\n    }\n\n    /**\n     * Get the Title from a Talk if it exists\n     * @return string String of the Talk title\n     */\n    private function getTalkTitle()\n    {\n        $cacheKey = $this->getInput('url') . 'TITLE';\n        $title = $this->loadCacheValue($cacheKey);\n        // The cache does not contain the title of the bridge, we must get it and save it in the cache\n        if ($title === null) {\n            $html = getSimpleHTMLDOMCached($this->getInput('url'));\n            $title = $html->find('title', 0)->plaintext;\n            // Save the value in the cache for the next 15 days\n            $this->saveCacheValue($cacheKey, $title, 86400 * 15);\n        }\n        return $title;\n    }\n\n    /**\n     * Get the Title from a Group if it exists\n     * @return string String of the Talk title\n     */\n    private function getGroupTitle()\n    {\n        $cacheKey = $this->getInput('group') . 'TITLE';\n        $title = $this->loadCacheValue($cacheKey);\n        // The cache does not contain the title of the bridge, we must get it and save it in the cache\n        if ($title == null) {\n            $html = getSimpleHTMLDOMCached($this->getGroupURI());\n            // Search the title in the javascript mess\n            preg_match('/threadGroupName\":\"([^\"]*)\",\"threadGroupUrlName\":\"' . $this->getInput('group') . '\"/m', $html, $matches);\n            $title = $matches[1];\n            // Save the value in the cache for the next 15 days\n            $this->saveCacheValue($cacheKey, $title, 86400 * 15);\n        }\n\n        $order = $this->getKey('order');\n        return $title . ' - ' . $order;\n    }\n\n    /**\n     * Get the HTML Title code from an item\n     * @return string String of the deal title\n     */\n    private function getHTMLTitle($jsonDealData)\n    {\n        $html = '<h2><a href=\"' . $this->getDealURI($jsonDealData) . '\">'\n                . $this->getTitle($jsonDealData) . '</a></h2>';\n\n        return $html;\n    }\n\n    /**\n     * Get the URI from a Deal if it exists\n     * @return string String of the deal URI\n     */\n    private function getDealURI($jsonDealData)\n    {\n        $dealSlug = $jsonDealData['props']['thread']['titleSlug'];\n        $dealId = $jsonDealData['props']['thread']['threadId'];\n        $uri = $this->i8n('bridge-uri') . $this->i8n('uri-deal') . $dealSlug . '-' . $dealId;\n        return $uri;\n    }\n\n    /**\n     * Get the Shipping costs from a Deal if it exists\n     * @return string String of the deal shipping Cost\n     */\n    private function getShippingCost($jsonDealData)\n    {\n        $isFree = $jsonDealData['props']['thread']['shipping']['isFree'];\n        $price = $jsonDealData['props']['thread']['shipping']['price'];\n        if ($isFree !== null) {\n                return '<div>' . $this->i8n('shipping') . ' : '\n                    . $price . ' ' . $this->i8n('currency')\n                    . '</div>';\n        } else {\n            return '';\n        }\n    }\n\n    /**\n     * Get the temperature from a Deal if it exists\n     * @return string String of the deal temperature\n     */\n    private function getTemperature($data)\n    {\n        return $data['props']['thread']['temperature'] . '°';\n    }\n\n\n    /**\n     * Get the Deal data from the \"data-vue2\" JSON attribute\n     * @return array Array containg the deal properties contained in the \"data-vue2\" attribute\n     */\n    private function getDealJsonData($deal)\n    {\n        $data = Json::decode($deal->find('div[class=js-vue3]', 0)->getAttribute('data-vue3'));\n        return $data;\n    }\n\n    /**\n     * Get the source of a Deal if it exists\n     * @return string String of the deal source\n     */\n    private function getSource($jsonData)\n    {\n        if ($jsonData['props']['thread']['merchant'] != null) {\n            $path = $this->i8n('uri-merchant') . $jsonData['props']['thread']['merchant']['merchantId'];\n            $text = $jsonData['props']['thread']['merchant']['merchantName'];\n            return '<div>' . $this->i8n('origin') . ' : <a href=\"' . static::URI . $path . '\">' . $text . '</a></div>';\n        } else {\n            return '';\n        }\n    }\n\n    /**\n     * Get the original Price and discout from a Deal if it exists\n     * @return string String of the deal original price and discount\n     */\n    private function getDiscount($jsonDealData)\n    {\n        $oldPrice = $jsonDealData['props']['thread']['nextBestPrice'];\n        $newPrice = $jsonDealData['props']['thread']['price'];\n        $percentage = $jsonDealData['props']['thread']['percentage'];\n\n        if ($oldPrice != 0) {\n            // If there is no percentage calculated, then calculate it manually\n            if ($percentage == 0) {\n                $percentage = round(100 - ($newPrice * 100 / $oldPrice), 2);\n            }\n            return '<div>' . $this->i8n('discount') . ' : <span style=\"text-decoration: line-through;\">'\n                . $oldPrice . ' ' . $this->i8n('currency')\n                . '</span>&nbsp; -'\n                . $percentage\n                . ' %</div>';\n        } else {\n            return '';\n        }\n    }\n\n    /**\n     * Get the Deal location if it exists\n     * @return string String of the deal location\n     */\n    private function getDealLocation($jsonDealData)\n    {\n        if ($jsonDealData['props']['thread']['isLocal']) {\n            $content = '<div>' . $this->i8n('deal-type') . ' : ' . $this->i8n('localdeal') . '</div>';\n        } else {\n            $content = '';\n        }\n        return $content;\n    }\n\n    /**\n     * Get the Picture URL from a Deal if it exists\n     * @return string String of the deal Picture URL\n     */\n    private function getImage($deal)\n    {\n        // Get thread Image JSON content\n        $content = Json::decode($deal->find('div[class=js-vue3]', 0)->getAttribute('data-vue3'));\n        //return '<img src=\"' . $content['props']['threadImageUrl'] . '\"/>';\n        return '<img src=\"' . $this->i8n('image-host') . $content['props']['thread']['mainImage']['path'] . '/'\n            . $content['props']['thread']['mainImage']['name'] . '/re/202x202/qt/70/'\n            . $content['props']['thread']['mainImage']['uid'] . '\"/>';\n    }\n\n    /**\n     * Get the originating country from a Deal if it exists\n     * @return string String of the deal originating country\n     * DEPRECATED : the deal on the result list does not contain this info anymore\n     */\n    private function getShipsFrom($dealMeta)\n    {\n        $metas = $dealMeta['props']['metaRibbons'] ?? [];\n        $shipsFrom = null;\n        foreach ($metas as $meta) {\n            if ($meta['type'] == 'dispatched-from') {\n                $shipsFrom = $meta['text'];\n            }\n        }\n        if ($shipsFrom != null) {\n            return '<div>' . $shipsFrom . '</div>';\n        }\n        return '';\n    }\n\n    /**\n     * Returns the RSS Feed title according to the parameters\n     * @return string the RSS feed Tiyle\n     */\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case $this->i8n('context-keyword'):\n                return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-keyword') . ' : ' . $this->getInput('q');\n                break;\n            case $this->i8n('context-group'):\n                return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-group') . ' : ' . $this->getGroupTitle();\n                break;\n            case $this->i8n('context-talk'):\n                return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-talk') . ' : ' . $this->getTalkTitle();\n                break;\n            default: // Return default value\n                return static::NAME;\n        }\n    }\n\n    /**\n     * Returns the RSS Feed URI according to the parameters\n     * @return string the RSS feed Title\n     */\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case $this->i8n('context-keyword'):\n                return $this->getSearchURI();\n                break;\n            case $this->i8n('context-group'):\n                return $this->getGroupURI();\n                break;\n            case $this->i8n('context-talk'):\n                return $this->getTalkURI();\n                break;\n            default: // Return default value\n                return static::URI;\n        }\n    }\n\n    /**\n     * Returns the RSS Feed URI for a keyword Feed\n     * @return string the RSS feed URI\n     */\n    private function getSearchURI()\n    {\n        $q = $this->getInput('q');\n        $hide_expired = $this->getInput('hide_expired');\n        $hide_local = $this->getInput('hide_local');\n        $priceFrom = $this->getInput('priceFrom');\n        $priceTo = $this->getInput('priceTo');\n        $url = $this->i8n('bridge-uri')\n            . 'search?q='\n            . urlencode($q)\n            . '&hide_expired=' . $hide_expired\n            . '&hide_local=' . $hide_local\n            . '&priceFrom=' . $priceFrom\n            . '&priceTo=' . $priceTo\n            /* Some default parameters\n             * search_fields : Search in Titres & Descriptions & Codes\n             * sort_by : Sort the search by new deals\n             * time_frame : Search will not be on a limited timeframe\n             */\n            . '&search_fields[]=1&search_fields[]=2&search_fields[]=3&sort_by=new&time_frame=0';\n        return $url;\n    }\n\n    /**\n     * Returns the RSS Feed URI for a group Feed\n     * @return string the RSS feed URI\n     */\n    private function getGroupURI()\n    {\n        $group = $this->getInput('group');\n        $order = $this->getInput('order');\n        $subgroups = $this->getInput('subgroups');\n\n        // This permit to keep the existing Feed to work\n        if ($order == $this->i8n('context-hot')) {\n            $sortBy = 'temp';\n        } else if ($order == $this->i8n('context-new')) {\n            $sortBy = 'new';\n        }\n\n        $url = $this->i8n('bridge-uri')\n            . $this->i8n('uri-group') . $group . '?sortBy=' . $sortBy . '&groups=' . $subgroups;\n        return $url;\n    }\n\n    /**\n     * Returns the RSS Feed URI for a Talk Feed\n     * @return string the RSS feed URI\n     */\n    private function getTalkURI()\n    {\n        $url = $this->getInput('url');\n        return $url;\n    }\n\n    /**\n     * This is some \"localisation\" function that returns the needed content using\n     * the \"$lang\" class variable in the local class\n     * @return various the local content needed\n     */\n    protected function i8n($key)\n    {\n        if (array_key_exists($key, $this->lang)) {\n            return $this->lang[$key];\n        } else {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PhoronixBridge.php",
    "content": "<?php\n\nclass PhoronixBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'Phoronix';\n    const URI = 'https://www.phoronix.com';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'RSS feed for Linux news website Phoronix';\n    const PARAMETERS = [[\n        'n' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Maximum number of items to return',\n            'defaultValue' => 10\n        ],\n        'svgAsImg' => [\n            'name' => 'SVG in \"image\" tag',\n            'type' => 'checkbox',\n            'title' => 'Some benchmarks are exported as SVG with \"object\" tag,\nbut some RSS readers don\\'t support this. \"img\" tag are supported by most browsers',\n            'defaultValue' => false\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://www.phoronix.com/rss.php', $this->getInput('n'));\n    }\n\n    protected function parseItem(array $item)\n    {\n        $itemUrl = $item['uri'];\n\n        $articlePage = getSimpleHTMLDOM($itemUrl);\n        $articlePage = defaultLinkTo($articlePage, $this->getURI());\n        // Extract final link. From Facebook's like plugin.\n        $parsedUrlQuery = parse_url($articlePage->find('iframe[src^=//www.facebook.com/plugins]', 0), PHP_URL_QUERY);\n        parse_str($parsedUrlQuery, $facebookQuery);\n        if (array_key_exists('href', $facebookQuery)) {\n            $itemUrl = $facebookQuery['href'];\n        }\n        $item['content'] = $this->extractContent($articlePage);\n\n        $pages = $articlePage->find('.pagination a[!title]');\n        foreach ($pages as $page) {\n            $pageURI = urljoin($itemUrl, html_entity_decode($page->href));\n            $page = getSimpleHTMLDOM($pageURI);\n            $item['content'] .= $this->extractContent($page);\n        }\n        return $item;\n    }\n\n    private function extractContent($page)\n    {\n        $content = $page->find('.content', 0);\n        $objects = $content->find('script[src^=//openbenchmarking.org]');\n        foreach ($objects as $object) {\n            $objectSrc = preg_replace('/p=0/', 'p=2', $object->src);\n            if ($this->getInput('svgAsImg')) {\n                $object->outertext = '<a href=\"' . $objectSrc . '\"><img src=\"' . $objectSrc . '\"/></a>';\n            } else {\n                $object->outertext = '<object data=\"' . $objectSrc . '\" type=\"image/svg+xml\"></object>';\n            }\n        }\n        $content = stripWithDelimiters($content, '<script', '</script>');\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/PicalaBridge.php",
    "content": "<?php\n\nclass PicalaBridge extends BridgeAbstract\n{\n    const TYPES      = [\n        'Actualités' => 'actualites',\n        'Économie'   => 'economie',\n        'Tests'      => 'tests',\n        'Pratique'   => 'pratique',\n    ];\n    const NAME          = 'Picala';\n    const URI           = 'https://www.picala.fr';\n    const DESCRIPTION   = 'Dernière nouvelles du média indépendant sur le vélo électrique';\n    const MAINTAINER    = 'Chouchen';\n    const PARAMETERS    = [\n        [\n            'type' => [\n                'name' => 'Type',\n                'type' => 'list',\n                'values' => self::TYPES,\n            ],\n        ],\n    ];\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('type'))) {\n            return sprintf('%s/%s', static::URI, $this->getInput('type'));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getIcon()\n    {\n        return 'https://picala-static.s3.amazonaws.com/static/img/favicon/favicon-32x32.png';\n    }\n\n    public function getDescription()\n    {\n        if (!is_null($this->getInput('type'))) {\n            return sprintf('%s - %s', static::DESCRIPTION, array_search($this->getInput('type'), self::TYPES));\n        }\n\n        return parent::getDescription();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('type'))) {\n            return sprintf('%s - %s', static::NAME, array_search($this->getInput('type'), self::TYPES));\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $fullhtml = getSimpleHTMLDOM($this->getURI());\n        foreach ($fullhtml->find('.list-container-category a') as $article) {\n            $firstImage = $article->find('img', 0);\n            $image = null;\n            if ($firstImage !== null) {\n                $srcsets = explode(',', $firstImage->getAttribute('srcset'));\n                $image = explode(' ', trim(array_shift($srcsets)))[0];\n            }\n\n            $item = [];\n            $item['uri'] = self::URI . $article->href;\n            $item['title'] = $article->find('h2', 0)->plaintext;\n            if ($image === null) {\n                $item['content'] = $article->find('.teaser__text', 0)->plaintext;\n            } else {\n                $item['content'] = sprintf(\n                    '<img src=\"%s\" /><br>%s',\n                    $image,\n                    $article->find('.teaser__text', 0)->plaintext\n                );\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PicartoBridge.php",
    "content": "<?php\n\nclass PicartoBridge extends BridgeAbstract\n{\n    const NAME = 'Picarto';\n    const URI = 'https://picarto.tv';\n    const DESCRIPTION = 'Produces a new feed item each time a channel goes live';\n    const CACHE_TIMEOUT = 300;\n    const PARAMETERS = [[\n            'channel' => [\n                'name'      => 'Channel name',\n                'type'      => 'text',\n                'required'  => true,\n                'title'     => 'Channel name',\n                'exampleValue' => 'Wysdrem',\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $channel = $this->getInput('channel');\n        $data = json_decode(getContents('https://api.picarto.tv/api/v1/channel/name/' . $channel), true);\n        if (!$data['online']) {\n            return;\n        }\n        $lastLive = new \\DateTime($data['last_live']);\n        $this->items[] = [\n            'uri' => 'https://picarto.tv/' . $channel,\n            'title' => $data['name'] . ' is now online',\n            'content' => sprintf('<img src=\"%s\"/>', $data['thumbnails']['tablet']),\n            'timestamp' => $lastLive->getTimestamp(),\n            'uid' => 'https://picarto.tv/' . $channel . $lastLive->getTimestamp(),\n        ];\n    }\n\n    public function getName()\n    {\n        return 'Picarto - ' . $this->getInput('channel');\n    }\n}\n"
  },
  {
    "path": "bridges/PickyWallpapersBridge.php",
    "content": "<?php\n\nclass PickyWallpapersBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'nel50n';\n    const NAME = 'PickyWallpapers';\n    const URI = 'https://www.pickywallpapers.com/';\n    const CACHE_TIMEOUT = 43200; // 12h\n    const DESCRIPTION = 'Returns the latests wallpapers from PickyWallpapers';\n\n    const PARAMETERS = [ [\n        'c' => [\n            'name' => 'category',\n            'exampleValue' => 'funny',\n            'required' => true\n        ],\n        's' => [\n            'name' => 'subcategory'\n        ],\n        'm' => [\n            'name' => 'Max number of wallpapers',\n            'defaultValue' => 12,\n            'type' => 'number'\n        ],\n        'r' => [\n            'name' => 'resolution',\n            'exampleValue' => '1920x1200, 1680x1050,…',\n            'defaultValue' => '1920x1200',\n            'pattern' => '[0-9]{3,4}x[0-9]{3,4}'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $lastpage = 1;\n        $num = 0;\n        $max = $this->getInput('m');\n        $resolution = $this->getInput('r'); // Wide wallpaper default\n\n        for ($page = 1; $page <= $lastpage; $page++) {\n            $html = getSimpleHTMLDOM($this->getURI() . '/page-' . $page . '/');\n\n            if ($page === 1) {\n                preg_match('/page-(\\d+)\\/$/', $html->find('.pages li a', -2)->href, $matches);\n                $lastpage = min($matches[1], ceil($max / 12));\n            }\n\n            foreach ($html->find('.items li img') as $element) {\n                $item = [];\n                $item['uri'] = str_replace('www', 'wallpaper', self::URI)\n                . '/'\n                . $resolution\n                . '/'\n                . basename($element->src);\n\n                $item['timestamp'] = time();\n                $item['title'] = $element->alt;\n                $item['content'] = $item['title']\n                . '<br><a href=\"'\n                . $item['uri']\n                . '\">'\n                . $element\n                . '</a>';\n\n                $this->items[] = $item;\n\n                $num++;\n                if ($num >= $max) {\n                    break 2;\n                }\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('s')) && !is_null($this->getInput('r')) && !is_null($this->getInput('c'))) {\n            $subcategory = $this->getInput('s');\n            $link = self::URI\n            . $this->getInput('r')\n            . '/'\n            . $this->getInput('c')\n            . '/'\n            . $subcategory;\n\n            return $link;\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('s'))) {\n            $subcategory = $this->getInput('s');\n            return 'PickyWallpapers - '\n            . $this->getInput('c')\n            . ($subcategory ? ' > ' . $subcategory : '')\n            . ' ['\n            . $this->getInput('r')\n            . ']';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/PicnobBridge.php",
    "content": "<?php\n\nclass PicnobBridge extends BridgeAbstract\n{\n        const MAINTAINER = 'sysadminstory';\n        const NAME = 'Picnob';\n        const URI = 'https://www.picnob.com/';\n        const CACHE_TIMEOUT = 3600; // 1h\n        const DESCRIPTION = 'Returns Picnob (Instagram viewer) posts by user or by hashtag';\n\n        const PARAMETERS = [\n                'Username' => [\n                        'u' => [\n                                'name' => 'username',\n                                'type' => 'text',\n                                'title' => 'Instagram username you want to follow',\n                                'exampleValue' => 'aesoprockwins',\n                                'required' => true,\n                        ],\n                ],\n                'Hashtag' => [\n                        'h' => [\n                                'name' => 'hashtag',\n                                'type' => 'text',\n                                'title' => 'Instagram hastag you want to follow, without the \\'#\\'',\n                                'exampleValue' => 'beautifulday',\n                                'required' => true,\n                        ],\n                ]\n        ];\n\n        public function getURI()\n        {\n            if (!is_null($this->getInput('u'))) {\n                    return urljoin(self::URI, '/profile/' . $this->getInput('u') . '/');\n            }\n\n            if (!is_null($this->getInput('h'))) {\n                    return urljoin(self::URI, '/tag/' . trim($this->getInput('h') . '/'));\n            }\n\n                return parent::getURI();\n        }\n\n        public function collectData()\n        {\n            $html = getSimpleHTMLDOM($this->getURI());\n            foreach ($html->find('.items') as $part) {\n                foreach ($part->find('.item') as $element) {\n                    $url = urljoin(self::URI, $element->find('a', 0)->href);\n\n                    $date = date_create();\n                    $relativeDate = date_interval_create_from_date_string(str_replace(' ago', '', $element->find('.time', 0)->plaintext));\n                    if ($relativeDate) {\n                        date_sub($date, $relativeDate);\n                    }\n\n                    $description = defaultLinkTo(trim($element->find('.sum', 0)->innertext), self::URI);\n\n                    $isVideo = (bool) $element->find('.icon_video', 0);\n                    $videoNote = $isVideo ? '<p><i>(video)</i></p>' : '';\n\n                    $isTV = (bool) $element->find('.icon_tv', 0);\n                    $tvNote = $isTV ? '<p><i>(TV)</i></p>' : '';\n\n                    $isMoreContent = (bool) $element->find('.icon_multi', 0);\n                    $moreContentNote = $isMoreContent ? '<p><i>(multiple images and/or videos)</i></p>' : '';\n\n                    $imageUrl = $element->find('.img', 0)->getAttribute('data-src');\n\n                    $uid = explode('/', parse_url($url, PHP_URL_PATH))[2];\n\n                    $this->items[] = [\n                        'uri'        => $url,\n                        'timestamp'  => date_format($date, 'r'),\n                        'title'      => strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description,\n                        'thumbnail'  => $imageUrl,\n                        'enclosures' => [$imageUrl],\n                        'content'    => <<<HTML\n<a href=\"{$url}\">\n        <img loading=\"lazy\" src=\"{$imageUrl}\" />\n</a>\n{$videoNote}\n{$tvNote}\n{$moreContentNote}\n<p>{$description}<p>\nHTML,\n                        'uid' => $uid\n                    ];\n                }\n            }\n        }\n\n        public function getName()\n        {\n            if (!is_null($this->getInput('u'))) {\n                    return 'Username ' . $this->getInput('u') . ' - Picnob';\n            }\n\n            if (!is_null($this->getInput('h'))) {\n                    return 'Hashtag ' . $this->getInput('h') . ' - Picnob';\n            }\n\n                return parent::getName();\n        }\n}\n"
  },
  {
    "path": "bridges/PicukiBridge.php",
    "content": "<?php\n\nclass PicukiBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'marcus-at-localhost';\n    const NAME = 'Picuki';\n    const URI = 'https://www.picuki.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns Picuki (Instagram viewer) posts by user and by hashtag';\n\n    const PARAMETERS = [\n        'global' => [\n            'count' => [\n                'name' => 'Count',\n                'type' => 'number',\n                'title' => 'How many posts to fetch',\n                'defaultValue' => 12\n            ]\n        ],\n        'Username' => [\n            'u' => [\n                'name' => 'username',\n                'exampleValue' => 'aesoprockwins',\n                'required' => true,\n            ],\n        ],\n        'Hashtag' => [\n            'h' => [\n                'name' => 'hashtag',\n                'exampleValue' => 'beautifulday',\n                'required' => true,\n            ],\n        ]\n    ];\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return urljoin(self::URI, '/profile/' . $this->getInput('u'));\n        }\n\n        if (!is_null($this->getInput('h'))) {\n            return urljoin(self::URI, '/tag/' . trim($this->getInput('h'), '#'));\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $re = '#let short_code = \"(.*?)\";\\s*$#m';\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $requestedCount = $this->getInput('count');\n        if ($requestedCount > 12) {\n            // Picuki shows 12 posts per page at initial load.\n            throw new \\Exception('Maximum count is 12');\n        }\n\n        $count = 0;\n        foreach ($html->find('div[class=.box-photo][data-s=media]') as $element) {\n            // skip ad items\n            if (in_array('adv', explode(' ', $element->class))) {\n                continue;\n            }\n\n            $url = $element->find('a', 0)->href;\n            $html_single = getSimpleHTMLDOMCached($url);\n            $sourceUrl = null;\n            if (preg_match($re, $html_single, $matches) > 0) {\n                $sourceUrl = 'https://instagram.com/p/' . $matches[1];\n            }\n\n            //$author = trim($element->find('.single-photo-nickname', 0)->plaintext);\n\n            $date = date_create();\n            $relativeDate = str_replace(' ago', '', $element->find('.time', 0)->plaintext);\n            date_sub($date, date_interval_create_from_date_string($relativeDate));\n\n            $description = trim($element->find('.photo-action-description', 0)->plaintext);\n\n            $isVideo = (bool) $element->find('.video-icon', 0);\n            $videoNote = $isVideo ? '<p><i>(video)</i></p>' : '';\n\n            $imageUrl = $element->find('.post-image', 0)->src;\n\n            // the last path segment needs to be encoded, because it contains special characters like + or |\n            $imageUrlParts = explode('/', $imageUrl);\n            $imageUrlParts[count($imageUrlParts) - 1] = urlencode($imageUrlParts[count($imageUrlParts) - 1]);\n            $imageUrl = implode('/', $imageUrlParts);\n\n            $this->items[] = [\n                'uri'        => $url,\n                /*'author'     => $author,*/\n                'timestamp'  => date_format($date, 'r'),\n                'title'      => strlen($description) > 60 ? mb_substr($description, 0, 57) . '...' : $description,\n                'thumbnail'  => $imageUrl,\n                'source'     => $sourceUrl,\n                'enclosures' => [$imageUrl],\n                'content'    => <<<HTML\n                    <a href=\"{$url}\">\n                        <img loading=\"lazy\" src=\"{$imageUrl}\" />\n                    </a>\n                    <a href=\"{$sourceUrl}\">{$sourceUrl}</a>\n                    {$videoNote}\n                    <p>{$description}<p>\n                    HTML\n            ];\n\n            $count++;\n            if ($count >= $requestedCount) {\n                break;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return $this->getInput('u') . ' - Picuki';\n        }\n\n        if (!is_null($this->getInput('h'))) {\n            return $this->getInput('h') . ' - Picuki';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/PikabuBridge.php",
    "content": "<?php\n\nclass PikabuBridge extends BridgeAbstract\n{\n    const NAME = 'Пикабу';\n    const URI = 'https://pikabu.ru';\n    const DESCRIPTION = 'Выводит посты по тегу, сообществу или пользователю';\n    const MAINTAINER = 'em92';\n\n    const PARAMETERS_FILTER = [\n        'name' => 'Фильтр',\n        'type' => 'list',\n        'values' => [\n            'Горячее' => 'hot',\n            'Свежее' => 'new',\n        ],\n        'defaultValue' => 'hot',\n    ];\n\n    const PARAMETERS = [\n        'По тегу' => [\n            'tag' => [\n                'name' => 'Тег',\n                'exampleValue' => 'it',\n                'required' => true\n            ],\n            'filter' => self::PARAMETERS_FILTER\n        ],\n        'По сообществу' => [\n            'community' => [\n                'name' => 'Сообщество',\n                'exampleValue' => 'linux',\n                'required' => true\n            ],\n            'filter' => self::PARAMETERS_FILTER\n        ],\n        'По пользователю' => [\n            'user' => [\n                'name' => 'Пользователь',\n                'exampleValue' => 'admin',\n                'required' => true\n            ]\n        ]\n    ];\n\n    protected $title = null;\n\n    public function getURI()\n    {\n        if ($this->getInput('tag')) {\n            return self::URI . '/tag/' . rawurlencode($this->getInput('tag')) . '/' . rawurlencode($this->getInput('filter'));\n        } elseif ($this->getInput('user')) {\n            return self::URI . '/@' . rawurlencode($this->getInput('user'));\n        } elseif ($this->getInput('community')) {\n            $uri = self::URI . '/community/' . rawurlencode($this->getInput('community'));\n            if ($this->getInput('filter') != 'hot') {\n                $uri .= '/' . rawurlencode($this->getInput('filter'));\n            }\n            return $uri;\n        } else {\n            return parent::getURI();\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://cs.pikabu.ru/assets/favicon.ico';\n    }\n\n    public function getName()\n    {\n        if (is_null($this->title)) {\n            return parent::getName();\n        } else {\n            return $this->title . ' - ' . parent::getName();\n        }\n    }\n\n    public function collectData()\n    {\n        $link = $this->getURI();\n\n        $text_html = getContents($link);\n        $text_html = iconv('windows-1251', 'utf-8', $text_html);\n        $html = str_get_html($text_html);\n\n        $this->title = $html->find('title', 0)->innertext;\n\n        foreach ($html->find('article.story') as $post) {\n            $time = $post->find('time.story__datetime', 0);\n            if (is_null($time)) {\n                continue;\n            }\n\n            $el_to_remove_selectors = [\n                '.story__read-more',\n                'script',\n                'svg.story-image__stretch',\n            ];\n\n            foreach ($el_to_remove_selectors as $el_to_remove_selector) {\n                foreach ($post->find($el_to_remove_selector) as $el) {\n                    $el->outertext = '';\n                }\n            }\n\n            foreach ($post->find('[data-type=gifx]') as $el) {\n                $src = $el->getAttribute('data-source');\n                $el->outertext = '<img src=\"' . $src . '\">';\n            }\n\n            foreach ($post->find('img') as $img) {\n                $src = $img->getAttribute('src');\n                if (!$src) {\n                    $src = $img->getAttribute('data-src');\n                    if (!$src) {\n                        continue;\n                    }\n                }\n                $img->outertext = '<img src=\"' . $src . '\">';\n\n                // it is assumed, that img's parents are links to post itself\n                // we don't need them\n                $img->parent()->outertext = $img->outertext;\n            }\n\n            $categories = [];\n            foreach ($post->find('.tags__tag') as $tag) {\n                if ($tag->getAttribute('data-tag')) {\n                    $categories[] = $tag->innertext;\n                }\n            }\n\n            $title_element = $post->find('.story__title-link', 0);\n            if (str_contains($title_element->href, 'from=cpm')) {\n                // skip sponsored posts\n                continue;\n            }\n\n            $title = $title_element->plaintext;\n            $community_link = $post->find('.story__community-link', 0);\n            // adding special marker for \"Maybe News\" section\n            // these posts are fake\n            if (!is_null($community_link) && $community_link->getAttribute('href') == '/community/maybenews') {\n                $title = '[' . trim($community_link->plaintext) . '] ' . $title;\n            }\n\n            $item = [];\n            $item['categories'] = $categories;\n            $item['author'] = trim($post->find('.user__nick', 0)->plaintext);\n            $item['title'] = $title;\n            $item['content'] = strip_tags(\n                backgroundToImg($post->find('.story__content-inner', 0)->innertext),\n                '<br><p><img><a><s>\n\t\t\t'\n            );\n            $item['uri'] = $title_element->href;\n            $item['timestamp'] = strtotime($time->getAttribute('datetime'));\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PillowfortBridge.php",
    "content": "<?php\n\nclass PillowfortBridge extends BridgeAbstract\n{\n    const NAME = 'Pillowfort';\n    const URI = 'https://www.pillowfort.social';\n    const DESCRIPTION = 'Returns recent posts from a user';\n    const MAINTAINER = 'KamaleiZestri';\n    const PARAMETERS = [[\n        'username' => [\n            'name' => 'Username',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue'  => 'Staff'\n        ],\n        'noava' => [\n            'name' => 'Hide avatar',\n            'type' => 'checkbox',\n            'title' => 'Check to hide user avatars.'\n        ],\n        'noreblog' => [\n            'name' => 'Hide reblogs',\n            'type' => 'checkbox',\n            'title' => 'Check to only show original posts.'\n        ],\n        'noretags' => [\n            'name' => 'Prefer original tags',\n            'type' => 'checkbox',\n            'title' => 'Check to use tags from original post(if available) instead of reblog\\'s tags'\n        ],\n        'image' => [\n            'name' => 'Select image type',\n            'type' => 'list',\n            'title' => 'Decides how the image is displayed, if at all.',\n            'values' => [\n                'None' => 'None',\n                'Small' => 'Small',\n                'Full' => 'Full'\n            ],\n            'defaultValue' => 'Full'\n        ]\n    ]];\n\n    /**\n     * The Pillowfort bridge.\n     *\n     * Pillowfort pages are dynamically generated from a json file\n     * which holds the last 20 or so posts from the given user.\n     * This bridge uses that json file and HTML/CSS similar\n     * to the Twitter bridge for formatting.\n     */\n    public function collectData()\n    {\n        $jsonSite = getContents($this->getJSONURI());\n\n        $jsonFile = json_decode($jsonSite, true);\n        $posts = $jsonFile['posts'];\n\n        foreach ($posts as $post) {\n            $item = $this->getItemFromPost($post);\n\n            //empty when 'noreblogs' is checked and current post is a reblog.\n            if (!empty($item)) {\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        $name = $this -> getUsername();\n        if ($name != '') {\n            return $name . ' - ' . self::NAME;\n        } else {\n            return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        $name = $this -> getUsername();\n        if ($name != '') {\n            return self::URI . '/' . $name;\n        } else {\n            return parent::getURI();\n        }\n    }\n\n    protected function getJSONURI()\n    {\n        return $this -> getURI() . '/json/?p=1';\n    }\n\n    protected function getUsername()\n    {\n        return $this -> getInput('username');\n    }\n\n    protected function genAvatarText($author, $avatar_url, $title)\n    {\n        $noava = $this -> getInput('noava');\n\n        if ($noava) {\n            return '';\n        } else {\n            return <<<EOD\n<a href=\"{self::URI}/posts/{$author}\">\n<img\n\tstyle=\"align:top; width:75px; border:1px solid black;\"\n\talt=\"{$author}\"\n\tsrc=\"{$avatar_url}\"\n\ttitle=\"{$title}\" />\n</a>\nEOD;\n        }\n    }\n\n    protected function genImagesText($media)\n    {\n        $dimensions = $this -> getInput('image');\n        $text = '';\n\n        //preg_replace used for images with spaces in the url\n\n        switch ($dimensions) {\n            case 'None':\n                foreach ($media as $image) {\n                    $imageURL = preg_replace('[ ]', '%20', $image['url']);\n                    $text .= <<<EOD\n<a href=\"{$imageURL}\">\n\t{$imageURL}\n</a>\nEOD;\n                }\n                break;\n\n            case 'Small':\n                foreach ($media as $image) {\n                    $imageURL = preg_replace('[ ]', '%20', $image['small_image_url']);\n                    $text .= <<<EOD\n<a href=\"{$imageURL}\">\n\t<img\n\t\tstyle=\"align:top; max-width:558px; border:1px solid black;\"\n\t\tsrc=\"{$imageURL}\" \n\t/>\n</a>\nEOD;\n                }\n                break;\n\n            case 'Full':\n                foreach ($media as $image) {\n                    $imageURL = preg_replace('[ ]', '%20', $image['url']);\n                    $text .= <<<EOD\n<a href=\"{$imageURL}\">\n\t<img\n\t\tstyle=\"align:top; max-width:558px; border:1px solid black;\"\n\t\tsrc=\"{$imageURL}\" \n\t/>\n</a>\nEOD;\n                }\n                break;\n\n            default:\n                break;\n        }\n\n        return $text;\n    }\n\n    protected function getItemFromPost($post)\n    {\n        //check if its a reblog.\n        if ($post['original_post_id'] == null) {\n            $embPost = false;\n        } else {\n            $embPost = true;\n        }\n\n        if ($this -> getInput('noreblog') && $embPost) {\n            return [];\n        }\n\n        $item = [];\n\n        $item['uid'] = $post['id'];\n        $item['timestamp'] = strtotime($post['created_at']);\n\n        if ($embPost) {\n            $item['uri'] = self::URI . '/posts/' . $post['original_post']['id'];\n            $item['author'] = $post['original_username'];\n            if ($post['original_post']['title'] != '') {\n                $item['title'] = $post['original_post']['title'];\n            } else {\n                $item['title'] = '[NO TITLE]';\n            }\n        } else {\n            $item['uri'] = self::URI . '/posts/' . $post['id'];\n            $item['author'] = $post['username'];\n            if ($post['title'] != '') {\n                $item['title'] = $post['title'];\n            } else {\n                $item['title'] = '[NO TITLE]';\n            }\n        }\n\n        /**\n         * 4 cases if it is a reblog.\n         * 1: reblog has tags, original has tags. defer to option.\n         * 2: reblog has tags, original has no tags. use reblog tags.\n         * 3: reblog has no tags, original has tags. use original tags.\n         * 4: reblog has no tags, original has no tags. use reblog tags not that it matters.\n         */\n        $item['categories'] = $post['tags'];\n        if ($embPost) {\n            if ($this -> getInput('noretags') || ($post['tags'] == null)) {\n                $item['categories'] = $post['original_post']['tag_list'];\n            }\n        }\n\n        $avatarText = $this -> genAvatarText(\n            $item['author'],\n            $post['avatar_url'],\n            $item['title']\n        );\n        $imagesText = $this -> genImagesText($post['media']);\n\n        $item['content'] = <<<EOD\n<div style=\"display: inline-block; vertical-align: top;\">\n\t{$avatarText}\n</div>\n<div style=\"display: inline-block; vertical-align: top;\">\n\t{$post['content']}\n</div>\n<div style=\"display: block; vertical-align: top;\">\n\t{$imagesText}\n</div>\nEOD;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/PinterestBridge.php",
    "content": "<?php\n\nclass PinterestBridge extends FeedExpander\n{\n    const MAINTAINER = 'pauder';\n    const NAME = 'Pinterest';\n    const URI = 'https://www.pinterest.com';\n    const DESCRIPTION = 'Returns the newest images on a board';\n\n    const PARAMETERS = [\n        'By username and board' => [\n            'u' => [\n                'name' => 'username',\n                'exampleValue' => 'VIGOIndustries',\n                'required' => true\n            ],\n            'b' => [\n                'name' => 'board',\n                'exampleValue' => 'bathroom-remodels',\n                'required' => true\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://s.pinimg.com/webapp/style/images/favicon-9f8f9adf.png';\n    }\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas($this->getURI() . '.rss');\n        $this->fixLowRes();\n    }\n\n    private function fixLowRes()\n    {\n        $newitems = [];\n        $pattern = '/https\\:\\/\\/i\\.pinimg\\.com\\/[a-zA-Z0-9]*x\\//';\n        foreach ($this->items as $item) {\n            $item['content'] = preg_replace($pattern, 'https://i.pinimg.com/originals/', $item['content']);\n            $item['enclosures'] = [\n                $item['uri'],\n            ];\n            $newitems[] = $item;\n        }\n        $this->items = $newitems;\n    }\n\n    public function getURI()\n    {\n        if ($this->queriedContext === 'By username and board') {\n            return self::URI . '/' . urlencode($this->getInput('u')) . '/' . urlencode($this->getInput('b'));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if ($this->queriedContext === 'By username and board') {\n            return $this->getInput('u') . ' - ' . $this->getInput('b') . ' - ' . self::NAME;\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/PirateCommunityBridge.php",
    "content": "<?php\n\nclass PirateCommunityBridge extends BridgeAbstract\n{\n    const NAME = 'Pirate-Community';\n    const URI = 'https://raymanpc.com/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns replies to topics';\n    const MAINTAINER = 'Roliga';\n    const PARAMETERS = [ [\n        't' => [\n            'name' => 'Topic ID',\n            'type' => 'number',\n            'exampleValue' => '12651',\n            'title' => 'Topic ID from topic URL. If the URL contains t=12 the ID is 12.',\n            'required' => true\n        ]]];\n\n    private $feedName = '';\n\n    public function detectParameters($url)\n    {\n        $parsed_url = parse_url($url);\n\n        $host = $parsed_url['host'] ?? null;\n\n        if ($host !== 'raymanpc.com') {\n            return null;\n        }\n\n        parse_str($parsed_url['query'], $parsed_query);\n\n        if (\n            $parsed_url['path'] === '/forum/viewtopic.php'\n            && array_key_exists('t', $parsed_query)\n        ) {\n            return ['t' => $parsed_query['t']];\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName;\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('t'))) {\n            return self::URI\n                . 'forum/viewtopic.php?t='\n                . $this->getInput('t')\n                . '&sd=d'; // sort posts decending by ate so first page has latest posts\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $this->feedName = $html->find('head title', 0)->plaintext;\n\n        foreach ($html->find('.post') as $reply) {\n            $item = [];\n\n            $item['uri'] = $this->getURI()\n                . $reply->find('h3 a', 0)->getAttribute('href');\n\n            $item['title'] = $reply->find('h3 a', 0)->plaintext;\n\n            $author_html = $reply->find('.author', 0);\n            // author_html contains the timestamp as text directly inside it,\n            // so delete all other child elements\n            foreach ($author_html->children as $child) {\n                $child->outertext = '';\n            }\n            // Timestamps are always in UTC+1\n            $item['timestamp'] = trim($author_html->innertext) . ' +01:00';\n\n            $item['author'] = $reply\n                ->find('.username, .username-coloured', 0)\n                ->plaintext;\n\n            $item['content'] = defaultLinkTo(\n                $reply->find('.content', 0)->innertext,\n                $this->getURI()\n            );\n\n            $item['enclosures'] = [];\n            foreach ($reply->find('.attachbox img.postimage') as $img) {\n                $item['enclosures'][] = urljoin($this->getURI(), $img->src);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PixivBridge.php",
    "content": "<?php\n\n/**\n * Good resource on API return values (Ex: illustType):\n * https://hackage.haskell.org/package/pixiv-0.1.0/docs/Web-Pixiv-Types.html\n */\nclass PixivBridge extends BridgeAbstract\n{\n    const NAME = 'Pixiv';\n    const URI = 'https://www.pixiv.net/';\n    const DESCRIPTION = 'Returns the tag search from pixiv.net';\n    const MAINTAINER = 'mruac';\n    const CONFIGURATION = [\n        'cookie' => [\n            'required' => false,\n            'defaultValue' => null\n        ],\n        'proxy_url' => [\n            'required' => false,\n            'defaultValue' => null\n        ]\n    ];\n\n    const PARAMETERS = [\n        'global' => [\n            'posts' => [\n                'name' => 'Post Limit',\n                'type' => 'number',\n                'defaultValue' => '10'\n            ],\n            'fullsize' => [\n                'name' => 'Full-size Image',\n                'type' => 'checkbox'\n            ],\n            'mode' => [\n                'name' => 'Post Type',\n                'type' => 'list',\n                'values' => [\n                    'All Works' => 'all',\n                    'Illustrations' => 'illustrations/',\n                    'Manga' => 'manga/',\n                    'Novels' => 'novels/'\n                ]\n            ],\n            'mature' => [\n                'name' => 'Include R-18 works',\n                'type' => 'checkbox'\n            ],\n            'ai' => [\n                'name' => 'Include AI-Generated works',\n                'type' => 'checkbox'\n            ]\n        ],\n        'Tag' => [\n            'tag' => [\n                'name' => 'Query to search',\n                'exampleValue' => 'オリジナル',\n                'required' => true\n            ]\n        ],\n        'User' => [\n            'userid' => [\n                'name' => 'User ID from profile URL',\n                'exampleValue' => '11',\n                'required' => true\n            ]\n        ]\n    ];\n\n    // maps from URLs to json keys by context\n    const JSON_KEY_MAP = [\n        'Tag' => [\n            'illustrations/' => 'illust',\n            'manga/' => 'manga',\n            'novels/' => 'novel'\n        ],\n        'User' => [\n            'illustrations/' => 'illusts',\n            'manga/' => 'manga',\n            'novels/' => 'novels'\n        ]\n    ];\n\n    // Hold the username for getName()\n    private $username = null;\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Tag':\n                $context = 'Tag';\n                $query = $this->getInput('tag');\n                break;\n            case 'User':\n                $context = 'User';\n                $query = $this->username ?? $this->getInput('userid');\n                break;\n            default:\n                return parent::getName();\n        }\n        return 'Pixiv ' . $this->getKey('mode') . \" from {$context} {$query}\";\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Tag':\n                $uri = static::URI . 'tags/' . urlencode($this->getInput('tag') ?? '');\n                break;\n            case 'User':\n                $uri = static::URI . 'users/' . $this->getInput('userid');\n                break;\n            default:\n                return parent::getURI();\n        }\n        if ($this->getInput('mode') != 'all') {\n            $uri = $uri . '/' . $this->getInput('mode');\n        }\n        return $uri;\n    }\n\n    private function getSearchURI($mode)\n    {\n        switch ($this->queriedContext) {\n            case 'Tag':\n                $query = urlencode($this->getInput('tag'));\n                $uri = static::URI . 'ajax/search/top/' . $query;\n                break;\n            case 'User':\n                $uri = static::URI . 'ajax/user/' . $this->getInput('userid')\n                    . '/profile/top';\n                break;\n            default:\n                throwClientException('Invalid Context');\n        }\n        return $uri;\n    }\n\n    private function getDataFromJSON($json, $json_key)\n    {\n        $key = $json_key;\n        if (\n            $this->queriedContext === 'Tag' &&\n            $this->getOption('cookie') !== null\n        ) {\n            switch ($json_key) {\n                case 'illust':\n                case 'manga':\n                    $key = 'illustManga';\n                    break;\n            }\n        }\n        $json = $json['body'][$key];\n        // Tags context contains subkey\n        if ($this->queriedContext === 'Tag') {\n            $json = $json['data'];\n            if ($this->getOption('cookie') !== null) {\n                switch ($json_key) {\n                    case 'illust':\n                        $json = array_reduce($json, function ($acc, $i) {\n                            if ($i['illustType'] === 0) {\n                                $acc[] = $i;\n                            }\n                            return $acc;\n                        }, []);\n                        break;\n                    case 'manga':\n                        $json = array_reduce($json, function ($acc, $i) {\n                            if ($i['illustType'] === 1) {\n                                $acc[] = $i;\n                            }return $acc;\n                        }, []);\n                        break;\n                }\n            }\n        }\n        return $json;\n    }\n\n    private function collectWorksArray()\n    {\n        $content = $this->getData($this->getSearchURI($this->getInput('mode')), true, true);\n        if ($this->getInput('mode') == 'all') {\n            $total = [];\n            foreach (self::JSON_KEY_MAP[$this->queriedContext] as $mode => $json_key) {\n                $current = $this->getDataFromJSON($content, $json_key);\n                $total = array_merge($total, $current);\n            }\n            $content = $total;\n        } else {\n            $json_key = self::JSON_KEY_MAP[$this->queriedContext][$this->getInput('mode')];\n            $content = $this->getDataFromJSON($content, $json_key);\n        }\n        return $content;\n    }\n\n    public function collectData()\n    {\n        $this->checkOptions();\n        $proxy_url = $this->getOption('proxy_url');\n        $proxy_url = $proxy_url ? rtrim($proxy_url, '/') : null;\n\n        $content = $this->collectWorksArray();\n        $content = array_filter($content, function ($v, $k) {\n            return !array_key_exists('isAdContainer', $v);\n        }, ARRAY_FILTER_USE_BOTH);\n\n        // Sort by updateDate to get newest works\n        usort($content, function ($a, $b) {\n            return $b['updateDate'] <=> $a['updateDate'];\n        });\n\n        //exclude AI generated works if unchecked.\n        if ($this->getInput('ai') !== true) {\n            $content = array_filter($content, function ($v) {\n                $isAI = $v['aiType'] === 2;\n                return !$isAI;\n            });\n        }\n\n        //exclude R-18 works if unchecked.\n        if ($this->getInput('mature') !== true) {\n            $content = array_filter($content, function ($v) {\n                $isMature = $v['xRestrict'] > 0;\n                return !$isMature;\n            });\n        }\n\n        $content = array_slice($content, 0, $this->getInput('posts'));\n\n        foreach ($content as $result) {\n            // Store username for getName()\n            if (!$this->username) {\n                $this->username = $result['userName'];\n            }\n\n            $item = [];\n            $item['uid'] = $result['id'];\n\n            $subpath = array_key_exists('illustType', $result) ? 'artworks/' : 'novel/show.php?id=';\n            $item['uri'] = static::URI . $subpath . $result['id'];\n\n            $item['title'] = $result['title'];\n            $item['author'] = $result['userName'];\n            $item['timestamp'] = $result['updateDate'];\n            $item['categories'] = $result['tags'];\n\n            if ($proxy_url) {\n                //use proxy image host if set.\n                if ($this->getInput('fullsize')) {\n                    $ajax_uri = static::URI . 'ajax/illust/' . $result['id'];\n                    $imagejson = $this->getData($ajax_uri, true, true);\n                    $img_url = preg_replace('/https:\\/\\/i\\.pximg\\.net/', $proxy_url, $imagejson['body']['urls']['original']);\n                } else {\n                    $img_url = preg_replace('/https:\\/\\/i\\.pximg\\.net/', $proxy_url, $result['url']);\n                }\n            } else {\n                $img_url = $result['url'];\n            }\n\n            // Currently, this might result in broken image due to their strict referrer check\n            $item['content'] = sprintf('<a href=\"%s\"><img src=\"%s\"/></a>', $img_url, $img_url);\n\n            // Additional content items\n            if (array_key_exists('pageCount', $result)) {\n                $item['content'] .= '<br>Page Count: ' . $result['pageCount'];\n            } else {\n                $item['content'] .= '<br>Word Count: ' . $result['wordCount'];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function checkOptions()\n    {\n        $proxy = $this->getOption('proxy_url');\n        if ($proxy) {\n            if (\n                !(strlen($proxy) > 0 && preg_match('/https?:\\/\\/.*/', $proxy))\n            ) {\n                throwServerException('Invalid proxy_url value set. The proxy must include the HTTP/S at the beginning of the url.');\n            }\n        }\n\n        $cookie = $this->getCookie();\n        if ($cookie) {\n            $isAuth = $this->loadCacheValue('is_authenticated');\n            if (!$isAuth) {\n                $res = $this->getData('https://www.pixiv.net/ajax/webpush', true, true);\n                if ($res['error'] === false) {\n                    $this->saveCacheValue('is_authenticated', true);\n                }\n            }\n        }\n    }\n\n    private function checkCookie(array $headers)\n    {\n        if (array_key_exists('set-cookie', $headers)) {\n            foreach ($headers['set-cookie'] as $value) {\n                if (str_starts_with($value, 'PHPSESSID=')) {\n                    parse_str(strtr($value, ['&' => '%26', '+' => '%2B', ';' => '&']), $cookie);\n                    if ($cookie['PHPSESSID'] != $this->getCookie()) {\n                        $this->saveCacheValue('cookie', $cookie['PHPSESSID']);\n                    }\n                    break;\n                }\n            }\n        }\n    }\n\n    private function getCookie()\n    {\n        // checks if cookie is set, if not initialise it with the cookie from the config\n        $value = $this->loadCacheValue('cookie');\n        if (!$value) {\n            $value = $this->getOption('cookie');\n\n            // 30 days + 1 day to let cookie chance to renew\n            $this->saveCacheValue('cookie', $this->getOption('cookie'), 2678400);\n        }\n        return $value;\n    }\n\n    //Cache getContents by default\n    private function getData(string $url, bool $cache = true, bool $getJSON = false, array $httpHeaders = [], array $curlOptions = [])\n    {\n        $cookie_str = $this->getCookie();\n        if ($cookie_str) {\n            $curlOptions[CURLOPT_COOKIE] = 'PHPSESSID=' . $cookie_str;\n        }\n\n        if ($cache) {\n            $response = $this->loadCacheValue($url);\n            if (!$response || is_array($response)) {\n                $response = getContents($url, $httpHeaders, $curlOptions, true);\n                $this->saveCacheValue($url, $response);\n            }\n        } else {\n            $response = getContents($url, $httpHeaders, $curlOptions, true);\n        }\n\n        $this->checkCookie($response->getHeaders());\n\n        if ($getJSON) {\n            return json_decode($response->getBody(), true);\n        }\n        return $response->getBody();\n    }\n}\n"
  },
  {
    "path": "bridges/PlantUMLReleasesBridge.php",
    "content": "<?php\n\n/**\n * PlantUML releases bridge showing latest releases content\n * @author nicolas-delsaux\n *\n */\nclass PlantUMLReleasesBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Riduidel';\n    const NAME = 'PlantUML Releases';\n    const AUTHOR = 'PlantUML team';\n    const URI = 'https://plantuml.com/changes';\n\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'PlantUML releases bridge, showing for each release the changelog';\n    const ITEM_LIMIT = 10;\n\n    public function getURI()\n    {\n        return self::URI;\n    }\n\n    public function collectData()\n    {\n        $html = defaultLinkTo(getSimpleHTMLDOM($this->getURI()), self::URI);\n\n        $num_items = 0;\n        $main = $html->find('div[id=root]', 0);\n        foreach ($main->find('h2') as $release) {\n            // Limit to $ITEM_LIMIT number of results\n            if ($num_items++ >= self::ITEM_LIMIT) {\n                break;\n            }\n            $item = [];\n            $item['author'] = self::AUTHOR;\n            $release_text = $release->innertext;\n            if (preg_match('/(.+) \\((.*)\\)/', $release_text, $matches)) {\n                $item['title'] = $matches[1];\n                $item['timestamp'] = preg_replace('/(\\d+) (\\w{3})\\w*, (\\d+)/', '${1} ${2} ${3}', $matches[2]);\n            } else {\n                $item['title'] = $release_text;\n            }\n            $item['uri'] = $this->getURI();\n            $item['content'] = $release->next_sibling();\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PokemonNewsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nfinal class PokemonNewsBridge extends BridgeAbstract\n{\n    const NAME = 'Pokemon.com news';\n    const URI = 'https://www.pokemon.com/us/pokemon-news';\n    const DESCRIPTION = 'Fetches the latest news from pokemon.com';\n    const MAINTAINER = 'dvikan';\n\n    public function collectData()\n    {\n        // todo: parse json api instead: https://www.pokemon.com/api/1/us/news/get-news.json\n        $url = 'https://www.pokemon.com/us/pokemon-news';\n        $dom = getSimpleHTMLDOM($url);\n        $haystack = (string)$dom;\n        if (str_contains($haystack, 'Request unsuccessful. Incapsula incident')) {\n            throw new \\Exception('Blocked by anti-bot');\n        }\n        foreach ($dom->find('.news-list ul li') as $item) {\n            $title = $item->find('h3', 0)->plaintext;\n            $description = $item->find('p.hidden-mobile', 0);\n            $dateString = $item->find('p.date', 0)->plaintext;\n            // e.g. September 15, 2022\n            $createdAt = date_create_from_format('F d, Y', $dateString);\n            // todo:\n            $tagsString = $item->find('p.tags', 0)->plaintext;\n            $path = $item->find('a', 0)->href;\n            $imagePath = $item->find('img', 0)->src;\n            $tags = explode('&', $tagsString);\n            $tags = array_map('trim', $tags);\n\n            $this->items[] = [\n                'title' => $title,\n                'uri' => sprintf('https://www.pokemon.com%s', $path),\n                'timestamp' => $createdAt ? $createdAt->getTimestamp() : time(),\n                'categories' => $tags,\n                'content' => sprintf(\n                    '<img src=\"https://pokemon.com%s\"><br><br>%s',\n                    $imagePath,\n                    $description ? $description->plaintext : ''\n                ),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PornhubBridge.php",
    "content": "<?php\n\nclass PornhubBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Mitsukarenai';\n    const NAME = 'Pornhub';\n    const URI = 'https://www.pornhub.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns videos from specified user,model,pornstar';\n\n    const PARAMETERS = [[\n        'q' => [\n            'name' => 'User name',\n            'exampleValue' => 'asa-akira',\n            'required' => true,\n        ],\n        'type' => [\n            'name' => 'User type',\n            'type' => 'list',\n            'values' => [\n                'user' => 'users',\n                'model' => 'model',\n                'pornstar' => 'pornstar',\n            ],\n            'defaultValue' => 'pornstar',\n        ],\n        'sort' => [\n            'name' => 'Sort by',\n            'type' => 'list',\n            'values' => [\n                'Most recent' => '?',\n                'Most views' => '?o=mv',\n                'Top rated' => '?o=tr',\n                'Longest' => '?o=lg',\n            ],\n            'defaultValue' => '?',\n        ],\n        'show_images' => [\n            'name' => 'Show thumbnails',\n            'type' => 'checkbox',\n        ],\n    ]];\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('type')) && !is_null($this->getInput('q'))) {\n            return 'PornHub ' . $this->getInput('type') . ':' . $this->getInput('q');\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $uri = 'https://www.pornhub.com/' . $this->getInput('type') . '/';\n        switch ($this->getInput('type')) {   // select proper permalink format per user type...\n            case 'model':\n                $uri .= urlencode($this->getInput('q')) . '/videos' . $this->getInput('sort');\n                break;\n            case 'users':\n                $uri .= urlencode($this->getInput('q')) . '/videos/public' . $this->getInput('sort');\n                break;\n            case 'pornstar':\n                $uri .= urlencode($this->getInput('q')) . '/videos/upload' . $this->getInput('sort');\n                break;\n        }\n\n        $show_images = $this->getInput('show_images');\n\n        $html = getSimpleHTMLDOM($uri, [\n            'cookie: accessAgeDisclaimerPH=1'\n        ]);\n\n        foreach ($html->find('div.videoUList ul.videos li.videoblock') as $element) {\n            $item = [];\n\n            $item['author'] = $this->getInput('q');\n\n            // Title\n            $title = $element->find('a', 0)->getAttribute('title');\n            if (is_null($title)) {\n                continue;\n            }\n            $item['title'] = $title;\n\n            // Url\n            $url = $element->find('a', 0)->href;\n            $item['uri'] = 'https://www.pornhub.com' . $url;\n\n            // Duration\n            $marker = $element->find('div.marker-overlays var', 0);\n            $duration = $marker->innertext ?? '';\n\n            // Content\n            $videoImage = $element->find('img', 0);\n            $image = $videoImage->getAttribute('data-src') ?: $videoImage->getAttribute('src');\n            if ($show_images === true) {\n                $item['content'] = sprintf('<a href=\"%s\"><img src=\"%s\"></a><br>%s', $item['uri'], $image, $duration);\n            }\n\n            $uploaded = explode('/', $image);\n            if (isset($uploaded[4])) {\n                // date hack, guess upload YYYYMMDD from thumbnail URL (format: https://ci.phncdn.com/videos/201907/25/--- )\n                $uploadTimestamp = strtotime($uploaded[4] . $uploaded[5]);\n                $item['timestamp'] = $uploadTimestamp;\n            } else {\n                // The thumbnail url did not have a date in it for some unknown reason\n            }\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PresidenciaPTBridge.php",
    "content": "<?php\n\nclass PresidenciaPTBridge extends BridgeAbstract\n{\n    const NAME = 'Presidência da República Portuguesa';\n    const URI = 'https://www.presidencia.pt';\n    const DESCRIPTION = 'Presidência da República Portuguesa';\n    const MAINTAINER = 'somini';\n    const PARAMETERS = [\n        'Section' => [\n            '/atualidade/noticias' => [\n                'name' => 'Notícias',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n            ],\n            '/atualidade/mensagens' => [\n                'name' => 'Mensagens',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n            ],\n            '/atualidade/atividade-legislativa' => [\n                'name' => 'Atividade Legislativa',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n            ],\n            '/atualidade/notas-informativas' => [\n                'name' => 'Notas Informativas',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n            ]\n        ]\n    ];\n\n    const PT_MONTH_NAMES = [\n        'janeiro',\n        'fevereiro',\n        'março',\n        'abril',\n        'maio',\n        'junho',\n        'julho',\n        'agosto',\n        'setembro',\n        'outubro',\n        'novembro',\n        'dezembro'];\n\n    public function getIcon()\n    {\n        return 'https://www.presidencia.pt/Theme/favicon/apple-touch-icon.png';\n    }\n\n    public function collectData()\n    {\n        $contexts = $this->getParameters();\n\n        foreach (array_keys($contexts['Section']) as $k) {\n            if ($this->getInput($k)) {\n                $html = getSimpleHTMLDOMCached($this->getURI() . $k);\n\n                foreach ($html->find('#atualidade-list article.card-block') as $element) {\n                    $item = [];\n\n                    $link = $element->find('a', 0);\n                    $etitle = $element->find('.article-title', 0);\n                    $edts = $element->find('.date', 0);\n                    $edt = $edts->innertext;\n\n                    $item['title'] = strip_tags($etitle->innertext);\n                    $item['uri'] = self::URI . $link->href;\n                    $item['description'] = $element;\n                    $item['timestamp'] = str_ireplace(\n                        array_map(function ($name) {\n                            return ' de ' . $name . ' de ';\n                        }, self::PT_MONTH_NAMES),\n                        array_map(function ($num) {\n                            return sprintf('-%02d-', $num);\n                        }, range(1, count(self::PT_MONTH_NAMES))),\n                        $edt\n                    );\n\n                    $this->items[] = $item;\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/PriviblurBridge.php",
    "content": "<?php\n\nclass PriviblurBridge extends BridgeAbstract\n{\n    const NAME = 'Priviblur';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://github.com/syeopite/priviblur';\n    const DESCRIPTION = 'Returns Tumblr posts from a Priviblur link';\n    const PARAMETERS = [\n        [\n            'url' => [\n                'name' => 'URL',\n                'exampleValue' => 'https://priviblur.fly.dev',\n                'required' => true,\n            ]\n        ]\n    ];\n\n    private $title;\n    private $favicon = 'https://www.tumblr.com/favicon.ico';\n\n    public function collectData()\n    {\n        $url = $this->getURI();\n        $html = getSimpleHTMLDOM($url);\n        $html = defaultLinkTo($html, $url);\n        $this->title = $html->find('head title', 0)->innertext;\n\n        if ($html->find('#blog-header img.avatar', 0)) {\n            $icon = $html->find('#blog-header img.avatar', 0)->src;\n            $this->favicon = str_replace('pnj', 'png', $icon);\n        }\n\n        $elements = $html->find('.post');\n        foreach ($elements as $element) {\n            $item = [];\n            $item['author'] = $element->find('.primary-post-author .blog-name', 0)->innertext;\n            $item['comments'] = $element->find('.interaction-buttons > a', 1)->href;\n            $item['content'] = $element->find('.post-body', 0);\n            $item['timestamp'] = $element->find('.primary-post-author time', 0)->innertext;\n            $item['title'] = $item['author'] . ': ' . $item['timestamp'];\n            $item['uid'] = $item['comments']; // tumblr url is canonical\n            $item['uri'] = $element->find('.interaction-buttons > a', 0)->href;\n\n            if ($element->find('.post-tags', 0)) {\n                $tags = html_entity_decode($element->find('.post-tags', 0)->plaintext);\n                $tags = explode('#', $tags);\n                $tags = array_map('trim', $tags);\n                array_shift($tags);\n                $item['categories'] = $tags;\n            }\n\n            $heading = $element->find('h1', 0);\n            if ($heading) {\n                $item['title'] = $heading->innertext;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if (isset($this->title)) {\n            $name = $this->title;\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        return $this->getInput('url') ?? parent::getURI();\n    }\n\n    public function getIcon()\n    {\n        return $this->favicon;\n    }\n}\n"
  },
  {
    "path": "bridges/QnapBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nfinal class QnapBridge extends BridgeAbstract\n{\n    const NAME = 'QNAP';\n    const URI = 'https://www.qnap.com/fr-fr/security-news/2022';\n    const DESCRIPTION = <<<'DESCRIPTION'\n<b>Use offical feed instead: https://www.qnap.com/fr-fr/security-news/feed </b><br><br>\nUnofficial feed for security news.\nDESCRIPTION;\n\n    const MAINTAINER = 'dvikan';\n\n    public function collectData()\n    {\n        $thisYear = date('Y');\n        $url = sprintf('https://www.qnap.com/api/v1/articles/security-news?locale=fr-fr&year=%s&page=1', $thisYear);\n        $response = json_decode(getContents($url));\n        foreach ($response->data as $post) {\n            $item = [];\n            $item['uri'] = sprintf('https://www.qnap.com%s', $post->url);\n            $item['title'] = $post->title;\n            $item['timestamp'] = \\DateTime::createFromFormat('Y-m-d', $post->date)->format('U');\n            $image = sprintf('<img src=\"https://www.qnap.com%s\">', $post->image_url);\n            $item['content'] = $image . '<br><br>' . $post->desc;\n            $this->items[] = $item;\n        }\n        usort($this->items, function ($a, $b) {\n            return $a['timestamp'] < $b['timestamp'];\n        });\n    }\n}\n"
  },
  {
    "path": "bridges/QwantzBridge.php",
    "content": "<?php\n\nclass QwantzBridge extends FeedExpander\n{\n    const NAME           = 'Dinosaur Comics';\n    const URI            = 'https://qwantz.com/';\n    const DESCRIPTION    = 'Latest comic.';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::URI . 'rssfeed.php');\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['author'] = 'Ryan North';\n\n        preg_match('/title=\"(.*?)\"/', $item['content'], $matches);\n        $title = $matches[1] ?? '';\n\n        $content = str_get_html(html_entity_decode($item['content']));\n        $comicURL = $content->find('img')[0]->{'src'};\n        $subject = $content->find('a')[1]->{'href'};\n        $subject = urldecode(substr($subject, strpos($subject, 'subject') + 8));\n        $p = (string)$content->find('P')[0];\n\n        $item['content'] = \"{$subject}<figure><img src=\\\"{$comicURL}\\\"><figcaption><p>{$title}</p></figcaption></figure>{$p}\";\n\n        return $item;\n    }\n\n    public function getIcon()\n    {\n        return self::URI . 'favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/QwenBlogBridge.php",
    "content": "<?php\n\nclass QwenBlogBridge extends FeedExpander\n{\n    const NAME = 'Qwen Blog';\n    const URI = 'https://qwenlm.github.io/blog/';\n    const DESCRIPTION = 'Fetch the latest items from Qwen';\n    const MAINTAINER = 'sqrtminusone';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [\n        '' => [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::URI . 'index.xml', $this->getInput('limit'));\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOM($item['uri']);\n        $content = $dom->find('div.post-content', 0);\n        if ($content == null) {\n            return $item;\n        }\n\n        // Fix code blocks\n        foreach ($dom->find('pre.chroma') as $code_block) {\n            // Somehow there are tags in <pre>??\n            $code_block_html = str_get_html($code_block->plaintext);\n            $code = '';\n            foreach ($code_block_html->find('span.line') as $line) {\n                $code .= $line->plaintext . \"\\n\";\n            }\n            $code_block->outertext = '<pre>' . $code . '</pre>';\n        }\n\n        $item['content'] = $content;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/QwerteeBridge.php",
    "content": "<?php\n\nclass QwerteeBridge extends BridgeAbstract\n{\n    const NAME = 'Qwertee';\n    const URI = 'https://www.qwertee.com';\n    const DESCRIPTION = 'Returns the daily designs';\n    const MAINTAINER = 'Bockiii';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 60 * 60 * 3; // 3 hours\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('div.big-slides', 0)->find('div.big-slide') as $element) {\n            $title = $element->find('div.index-tee', 0)->getAttribute('data-name', 0);\n            $today = date('m/d/Y');\n            $item = [];\n            $item['uri'] = self::URI;\n            $item['title'] = $title;\n            $item['uid'] = $title;\n            $item['timestamp'] = $today;\n            $item['content'] = '<a href=\"'\n            . $item['uri']\n            . '\"><img src=\"'\n            . $element->find('img', 0)->getAttribute('src', 0)\n            . '\" /></a>';\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/RadioFranceBridge.php",
    "content": "<?php\n\n/**\n * A bridge allowing fetching of Radio France radios transcripts.\n * I expect it to work at least for France Inter and France Culture.\n * I currently test it with\n * * Burne Out (https://www.radiofrance.fr/franceinter/podcasts/burne-out)\n * * La méthode scientifique (https://www.radiofrance.fr/franceculture/podcasts/la-methode-scientifique)\n * * Las science CQFD\n */\nclass RadioFranceBridge extends BridgeAbstract\n{\n    const NAME          = 'Radio France';\n    const URI           = 'https://www.radiofrance.fr/franceinter/podcasts';\n    const DESCRIPTION   = 'A bridge allowing to read transcripts for Radio France shows';\n    const MAINTAINER    = 'Riduidel';\n    const DEFAULT_DOMAIN = 'https://www.radiofrance.fr';\n\n    /*\n     * The URL Prefix of the (Webapp-)API\n     * @const APIENDPOINT https-URL of the used endpoint\n     */\n    const APIENDPOINT = 'https://www.radiofrance.fr/api/v2.0/path';\n    const PARAMETERS = [\n        [\n        'domain' => [\n            'name' => 'Domain to use',\n            'required' => true,\n            'defaultValue' => self::DEFAULT_DOMAIN\n        ],\n        'page' => [\n            'name' => 'Initial page to load',\n            'required' => true,\n            'exampleValue' => 'franceinter/podcasts/burne-out'\n        ]\n        ]];\n\n    private function getDomain()\n    {\n        $domain = $this->getInput('domain');\n        if (empty($domain)) {\n            $domain = self::DEFAULT_DOMAIN;\n        }\n        if (strpos($domain, '://') === false) {\n            $domain = 'https://' . $domain;\n        }\n        return $domain;\n    }\n\n    public function getURI()\n    {\n        return $this->getDomain() . '/' . $this->getInput('page');\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        // An array of dom nodes\n        $documentsList = $html->find('.DocumentsList', 0);\n        $documentsListWrapper = $documentsList->find('.DocumentsList-wrapper', 0);\n        $cardList = $documentsListWrapper->find('.CardMedia');\n\n        foreach ($cardList as $card) {\n            $item = [];\n            $title_link = $card->find('.ConceptTitle a', 0);\n            $item['title'] = $title_link->plaintext;\n            $uri = $title_link->getAttribute('href', 0);\n            switch (substr($uri, 0, 1)) {\n                case 'h': // absolute uri\n                    $item['uri'] = $uri;\n                    break;\n                case '/': // domain relative uri\n                    $item['uri'] = $this->getDomain() . $uri;\n                    break;\n                default:\n                    $item['uri'] = $this->getDomain() . '/' . $uri;\n            }\n            // Finally, obtain the mp3 from some weird Radio France API (url obtained by reading network calls, no less)\n            $media_url = self::APIENDPOINT . '?value=' . $uri;\n            $rawJSON = getSimpleHTMLDOMCached($media_url);\n            $processedJSON = json_decode($rawJSON);\n            $model_content = $processedJSON->content;\n            if (empty($model_content->manifestations)) {\n                error_log(\"Seems like $uri has no manifestation\");\n            } else {\n                $item['enclosures'] = [ $model_content->manifestations[0]->url ];\n\n                $item['content'] = '';\n                if (isset($model_content->visual)) {\n                    $item['content'] .= \"<img \n                        src=\\\"{$model_content->visual->src}\\\" \n                        alt=\\\"{$model_content->visual->legend}\\\"\n                        style=\\\"float:left; width:400px; margin: 1em;\\\"/>\";\n                }\n                if (isset($model_content->standFirst)) {\n                    $item['content'] .= $model_content->standFirst;\n                }\n                if (isset($model_content->bodyJson)) {\n                    if (!empty($item['content'])) {\n                        $item['content'] .= '<hr/>';\n                    }\n                    $pseudo_html_array = array_map([$this, 'convertJsonElementToHTML'], $model_content->bodyJson);\n                    $pseudo_html_text = array_reduce(\n                        $pseudo_html_array,\n                        function ($text, $element) {\n                            return $text . \"\\n\" . $element;\n                        },\n                        ''\n                    );\n                    $item['content'] .= $pseudo_html_text;\n                }\n                if (isset($model_content->producers)) {\n                    $item['author'] = $this->readAuthorsNamesFrom($model_content->producers);\n                } elseif (isset($model_content->staff)) {\n                    $item['author'] = $this->readAuthorsNamesFrom($model_content->staff);\n                }\n                $time = $card->find('time', 0);\n                $timevalue = $time->getAttribute('datetime');\n                $item['timestamp'] = strtotime($timevalue);\n\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    private function readAuthorsNamesFrom($persons_array)\n    {\n        $persons_names = array_map(function ($person_element) {\n            return $person_element->name;\n        }, $persons_array);\n        return array_reduce($persons_names, function ($a, $b) {\n            if (!empty($a)) {\n                $a .= ', ';\n            }\n            return $a . $b;\n        }, '');\n    }\n\n    private function convertJsonElementToHTML($jsonElement)\n    {\n        $childText = isset($jsonElement->children) ? $this->convertJsonChildrenToHTML($jsonElement->children) : '';\n        $valueText = isset($jsonElement->value) ? $jsonElement->value : '';\n        switch ($jsonElement->type) {\n            case 'text':\n                return \"{$childText}{$valueText}\";\n            case 'heading':\n                $level = $jsonElement->level;\n                return \"<h$level>{$childText}{$valueText}</h$level>\";\n            case 'list':\n                $tag = 'ul';\n                if (isset($jsonElement->ordered)) {\n                    if ($jsonElement->ordered) {\n                        $tag = 'ol';\n                    }\n                }\n                return \"<$tag>\\n\" . $childText . \"</$tag>\\n\";\n            case 'list_item':\n                return \"<li>{$childText}{$valueText}</li>\\n\";\n            case 'bounce':\n                return '';\n            case 'paragraph':\n                return \"<p>{$childText}{$valueText}</p>\\n\";\n            case 'quote':\n                return \"<blockquote>{$childText}{$valueText}</blockquote>\\n\";\n            case 'link':\n                return \"<a href=\\\"{$jsonElement->data->href}\\\">{$childText}{$valueText}</a>\\n\";\n            case 'audio':\n                return '';\n            case 'embed':\n                return $jsonElement->data->html;\n            default:\n                return $jsonElement->value;\n        }\n    }\n\n    private function convertJsonChildrenToHTML($children)\n    {\n        $converted = array_map([$this, 'convertJsonElementToHTML'], $children);\n        return array_reduce($converted, function ($a, $b) {\n            return $a . $b;\n        }, '');\n    }\n\n    private function removeAds($element)\n    {\n        $ads = $element->find('AdSlot');\n        foreach ($ads as $ad) {\n            $ad->remove();\n        }\n        return $element;\n    }\n\n    /**\n     * Replaces all relative URIs with absolute ones\n     * @param $element A simplehtmldom element\n     * @return The $element->innertext with all URIs replaced\n     */\n    private function replaceUriInHtmlElement($element)\n    {\n        $returned = $element->innertext;\n        foreach (self::REPLACED_ATTRIBUTES as $initial => $final) {\n            $returned = str_replace($initial . '=\"/', $final . '=\"' . self::URI . '/', $returned);\n        }\n        return $returned;\n    }\n}\n"
  },
  {
    "path": "bridges/RadioMelodieBridge.php",
    "content": "<?php\n\nclass RadioMelodieBridge extends BridgeAbstract\n{\n    const NAME = 'Radio Melodie Actu';\n    const URI = 'https://www.radiomelodie.com';\n    const DESCRIPTION = 'Retourne les actualités publiées par Radio Melodie';\n    const MAINTAINER = 'sysadminstory';\n\n    public function getIcon()\n    {\n        return self::URI . '/img/favicon.png';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI . '/actu/');\n        $list = $html->find('div[class=listArticles]', 0)->children();\n\n        foreach ($list as $element) {\n            if ($element->tag == 'a') {\n                $articleURL = self::URI . $element->href;\n                $article = getSimpleHTMLDOM($articleURL);\n                $this->rewriteAudioPlayers($article);\n                // Reload the modified content\n                $article = str_get_html($article->save());\n                $textDOM = $article->find('article', 0);\n\n                // Remove HTML code for the article title\n                $textDOM->find('h1', 0)->outertext = '';\n\n                // Fix the CSS for the author\n                $textDOM->find('div[class=author]', 0)->find('img', 0)\n                       ->setAttribute('style', 'width: 60px; margin: 0 15px; display: inline-block; vertical-align: top;');\n\n\n                // Initialise arrays\n                $item = [];\n                $audio = [];\n                $picture = [];\n\n                // Get the Main picture URL\n                $picture[] = $article->find('figure[class*=photoviewer]', 0)->find('img', 0)->src;\n                $audioHTML = $article->find('audio');\n\n                // Add the audio element to the enclosure\n                foreach ($audioHTML as $audioElement) {\n                    $audioURL = $audioElement->src;\n                    $audio[] = $audioURL;\n                }\n\n                // Rewrite pictures URL\n                $imgs = $textDOM->find('img[src^=\"http://www.radiomelodie.com/image.php]');\n                foreach ($imgs as $img) {\n                    $img->src = $this->rewriteImage($img->src);\n                    $article->save();\n                }\n\n                // Remove Google Ads\n                $ads = $article->find('div[class=adInline]');\n                foreach ($ads as $ad) {\n                    $ad->outertext = '';\n                    $article->save();\n                }\n\n                // Extract the author\n                $author = $article->find('div[class=author]', 0)->children(1)->children(0)->plaintext;\n\n                // Handle date to timestamp\n                $dateHTML = $article->find('div[class=author]', 0)->children(1)->plaintext;\n\n                preg_match('/([a-z]{4,10}[ ]{1,2}[0-9]{1,2} [\\p{L}]{3,10} [0-9]{4} à [0-9]{2}:[0-9]{2})/mus', $dateHTML, $matches);\n                $dateText = $matches[1];\n\n                $timestamp = $this->parseDate($dateText);\n\n                $item['enclosures'] = array_merge($picture, $audio);\n                $item['author'] = $author;\n                $item['uri'] = $articleURL;\n                $item['title'] = $article->find('meta[property=og:title]', 0)->content;\n                if ($timestamp !== false) {\n                    $item['timestamp'] = $timestamp;\n                }\n\n                // Remove the share article part\n                $textDOM->find('div[class=share]', 0)->outertext = '';\n                $textDOM->find('div[class=share]', 1)->outertext = '';\n\n                // Rewrite relative Links\n                $textDOM = defaultLinkTo($textDOM, self::URI . '/');\n\n                $article->save();\n                $text = $textDOM->innertext;\n                $item['content'] = '<h1>' . $item['title'] . '</h1>' . $dateText . '<br/>' . $text;\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    /*\n     * Function to rewrite image URL to use the real Image URL and not the resized one (which is very slow)\n     */\n    private function rewriteImage($url)\n    {\n        $parts = explode('?', $url);\n        parse_str(html_entity_decode($parts[1]), $params);\n        return self::URI . '/' . $params['image'];\n    }\n\n    /*\n     * Function to rewrite Audio Players to use the <audio> tag and not the javascript audio player\n     */\n    private function rewriteAudioPlayers($html)\n    {\n        // Find all audio Players\n        $audioPlayers = $html->find('div[class=audioPlayer]');\n\n        foreach ($audioPlayers as $audioPlayer) {\n            // Get the javascript content below the player\n            $js = $audioPlayer->next_sibling();\n\n            // Extract the audio file URL\n            preg_match('/wavesurfer[0-9]+.load\\(\\'(.*)\\'\\)/m', $js->innertext, $urls);\n\n            // Create the plain HTML <audio> content to play this audio file\n            $content = '<audio style=\"width: 100%\" src=\"' . self::URI . $urls[1] . '\" controls ></audio>';\n\n            // Replace the <script> tag by the <audio> tag\n            $js->outertext = $content;\n            // Remove the initial Audio Player\n            $audioPlayer->outertext = '';\n        }\n    }\n\n    /*\n     * Function to parse the article date\n     */\n    private function parseDate($date_fr)\n    {\n        // French date texts\n        $search_fr = [\n            'janvier',\n            'février',\n            'mars',\n            'avril',\n            'mai',\n            'juin',\n            'juillet',\n            'août',\n            'septembre',\n            'octobre',\n            'novembre',\n            'décembre',\n            'lundi',\n            'mardi',\n            'mercredi',\n            'jeudi',\n            'vendredi',\n            'samedi',\n            'dimanche'\n        ];\n\n        // English replacement date text\n        $replace_en = [\n            'january',\n            'february',\n            'march',\n            'april',\n            'may',\n            'june',\n            'july',\n            'august',\n            'september',\n            'october',\n            'november',\n            'december',\n            'monday',\n            'tuesday',\n            'wednesday',\n            'thursday',\n            'friday',\n            'saturday',\n            'sunday'\n        ];\n\n        $dateFormat = 'l j F Y \\à H:i';\n\n        // Convert the date from French to English\n        $date_en = str_replace($search_fr, $replace_en, $date_fr);\n\n        // Parse the date and convert it to an array\n        $date_array = date_parse_from_format($dateFormat, $date_en);\n\n        // Convert the array to a unix timestamp\n        $timestamp = mktime(\n            $date_array['hour'],\n            $date_array['minute'],\n            $date_array['second'],\n            $date_array['month'],\n            $date_array['day'],\n            $date_array['year']\n        );\n\n        return $timestamp;\n    }\n}\n"
  },
  {
    "path": "bridges/RainLoopBridge.php",
    "content": "<?php\n\nclass RainLoopBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Simounet';\n    const NAME = 'RainLoop';\n    const URI_BASE = 'https://www.rainloop.net';\n    const URI = self::URI_BASE . '/changelog/';\n    const CACHE_TIMEOUT = 21600; //6h\n    const DESCRIPTION = 'RainLoop\\'s changelog';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $mainContent = $html->find('.main-center', 0);\n        $elements = $mainContent->find('.row-fluid');\n        foreach ($elements as $i => $element) {\n            if ($i === 0) {\n                continue;\n            }\n\n            $titleEl = $element->find('.h3', 0);\n            $title = is_object($titleEl) ? $titleEl->plaintext : '';\n\n            $postUrl = self::URI . $title;\n\n            $contentEl = $element->find('.span9', 0);\n            $content = is_object($contentEl) ? $contentEl->xmltext() : '';\n\n            $item = [];\n            $item['uri'] = $postUrl;\n            $item['title'] = $title;\n            $item['content'] = $content;\n            $item['timestamp'] = strtotime('now');\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/RainbowSixSiegeBridge.php",
    "content": "<?php\n\nclass RainbowSixSiegeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'Rainbow Six Siege News';\n    const URI = 'https://www.ubisoft.com/en-us/game/rainbow-six/siege/news-updates';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Latest news about Rainbow Six Siege';\n\n    // API key to call Ubisoft API, extracted from the React frontend\n    const NIMBUS_API_KEY = '3b5a8be6dde511ec9d640242ac120002';\n\n    public function getIcon()\n    {\n        return 'https://static-dm.akamaized.net/siege/prod/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $dlUrl = 'https://nimbus.ubisoft.com/api/v1/items?categoriesFilter=all';\n        $dlUrl = $dlUrl . '&limit=6&mediaFilter=all&skip=0&startIndex=0&tags=BR-rainbow-six%20GA-siege';\n        $dlUrl = $dlUrl . '&locale=en-us&fallbackLocale=en-us&environment=master';\n        $jsonString = getContents($dlUrl, [\n            'Authorization: ' . self::NIMBUS_API_KEY,\n        ]);\n\n        $json = json_decode($jsonString, true);\n        $json = $json['items'];\n\n        // Start at index 2 to remove highlighted articles\n        for ($i = 0; $i < count($json); $i++) {\n            $jsonItem = $json[$i];\n\n            $uri = 'https://www.ubisoft.com/en-us/game/rainbow-six/siege/news-updates';\n            $uri = $uri . $jsonItem['button']['buttonUrl'];\n\n            $thumbnail = '<img src=\"' . $jsonItem['thumbnail']['url'] . '\" alt=\"Thumbnail\" />';\n            $content = $thumbnail . '<br />' . markdownToHtml($jsonItem['content']);\n\n            $item = [];\n\n            // The date string includes (Coordinated Universal Time) at the end\n            // so remove it to use strtotime\n            $date_str = str_replace('(Coordinated Universal Time)', '', $jsonItem['date']);\n            $item['timestamp'] = strtotime($date_str);\n\n            $item['uri'] = $uri;\n            $item['id'] = $jsonItem['id'];\n            $item['title'] = $jsonItem['title'];\n            $item['content'] = $content;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/RedditBridge.php",
    "content": "<?php\n\n/**\n * This bridge does NOT use reddit's official rss feeds.\n *\n * This bridge uses reddit's json api: https://old.reddit.com/search.json?q=\n */\nclass RedditBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dawidsowa';\n    const NAME = 'Reddit';\n    const URI = 'https://old.reddit.com';\n    const CACHE_TIMEOUT = 60 * 60 * 2; // 2h\n    const DESCRIPTION = 'Return hot submissions from Reddit';\n\n    const PARAMETERS = [\n        'global' => [\n            'score' => [\n                'name' => 'Minimal score',\n                'required' => false,\n                'type' => 'number',\n                'exampleValue' => 100,\n                'title' => 'Filter out posts with lower score. Set to -1 to disable. If both score and comments are set, an OR is applied.',\n            ],\n            'min_comments' => [\n                'name' => 'Minimal number of comments',\n                'required' => false,\n                'type' => 'number',\n                'exampleValue' => 100,\n                'title' => 'Filter out posts with lower number of comments. Set to -1 to disable. If both score and comments are set, an OR is applied.',\n                'defaultValue' => -1\n            ],\n            'd' => [\n                'name' => 'Sort By',\n                'type' => 'list',\n                'title' => 'Sort by new, hot, top or relevancy',\n                'values' => [\n                    'Hot' => 'hot',\n                    'Relevance' => 'relevance',\n                    'New' => 'new',\n                    'Top' => 'top',\n                    'Comments' => 'comments',\n                ],\n                'defaultValue' => 'Hot'\n            ],\n            't' => [\n                'name' => 'Time',\n                'type' => 'list',\n                'title' => 'Sort by new, hot, top or relevancy',\n                'values' => [\n                    'All' => 'all',\n                    'Year' => 'year',\n                    'Month' => 'month',\n                    'Week' => 'week',\n                    'Day' => 'day',\n                    'Hour' => 'hour',\n                ],\n                'defaultValue' => 'week'\n            ],\n            'search' => [\n                'name' => 'Keyword search',\n                'required' => false,\n                'exampleValue' => 'cats, dogs',\n                'title' => 'Keyword search, separated by commas'\n            ],\n            'frontend' => [\n                'type' => 'list',\n                'name' => 'frontend',\n                'title' => 'choose frontend for  reddit',\n                'values' => [\n                    'old.reddit.com' => 'https://old.reddit.com',\n                    'reddit.com' => 'https://reddit.com',\n                    'libreddit.kavin.rocks' => 'https://libreddit.kavin.rocks',\n                ]\n            ]\n        ],\n        'single' => [\n            'r' => [\n                'name' => 'SubReddit',\n                'required' => true,\n                'exampleValue' => 'selfhosted',\n                'title' => 'SubReddit name'\n            ],\n            'f' => [\n                'name' => 'Flair',\n                'required' => false,\n                'exampleValue' => 'Proxy',\n                'title' => 'Flair filter'\n            ]\n        ],\n        'multi' => [\n            'rs' => [\n                'name' => 'SubReddits',\n                'required' => true,\n                'exampleValue' => 'selfhosted, php',\n                'title' => 'SubReddit names, separated by commas'\n            ]\n        ],\n        'user' => [\n            'u' => [\n                'name' => 'User',\n                'required' => true,\n                'exampleValue' => 'shwikibot',\n                'title' => 'User name'\n            ],\n            'comments' => [\n                'type' => 'checkbox',\n                'name' => 'Comments',\n                'title' => 'Whether to return comments',\n                'defaultValue' => false\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $forbiddenKey = 'reddit_forbidden';\n        if ($this->cache->get($forbiddenKey)) {\n            throwRateLimitException();\n        }\n\n        $rateLimitKey = 'reddit_rate_limit';\n        if ($this->cache->get($rateLimitKey)) {\n            throwRateLimitException();\n        }\n\n        try {\n            $this->collectDataInternal();\n        } catch (HttpException $e) {\n            if ($e->getCode() === 403) {\n                // 403 Forbidden\n                // This can possibly mean that reddit has permanently blocked this server's ip address\n                $this->cache->set($forbiddenKey, true, 60 * 61);\n                throwRateLimitException();\n            } elseif ($e->getCode() === 429) {\n                $this->cache->set($rateLimitKey, true, 60 * 61);\n                throwRateLimitException();\n            }\n            throw $e;\n        }\n    }\n\n    private function collectDataInternal(): void\n    {\n        $user = false;\n        $comments = false;\n        $frontend = $this->getInput('frontend');\n        if ($frontend == '') {\n            $frontend = 'https://old.reddit.com';\n        }\n        $section = $this->getInput('d');\n        $time = $this->getInput('t');\n\n        switch ($this->queriedContext) {\n            case 'single':\n                $subreddits[] = $this->getInput('r');\n                break;\n            case 'multi':\n                $subreddits = explode(',', $this->getInput('rs'));\n                break;\n            case 'user':\n                $subreddits[] = $this->getInput('u');\n                $user = true;\n                $comments = $this->getInput('comments');\n                break;\n        }\n\n        $search = $this->getInput('search');\n        $flareInput = $this->getInput('f');\n\n        foreach ($subreddits as $subreddit) {\n            $version = 'v0.0.2';\n            $useragent = \"rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)\";\n            $url = self::createUrl($search, $flareInput, $subreddit, $user, $section, $time, $this->queriedContext);\n\n            $response = getContents($url, ['User-Agent: ' . $useragent], [], true);\n\n            $json = $response->getBody();\n\n            $parsedJson = Json::decode($json, false);\n\n            foreach ($parsedJson->data->children as $post) {\n                if ($post->kind == 't1' && !$comments) {\n                    continue;\n                }\n\n                $data = $post->data;\n\n                $min_score = $this->getInput('score');\n                $min_comments = $this->getInput('min_comments');\n                if ($min_score >= 0 && $min_comments >= 0) {\n                    if ($data->num_comments < $min_comments || $data->score < $min_score) {\n                        continue;\n                    }\n                } elseif ($min_score >= 0) {\n                    if ($data->score < $min_score) {\n                        continue;\n                    }\n                } elseif ($min_comments >= 0) {\n                    if ($data->num_comments < $min_comments) {\n                        continue;\n                    }\n                }\n\n                $item = [];\n                $item['author'] = $data->author;\n                $item['uid'] = $data->id;\n                $item['timestamp'] = $data->created_utc;\n                $item['uri'] = $this->urlEncodePathParts($data->permalink);\n\n                if ($frontend != 'https://old.reddit.com') {\n                    $item['uri'] = preg_replace('#^https://old\\.reddit\\.com#', $frontend, $item['uri']);\n                }\n\n                $item['categories'] = [];\n\n                if ($post->kind == 't1') {\n                    $item['title'] = 'Comment: ' . $data->link_title;\n                } else {\n                    $item['title'] = $data->title;\n\n                    $item['categories'][] = $data->link_flair_text;\n                    $item['categories'][] = $data->pinned ? 'Pinned' : null;\n                    $item['categories'][] = $data->spoiler ? 'Spoiler' : null;\n                }\n\n                $item['categories'][] = $data->over_18 ? 'NSFW' : null;\n                $item['categories'] = array_filter($item['categories']);\n\n                if ($post->kind == 't1') {\n                    // Comment\n\n                    $item['content'] = htmlspecialchars_decode($data->body_html);\n                } elseif ($data->is_self && isset($data->selftext_html)) {\n                    // Text post\n\n                    $item['content'] = htmlspecialchars_decode($data->selftext_html);\n                } elseif (isset($data->post_hint) && $data->post_hint == 'link') {\n                    // Link with preview\n\n                    if (isset($data->media)) {\n                        // todo: maybe switch on the type\n                        if (isset($data->media->oembed->html)) {\n                            // Reddit embeds content for some sites (e.g. Twitter)\n                            $embed = htmlspecialchars_decode($data->media->oembed->html);\n                        } else {\n                            $embed = '';\n                        }\n                    } else {\n                        $embed = '';\n                    }\n\n                    $item['content'] = $this->createFigureLink($data->url, $data->thumbnail, $data->domain) . $embed;\n                } elseif (isset($data->post_hint) && $data->post_hint == 'image') {\n                    // Single image\n\n                    $item['content'] = $this->createLink($this->urlEncodePathParts($data->permalink), '<img src=\"' . $data->url . '\" />');\n                } elseif ($data->is_gallery ?? false) {\n                    // Multiple images\n\n                    $images = [];\n                    foreach ($data->gallery_data->items as $media) {\n                        $id = $media->media_id;\n                        $type = $data->media_metadata->$id->m == 'image/gif' ? 'gif' : 'u';\n                        $src = $data->media_metadata->$id->s->$type;\n                        $images[] = '<figure><img src=\"' . $src . '\"/></figure><br>';\n                    }\n\n                    $item['content'] = implode('', $images);\n                } elseif ($data->is_video) {\n                    // Video\n\n                    if ($data->media->reddit_video) {\n                        $item['content'] = $this->createVideoContent($data->media->reddit_video);\n                    } else {\n                        // Higher index -> Higher resolution\n                        end($data->preview->images[0]->resolutions);\n                        $index = key($data->preview->images[0]->resolutions);\n                        $item['content'] = $this->createFigureLink($data->url, $data->preview->images[0]->resolutions[$index]->url, 'Video');\n                    }\n                } elseif (isset($data->media) && $data->media->type == 'youtube.com') {\n                    // Youtube link\n                    $item['content'] = handleYoutube($data->url);\n                } elseif (explode('.', $data->domain)[0] == 'self') {\n                    // Crossposted text post\n                    // TODO (optionally?) Fetch content of the original post.\n                    $item['content'] = $this->createLink($this->urlEncodePathParts($data->permalink), 'Crossposted from r/' . explode('.', $data->domain)[1]);\n                } else {\n                    // Link WITHOUT preview\n                    $item['content'] = $this->createLink($data->url, $data->domain);\n                }\n\n                $this->items[] = $item;\n            }\n        }\n        // Sort the order to put the latest posts first, even for mixed subreddits\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] <=> $a['timestamp'];\n        });\n    }\n\n    public static function createUrl($search, $flareInput, $subreddit, bool $user, $section, $time, $queriedContext): string\n    {\n        $keywords = '';\n\n        if ($search) {\n            $keywords = str_replace([',', ' '], ' ', $search);\n            $keywords = $keywords . ' ';\n        }\n\n        if ($flareInput && $queriedContext == 'single') {\n            $flair = $flareInput;\n            $flair = str_replace([',', ' '], ' ', $flair);\n            $flair = 'flair:\"' . $flair . '\" ';\n        } else {\n            $flair = '';\n        }\n        $name = trim($subreddit);\n        $query = [\n            'q' => $keywords . $flair . ($user ? 'author:' : 'subreddit:') . $name,\n            'sort' => $section,\n            'include_over_18' => 'on',\n            't' => $time\n        ];\n        return 'https://old.reddit.com/search.json?' . http_build_query($query);\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.redditstatic.com/desktop2x/img/favicon/favicon-96x96.png';\n    }\n\n    public function getName()\n    {\n        if ($this->queriedContext == 'single') {\n            return 'Reddit r/' . $this->getInput('r');\n        } elseif ($this->queriedContext == 'user') {\n            return 'Reddit u/' . $this->getInput('u');\n        } else {\n            return self::NAME;\n        }\n    }\n\n    private function urlEncodePathParts($link)\n    {\n        return self::URI . implode('/', array_map('urlencode', explode('/', $link)));\n    }\n\n    private function createFigureLink($href, $src, $caption)\n    {\n        return sprintf('<a href=\"%s\"><figure><figcaption>%s</figcaption><img src=\"%s\"/></figure></a>', $href, $caption, $src);\n    }\n\n    private function createLink($href, $text)\n    {\n        return sprintf('<a href=\"%s\">%s</a>', $href, $text);\n    }\n\n    private function createVideoContent(\\stdClass $video): string\n    {\n        return <<<HTML\n            <video width=\"$video->width\" height=\"$video->height\" controls>\n                <source src=\"$video->fallback_url\" type=\"video/mp4\">\n                Your browser does not support the video tag.\n            </video>\n        HTML;\n    }\n\n    public function detectParameters($url)\n    {\n        try {\n            $urlObject = Url::fromString($url);\n        } catch (UrlException $e) {\n            return null;\n        }\n\n        $host = $urlObject->getHost();\n        $path = $urlObject->getPath();\n\n        $pathSegments = explode('/', $path);\n\n        if ($host !== 'www.reddit.com' && $host !== 'old.reddit.com') {\n            return null;\n        }\n\n        if ($pathSegments[1] == 'r') {\n            return [\n                'context' => 'single',\n                'r' => $pathSegments[2],\n            ];\n        } elseif ($pathSegments[1] == 'user') {\n            return [\n                'context' => 'user',\n                'u' => $pathSegments[2],\n            ];\n        } else {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/Releases3DSBridge.php",
    "content": "<?php\n\nclass Releases3DSBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = '3DS Scene Releases';\n    const URI = 'http://3dsdb.com/';\n    const CACHE_TIMEOUT = 10800; // 3h\n    const DESCRIPTION = 'Returns the newest scene releases for Nintendo 3DS.';\n\n    public function collectData()\n    {\n        $this->collectDataUrl(self::URI . 'xml.php');\n    }\n\n    protected function collectDataUrl($dataUrl)\n    {\n        $xml = getContents($dataUrl);\n        $limit = 0;\n\n        foreach (array_reverse(explode('<release>', $xml)) as $element) {\n            if ($limit >= 5) {\n                break;\n            }\n\n            if (strpos($element, '</release>') === false) {\n                continue;\n            }\n\n            $releasename = extractFromDelimiters($element, '<releasename>', '</releasename>');\n            if (empty($releasename)) {\n                continue;\n            }\n\n            $id = extractFromDelimiters($element, '<id>', '</id>');\n            $name = extractFromDelimiters($element, '<name>', '</name>');\n            $publisher = extractFromDelimiters($element, '<publisher>', '</publisher>');\n            $region = extractFromDelimiters($element, '<region>', '</region>');\n            $group = extractFromDelimiters($element, '<group>', '</group>');\n            $imagesize = extractFromDelimiters($element, '<imagesize>', '</imagesize>');\n            $serial = extractFromDelimiters($element, '<serial>', '</serial>');\n            $titleid = extractFromDelimiters($element, '<titleid>', '</titleid>');\n            $imgcrc = extractFromDelimiters($element, '<imgcrc>', '</imgcrc>');\n            $filename = extractFromDelimiters($element, '<filename>', '</filename>');\n            $trimmedsize = extractFromDelimiters($element, '<trimmedsize>', '</trimmedsize>');\n            $firmware = extractFromDelimiters($element, '<firmware>', '</firmware>');\n            $type = extractFromDelimiters($element, '<type>', '</type>');\n            $card = extractFromDelimiters($element, '<card>', '</card>');\n\n            //Main section : Release description from 3DS database\n            $releaseDescription = '<h3>Release Details</h3><b>Release ID: </b>' . $id\n            . '<br /><b>Game Name: </b>' . $name\n            . '<br /><b>Publisher: </b>' . $publisher\n            . '<br /><b>Region: </b>' . $region\n            . '<br /><b>Group: </b>' . $group\n            . '<br /><b>Image size: </b>' . (intval($imagesize) / 8)\n            . 'MB<br /><b>Serial: </b>' . $serial\n            . '<br /><b>Title ID: </b>' . $titleid\n            . '<br /><b>Image CRC: </b>' . $imgcrc\n            . '<br /><b>File Name: </b>' . $filename\n            . '<br /><b>Release Name: </b>' . $releasename\n            . '<br /><b>Trimmed size: </b>' . intval(intval($trimmedsize) / 1048576)\n            . 'MB<br /><b>Firmware: </b>' . $firmware\n            . '<br /><b>Type: </b>' . $this->typeToString($type)\n            . '<br /><b>Card: </b>' . $this->cardToString($card)\n            . '<br />';\n\n            //Build search links section to facilitate release search using search engines\n            $releaseNameEncoded = urlencode(str_replace(' ', '+', $releasename));\n            $searchLinkGoogle = 'https://google.com/?q=' . $releaseNameEncoded;\n            $searchLinkDuckDuckGo = 'https://duckduckgo.com/?q=' . $releaseNameEncoded;\n            $searchLinkQwant = 'https://lite.qwant.com/?q=' . $releaseNameEncoded . '&t=web';\n            $releaseSearchLinks = '<h3>Search this release</h3><ul><li><a href=\"'\n            . $searchLinkGoogle\n            . '\">Search using Google</a></li><li><a href=\"'\n            . $searchLinkDuckDuckGo\n            . '\">Search using DuckDuckGo</a></li><li><a href=\"'\n            . $searchLinkQwant\n            . '\">Search using Qwant</a></li></ul>';\n\n            //Build and add final item with the above three sections\n            $item = [];\n            $item['title'] = $name;\n            $item['author'] = $publisher;\n            //$item['timestamp'] = $ignDate;\n            //$item['enclosures'] = [$ignCoverArt];\n            $item['uri'] = empty($ignLink) ? $searchLinkDuckDuckGo : $ignLink;\n            $item['content'] = $releaseDescription . $releaseSearchLinks;\n            $this->items[] = $item;\n            $limit++;\n        }\n    }\n\n    private function typeToString($type)\n    {\n        switch ($type) {\n            case 1:\n                return 'Card Game';\n            case 4:\n                return 'eShop';\n            default:\n                return '??? (' . $type . ')';\n        }\n    }\n\n    private function cardToString($card)\n    {\n        switch ($card) {\n            case 1:\n                return 'Regular (CARD1)';\n            case 2:\n                return 'NAND (CARD2)';\n            default:\n                return '??? (' . $card . ')';\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ReleasesSwitchBridge.php",
    "content": "<?php\n\n// This bridge depends on Releases3DSBridge\nif (!class_exists('Releases3DSBridge')) {\n    include('Releases3DSBridge.php');\n}\n\nclass ReleasesSwitchBridge extends Releases3DSBridge\n{\n    const NAME = 'Switch Scene Releases';\n    const URI = 'http://nswdb.com/';\n    const DESCRIPTION = 'Returns the newest scene releases for Nintendo Switch.';\n\n    public function collectData()\n    {\n        $this->collectDataUrl(self::URI . 'xml.php');\n    }\n}\n"
  },
  {
    "path": "bridges/RemixAudioBridge.php",
    "content": "<?php\n\nclass RemixAudioBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Simounet';\n    const NAME = 'RemixAudio';\n    const URI = 'https://remix.audio';\n    const CACHE_TIMEOUT = 0; //6h\n    //const CACHE_TIMEOUT = 21600; //6h\n    const DESCRIPTION = 'RemixAudio profiles';\n    const PROFILE_QUERY_PARAM = 'profile';\n\n    const PARAMETERS = [\n        [\n            self::PROFILE_QUERY_PARAM => [\n                'name' => 'Profile',\n                'type' => 'text',\n                'exampleValue' => 'Amoraboy',\n                'required' => true\n            ]\n        ]\n    ];\n\n    private $feedTitle = null;\n    private $feedIcon = null;\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $user = $this->getUser($html);\n\n        $this->feedTitle = $user['name'];\n        $this->feedIcon = $user['avatar'];\n\n        $elements = $html->find('.song-container');\n        foreach ($elements as $element) {\n            $titleEl = $element->find('.song-title', 0);\n            $title = is_object($titleEl) ? $titleEl->plaintext : '';\n\n            $urlEl = $titleEl->find('a', 0);\n\n            $publishedEl = $element->find('.timeago', 0);\n\n            $songEl = $element->find('.song-play-btn', 0);\n            $song = is_object($songEl) ? '<audio controls><source src=\"' . $songEl->getAttribute('data-track-url') . '\" type=\"audio/mpeg\" /></audio>' : '';\n\n            $item = [];\n            $item['uri'] = $urlEl->href;\n            $item['title'] = $title;\n            $item['timestamp'] = strtotime($publishedEl->title);\n            $item['content'] = '<p>' . $user['name'] . ' - ' . $title . '</p>' . $song;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getIcon()\n    {\n        if ($this->feedIcon) {\n            return $this->feedIcon;\n        }\n\n        return parent::getIcon();\n    }\n\n    public function getName()\n    {\n        if ($this->feedTitle) {\n            return $this->feedTitle . ' - ' . self::NAME;\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        $profile = $this->getProfile();\n        if ($profile) {\n            return self::URI . '/profile/' . $profile;\n        }\n\n        return parent::getURI();\n    }\n\n    private function getUser($html)\n    {\n        return [\n            'avatar' => $html->find('.cover-avatar img', 0)->src,\n            'name' => $html->find('.cover-username a', 0)->plaintext\n        ];\n    }\n\n    private function getProfile()\n    {\n        return $this->getInput(self::PROFILE_QUERY_PARAM);\n    }\n}\n"
  },
  {
    "path": "bridges/ReporterreBridge.php",
    "content": "<?php\n\n/**\n * See https://reporterre.net/spip.php?page=backend-simple\n */\nclass ReporterreBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'nyutag';\n    const NAME = 'Reporterre';\n    const URI = 'https://www.reporterre.net/';\n    const DESCRIPTION = 'Returns the newest articles. See also their official feed https://reporterre.net/spip.php?page=backend-simple';\n\n    public function collectData()\n    {\n        //$url = self::URI . 'spip.php?page=backend';\n        $url = self::URI . 'spip.php?page=backend-simple';\n        $html = getSimpleHTMLDOM($url);\n        $limit = 0;\n\n        foreach ($html->find('item') as $element) {\n            if ($limit < 5) {\n                $item = [];\n                $item['title'] = html_entity_decode($element->find('title', 0)->plaintext);\n                $item['timestamp'] = strtotime($element->find('dc:date', 0)->plaintext);\n                $item['uri'] = $element->find('guid', 0)->innertext;\n                //$item['content'] = html_entity_decode($this->extractContent($item['uri']));\n                $item['content'] = htmlspecialchars_decode($element->find('description', 0)->plaintext);\n                $this->items[] = $item;\n                $limit++;\n            }\n        }\n    }\n\n    private function extractContent($url)\n    {\n        $html2 = getSimpleHTMLDOM($url);\n        $html2 = defaultLinkTo($html2, self::URI);\n\n        foreach ($html2->find('div[style=text-align:justify]') as $e) {\n            $text = $e->outertext;\n        }\n\n        $html2->clear();\n        unset($html2);\n\n        $text = strip_tags($text, '<p><br><a><img>');\n        return $text;\n    }\n}\n"
  },
  {
    "path": "bridges/ReutersBridge.php",
    "content": "<?php\n\nclass ReutersBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'hollowleviathan, spraynard, csisoap';\n    const NAME = 'Reuters';\n    const URI = 'https://www.reuters.com';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns news from Reuters';\n\n    private $feedName = self::NAME;\n    private $useWireAPI = false;\n\n    /**\n     * Wireitem types allowed in the final story output\n     */\n    const ALLOWED_WIREITEM_TYPES = [\n        'story',\n        'headlines'\n    ];\n\n    /**\n     * Wireitem template types allowed in the final story output\n     */\n    const ALLOWED_TEMPLATE_TYPES = [\n        'story',\n        'headlines'\n    ];\n\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'News Feed',\n                'type' => 'list',\n                'title' => 'Feeds from Reuters U.S/International edition',\n                'values' => [\n                    'Top News' => 'home/topnews',\n                    'Fact Check' => '/fact-check',\n                    'Entertainment' => 'chan:8ym8q8dl',\n                    'Politics' => 'politics',\n                    'Wire' => 'wire',\n                    'Breakingviews' => '/breakingviews',\n                    'World' => [\n                        'World' => 'world',\n                        'Africa' => '/world/africa',\n                        'Americas' => '/world/americas',\n                        'Asia-Pacific' => '/world/asia-pacific',\n                        'China' => 'china',\n                        'europe' => '/world/europe',\n                        'India' => '/world/india',\n                        'Middle East' => '/world/middle-east',\n                        'UK' => 'chan:61leiu7j',\n                        'USA News' => 'us',\n                        'The Great Reboot' => '/world/the-great-reboot',\n                        'Reuters Next' => '/world/reuters-next'\n                    ],\n                    'Business' => [\n                        'Business' => 'business',\n                        'Aerospace and Defense' => 'aerospace',\n                        'Autos Transportation' => '/business/autos-transportation',\n                        'Energy' => 'energy',\n                        'Finance' => '/business/finance',\n                        'Health' => 'chan:8hw7807a',\n                        'Media Telecom' => '/business/media-telecom',\n                        'Retail Consumer' => '/business/retail-consumer',\n                        'Sustainable Business' => '/business/sustainable-business',\n                        'Change Suite' => '/business/change-suite',\n                        'Future of Health' => '/business/future-of-health',\n                        'Future of Money' => '/business/future-of-money',\n                        'Take Five' => '/business/take-five',\n                        'Reuters Impact' => '/business/reuters-impact',\n                    ],\n                    'Legal' => [\n                        'Legal' => '/legal',\n                        'Government' => '/legal/government',\n                        'Legal Industry' => '/legal/legalindustry',\n                        'Litigation' => '/legal/litigation',\n                        'Transactional' => '/legal/transactional',\n                    ],\n                    'Markets' => [\n                        'Markets' => 'markets',\n                        'Asian Markets' => '/markets/asia',\n                        'Commodities' => '/markets/commodities',\n                        'Currencies' => '/markets/currencies',\n                        'Deals' => '/markets/deals',\n                        'European Markets' => '/markets/europe',\n                        'Funds' => '/markets/fund',\n                        'Global Market Data' => '/markets/global-market-data',\n                        'Rates & Bonds' => '/markets/rates-bonds',\n                        'Stocks' => '/markets/stocks',\n                        'U.S Markets' => '/markets/us',\n                        'Wealth' => '/markets/wealth',\n                        'Macro Matters' => '/markets/macromatters',\n                    ],\n                    'Technology' => [\n                        'Technology' => 'tech',\n                        'Artificial Intelligence' => '/technology/artificial-intelligence',\n                        'Disrupted' => '/technology/disrupted',\n                        'Reuters Momentum' => '/technology/reuters-momentum',\n                    ],\n                    'Sports' => [\n                        'Sports' => 'sports',\n                        'Athletics' => '/lifestyle/sports/athletics',\n                        'Cricket' => '/lifestyle/sports/cricket',\n                        'Cycling' => '/lifestyle/sports/cycling',\n                        'Golf' => '/lifestyle/sports/golf',\n                        'Motor Sports' => '/lifestyle/sports/motor-sports',\n                        'Soccer' => '/lifestyle/sports/soccer',\n                        'Tennis' => '/lifestyle/sports/tennis',\n                    ],\n                    'Lifestyle' => [\n                        'Lifestyle' => 'life',\n                        'Oddly Enough' => '/lifestyle/oddly-enough',\n                        'Science' => 'science',\n                    ]\n                ]\n            ]\n        ]\n    ];\n\n    const BACKWARD_COMPATIBILITY = [\n        'world' => '/world',\n        'china' => '/world/china',\n        'chan:61leiu7j' => '/world/uk',\n        'us' => '/world/us',\n        'business' => '/business',\n        'aerospace' => '/business/aerospace-defense',\n        'energy' => '/business/energy',\n        'environment' => '/business/environment',\n        'chan:8hw7807a' => '/business/healthcare-pharmaceuticals',\n        'markets' => '/markets',\n        'tech' => '/technology',\n        'sports' => '/lifestyle/sports',\n        'life' => '/lifestyle',\n        'science' => '/lifestyle/science',\n        'home/topnews' => '/home',\n    ];\n\n    const OLD_WIRE_SECTION = [\n        'home/topnews',\n        'chan:8ym8q8dl',\n        'politics',\n        'wire'\n    ];\n\n    public function collectData()\n    {\n        $endpoint = $this->getSectionEndpoint();\n        $url = $this->getAPIURL($endpoint, 'section');\n        $json = getContents($url);\n        $data = Json::decode($json);\n\n        $stories = [];\n        $section_name = '';\n        if ($this->useWireAPI) {\n            $reuters_wireitems = $data['wireitems'];\n            $section_name = $data['wire_name'];\n            $processedData = $this->processData($reuters_wireitems);\n\n            // Merge all articles from Editor's Highlight section into existing array of templates.\n            $top_section = reset($processedData);\n            if ($top_section['type'] == 'headlines') {\n                $top_section = array_shift($processedData);\n                $articles = $top_section['headlines'];\n                $processedData = array_merge($articles, $processedData);\n            }\n            $stories = $processedData;\n        } else {\n            $section_name = $data['result']['section']['name'];\n            if (isset($data['arcResult']['articles'])) {\n                $stories = $data['arcResult']['articles'];\n            } else {\n                $stories = $data['result']['articles'];\n            }\n        }\n        $this->feedName = $section_name . ' | Reuters';\n\n        usort($stories, function ($story1, $story2) {\n            return $story2['published_time'] <=> $story1['published_time'];\n        });\n\n        $stories = array_slice($stories, 0, 20);\n\n        foreach ($stories as $story) {\n            $uid = '';\n            $author = '';\n            $category = [];\n            $content = $story['description'];\n            $title = '';\n            $timestamp = $story['published_time'];\n            $url = '';\n            $article_uri = '';\n            $source_type = '';\n            if ($this->useWireAPI) {\n                $uid = $story['story']['usn'];\n                $article_uri = $story['template_action']['api_path'];\n                $title = $story['story']['hed'];\n                $url = $story['template_action']['url'];\n            } else {\n                $uid = $story['id'];\n                $url = self::URI . $story['canonical_url'];\n                $title = $story['title'];\n                $article_uri = $story['canonical_url'];\n                $source_type = $story['source']['name'];\n            }\n\n            // Some article cause unexpected behaviour like redirect to another site not API.\n            // Attempt to check article source type to avoid this.\n            if (!$this->useWireAPI && $source_type != 'Package') { // Only Reuters PF api have this, Wire don't.\n                $author = $this->handleAuthorName($story['authors'] ?? []);\n                $timestamp = $story['published_time'];\n                $image_placeholder = '';\n                if (isset($story['thumbnail'])) {\n                    $image_placeholder = $this->handleImage([$story['thumbnail']]);\n                }\n                $content = $story['description'] . $image_placeholder;\n                if (isset($story['primary_section']['name'])) {\n                    $category = [$story['primary_section']['name']];\n                } else {\n                    $category = [];\n                }\n            } else {\n                $content_detail = $this->getArticle($article_uri);\n                $description = $content_detail['content'];\n                $description = defaultLinkTo($description, $this->getURI());\n\n                $author = $content_detail['author'];\n                $images = $content_detail['images'];\n                $category = $content_detail['category'];\n                //$content = \"$description  $images\";\n                //$timestamp = $content_detail['published_at'];\n            }\n\n            $this->addStories($title, $content, $timestamp, $author, $url, $category);\n        }\n    }\n\n    /**\n     * Takes in data from Reuters Wire API and\n     * creates structured data in the form of a list\n     * of story information.\n     * @param array $data JSON collected from the Reuters Wire API\n     */\n    private function processData($data)\n    {\n        /**\n         * Gets a list of wire items which are groups of templates\n         */\n        $reuters_allowed_wireitems = array_filter(\n            $data,\n            function ($wireitem) {\n                return in_array(\n                    $wireitem['wireitem_type'],\n                    self::ALLOWED_WIREITEM_TYPES\n                );\n            }\n        );\n\n        /*\n        * Gets a list of \"Templates\", which is data containing a story\n        */\n        $reuters_wireitem_templates = array_reduce(\n            $reuters_allowed_wireitems,\n            function (array $carry, array $wireitem) {\n                $wireitem_templates = $wireitem['templates'];\n                return array_merge(\n                    $carry,\n                    array_filter(\n                        $wireitem_templates,\n                        function (\n                            array $template_data\n                        ) {\n                            return in_array(\n                                $template_data['type'],\n                                self::ALLOWED_TEMPLATE_TYPES\n                            );\n                        }\n                    )\n                );\n            },\n            []\n        );\n\n        return $reuters_wireitem_templates;\n    }\n\n    private function getSectionEndpoint()\n    {\n        $endpoint = $this->getInput('feed');\n        if (isset(self::BACKWARD_COMPATIBILITY[$endpoint])) {\n            $endpoint = self::BACKWARD_COMPATIBILITY[$endpoint];\n        } elseif (in_array($endpoint, self::OLD_WIRE_SECTION)) {\n            $this->useWireAPI = true;\n        }\n        return $endpoint;\n    }\n\n    /**\n    * @param string $endpoint - A endpoint is provided could be article URI or ID.\n    * @param string $fetch_type - Provide what kind of fetch do you want? Article or Section.\n    * @param boolean $is_article_uid {true|false} - A boolean flag to determined if using UID instead of url to fetch.\n    * @return string A completed API URL to fetch data\n    */\n    private function getAPIURL($endpoint, $fetch_type, $is_article_uid = false)\n    {\n        $base_url = self::URI . '/pf/api/v3/content/fetch/';\n        $wire_url = 'https://wireapi.reuters.com/v8';\n        switch ($fetch_type) {\n            case 'article':\n                if ($this->useWireAPI) {\n                    return $wire_url . $endpoint;\n                }\n\n                $base_query = [\n                    'website' => 'reuters',\n                ];\n                $query = [];\n\n                if ($is_article_uid) {\n                    $query = [\n                        'id' => $endpoint\n                    ];\n                } else {\n                    $query = [\n                        'website_url' => $endpoint,\n                    ];\n                }\n\n                $query = array_merge($base_query, $query);\n                $json_query = json_encode($query);\n                return $base_url . 'article-by-id-or-url-v1?query=' . $json_query;\n                break;\n            case 'section':\n                if ($this->useWireAPI) {\n                    if (strpos($endpoint, 'chan:') !== false) {\n                        // Now checking whether that feed has unique ID or not.\n                        $feed_uri = \"/feed/rapp/us/wirefeed/$endpoint\";\n                    } else {\n                        $feed_uri = \"/feed/rapp/us/tabbar/feeds/$endpoint\";\n                    }\n                    return $wire_url . $feed_uri;\n                }\n                $query = [\n                    'section_id' => $endpoint,\n                    'size' => 30,\n                    'website' => 'reuters'\n                ];\n\n                if ($endpoint != '/home') {\n                    $query = array_merge($query, [\n                        'fetch_type' => 'section',\n                    ]);\n                }\n\n                $json_query = json_encode($query);\n                return $base_url . 'articles-by-section-alias-or-id-v1?query=' . $json_query;\n                break;\n        }\n        throwServerException('unsupported endpoint');\n    }\n\n    private function addStories($title, $content, $timestamp, $author, $url, $category)\n    {\n        $item = [];\n        $item['categories'] = $category;\n        $item['author'] = $author;\n        $item['content'] = $content;\n        $item['title'] = $title;\n        $item['timestamp'] = $timestamp;\n        $item['uri'] = $url;\n        $this->items[] = $item;\n    }\n\n    private function getArticle($feed_uri, $is_article_uid = false)\n    {\n        // Temp fix to try to avoid reuters anti-bot\n        return [\n            'content' => '',\n            'author' => '',\n            'category' => '',\n            'images' => '',\n            'published_at' => ''\n        ];\n        // This will make another request to API to get full detail of article and author's name.\n        $url = $this->getAPIURL($feed_uri, 'article', $is_article_uid);\n\n        try {\n            $json = getContents($url);\n            $rawData = Json::decode($json);\n        } catch (\\JsonException $e) {\n            return [\n                'content' => '',\n                'author' => '',\n                'category' => '',\n                'images' => '',\n                'published_at' => ''\n            ];\n        }\n        $article_content = '';\n        $authorlist = '';\n        $category = [];\n        $image_list = [];\n        $published_at = '';\n        if ($this->useWireAPI) {\n            $reuters_wireitems = $rawData['wireitems'];\n            $processedData = $this->processData($reuters_wireitems);\n\n            $first = reset($processedData);\n            $article_content = $first['story']['body_items'];\n            $authorlist = $first['story']['authors'];\n            $category = [$first['story']['channel']['name']];\n            $image_list = $first['story']['images'];\n            $published_at = $first['story']['published_at'];\n        } else {\n            $article_content = $rawData['result']['content_elements'];\n            $authorlist = $rawData['result']['authors'];\n            $category = [$rawData['result']['taxonomy']['ads_primary_section']['name']];\n            $image_list = [];\n            if (!empty($rawData['result']['related_content']['galleries'])) {\n                $galleries = $rawData['result']['related_content']['galleries'];\n                foreach ($galleries as $gallery) {\n                    $image_list = array_merge($image_list, $gallery['content_elements']);\n                }\n            } elseif (!empty($rawData['result']['related_content']['images'])) {\n                $image_list = $rawData['result']['related_content']['images'];\n            }\n            $published_at = $rawData['result']['published_time'];\n        }\n\n        $content_detail = [\n            'content' => $this->handleArticleContent($article_content),\n            'author' => $this->handleAuthorName($authorlist),\n            'category' => $category,\n            'images' => $this->handleImage($image_list),\n            'published_at' => $published_at\n        ];\n        return $content_detail;\n    }\n\n    private function handleImage($images)\n    {\n        $img_placeholder = '';\n\n        foreach ($images as $image) {\n            // Add more image to article.\n            $image_url = $image['url'];\n            $image_caption = $image['caption'] ?? $image['alt_text'] ?? $image['subtitle'] ?? '';\n            $image_alt_text = '';\n            $image_alt_text = $image['alt_text'] ?? $image_caption;\n            $img = \"<img src=\\\"$image_url\\\" alt=\\\"$image_alt_text\\\">\";\n            $img_caption = \"<figcaption style=\\\"text-align: center;\\\"><i>$image_caption</i></figcaption>\";\n            $figure = \"<figure>$img \\t $img_caption</figure>\";\n            $img_placeholder = $img_placeholder . $figure;\n        }\n\n        return $img_placeholder;\n    }\n\n    private function handleAuthorName($authors)\n    {\n        $author = '';\n        $counter = 0;\n        foreach ($authors as $data) {\n            //Formatting author's name.\n            $name = $data['name'];\n            $counter++;\n            if ($counter == count($authors)) {\n                $author .= $name;\n            } else {\n                $author .= $name . ', ';\n            }\n        }\n        return $author;\n    }\n\n    private function handleArticleContent($contents)\n    {\n        $description = '';\n        foreach ($contents as $content) {\n            $data = '';\n            if (isset($content['content'])) {\n                $data = $content['content'];\n            }\n            switch ($content['type']) {\n                case 'paragraph':\n                    $description = $description . \"<p>$data</p>\";\n                    break;\n                case 'heading':\n                    $description = $description . \"<h3>$data</h3>\";\n                    break;\n                case 'infographics':\n                    $description = $description . \"<img src=\\\"$data\\\">\";\n                    break;\n                case 'inline_items':\n                    $item_list = $content['items'];\n                    $description = $description . '<p>';\n                    foreach ($item_list as $item) {\n                        if ($item['type'] == 'text') {\n                            $description = $description . $item['content'];\n                        } else {\n                            $description = $description . $item['symbol'];\n                        }\n                    }\n                    $description = $description . '</p>';\n                    break;\n                case 'p_table':\n                    $description = $description . $content['content'];\n                    break;\n                case 'upstream_embed':\n                    $media_type = $content['media_type'];\n                    $cid = $content['cid'];\n                    $embed = '';\n                    switch ($media_type) {\n                        case 'tweet':\n                            try {\n                                $tweet_url = \"https://twitter.com/dummyname/statuses/$cid\";\n                                $get_embed_url = 'https://publish.twitter.com/oembed?url='\n                                                                 . urlencode($tweet_url) .\n                                                                '&partner=&hide_thread=false';\n\n                                $oembed_json = json_decode(getContents($get_embed_url), true);\n                                $embed .= $oembed_json['html'];\n                            } catch (\\Exception $e) {\n                                // In case not found any tweet.\n                                $embed .= '';\n                            }\n                            break;\n                        case 'instagram':\n                            $url = \"https://instagram.com/p/$cid/media/?size=l\";\n                            $embed .= <<<EOD\n<img \n\tsrc=\"{$url}\"\n\talt=\"instagram-image-$cid\"\n>\nEOD;\n                            break;\n                        case 'youtube':\n                            $embed .= handleYoutube($cid);\n                            break;\n                    }\n                    $description .= $embed;\n                    break;\n                case 'social_media':\n                    if ($content['sub_type'] == 'twitter') {\n                        $description .= $content['html'];\n                    }\n                    break;\n                case 'table':\n                    $table = '<table>';\n                    $theaders = $content['header'] ?? null;\n                    if ($theaders) {\n                        $tr = '<tr>';\n                        foreach ($theaders as $header) {\n                            $tr .= '<th>' . $header . '</th>';\n                        }\n                        $tr .= '</tr>';\n                        $table .= $tr;\n                    }\n                    $rows = $content['rows'];\n                    foreach ($rows as $row) {\n                        if (!is_array($row)) {\n                            // some rows are null\n                            continue;\n                        }\n                        $tr = '<tr>';\n                        foreach ($row as $data) {\n                            $tr .= '<td>' . $data . '</td>';\n                        }\n                        $tr .= '</tr>';\n                        $table .= $tr;\n                    }\n                    $table .= '</table>';\n                    $description .= $table;\n                    break;\n                case 'image':\n                    $description .= $this->handleImage([$content]);\n            }\n        }\n\n        return $description;\n    }\n\n    public function getName()\n    {\n        return $this->feedName;\n    }\n}\n"
  },
  {
    "path": "bridges/RibatejanaBridge.php",
    "content": "<?php\n\nclass RibatejanaBridge extends BarraqueiroBridgeAbstract\n{\n    const NAME = 'Ribatejana';\n    const URI = 'https://ribatejana.pt/';\n    const DESCRIPTION = 'Ribatejana - Informação ao Público';\n\n    public function collectData()\n    {\n        parent::collectDataBarraqueiro(self::URI, self::URI . '/ribatejana/Ribatejana');\n    }\n}\n"
  },
  {
    "path": "bridges/RiptApparelBridge.php",
    "content": "<?php\n\nclass RiptApparelBridge extends BridgeAbstract\n{\n    const NAME = 'RIPT Apparel';\n    const URI = 'https://www.riptapparel.com';\n    const DESCRIPTION = 'Returns the daily designs';\n    const MAINTAINER = 'Bockiii';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 60 * 60 * 3; // 3 hours\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('div.daily-designs', 0)->find('div.collection') as $element) {\n            $title = $element->find('div.design-info', 0)->find('div.title', 0)->innertext;\n            $uri = self::URI . $element->find('div.design-info', 0)->find('a', 0)->href;\n            $today = date('m/d/Y');\n            $imagesrcset = $element->find('div.design-images', 0)->find('div[data-subtype=\"Mens\"]', 0)->find('img', 0);\n            $image = rtrim(explode(',', $imagesrcset->getAttribute('data-srcset'))[2], ' 900w');\n            $item = [];\n            $item['uri'] = $uri;\n            $item['title'] = $title;\n            $item['uid'] = $title;\n            $item['timestamp'] = $today;\n            $item['content'] = '<a href=\"'\n            . $uri\n            . '\"><img src=\"'\n            . $image\n            . '\" /></a>';\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/RoadAndTrackBridge.php",
    "content": "<?php\n\nclass RoadAndTrackBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'Road And Track';\n    const URI = 'https://www.roadandtrack.com/';\n    const CACHE_TIMEOUT = 86400; // 24h\n    const DESCRIPTION = 'Returns the latest news from Road & Track.';\n\n    public function collectData()\n    {\n        $page = getSimpleHTMLDOM(self::URI);\n\n        $limit = 5;\n\n        foreach ($page->find('a.enk2x9t2') as $article) {\n            $this->items[] = $this->fetchArticle($article->href);\n\n            if (count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    private function fixImages($content)\n    {\n        $enclosures = [];\n        foreach ($content->find('img') as $image) {\n            $image->src = explode('?', $image->getAttribute('data-src'))[0];\n            $enclosures[] = $image->src;\n        }\n\n        foreach ($content->find('.embed-image-wrap, .content-lede-image-wrap') as $imgContainer) {\n            $imgContainer->style = '';\n        }\n\n        return $enclosures;\n    }\n\n    private function fetchArticle($articleLink)\n    {\n        $articleLink = self::URI . $articleLink;\n        $article = getSimpleHTMLDOM($articleLink);\n        $item = [];\n\n        $title = $article->find('.content-hed', 0);\n        if ($title) {\n            $item['title'] = $title->innertext;\n        }\n\n        $item['author'] = $article->find('.byline-name', 0)->innertext ?? '';\n\n        $contentInfoDate = $article->find('.content-info-date', 0);\n        if ($contentInfoDate) {\n            $datetime = $contentInfoDate->getAttribute('datetime');\n            $item['timestamp'] = strtotime($datetime);\n        }\n\n        $content = $article->find('.content-container', 0);\n        if ($content->find('.content-rail', 0) !== null) {\n            $content->find('.content-rail', 0)->innertext = '';\n        }\n\n        $enclosures = $this->fixImages($content);\n\n        $item['enclosures'] = $enclosures;\n        $item['content'] = $content;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/RobinhoodSnacksBridge.php",
    "content": "<?php\n\nclass RobinhoodSnacksBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'johnpc';\n    const NAME = 'Robinhood Snacks Newsletter';\n    const URI = 'https://snacks.robinhood.com/newsletters/';\n    const CACHE_TIMEOUT = 86400; // 24h\n    const DESCRIPTION = 'Returns newsletters from Robinhood Snacks';\n\n    // Work around 403 by pretending to be a legit browser\n    const FAKE_HEADERS = [\n        'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:100.0) Gecko/20100101 Firefox/100.0',\n        'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',\n        'Accept-Language: es-ES,en-US;q=0.7,en;q=0.3',\n        'Accept-Encoding: gzip, deflate, br',\n        'Connection: keep-alive',\n        'Upgrade-Insecure-Requests: 1',\n        'Sec-Fetch-Dest: document',\n        'Sec-Fetch-Mode: navigate',\n        'Sec-Fetch-Site: none',\n        'Sec-Fetch-User: ?1',\n        'Pragma: no-cache',\n        'Cache-Control: no-cache',\n        'TE: trailers'\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI, self::FAKE_HEADERS);\n        $html = defaultLinkTo($html, $this->getURI());\n\n        $elements = $html->find('#__next > div > div > div > div > a');\n\n        foreach ($elements as $element) {\n            if ($element->href === 'https://snacks.robinhood.com/newsletters/page/2/') {\n                continue;\n            }\n\n            $content = $element->find('div > div', 2);\n\n            // Remove element that is not parsed (span with weekly tag)\n            $unwanted_selector = 'span';\n            foreach ($content->find($unwanted_selector) as $found) {\n                $found->outertext = '';\n            }\n\n            $title = $content->find('div', 0)->innertext;\n            $timestamp = strtotime($content->find('div', 1)->innertext);\n            $uri = $element->href;\n\n            $this->items[] = [\n                'uri' => $uri,\n                'title' => $title,\n                'timestamp' => $timestamp,\n                'content' => $this->getArticleContent($uri)\n            ];\n        }\n    }\n\n    private function getArticleContent($uri)\n    {\n        $article_html = getSimpleHTMLDOMCached($uri, self::CACHE_TIMEOUT, self::FAKE_HEADERS);\n        if (!$article_html) {\n            return '';\n        }\n\n        $content = $article_html->find('#__next > div > div > div > span', 0);\n        $content->removeChild($content->find('div', 0));\n        $content->removeChild($content->find('h1', 0));\n        $content->removeChild($content->find('img', 1));\n\n        // Remove elements that are not part of article content\n        $unwanted_selector = 'style';\n        foreach ($content->find($unwanted_selector) as $found) {\n            $found->outertext = '';\n        }\n\n        // Images cleanup\n        $already_displayed_pictures = [];\n        foreach ($content->find('img') as $found) {\n            // Skip loader images\n            if (str_contains($found->src, 'data:image/gif;base64')) {\n                $found->outertext = '';\n                continue;\n            }\n\n            // Skip multiple images with same src\n            // and remove duplicated image description\n            if (in_array($found->src, $already_displayed_pictures)) {\n                $found->parent->parent->parent->outertext = '';\n                $found->parent->parent->parent->nextSibling()->nextSibling()->outertext = '';\n                continue;\n            }\n\n            // Remove srcset attribute\n            $found->removeAttribute('srcset');\n\n            // If relative img, fix path\n            if (str_starts_with($found->src, '/_next')) {\n                $found->setAttribute('src', 'https://snacks.robinhood.com' . $found->getAttribute('src'));\n            }\n\n            $already_displayed_pictures[] = $found->src;\n        }\n\n        $content_text = $content->innertext;\n\n        // Remove noscript tag to display images\n        $content_text = str_replace('<noscript>', '', $content_text);\n\n        return $content_text;\n    }\n}\n"
  },
  {
    "path": "bridges/RoosterTeethBridge.php",
    "content": "<?php\n\nclass RoosterTeethBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'tgkenney';\n    const NAME = 'Rooster Teeth';\n    const URI = 'https://roosterteeth.com';\n    const DESCRIPTION = 'Gets the latest channel videos from the Rooster Teeth website';\n    const API = 'https://svod-be.roosterteeth.com/';\n\n    const PARAMETERS = [\n        'Options' => [\n            'channel' => [\n                'type' => 'list',\n                'name' => 'Channel',\n                'title' => 'Select a channel to filter by',\n                'values' => [\n                    'All channels' => 'all',\n                    'Achievement Hunter' => 'achievement-hunter',\n                    'Camp Camp' => 'camp-camp',\n                    'Cow Chop' => 'cow-chop',\n                    'Death Battle' => 'death-battle',\n                    'Friends of RT' => 'friends-of-rt',\n                    'Funhaus' => 'funhaus',\n                    'Inside Gaming' => 'inside-gaming',\n                    'JT Music' => 'jt-music',\n                    'Kinda Funny' => 'kinda-funny',\n                    'Red vs. Blue Universe' => 'red-vs-blue-universe',\n                    'Rooster Teeth' => 'rooster-teeth',\n                    'RWBY Universe' => 'rwby-universe',\n                    'Squad Team Force' => 'squad-team-force',\n                    'Sugar Pine 7' => 'sugar-pine-7',\n                    'The Yogscast' => 'the-yogscast',\n                ]\n            ],\n            'sort' => [\n                'type' => 'list',\n                'name' => 'Sort',\n                'title' => 'Select a sort order',\n                'values' => [\n                    'Newest -> Oldest' => 'desc',\n                    'Oldest -> Newest' => 'asc'\n                ],\n                'defaultValue' => 'desc'\n            ],\n            'first' => [\n                'type' => 'list',\n                'name' => 'RoosterTeeth First',\n                'title' => 'Select whether to include \"First\" videos before they are public',\n                'values' => [\n                    'True' => true,\n                    'False' => false\n                ]\n            ],\n            'episodeImage' => [\n                'name' => 'Episode Image',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked',\n                'title' => 'Select whether to include an episode image (if available)',\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Maximum number of items to return',\n                'defaultValue' => 10\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        if ($this->getInput('channel') !== 'all') {\n            $uri = self::API\n                . 'api/v1/episodes?per_page='\n                . $this->getInput('limit')\n                . '&channel_id='\n                . $this->getInput('channel')\n                . '&order=' . $this->getInput('sort')\n                . '&page=1';\n\n            $htmlJSON = getSimpleHTMLDOM($uri);\n        } else {\n            $uri = self::API\n                . '/api/v1/episodes?per_page='\n                . $this->getInput('limit')\n                . '&filter=all&order='\n                . $this->getInput('sort')\n                . '&page=1';\n\n            $htmlJSON = getSimpleHTMLDOM($uri);\n        }\n\n        $htmlArray = json_decode($htmlJSON, true);\n\n        foreach ($htmlArray['data'] as $key => $value) {\n            $item = [];\n\n            if (!$this->getInput('first') && $value['attributes']['is_sponsors_only']) {\n                continue;\n            }\n\n            $publicDate = date_create($value['attributes']['member_golive_at']);\n            $dateDiff = date_diff($publicDate, date_create(), false);\n\n            if (!$this->getInput('first') && $dateDiff->invert == 1) {\n                continue;\n            }\n\n            $item['uri'] = self::URI . $value['canonical_links']['self'];\n            $item['title'] = $value['attributes']['title'];\n            $item['timestamp'] = $value['attributes']['member_golive_at'];\n            $item['author'] = $value['attributes']['show_title'];\n            $item['content'] = $this->getItemContent($value);\n\n            $this->items[] = $item;\n        }\n    }\n\n    protected function getItemContent(array $value): string\n    {\n        $content = nl2br($value['attributes']['description']);\n\n        if (isset($value['attributes']['length'])) {\n            $duration_format = $value['attributes']['length'] > 3600 ? 'G:i:s' : 'i:s';\n            $content = sprintf(\n                'Duration: %s<br><br>%s',\n                gmdate($duration_format, $value['attributes']['length']),\n                $content\n            );\n        }\n\n        if ($this->getInput('episodeImage') === true) {\n            foreach ($value['included']['images'] ?? [] as $image) {\n                if ($image['type'] == 'episode_image') {\n                    $content = sprintf(\n                        '<img src=\"%s\"/><br><br>%s',\n                        $image['attributes']['medium'],\n                        $content,\n                    );\n                    break;\n                }\n            }\n        }\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/RtsBridge.php",
    "content": "<?php\n\nclass RtsBridge extends BridgeAbstract\n{\n    const NAME = 'Radio Télévision Suisse';\n    const URI = 'https://www.rts.ch/';\n    const MAINTAINER = 'imagoiq';\n    const DESCRIPTION = 'Returns newest videos from RTS';\n\n    const PARAMETERS = [\n        'ID de l\\'émission' => [\n            'idShow' => [\n                'name' => 'Show id',\n                'required' => true,\n                'exampleValue' => 385418,\n                'title' => 'ex. 385418 pour\n\t\t\t\thttps://www.rts.ch/play/tv/emission/a-bon-entendeur?id=385418'\n            ]\n        ],\n        'ID de la section' => [\n            'idSection' => [\n                'name' => 'Section id',\n                'required' => true,\n                'exampleValue' => 'ce802a54-8877-49cc-acd6-8d244762829b',\n                'title' => 'ex. ce802a54-8877-49cc-acd6-8d244762829b pour\n\t\t\t\thttps://www.rts.ch/play/tv/detail/humour?id=ce802a54-8877-49cc-acd6-8d244762829b'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'ID de l\\'émission':\n                $showId = $this->getInput('idShow');\n\n                $url = 'https://www.rts.ch/play/v3/api/rts/production/videos-by-show-id?showId='\n                . $showId;\n                break;\n            case 'ID de la section':\n                $sectionId = $this->getInput('idSection');\n\n                $url = 'https://www.rts.ch/play/v3/api/rts/production/media-section?sectionId='\n                . $sectionId;\n                break;\n        }\n\n        $header = [];\n        $input = getContents($url, $header);\n        $input_json = json_decode($input, true);\n\n        foreach ($input_json['data']['data'] as $element) {\n            $item = [];\n            $item['uri'] = 'https://www.rts.ch/play/tv/-/video/-?urn=' . $element['urn'];\n            $item['uid'] = $element['id'];\n\n            $item['timestamp'] = strtotime($element['date']);\n            $item['title'] = $element['show']['title'] . ' - ' . $element['title'];\n\n            $item['duration'] = round((int)$element['duration'] / 60000);\n            $durationInHour = date('g\\hi', mktime(0, $item['duration']));\n            $durationInMin = date('i\\m\\i\\n', mktime(0, $item['duration']));\n            $durationText = $item['duration'] > 60 ? $durationInHour : $durationInMin;\n\n            $item['content'] = $element['description']\n            . '<br/><br/>'\n            . $durationText\n            . '<br><a href=\"'\n            . $item['uri']\n            . '\"><img src=\"'\n            . $element['imageUrl']\n            . '/scale/width/700\" alt=\"\"/></a>';\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/Rue89Bridge.php",
    "content": "<?php\n\nclass Rue89Bridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'Rue89';\n    const URI = 'https://www.nouvelobs.com/rue89/';\n    const DESCRIPTION = 'Returns the newest posts from Rue89';\n\n    public function collectData()\n    {\n        $jsonArticles = getContents('https://appdata.nouvelobs.com/rue89/feed.json');\n        $articles = json_decode($jsonArticles)->items;\n        foreach ($articles as $article) {\n            $this->items[] = $this->getArticle($article);\n        }\n    }\n\n    private function getArticle($articleInfo)\n    {\n        $articleJson = getContents($articleInfo->json_url);\n        $article = json_decode($articleJson);\n        $item = [];\n        $item['title'] = $article->title;\n        $item['uri'] = $article->url;\n        if ($article->content_premium !== null) {\n            $item['content'] = $article->content_premium;\n        } else {\n            $item['content'] = $article->content;\n        }\n        $item['timestamp'] = $article->date_publi;\n        $item['author'] = $article->author->show_name;\n\n        $item['enclosures'] = [];\n        foreach ($article->images as $image) {\n            $item['enclosures'][] = $image->url;\n        }\n\n        $item['categories'] = [];\n        foreach ($article->categories as $category) {\n            $item['categories'][] = $category->title;\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/Rule34Bridge.php",
    "content": "<?php\n\nclass Rule34Bridge extends GelbooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Rule34';\n    const URI = 'https://api.rule34.xxx/';\n    const DESCRIPTION = 'Returns images from given page';\n}\n"
  },
  {
    "path": "bridges/Rule34pahealBridge.php",
    "content": "<?php\n\nclass Rule34pahealBridge extends Shimmie2Bridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Rule34paheal';\n    const URI = 'https://rule34.paheal.net/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    const PATHTODATA = '.shm-thumb';\n\n    protected function getItemFromElement($element)\n    {\n        $item = [];\n        $item['uri'] = rtrim($this->getURI(), '/') . $element->find('.shm-thumb-link', 0)->href;\n        $item['id'] = (int)preg_replace('/[^0-9]/', '', $element->getAttribute(static::IDATTRIBUTE));\n        $item['timestamp'] = time();\n        $thumbnailUri = $element->find('a', 1)->href;\n        $item['categories'] = explode(' ', $element->getAttribute('data-tags'));\n        $item['title'] = $this->getName() . ' | ' . $item['id'];\n        $item['content'] = '<a href=\"'\n        . $item['uri']\n        . '\"><img src=\"'\n        . $thumbnailUri\n        . '\" /></a><br>Tags: '\n        . $element->getAttribute('data-tags');\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/RumbleBridge.php",
    "content": "<?php\n\nclass RumbleBridge extends BridgeAbstract\n{\n    const NAME = 'Rumble.com';\n    const URI = 'https://rumble.com/';\n    const DESCRIPTION = 'Fetches the latest channel/user videos and livestreams.';\n    const MAINTAINER = 'dvikan, NotsoanoNimus';\n    const CACHE_TIMEOUT = 60 * 60; // 1h\n    const PARAMETERS = [\n        [\n            'account' => [\n                'name' => 'Account',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Name of the target account to create into a feed.',\n                'defaultValue' => 'bjornandreasbullhansen',\n            ],\n            'type' => [\n                'name' => 'Account Type',\n                'type' => 'list',\n                'title' => 'The type of profile to create a feed from.',\n                'values' => [\n                    'Channel (All)' => 'channel',\n                    'Channel Videos' => 'channel-videos',\n                    'Channel Livestreams' => 'channel-livestream',\n                    'User (All)' => 'user',\n                ],\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        $account = $this->getInput('account');\n        $type = $this->getInput('type');\n        $url = self::getURI();\n\n        if (!preg_match('#^[\\w\\-_.@]+$#', $account) || strlen($account) > 64) {\n            throw new \\Exception('Invalid target account.');\n        }\n\n        switch ($type) {\n            case 'user':\n                $url .= \"user/$account\";\n                break;\n            case 'channel':\n                $url .= \"c/$account\";\n                break;\n            case 'channel-videos':\n                $url .= \"c/$account/videos\";\n                break;\n            case 'channel-livestream':\n                $url .= \"c/$account/livestreams\";\n                break;\n            default:\n                // Shouldn't ever happen.\n                throw new \\Exception('Invalid media type.');\n        }\n\n        $dom = getSimpleHTMLDOM($url);\n        foreach ($dom->find('ol.thumbnail__grid div.thumbnail__grid--item') as $video) {\n            $href = $video->find('a', 0)->href;\n\n            $item = [\n                'title'     => $video->find('h3', 0)->plaintext,\n                'author'    => $account . '@rumble.com',\n                'content'   => defaultLinkTo($video, self::URI)->innertext,\n            ];\n\n            $time = $video->find('time', 0);\n            if ($time) {\n                $publishedAt = new \\DateTimeImmutable($time->getAttribute('datetime'));\n                $item['timestamp'] = $publishedAt->getTimestamp();\n            }\n\n            $href = ltrim($href, '/');\n            $itemUrl = Url::fromString(self::URI . $href);\n            // Remove tracking parameter in query string\n            $item['uri'] = $itemUrl->withQueryString(null)->__toString();\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->getInput('account')) {\n            return 'Rumble.com - ' . $this->getInput('account');\n        }\n        return self::NAME;\n    }\n}\n"
  },
  {
    "path": "bridges/RutubeBridge.php",
    "content": "<?php\n\nclass RutubeBridge extends BridgeAbstract\n{\n    const NAME = 'Rutube';\n    const URI = 'https://rutube.ru';\n    const MAINTAINER = 'em92';\n    const DESCRIPTION = 'Выводит ленту видео';\n\n    const PARAMETERS = [\n        'По каналу' => [\n            'c' => [\n                'name' => 'ИД канала',\n                'exampleValue' => 1342940,  // Мятежник Джек\n                'type' => 'number',\n                'required' => true\n            ],\n        ],\n        'По плейлисту' => [\n            'p' => [\n                'name' => 'ИД плейлиста',\n                'exampleValue' => 83641,  // QRUSH\n                'type' => 'number',\n                'required' => true\n            ],\n        ],\n        'По результатам поиска' => [\n            's' => [\n                'name' => 'Запрос',\n                'exampleValue' => 'SUREN',\n                'required' => true,\n            ]\n        ]\n    ];\n\n    protected $title;\n\n    public function getURI()\n    {\n        if ($this->getInput('c')) {\n            return self::URI . '/channel/' . strval($this->getInput('c')) . '/videos/';\n        } elseif ($this->getInput('p')) {\n            return self::URI . '/plst/' . strval($this->getInput('p')) . '/';\n        } elseif ($this->getInput('s')) {\n            return self::URI . '/search/?suggest=1&query=' . strval($this->getInput('s'));\n        } else {\n            return parent::getURI();\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://static.rutube.ru/static/favicon.ico';\n    }\n\n    public function getName()\n    {\n        if (is_null($this->title)) {\n            return parent::getName();\n        } else {\n            return $this->title . ' - ' . parent::getName();\n        }\n    }\n\n    private function getJSONData($html)\n    {\n        $jsonDataRegex = '/window.reduxState = (.*);/';\n        preg_match($jsonDataRegex, $html, $matches) or throwServerException('Could not find reduxState');\n        $map = [\n            '\\x26' => '&',\n            '\\x3c' => '<',\n            '\\x3d' => '=',\n            '\\x3e' => '>',\n            '\\x3f' => '?',\n        ];\n        $jsonString = str_replace(array_keys($map), array_values($map), $matches[1]);\n        return json_decode($jsonString, false);\n    }\n\n    private function getVideosFromReduxState()\n    {\n        $link = $this->getURI();\n\n        $html = getContents($link);\n        $reduxState = $this->getJSONData($html);\n        $videos = [];\n        if ($this->getInput('c')) {\n            $videosMethod1 = 'allVideos(' . $this->getInput('c') . ',)';\n            $videosMethod2 = 'videos(' . $this->getInput('c') . ',)';\n            if (isset($reduxState->api->queries->$videosMethod1)) {\n                $videos = $reduxState->api->queries->$videosMethod1->data->results;\n            } else {\n                // Fallback to other method\n                // No idea what is difference between \"allVideos\" and \"videos\"\n                // Introduced while resolving https://github.com/RSS-Bridge/rss-bridge/issues/4716\n                $videos = $reduxState->api->queries->$videosMethod2->data->results;\n            }\n            $channelInfoMethod = 'channelInfo({\"userChannelId\":' . $this->getInput('c') . '})';\n            $this->title = $reduxState->api->queries->$channelInfoMethod->data->name;\n        } elseif ($this->getInput('p')) {\n            $playListVideosMethod = 'getPlaylistVideos(' . $this->getInput('p') . ')';\n            $videos = $reduxState->api->queries->$playListVideosMethod->data->results;\n            $playListMethod = 'getPlaylist(' . $this->getInput('p') . ')';\n            $this->title = $reduxState->api->queries->$playListMethod->data->title;\n        } elseif ($this->getInput('s')) {\n            $this->title = 'Поиск ' . $this->getInput('s');\n        }\n\n        return $videos;\n    }\n\n    private function getVideosFromSearchAPI()\n    {\n        $contents = getContents(self::URI . '/api/search/video/?suggest=1&client=wdp&query=' . $this->getInput('s'));\n        $json = json_decode($contents);\n        return $json->results;\n    }\n\n    public function collectData()\n    {\n        if ($this->getInput('c') || $this->getInput('p')) {\n            $videos = $this->getVideosFromReduxState();\n        } else {\n            $videos = $this->getVideosFromSearchAPI();\n        }\n\n        foreach ($videos as $video) {\n            $item = [];\n            $item['title'] = $video->title;\n            $item['uri'] = $video->video_url;\n            $content = '<a href=\"' . $video->video_url . '\">';\n            $content .= '<img src=\"' . $video->thumbnail_url . '\" />';\n            $content .= '</a><br/>';\n            $content .= nl2br(\n                // Converting links in plaintext\n                // Copied from https://stackoverflow.com/a/12590772\n                preg_replace(\n                    '$(https?://[a-z0-9_./?=&#-]+)(?![^<>]*>)$i',\n                    ' <a href=\"$1\" target=\"_blank\">$1</a> ',\n                    $video->description . ' '\n                )\n            );\n            $item['timestamp'] = $video->publication_ts;\n            $item['author'] = $video->author->name;\n            $item['content'] = $content;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SIMARBridge.php",
    "content": "<?php\n\nclass SIMARBridge extends BridgeAbstract\n{\n    const NAME = 'SIMAR';\n    const URI = 'http://www.simar-louresodivelas.pt/';\n    const DESCRIPTION = 'Verificar estado da rede SIMAR';\n    const MAINTAINER = 'somini';\n    const PARAMETERS = [\n        'Público' => [\n            'interventions' => [\n                'type' => 'checkbox',\n                'name' => 'Incluir Intervenções?',\n                'defaultValue' => 'checked',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $e_home = $html->find('#home', 0)\n            or throwServerException('Invalid site structure');\n\n        foreach ($e_home->find('span') as $element) {\n            $item = [];\n\n            $item['title'] = 'Rotura: ' . $element->plaintext;\n            $item['content'] = $element->innertext;\n            $item['uid'] = 'urn:sha1:' . hash('sha1', $item['content']);\n\n            $this->items[] = $item;\n        }\n\n        if ($this->getInput('interventions')) {\n            $e_main1 = $html->find('#menu1', 0)\n                or throwServerException('Invalid site structure');\n\n            foreach ($e_main1->find('a') as $element) {\n                $item = [];\n\n                $item['title'] = 'Intervenção: ' . $element->plaintext;\n                $item['uri'] = $this->getURI() . $element->href;\n                $item['content'] = $element->innertext;\n\n                /* Try to get the actual contents for this kind of item */\n                $item_html = getSimpleHTMLDOMCached($item['uri']);\n                if ($item_html) {\n                    $e_item = $item_html->find('.auto-style59', 0);\n                    foreach ($e_item->find('p') as $paragraph) {\n                        /* Remove empty paragraphs */\n                        if (preg_match('/^(\\W|&nbsp;)+$/', $paragraph->innertext) == 1) {\n                            $paragraph->outertext = '';\n                        }\n                    }\n                    if ($e_item) {\n                        $item['content'] = $e_item->innertext;\n                    }\n                }\n\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SafebooruBridge.php",
    "content": "<?php\n\nclass SafebooruBridge extends GelbooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Safebooru';\n    const URI = 'https://safebooru.org/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    protected function buildThumbnailURI($element)\n    {\n        $regex = '/\\.\\w+$/';\n        return $this->getURI() . 'thumbnails/' . $element->directory\n        . '/thumbnail_' . preg_replace($regex, '.jpg', $element->image);\n    }\n}\n"
  },
  {
    "path": "bridges/SamMobileUpdateBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass SamMobileUpdateBridge extends BridgeAbstract\n{\n    const NAME = 'SamMobile updates';\n    // pull info from this site\n    const URI = 'https://www.sammobile.com/samsung/security/';\n    const DESCRIPTION = 'Fetches the latest security patches for Samsung devices';\n    const MAINTAINER = 'floviolleau';\n    const PARAMETERS = [\n        [\n            'model' => [\n                'name' => 'Model',\n                'exampleValue' => 'SM-S926B',\n                'required' => true,\n            ],\n            'country' => [\n                'name' => 'Country',\n                'exampleValue' => 'EUX',\n                'required' => true,\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 7200; // 2h\n\n    public function collectData()\n    {\n        $model = $this->getInput('model');\n        $country = $this->getInput('country');\n        $uri = self::URI . $model . '/' . $country;\n        $html = getSimpleHTMLDOM($uri);\n\n        $elementsDom = $html->find('.main-content-item__content.main-content-item__content-md table tbody tr');\n\n        foreach ($elementsDom as $elementDom) {\n            $item = [];\n\n            $td = $elementDom->find('td');\n\n            $title = 'Security patch: ' . $td[2] . ' - Android version: ' . $td[3] . ' - PDA: ' . $td[4];\n            $text = 'Model: ' . $td[0] . '<br>Country/Carrier: ' . $td[1] . '<br>Security patch: ' . $td[2] . '<br>OS version: Android ' . $td[3] . '<br>PDA: ' . $td[4];\n\n            $item['uri'] = $uri;\n            $item['title'] = $title;\n            $item['author'] = self::MAINTAINER;\n            $item['timestamp'] = (new DateTime($td[2]->innertext))->getTimestamp();\n            $item['content'] = $text;\n            $item['uid'] = hash('sha256', $item['title']);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SamsungMobileChangelogBridge.php",
    "content": "<?PHP\n\nclass SamsungMobileChangelogBridge extends BridgeAbstract\n{\n    const NAME = 'Samsung Mobile Changelog';\n    const URI = 'https://doc.samsungmobile.com/';\n    const DESCRIPTION = 'Changelog of selected device from the Samsung Mobile documentation in English';\n    const MAINTAINER = 'ajain-93';\n    const PARAMETERS = [\n        [\n            'device' => [\n                'name' => 'Device Model',\n                'title' => 'The model name found in Settings → About phone/tablet\\nSM-931B/DS → SM-S931B',\n                'required' => true,\n                'exampleValue' => 'SM-S931B',\n            ],\n            'region' => [\n                'name' => 'Region',\n                'title' => 'The 3 letter region code found in Service provider software version in\\nSettings → About phone/tablet → Software information',\n                'required' => true,\n                'exampleValue' => 'EUX',\n            ],\n        ]\n    ];\n    private $device_name = '';\n\n    const STR_BUILD_NUMBER = 'Build Number';\n    const STR_ANDROID_VERSION = 'Android version';\n    const STR_RELEASE_DATE = 'Release Date';\n    const STR_SECURITY_PATCH_LEVEL = 'Security patch level';\n\n\n    public function collectData()\n    {\n        $URL = $this->getURI();\n\n        $html = getSimpleHTMLDOMCached($URL)\n            or throwServerException('Could not request changelog page: ' . $URL);\n\n        // Iterate through language options, and find the English version\n        $url_language = $this->getBaseURL() . $html->find('option[value=EN]', 0)->plaintext;\n        if (!isset($url_language)) {\n            throwServerException('Unable to find English version');\n        }\n\n        $html = getSimpleHTMLDOMCached($url_language)\n            or throwServerException('Could not request changelog: ' . $url_language);\n        $container = $html->find('div.container', 0);\n        $this->device_name = trim($html->find('h1', 0)->plaintext);\n        // Debug::log('Device: ' . $device);\n\n        $reachedStart = false;\n        foreach ($container->children() as $element) {\n            if ($element->tag == 'hr') {\n                $reachedStart = true;\n                $item = [];\n                continue;\n            } else if (!$reachedStart) {\n                // Skip non-changelog elements\n                continue;\n            } else if ($element->tag == 'div' && $element->getAttribute('class') == 'row') {\n                // Debug::log('Processing row element');\n                $build = $element->find('div', 0)->plaintext;\n                $build = str_replace(self::STR_BUILD_NUMBER . ' : ', '', $build);\n\n                $version = $element->find('div', 1)->plaintext;\n                $version = str_replace(self::STR_ANDROID_VERSION . ' : ', '', $version);\n\n                $date = $element->find('div', 2)->plaintext;\n                $date = str_replace(self::STR_RELEASE_DATE . ' : ', '', $date);\n\n                $patch = $element->find('div', 3)->plaintext;\n                $patch = str_replace(self::STR_SECURITY_PATCH_LEVEL . ' : ', '', $patch);\n\n                $item['title'] = $date . ' ' . $build;\n                $item['uri'] = $URL;\n                $item['timestamp'] = strtotime($date);\n                $item['content'] = '<b>' . self::STR_BUILD_NUMBER . ':</b> ' . $build . '<br>';\n                $item['content'] .= '<b>' . self::STR_ANDROID_VERSION . ':</b> ' . $version . '<br>';\n                $item['content'] .= '<b>' . self::STR_RELEASE_DATE . ':</b> ' . $date . '<br>';\n                $item['content'] .= '<b>' . self::STR_SECURITY_PATCH_LEVEL . ':</b> ' . $patch . '<br>';\n                $item['content'] .= '<br><b>Changelog: </b><br>';\n\n                continue;\n            } else {\n                $item['content'] .= $element;\n                $this->items[] = $item;\n\n                // break;\n                continue;\n            }\n        }\n    }\n\n\n    private function getBaseURL()\n    {\n        return self::URI . $this->getInput('device') . '/' . $this->getInput('region') . '/';\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('device')) {\n            return $this->getBaseURL() . 'doc.html';\n        } else {\n            return self::URI;\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->device_name) {\n            return htmlspecialchars_decode($this->device_name) . ' - Changelog';\n        } else {\n            return self::NAME;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ScalableCapitalBlogBridge.php",
    "content": "<?php\n\nuse Facebook\\WebDriver\\WebDriverBy;\nuse Facebook\\WebDriver\\WebDriverExpectedCondition;\n\nclass ScalableCapitalBlogBridge extends WebDriverAbstract\n{\n    const NAME = 'Scalable Capital Blog';\n    const URI = 'https://de.scalable.capital/blog';\n    const DESCRIPTION = 'Alle Artikel';\n    const MAINTAINER = 'hleskien';\n\n    /**\n     * Adds accept language german to the Chrome Options.\n     *\n     * @return Facebook\\WebDriver\\Chrome\\ChromeOptions\n     */\n    protected function getBrowserOptions()\n    {\n        $chromeOptions = parent::getBrowserOptions();\n        $chromeOptions->addArguments(['--accept-lang=de']);\n        return $chromeOptions;\n    }\n\n    /**\n     * Puts the content of the first page into the $items array.\n     *\n     * @throws Facebook\\WebDriver\\Exception\\NoSuchElementException\n     * @throws Facebook\\WebDriver\\Exception\\TimeoutException\n     */\n    public function collectData()\n    {\n        parent::collectData();\n\n        try {\n            // wait until last item is loaded\n            $this->getDriver()->wait()->until(WebDriverExpectedCondition::visibilityOfElementLocated(\n                WebDriverBy::xpath('//div[contains(@class, \"articles\")]//div[@class=\"items\"]//div[contains(@class, \"item\")][15]')\n            ));\n            $this->setIcon($this->getDriver()->findElement(WebDriverBy::xpath('//link[@rel=\"shortcut icon\"]'))->getAttribute('href'));\n\n            $items = $this->getDriver()->findElements(WebDriverBy::xpath('//div[contains(@class, \"articles\")]//div[@class=\"items\"]//div[contains(@class, \"item\")]'));\n            foreach ($items as $item) {\n                $feedItem = [];\n\n                $feedItem['enclosures'] = ['https://de.scalable.capital' . $item->findElement(WebDriverBy::tagName('img'))->getAttribute('src')];\n\n                $heading = $item->findElement(WebDriverBy::tagName('a'));\n                $feedItem['title'] = $heading->getText();\n\n                $feedItem['uri'] = 'https://de.scalable.capital' . $heading->getAttribute('href');\n                $feedItem['content'] = $item->findElement(WebDriverBy::xpath('.//div[@class=\"summary\"]'))->getText();\n\n                $date = $item->findElement(WebDriverBy::xpath('.//div[@class=\"published-date\"]'))->getText();\n                $feedItem['timestamp'] = $this->formatItemTimestamp($date);\n\n                $feedItem['author'] = $item->findElement(WebDriverBy::xpath('.//div[@class=\"author\"]'))->getText();\n\n                $this->items[] = $feedItem;\n            }\n        } finally {\n            $this->cleanUp();\n        }\n    }\n\n    /**\n     * Converts the given date (dd.mm.yyyy) into a timestamp.\n     *\n     * @param $value string\n     * @return int\n     */\n    protected function formatItemTimestamp($value)\n    {\n        $formatter = new IntlDateFormatter('de', IntlDateFormatter::LONG, IntlDateFormatter::NONE);\n        return $formatter->parse($value);\n    }\n}"
  },
  {
    "path": "bridges/SchweinfurtBuergerinformationenBridge.php",
    "content": "<?php\n\nclass SchweinfurtBuergerinformationenBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'mibe';\n    const NAME = 'Schweinfurt Bürgerinformationen';\n    const URI = 'https://www.schweinfurt.de/rathaus-politik/pressestelle/buergerinformationen/index.html';\n    const ARTICLE_URI = 'https://www.schweinfurt.de/rathaus-politik/pressestelle/buergerinformationen/%d.html';\n    const INDEX_CACHE_TIMEOUT = 10800; // 3h\n    const ARTICLE_CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns the latest news for citizens of Schweinfurt';\n    const PARAMETERS = [\n        [\n            'pages' => [\n                'name' => 'Number of pages',\n                'type' => 'number',\n                'title' => 'Specifies the number of pages to fetch. Usually one or two are enough.',\n                'exampleValue' => '1',\n                'defaultValue' => '1',\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://www.schweinfurt.de/__/images/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        // Get number of pages to retrieve. One page is the minimum.\n        $pages = $this->getInput('pages');\n        if (!is_int($pages) || $pages < 1) {\n            $pages = 1;\n        }\n\n        $articleIDs = [];\n\n        for ($page = 0; $page < $pages; $page++) {\n            $newIDs = $this->getArticleIDsFromPage($page);\n            $articleIDs = array_merge($articleIDs, $newIDs);\n        }\n\n        foreach ($articleIDs as $articleID) {\n            $this->items[] = $this->generateItemFromArticle($articleID);\n        }\n    }\n\n    private function getArticleIDsFromPage($page)\n    {\n        $url = sprintf(self::URI . '?art_pager=%d', $page);\n        $html = getSimpleHTMLDOMCached($url, self::INDEX_CACHE_TIMEOUT);\n\n        $articles = $html->find('div.artikel-uebersicht');\n        $articleIDs = [];\n\n        foreach ($articles as $article) {\n            // The article ID is in the 'id' attribute of the div element, prefixed with 'artikel_id_'\n            if (preg_match('/artikel_id_(\\d+)/', $article->id, $match)) {\n                $articleIDs[] = $match[1];\n            } else {\n                throwServerException('Couldn\\'t determine article ID from index page.');\n            }\n        }\n\n        return $articleIDs;\n    }\n\n    private function generateItemFromArticle($id)\n    {\n        $url = sprintf(self::ARTICLE_URI, $id);\n        $html = getSimpleHTMLDOMCached($url, self::ARTICLE_CACHE_TIMEOUT);\n\n        $div = $html->find('div#artikel-detail', 0);\n        $divContent = $div->find('.c-content', 0);\n        $images = $divContent->find('img');\n\n        // Every external link has a little arrow symbol image attached to it.\n        // Remove this image. This has to be done before building $content.\n        foreach ($images as $image) {\n            if ($image->class == 'imgextlink') {\n                $image->outertext = '';\n            }\n        }\n\n        // Make relative URLs in content container absolute\n        defaultLinkTo($divContent, self::URI);\n\n        $title = $div->find('.c-title', 0)->innertext;\n        $teaser = $div->find('.c-teaser', 0)->innertext;\n        $content = $divContent->innertext;\n\n        // The title can contain HTML entities. These can be converted back\n        // to regular UTF-8 characters.\n        $title = html_entity_decode($title, ENT_HTML5, 'UTF-8');\n\n        // If there's a teaser, make it more eye-catching,\n        // so that it is clear, that this is not part of the actual content.\n        if (strlen(trim($teaser)) > 0) {\n            $content = '<i><strong>' . $teaser . '</strong></i>' . $content;\n        }\n\n        $item = [\n            'uri' => $url,\n            'title' => $title,\n            'content' => $content,\n            'uid' => $id,\n            ];\n\n        // Let's see if there are images in the content, and if yes, attach\n        // them as enclosures, but not images which are used for linking to an external site and data URIs.\n        foreach ($images as $image) {\n            if ($image->class != 'imgextlink' && parse_url($image->src, PHP_URL_SCHEME) != 'data') {\n                $item['enclosures'][] = $image->src;\n            }\n        }\n\n        // Get the date of the article. Example: \"zuletzt geändert: 26.05.2020\"\n        $editDate = $div->find('div#edit', 0)->plaintext;\n        $editDate = substr($editDate, strrpos($editDate, ' ') + 1);\n        $editDate = DateTime::createFromFormat('d.m.Y', $editDate);\n\n        if ($editDate !== false) {\n            $item['timestamp'] = $editDate->getTimestamp();\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ScientificAmericanBridge.php",
    "content": "<?php\n\nclass ScientificAmericanBridge extends FeedExpander\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Scientific American';\n    const URI = 'https://www.scientificamerican.com/';\n\n    const CACHE_TIMEOUT = 60 * 60 * 1; // 1 hour\n    const DESCRIPTION = 'All articles from the latest feed, plus articles in issues.';\n\n    const PARAMETERS = [\n        '' => [\n            'parseIssues' => [\n                'name' => 'Number of issues to parse and add to the feed. Takes longer to load, but includes all articles.',\n                'type' => 'number',\n                'defaultValue' => 0,\n            ],\n            'addContents' => [\n                'name' => 'Also fetch contents for articles',\n                'type' => 'checkbox',\n                'defaultValue' => 'checked'\n            ]\n        ]\n    ];\n\n    const FEED = 'http://rss.sciam.com/ScientificAmerican-Global';\n    const ISSUES = 'https://www.scientificamerican.com/archive/issues/';\n\n    public function collectData()\n    {\n        $this->collectIssues();\n        $items = [\n            ...$this->collectFeed(),\n            ...$this->collectIssues()\n        ];\n\n        $saved = [];\n\n        foreach ($items as $item) {\n            if (!array_key_exists($item['uri'], $saved)) {\n                $saved[$item['uri']] = 1;\n                if ($this->getInput('addContents') == 1) {\n                    $this->items[] = $this->updateItem($item);\n                } else {\n                    $this->items[] = $item;\n                }\n            }\n        }\n\n        if ($this->getInput('addContents') == 1) {\n            usort($this->items, function ($item1, $item2) {\n                return $item2['timestamp'] - $item1['timestamp'];\n            });\n        }\n    }\n\n    private function collectFeed()\n    {\n        $this->collectExpandableDatas(self::FEED);\n        $items = $this->items;\n        $this->items = [];\n        return $items;\n    }\n\n    private function collectIssues()\n    {\n        $html = getSimpleHTMLDOMCached(self::ISSUES);\n        $content = $html->getElementById('app');\n        $issues_list = $content->find('div[class^=\"issue__list\"]', 0);\n        if ($issues_list == null) {\n            return [];\n        }\n        $issues = $issues_list->find('div[class^=\"list__item\"]');\n        $issues_count = min(\n            (int)$this->getInput('parseIssues'),\n            count($issues)\n        );\n\n        $items = [];\n        for ($i = 0; $i < $issues_count; $i++) {\n            $a = $issues[$i]->find('a', 0);\n            $link = 'https://scientificamerican.com' . $a->getAttribute('href');\n            array_push($items, ...$this->parseIssue($link));\n        }\n        return $items;\n    }\n\n    private function parseIssue($issue_link)\n    {\n        $items = [];\n        $html = getSimpleHTMLDOMCached($issue_link);\n\n        $blocks = $html->find('[class^=\"issueArchiveArticleListCompact\"]');\n        foreach ($blocks as $block) {\n            $articles = $block->find('article[class*=\"article\"]');\n            foreach ($articles as $article) {\n                $a = $article->find('a[class^=\"articleLink\"]', 0);\n                $link = 'https://scientificamerican.com' . $a->getAttribute('href');\n                $title = $a->find('h2[class^=\"articleTitle\"]', 0);\n                array_push($items, [\n                    'uri' => $link,\n                    'title' => $title->plaintext,\n                    'uid' => $link,\n                    'content' => ''\n                ]);\n            }\n        }\n\n        return $items;\n    }\n\n    private function updateItem($item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        $article = $html->find('#app', 0)->find('article', 0);\n\n        $time = $article->find('p[class^=\"article_pub_date\"]', 0);\n        if ($time) {\n            $datetime = DateTime::createFromFormat('F j, Y', $time->plaintext);\n            $datetime->setTime(0, 0, 0, 0);\n            $item['timestamp'] = $datetime->format('U');\n        }\n\n        $authors = $article->find('a[class^=\"article_authors__link\"]');\n        if ($authors) {\n            $author = implode('; ', array_map(fn($a) => $a->plaintext, $authors));\n            $item['author'] = $author;\n        }\n\n        $res = '';\n        $desc = $article->find('div[class^=\"article_dek\"]', 0);\n        if ($desc) {\n            $res .= $desc->innertext;\n        }\n\n        $lead_figure = $article->find('figure[class^=\"lead_image\"]', 0);\n        if ($lead_figure) {\n            $res .= $lead_figure->outertext;\n        }\n\n        $content = $article->find('div[class^=\"article__content\"]', 0);\n        if ($content) {\n            foreach ($content->children() as $block) {\n                if (str_contains($block->innertext, 'On supporting science journalism')) {\n                    continue;\n                }\n                if (\n                    ($block->tag == 'p' && $block->getAttribute('data-block') == 'sciam/paragraph')\n                    || ($block->tag == 'figure' && str_starts_with($block->class, 'article__image'))\n                ) {\n                    $iframe = $block->find('iframe', 0);\n                    if ($iframe) {\n                        $res .= \"<a href=\\\"{$iframe->src}\\\">{$iframe->src}</a>\";\n                    } else {\n                        $res .= $block->outertext;\n                    }\n                } else if ($block->tag == 'h2') {\n                    $res .= '<h3>' . $block->innertext . '</h3>';\n                } else if ($block->tag == 'blockquote') {\n                    $res .= $block->outertext;\n                } else if ($block->tag == 'hr' && $block->getAttribute('data-block') == 'sciam/raw_html') {\n                    $res .= '<hr />';\n                }\n            }\n        }\n\n        $footer = $article->find('footer[class*=\"footer\"]', 0);\n        if ($footer) {\n            $bios = $footer->find('div[class^=bio]');\n            $bio = implode('', array_map(fn($b) => $b->innertext, $bios));\n            $res .= $bio;\n        }\n\n        $item['content'] = $res;\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ScmbBridge.php",
    "content": "<?php\n\nclass ScmbBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Astalaseven';\n    const NAME = 'Se Coucher Moins Bête';\n    const URI = 'https://secouchermoinsbete.fr';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns the newest anecdotes.';\n\n    public function collectData()\n    {\n        $html = '';\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('article') as $article) {\n            $item = [];\n            $item['uri'] = self::URI . $article->find('p.summary a', 0)->href;\n            $item['title'] = $article->find('header h1 a', 0)->innertext;\n\n            // remove text \"En savoir plus\" from anecdote content\n            $readMoreButton = $article->find('span.read-more', 0);\n            if ($readMoreButton) {\n                $readMoreButton->outertext = '';\n            }\n            $content = $article->find('p.summary a', 0)->innertext;\n\n            // remove superfluous spaces at the end\n            $content = substr($content, 0, strlen($content) - 17);\n\n            // get publication date\n            $str_date = $article->find('time', 0)->datetime;\n            [$date, $time] = explode(' ', $str_date);\n            [$y, $m, $d] = explode('-', $date);\n            [$h, $i] = explode(':', $time);\n            $timestamp = mktime($h, $i, 0, $m, $d, $y);\n            $item['timestamp'] = $timestamp;\n\n            $item['content'] = $content;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ScoopItBridge.php",
    "content": "<?php\n\nclass ScoopItBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Pitchoule';\n    const NAME = 'ScoopIt';\n    const URI = 'https://www.scoop.it/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns most recent results from ScoopIt.';\n\n    const PARAMETERS = [ [\n        'u' => [\n            'name' => 'keyword',\n            'exampleValue' => 'docker',\n            'required' => true\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $this->request = $this->getInput('u');\n        $link = self::URI . 'search?q=' . urlencode($this->getInput('u'));\n\n        $html = getSimpleHTMLDOM($link);\n\n        foreach ($html->find('div.post-view') as $element) {\n            $item = [];\n            $item['uri'] = $element->find('a', 0)->href;\n            $item['title'] = preg_replace(\n                '~[[:cntrl:]]~',\n                '',\n                $element->find('div.tCustomization_post_title', 0)->plaintext\n            );\n\n            $item['content'] = preg_replace(\n                '~[[:cntrl:]]~',\n                '',\n                $element->find('div.tCustomization_post_description', 0)->plaintext\n            );\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ScribbleHubBridge.php",
    "content": "<?php\n\nclass ScribbleHubBridge extends FeedExpander\n{\n    const MAINTAINER = 'phantop';\n    const NAME = 'Scribble Hub';\n    const URI = 'https://scribblehub.com/';\n    const DESCRIPTION = 'Returns chapters from Scribble Hub.';\n    const PARAMETERS = [\n        'All' => [],\n        'Author' => [\n            'uid' => [\n                'name' => 'uid',\n                'required' => true,\n                // Example: miriamrobern's stories\n                'exampleValue' => '149271',\n            ],\n        ],\n        'Series' => [\n            'sid' => [\n                'name' => 'sid',\n                'required' => true,\n                // Example: latest chapters from Uskweirs\n                'exampleValue' => '965299',\n            ],\n        ],\n        'List' => [\n            'url' => [\n                'name' => 'url',\n                'required' => true,\n                // Example: latest stories with the 'Transgender' tag\n                'exampleValue' => 'https://www.scribblehub.com/series-finder/?sf=1&gi=6&tgi=1088&sort=dateadded',\n            ],\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return self::URI . 'favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $url = 'https://rssscribblehub.com/rssfeed.php?type=';\n        if ($this->queriedContext === 'List') {\n            $this->collectList($this->getURI());\n            return;\n        }\n        if ($this->queriedContext === 'Author') {\n            $url = $url . 'author&uid=' . $this->getInput('uid');\n        } else { //All and Series use the same source feed\n            $url = $url . 'main';\n        }\n        $this->collectExpandableDatas($url);\n    }\n\n    private function collectList($url)\n    {\n        $html = getSimpleHTMLDOMCached($url);\n        foreach ($html->find('.search_main_box') as $element) {\n            $item = [];\n\n            $item['author'] = $element->find('[title=\"Author\"]', 0)->plaintext;\n            $item['enclosures'] = [$element->find('.search_img img', 0)->src];\n            $title = $element->find('.search_title a', 0);\n            $item['title'] = $title->plaintext;\n            $item['uri'] = $title->href;\n            $item['uid'] = $item['uri'];\n\n            $strdate = $element->find('[title=\"Last Updated\"]', 0)->plaintext;\n            $item['timestamp'] = strtotime($strdate);\n\n            foreach ($element->find('.fic_genre') as $tag) {\n                $item['categories'][] = $tag->plaintext;\n            }\n\n            // Get minimal description in case further requests fail\n            $item['content'] = str_get_html($element->find('.search_body', 0));\n            foreach ($item['content']->firstChild()->children() as $child) {\n                $child->remove();\n            }\n\n            try {\n                $details = getSimpleHTMLDOMCached($item['uri']);\n            } catch (HttpException $e) {\n                // 403 Forbidden, This means we got anti-bot response\n                if ($e->getCode() === 403 || $e->getCode() === 429) {\n                    $this->items[] = $item;\n                    continue;\n                }\n                throw $e;\n            }\n            $item['enclosures'] = [$details->find('.fic_image img', 0)->src];\n            $item['content'] = $details->find('.wi_fic_desc', 0);\n\n            foreach ($details->find('.stag') as $tag) {\n                $item['categories'][] = $tag->plaintext;\n            }\n\n            $read_url = $details->find('.read_buttons a', 0)->href;\n            $item['comments'] = $read_url . '#comments';\n            try {\n                $read_html = getSimpleHTMLDOMCached($read_url);\n            } catch (HttpException $e) {\n                // 403 Forbidden, This means we got anti-bot response\n                if ($e->getCode() === 403 || $e->getCode() === 429) {\n                    $this->items[] = $item;\n                    continue;\n                }\n                throw $e;\n            }\n            $item['content'] .= \"<hr><h3><a href=\\\"$read_url\\\">\";\n            $item['content'] .= $read_html->find('.chapter-title', 0);\n            $item['content'] .= '</a></h3>';\n            $item['content'] .= $read_html->find('#chp_raw', 0);\n\n            $this->items[] = $item;\n        }\n    }\n\n    protected $author = '';\n\n    protected function parseItem(array $item)\n    {\n        //For series, filter out other series from 'All' feed\n        if (\n            $this->queriedContext === 'Series'\n            && preg_match('/read\\/' . $this->getInput('sid') . '-/', $item['uri']) !== 1\n        ) {\n            return [];\n        }\n\n        if ($this->queriedContext === 'Author') {\n            $this->author = $item['author'];\n        }\n\n        $item['comments'] = $item['uri'] . '#comments';\n        $item['uid'] = $item['uri'];\n\n        try {\n            $dom = getSimpleHTMLDOMCached($item['uri']);\n        } catch (HttpException $e) {\n            // 403 Forbidden, This means we got anti-bot response\n            if ($e->getCode() === 403 || $e->getCode() === 429) {\n                return $item;\n            }\n            throw $e;\n        }\n\n        $dom = defaultLinkTo($dom, self::URI);\n\n        //Retrieve full description from page contents\n        $item['content'] = $dom->find('#chp_raw', 0);\n\n        //Retrieve image for thumbnail\n        $item_image = $dom->find('.s_novel_img > img', 0)->src;\n        $item['enclosures'] = [$item_image];\n\n        //Restore lost categories\n        $item_story = html_entity_decode($dom->find('.chp_byauthor > a', 0)->innertext);\n        $item_sid   = $dom->find('#mysid', 0)->value;\n        $item['categories'] = [$item_story, $item_sid];\n\n        //Generate UID\n        $item_pid = $dom->find('#mypostid', 0)->value;\n\n        return $item;\n    }\n\n    public function getName()\n    {\n        $name = parent::getName() . \" $this->queriedContext\";\n        switch ($this->queriedContext) {\n            case 'Author':\n                $title = $this->author;\n                break;\n            case 'Series':\n                try {\n                    $page = getSimpleHTMLDOMCached(self::URI . 'series/' . $this->getInput('sid') . '/a');\n                } catch (HttpException $e) {\n                    // 403 Forbidden, This means we got anti-bot response\n                    if ($e->getCode() === 403) {\n                        return $name;\n                    }\n                    throw $e;\n                }\n                $title = html_entity_decode($page->find('.fic_title', 0)->plaintext);\n                break;\n            case 'List':\n                $page = getSimpleHTMLDOMCached($this->getURI());\n                $title = $page->find('head > title', 0)->plaintext;\n                $title = explode(' |', $title)[0];\n                break;\n        }\n        if (isset($title)) {\n            $name .= \" - $title\";\n        }\n        return $name;\n    }\n\n    public function getURI()\n    {\n        $uri = parent::getURI();\n        switch ($this->queriedContext) {\n            case 'Author':\n                $uri = self::URI . 'profile/' . $this->getInput('uid');\n                break;\n            case 'Series':\n                $uri = self::URI . 'series/' . $this->getInput('sid') . '/a';\n                break;\n            case 'List':\n                $uri = $this->getInput('url');\n                break;\n        }\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/ScribdBridge.php",
    "content": "<?php\n\nclass ScribdBridge extends BridgeAbstract\n{\n    const NAME = 'Scribd';\n    const URI = 'https://www.scribd.com';\n    const DESCRIPTION = 'Returns documents uploaded by a user.';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n        'profile' => [\n            'name' => 'Profile URL',\n            'type' => 'text',\n            'required' => true,\n            'title' => 'Profile URL. Example: https://www.scribd.com/user/164147088/Ars-Technica',\n            'exampleValue' => 'https://www.scribd.com/user/164147088/Ars-Technica'\n        ],\n    ]];\n\n    const CACHE_TIMEOUT = 3600;\n\n    private $profileUrlRegex = '/scribd\\.com\\/(user\\/[0-9]+\\/[\\w-]+)\\/?/';\n    private $feedName = '';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $this->feedName = $html->find('div.header', 0)->plaintext;\n\n        foreach ($html->find('ul.document_cells > li') as $index => $li) {\n            $item = [];\n\n            $item['title'] = $li->find('div.under_title', 0)->plaintext;\n            $item['uri'] = $li->find('a', 0)->href;\n            $item['author'] = $li->find('span.uploader', 0)->plaintext;\n            $item['uid'] = $li->find('a', 0)->href;\n\n            $pageHtml = getSimpleHTMLDOMCached($item['uri'], 3600);\n\n            $image = $pageHtml->find('meta[property=\"og:image\"]', 0)->content;\n            $description = $pageHtml->find('meta[property=\"og:description\"]', 0)->content;\n\n            foreach ($pageHtml->find('ul.interest_pills li') as $pills) {\n                $item['categories'][] = $pills->plaintext;\n            }\n\n            $item['content'] = <<<EOD\n<p>{$description}<p><p><img src=\"{$image}\"></p>\nEOD;\n\n            $item['enclosures'][] = $image;\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 15) {\n                break;\n            }\n        }\n    }\n\n    public function getName()\n    {\n        if ($this->feedName) {\n            return $this->feedName . ' - Scribd';\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('profile'))) {\n            preg_match($this->profileUrlRegex, $this->getInput('profile'), $user)\n                or throwServerException('Could not extract user ID and name from given profile URL.');\n\n            return self::URI . '/' . $user[1] . '/uploads';\n        }\n\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/SensCritiqueBridge.php",
    "content": "<?php\n\nclass SensCritiqueBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'kranack';\n    const NAME = 'Sens Critique';\n    const URI = 'https://www.senscritique.com/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Sens Critique news';\n\n    const PARAMETERS = [ [\n        's' => [\n            'name' => 'Series',\n            'type' => 'checkbox',\n            'defaultValue' => 'checked'\n        ],\n        'g' => [\n            'name' => 'Video Games',\n            'type' => 'checkbox'\n        ],\n        'b' => [\n            'name' => 'Books',\n            'type' => 'checkbox'\n        ],\n        'bd' => [\n            'name' => 'BD',\n            'type' => 'checkbox'\n        ],\n        'mu' => [\n            'name' => 'Music',\n            'type' => 'checkbox'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $categories = [];\n        foreach (self::PARAMETERS[$this->queriedContext] as $category => $properties) {\n            if ($this->getInput($category)) {\n                $uri = self::URI;\n                switch ($category) {\n                    case 's':\n                        $uri .= 'series/actualite';\n                        break;\n                    case 'g':\n                        $uri .= 'jeuxvideo/actualite';\n                        break;\n                    case 'b':\n                        $uri .= 'livres/actualite';\n                        break;\n                    case 'bd':\n                        $uri .= 'bd/actualite';\n                        break;\n                    case 'mu':\n                        $uri .= 'musique/actualite';\n                        break;\n                }\n                $html = getSimpleHTMLDOM($uri);\n                // This selector name looks like it's automatically generated\n                $list = $html->find('div[data-testid=\"row\"]', 0);\n\n                $this->extractDataFromList($list);\n            }\n        }\n    }\n\n    private function extractDataFromList($list)\n    {\n        if ($list === null) {\n            throwClientException('Cannot extract data from list');\n        }\n\n        foreach ($list->find('div[data-testid=\"product-list-item\"]') as $movie) {\n            $synopsis = $movie->find('p[data-testid=\"synopsis\"]', 0);\n\n            $item = [];\n            $item['title'] = $movie->find('h2 a', 0)->plaintext;\n            $item['content'] = sprintf(\n                '<img src=\"%s\"/><p>%s</p><p>%s</p>%s',\n                $movie->find('span[data-testid=\"poster-img-wrapper\"]', 0)->{'data-srcname'},\n                $movie->find('p[data-testid=\"other-infos\"]', 0)->innertext,\n                $movie->find('p[data-testid=\"creators\"]', 0)->innertext,\n                $synopsis ? sprintf('<p>%s</p>', $synopsis->innertext) : ''\n            );\n            $item['id'] = $this->getURI() . ltrim($movie->find('a', 0)->href, '/');\n            $item['uri'] = $this->getURI() . ltrim($movie->find('a', 0)->href, '/');\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SeznamZpravyBridge.php",
    "content": "<?php\n\nclass SeznamZpravyBridge extends BridgeAbstract\n{\n    const NAME = 'Seznam Zprávy';\n    const URI = 'https://seznamzpravy.cz';\n    const DESCRIPTION = 'Returns newest stories from Seznam Zprávy';\n    const MAINTAINER = 'thezeroalpha';\n    const PARAMETERS = [\n        'By Author' => [\n            'author' => [\n                'name' => 'Author String',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'The dash-separated author string, as shown in the URL bar.',\n                'pattern' => '[a-z]+-[a-z]+-[0-9]+',\n                'exampleValue' => 'radek-nohl-1'\n            ],\n        ]\n    ];\n\n    private $feedName;\n\n    public function getName()\n    {\n        if (isset($this->feedName)) {\n            return $this->feedName;\n        }\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $ONE_DAY = 86500;\n        switch ($this->queriedContext) {\n            case 'By Author':\n                $url = 'https://www.seznamzpravy.cz/autor/';\n                $selectors = [\n                'breadcrumbs' => 'div[data-dot=ogm-breadcrumb-navigation]',\n                'articleList' => 'ul.ogm-document-timeline-page li article[data-dot=mol-timeline-item]',\n                'articleTitle' => 'a[data-dot=mol-article-card-title]',\n                'articleDM' => 'span.mol-formatted-date__date',\n                'articleTime' => 'span.mol-formatted-date__time',\n                'articleContent' => 'div[data-dot=ogm-article-content]',\n                'articleImage' => 'div[data-dot=ogm-main-media] img',\n                'articleParagraphs' => 'div[data-dot=mol-paragraph]'\n                ];\n\n                $html = getSimpleHTMLDOMCached($url . $this->getInput('author'), $ONE_DAY);\n                $mainBreadcrumbs = $html->find($selectors['breadcrumbs'], 0)\n                or throwServerException('Could not get breadcrumbs for: ' . $this->getURI());\n\n                $author = $mainBreadcrumbs->last_child()->plaintext\n                or throwServerException('Could not get author for: ' . $this->getURI());\n\n                $this->feedName = $author . ' - Seznam Zprávy';\n\n                $articles = $html->find($selectors['articleList'])\n                or throwServerException('Could not find articles for: ' . $this->getURI());\n\n                foreach ($articles as $article) {\n                    // Get article URL\n                    $titleLink = $article->find($selectors['articleTitle'], 0)\n                    or throwServerException('Could not find title for: ' . $this->getURI());\n                    $articleURL = $titleLink->href;\n\n                    $articleContentHTML = getSimpleHTMLDOMCached($articleURL, $ONE_DAY);\n\n                    // Article header image\n                    $articleImageElem = $articleContentHTML->find($selectors['articleImage'], 0);\n\n                    // Article text content\n                    $contentElem = $articleContentHTML->find($selectors['articleContent'], 0)\n                    or throwServerException('Could not get article content for: ' . $articleURL);\n                    $contentParagraphs = $contentElem->find($selectors['articleParagraphs'])\n                    or throwServerException('Could not find paragraphs for: ' . $articleURL);\n\n                    // If the article has an image, put that image at the start\n                    $contentInitialValue = isset($articleImageElem) ? $articleImageElem->outertext : '';\n                    $contentText = array_reduce($contentParagraphs, function ($s, $elem) {\n                        return $s . $elem->innertext;\n                    }, $contentInitialValue);\n\n                    // Article categories\n                    $breadcrumbsElem = $articleContentHTML->find($selectors['breadcrumbs'], 0)\n                        or throwServerException('Could not find breadcrumbs for: ' . $articleURL);\n                    $breadcrumbs = $breadcrumbsElem->children();\n                    $numBreadcrumbs = count($breadcrumbs);\n                    $categories = [];\n                    foreach ($breadcrumbs as $cat) {\n                        if (--$numBreadcrumbs <= 0) {\n                            break;\n                        }\n                        $categories[] = trim($cat->plaintext);\n                    }\n\n                    // Article date & time\n                    $articleTimeElem = $article->find($selectors['articleTime'], 0)\n                    or throwServerException('Could not find article time for: ' . $articleURL);\n                    $articleTime = $articleTimeElem->plaintext;\n\n                    $articleDMElem = $article->find($selectors['articleDM'], 0);\n                    if (isset($articleDMElem)) {\n                        $articleDMText = $articleDMElem->plaintext;\n                    } else {\n                        // If there is no date but only a time, the article was published today\n                        $articleDMText = date('d.m.');\n                    }\n                    $articleDMY = preg_replace('/[^0-9\\.]/', '', $articleDMText) . date('Y');\n\n                    // Add article to items, potentially with header image as enclosure\n                    $item = [\n                    'title' => $titleLink->plaintext,\n                    'uri' => $titleLink->href,\n                    'timestamp' => strtotime($articleDMY . ' ' . $articleTime),\n                    'author' => $author,\n                    'content' => $contentText,\n                    'categories' => $categories\n                    ];\n                    if (isset($articleImageElem)) {\n                        $item['enclosures'] = ['https:' . $articleImageElem->src];\n                    }\n                    $this->items[] = $item;\n                }\n                break;\n        }\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ShadertoyBridge.php",
    "content": "<?php\n\nclass ShadertoyBridge extends BridgeAbstract\n{\n    const NAME = 'Shadertoy';\n    const URI = 'https://www.shadertoy.com';\n    const DESCRIPTION = 'Latest submissions on Shadertoy';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const PARAMETERS = [\n        [\n            'category' => [\n                'name' => 'category',\n                'type' => 'list',\n                'exampleValue' => 'Popular',\n                'title' => 'Select a category',\n                'values' => [\n                    'Shaders of the Week' => 'sotw',\n                    'Popular' => 'popular',\n                    'Newest' => 'newest',\n                    'Hot' => 'hot',\n                ]\n            ]\n        ]\n    ];\n\n    public function postprocessDescription($content)\n    {\n        // replace [url] tags\n        $pattern = '/\\[\\/?url.*?\\]/';\n        $replace = '';\n        $content = preg_replace($pattern, $replace, $content);\n\n        // find URLs and turn then into hyperlinks\n        $pattern = '/(http|https|ftp|ftps)\\:\\/\\/[a-zA-Z0-9\\-\\.]+\\.[a-zA-Z]{2,3}(\\/\\S*)?/';\n        $replace = '<a href=\"$0\">$0</a>';\n        $content = preg_replace($pattern, $replace, $content);\n\n        return $content;\n    }\n\n    public function collectData()\n    {\n        $category = $this->getInput('category');\n        $json = null;\n\n        if ($category == 'sotw') {\n            $url = static::URI . '/playlist/week';\n            $contents = getContents($url);\n            $shaderids = extractFromDelimiters($contents, 'var gShaderIDs = ', ';');\n            $shaderids = str_replace('\\'', '\"', $shaderids);\n\n            $url = static::URI . '/shadertoy';\n            $data = 's=' . rawurlencode('{ \"shaders\": ' . $shaderids . ' }') . '&nt=0&nl=0&np=0';\n            $header = [\n                'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0',\n                'Content-Type: application/x-www-form-urlencoded',\n                'Accept: */*',\n                'Origin: https://www.shadertoy.com',\n                'Referer: https://www.shadertoy.com/playlist/week',\n            ];\n\n            $opts = [\n                CURLOPT_POST => true,\n                CURLOPT_POSTFIELDS => $data,\n                CURLOPT_RETURNTRANSFER => true\n            ];\n            $json = getContents($url, $header, $opts);\n        } else {\n            $url = static::URI . '/results?sort=' . $category;\n            $contents = getContents($url);\n            $json = extractFromDelimiters($contents, 'var gShaders=', 'var gUseScreenshots');\n            $json = substr(trim($json), 0, -1);\n        }\n\n        $json = Json::decode($json);\n\n        if (!$json) {\n            throw new Exception(sprintf('Unable to find css selector on `%s`', static::URI));\n        }\n\n        foreach ($json as $article) {\n            $id = $article['info']['id'];\n\n            $title = $article['info']['name'];\n            $author = $article['info']['username'];\n            $uri = static::URI . '/view/' . $id;\n            $content = '<p><img src=\"' . static::URI . '/media/shaders/' . $id . '.jpg\"></p><p>' . $this->postprocessDescription($article['info']['description']) . '</p>';\n            $timestamp = $article['info']['date'];\n\n            $this->items[] = [\n                'title' => $title,\n                'author' => $author,\n                'uri' => $uri,\n                'content' => $content,\n                'timestamp' => $timestamp,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ShanaprojectBridge.php",
    "content": "<?php\n\nclass ShanaprojectBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Shanaproject';\n    const URI = 'https://www.shanaproject.com';\n    const DESCRIPTION = 'Returns a list of anime from the current Season Anime List';\n    const PARAMETERS = [\n        [\n            'min_episodes' => [\n                'name' => 'Minimum Episodes',\n                'type' => 'number',\n                'title' => 'Minimum number of episodes before including in feed',\n                'defaultValue' => 0,\n            ],\n            'min_total_episodes' => [\n                'name' => 'Minimum Total Episodes',\n                'type' => 'number',\n                'title' => 'Minimum total number of episodes before including in feed',\n                'defaultValue' => 0,\n            ],\n            'require_banner' => [\n                'name' => 'Require Banner',\n                'type' => 'checkbox',\n                'title' => 'Only include anime with custom banner image',\n                'defaultValue' => false,\n            ],\n        ],\n    ];\n\n    private $uri;\n\n    public function getURI()\n    {\n        return $this->uri ?? parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $html = $this->loadSeasonAnimeList();\n\n        $animes = $html->find('div.header_display_box_info')\n            or throwServerException('Could not find anime headers!');\n\n        $min_episodes = $this->getInput('min_episodes') ?: 0;\n        $min_total_episodes = $this->getInput('min_total_episodes') ?: 0;\n\n        foreach ($animes as $anime) {\n            [\n                $episodes_released,\n                /* of */,\n                $episodes_total\n            ] = explode(' ', $this->extractAnimeEpisodeInformation($anime));\n\n            // Skip if not enough episodes yet\n            if ($episodes_released < $min_episodes) {\n                continue;\n            }\n\n            // Skip if too many episodes in total\n            if ($episodes_total !== '?' && $episodes_total < $min_total_episodes) {\n                continue;\n            }\n\n            // Skip if https://static.shanaproject.com/no-art.jpg\n            if (\n                $this->getInput('require_banner')\n                && strpos($this->extractAnimeBackgroundImage($anime), 'no-art') !== false\n            ) {\n                continue;\n            }\n\n            $this->items[] = [\n                'title' => $this->extractAnimeTitle($anime),\n                'author' => $this->extractAnimeAuthor($anime),\n                'uri' => $this->extractAnimeUri($anime),\n                'timestamp' => $this->extractAnimeTimestamp($anime),\n                'content' => $this->buildAnimeContent($anime),\n            ];\n        }\n    }\n\n    // Returns an html object for the Season Anime List (latest season)\n    private function loadSeasonAnimeList()\n    {\n        $html = getSimpleHTMLDOM(self::URI . '/seasons');\n\n        $html = defaultLinkTo($html, self::URI . '/seasons');\n\n        $season = $html->find('div.follows_menu > a', 1)\n            or throwServerException('Could not find \\'Season Anime List\\'!');\n\n        $html = getSimpleHTMLDOM($season->href);\n\n        $this->uri = $season->href;\n\n        $html = defaultLinkTo($html, $season->href);\n\n        return $html;\n    }\n\n    // Extracts the anime title\n    private function extractAnimeTitle($anime)\n    {\n        $title = $anime->find('a', 0)\n            or throwServerException('Could not find anime title!');\n        return trim($title->innertext);\n    }\n\n    // Extracts the anime URI\n    private function extractAnimeUri($anime)\n    {\n        $uri = $anime->find('a', 0)\n            or throwServerException('Could not find anime URI!');\n        return $uri->href;\n    }\n\n    // Extracts the anime release date (timestamp)\n    private function extractAnimeTimestamp($anime)\n    {\n        $timestamp = $anime->find('span.header_info_block', 1);\n\n        if (!$timestamp) {\n            return null;\n        }\n\n        return strtotime($timestamp->innertext);\n    }\n\n    // Extracts the anime studio name (author)\n    private function extractAnimeAuthor($anime)\n    {\n        $author = $anime->find('span.header_info_block', 2);\n\n        if (!$author) {\n            return null; // Sometimes the studio is unknown, so leave empty\n        }\n\n        return trim($author->innertext);\n    }\n\n    // Extracts the episode information (x of y released)\n    private function extractAnimeEpisodeInformation($anime)\n    {\n        $episode = $anime->find('div.header_info_episode', 0)\n            or throwServerException('Could not find anime episode information!');\n\n        $retVal = preg_replace('/\\r|\\n/', ' ', $episode->plaintext);\n        $retVal = preg_replace('/\\s+/', ' ', $retVal);\n\n        return $retVal;\n    }\n\n    // Extracts the background image\n    private function extractAnimeBackgroundImage($anime)\n    {\n        // Getting the picture is a little bit tricky as it is part of the style.\n        // Luckily the style is part of the parent div :)\n\n        if (preg_match('/url\\(\\/\\/([^\\)]+)\\)/i', $anime->parent->style, $matches)) {\n            return $matches[1];\n        }\n\n        throwServerException('Could not extract background image!');\n    }\n\n    // Builds an URI to search for a specific anime (subber is left empty)\n    private function buildAnimeSearchUri($anime)\n    {\n        return self::URI\n        . '/search/?title='\n        . urlencode($this->extractAnimeTitle($anime))\n        . '&subber=';\n    }\n\n    // Builds the content string for a given anime\n    private function buildAnimeContent($anime)\n    {\n        // We'll use a template string to place our contents\n        return '<a href=\"'\n        . $this->extractAnimeUri($anime)\n        . '\"><img src=\"http://'\n        . $this->extractAnimeBackgroundImage($anime)\n        . '\" alt=\"'\n        . htmlspecialchars($this->extractAnimeTitle($anime))\n        . '\" style=\"border: 1px solid black\"></a><br><p>'\n        . $this->extractAnimeEpisodeInformation($anime)\n        . '</p><br><p><a href=\"'\n        . $this->buildAnimeSearchUri($anime)\n        . '\">Search episodes</a></p>';\n    }\n}\n"
  },
  {
    "path": "bridges/Shimmie2Bridge.php",
    "content": "<?php\n\nclass Shimmie2Bridge extends DanbooruBridge\n{\n    const NAME = 'Shimmie v2';\n    const URI = 'https://shimmie.shishnet.org/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    const PATHTODATA = '.shm-thumb-link';\n    const IDATTRIBUTE = 'data-post-id';\n\n    protected function getFullURI()\n    {\n        return $this->getURI()\n        . 'post/list/'\n        . $this->getInput('t')\n        . '/'\n        . $this->getInput('p');\n    }\n\n    protected function getItemFromElement($element)\n    {\n        $item = [];\n        $item['uri'] = $this->getURI() . $element->href;\n        $item['id'] = (int)preg_replace('/[^0-9]/', '', $element->getAttribute(static::IDATTRIBUTE));\n        $item['timestamp'] = time();\n        $thumbnailUri = $this->getURI() . $element->find('img', 0)->src;\n        $item['categories'] = explode(' ', $element->getAttribute('data-tags'));\n        $item['title'] = $this->getName() . ' | ' . $item['id'];\n        $item['content'] = '<a href=\"'\n        . $item['uri']\n        . '\"><img src=\"'\n        . $thumbnailUri\n        . '\" /></a><br>Tags: '\n        . $element->getAttribute('data-tags');\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/SitemapBridge.php",
    "content": "<?php\n\nclass SitemapBridge extends CssSelectorBridge\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'Sitemap';\n    const URI = 'https://github.com/RSS-Bridge/rss-bridge/';\n    const DESCRIPTION = 'Convert any site to RSS feed using SEO Sitemap and CSS selectors (Advanced Users)';\n    const PARAMETERS = [\n        [\n            'home_page' => [\n                'name' => 'Site URL: Home page with latest articles',\n                'title' => <<<EOT\n                    The bridge will analyze the site like a search engine does.\n                    The URL specified here determines the feed title and URL.\n                    EOT,\n                'exampleValue' => 'https://example.com/blog/',\n                'required' => true\n            ],\n            'url_pattern' => [\n                'name' => 'Pattern for site URLs to take in feed',\n                'title' => 'Select items by applying a regular expression on their URL',\n                'exampleValue' => 'https://example.com/article/.*',\n                'required' => true\n            ],\n            'content_selector' => [\n                'name' => 'Selector for each article content',\n                'title' => <<<EOT\n                    This bridge works using CSS selectors, e.g. \"div.article\" will match <div class=\"article\">.\n                    Everything inside that element becomes feed item content.\n                    EOT,\n                'exampleValue' => 'article.content',\n                'required' => true\n            ],\n            'content_cleanup' => [\n                'name' => '[Optional] Content cleanup: List of items to remove',\n                'title' => 'Selector for unnecessary elements to remove inside article contents.',\n                'exampleValue' => 'div.ads, div.comments',\n            ],\n            'title_cleanup' => [\n                'name' => '[Optional] Text to remove from article title',\n                'title' => 'Specify here some text from page title that need to be removed, e.g. \" | BlogName\".',\n                'exampleValue' => ' | BlogName',\n            ],\n            'site_map' => [\n                'name' => '[Optional] sitemap.xml URL',\n                'title' => <<<EOT\n                    By default, the bridge will analyze robots.txt to find out URL for sitemap.xml.\n                    Alternatively, you can specify here the direct URL for sitemap XML.\n                    The sitemap.xml file must have <loc> and <lastmod> fields for the bridge to work:\n                    Eg. <url><loc>https://article/url</loc><lastmod>2000-12-31T23:59Z</lastmod></url>\n                    <loc> is feed item URL, <lastmod> for selecting the most recent entries.\n                    EOT,\n                'exampleValue' => 'https://example.com/sitemap.xml',\n            ],\n            'discard_thumbnail' => [\n                'name' => '[Optional] Discard thumbnail set by site author',\n                'title' => 'Some sites set their logo as thumbnail for every article. Use this option to discard it.',\n                'type' => 'checkbox',\n            ],\n            'thumbnail_as_header' => [\n                'name' => '[Optional] Insert thumbnail as article header',\n                'title' => 'Insert article main image on top of article contents.',\n                'type' => 'checkbox',\n            ],\n            'limit' => self::LIMIT\n        ]\n    ];\n\n    public function collectData()\n    {\n        $this->homepageUrl = $this->getInput('home_page');\n        $url_pattern = $this->getInput('url_pattern');\n        $content_selector = $this->getInput('content_selector');\n        $content_cleanup = $this->getInput('content_cleanup');\n        $title_cleanup = $this->getInput('title_cleanup');\n        $site_map = $this->getInput('site_map');\n        $discard_thumbnail = $this->getInput('discard_thumbnail');\n        $thumbnail_as_header = $this->getInput('thumbnail_as_header');\n        $limit = $this->getInput('limit');\n\n        $this->feedName = $this->titleCleanup($this->getPageTitle($this->homepageUrl), $title_cleanup);\n        $sitemap_url = empty($site_map) ? $this->homepageUrl : $site_map;\n        $sitemap_xml = $this->getSitemapXml($sitemap_url, !empty($site_map));\n        $links = $this->sitemapXmlToList($sitemap_xml, $url_pattern, empty($limit) ? 10 : $limit);\n\n        if (empty($links) && empty($this->sitemapXmlToList($sitemap_xml))) {\n            throwClientException('Could not retrieve URLs with Timestamps from Sitemap: ' . $sitemap_url);\n        }\n\n        foreach ($links as $link) {\n            $item = $this->expandEntryWithSelector($link, $content_selector, $content_cleanup, $title_cleanup);\n            if ($discard_thumbnail && isset($item['enclosures'])) {\n                unset($item['enclosures']);\n            }\n            if ($thumbnail_as_header && isset($item['enclosures'])) {\n                $item['content'] = '<p><img src=\"' . $item['enclosures'][0] . '\" /></p>' . $item['content'];\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Retrieve site map from specified URL\n     * @param string $url URL pointing to any page of the site, e.g. \"https://example.com/blog\" OR directly to the site map e.g. \"https://example.com/sitemap.xml\"\n     * @param string $is_site_map TRUE if the specified URL points directly to the sitemap XML\n     * @return object Sitemap DOM (from parsed XML)\n     */\n    protected function getSitemapXml(&$url, $is_site_map = false)\n    {\n        if (!$is_site_map) {\n            $robots_txt = getSimpleHTMLDOM(urljoin($url, '/robots.txt'))->outertext;\n            preg_match('/Sitemap: ([^ ]+)/', $robots_txt, $matches);\n            if (empty($matches)) {\n                $sitemap = getSimpleHTMLDOM(urljoin($url, '/sitemap.xml'));\n                if (!empty($sitemap->find('urlset, sitemap'))) {\n                    $url = urljoin($url, '/sitemap.xml');\n                    return $sitemap;\n                } else {\n                    throwClientException('Failed to locate Sitemap from /robots.txt or /sitemap.xml. Try setting it manually.');\n                }\n            }\n            $url = $matches[1];\n        }\n        return getSimpleHTMLDOM($url);\n    }\n\n    /**\n     * Retrieve N most recent URLs from Site Map\n     * @param object $sitemap Site map XML DOM\n     * @param string $url_pattern Optional pattern to look for in URLs\n     * @param int $limit Optional maximum amount of URLs to return\n     * @param bool $keep_date TRUE to keep dates (url => date array instead of url array)\n     * @return array Array of URLs\n     */\n    protected function sitemapXmlToList($sitemap, $url_pattern = '', $limit = 0, $keep_date = false)\n    {\n        $links = [];\n\n        foreach ($sitemap->find('sitemap') as $nested_sitemap) {\n            $url = $nested_sitemap->find('loc');\n            if (!empty($url)) {\n                $url = trim($url[0]->plaintext);\n                if (str_ends_with(strtolower($url), '.xml')) {\n                    $nested_sitemap_xml = $this->getSitemapXml($url, true);\n                    $nested_sitemap_links = $this->sitemapXmlToList($nested_sitemap_xml, $url_pattern, null, true);\n                    $links = array_merge($links, $nested_sitemap_links);\n                }\n            }\n        }\n\n        if (!empty($url_pattern)) {\n            $url_pattern = str_replace('/', '\\/', $url_pattern);\n        }\n\n        foreach ($sitemap->find('url') as $item) {\n            $url = $item->find('loc');\n            $lastmod = $item->find('lastmod');\n            if (!empty($url) && !empty($lastmod)) {\n                $url = trim($url[0]->plaintext);\n                $lastmod = trim($lastmod[0]->plaintext);\n                $timestamp = strtotime($lastmod);\n                if (empty($url_pattern) || preg_match('/' . $url_pattern . '/', $url) === 1) {\n                    $links[$url] = $timestamp;\n                }\n            }\n        }\n\n        arsort($links);\n\n        if ($limit > 0 && count($links) > $limit) {\n            $links = array_slice($links, 0, $limit);\n        }\n\n        return $keep_date ? $links : array_keys($links);\n    }\n}\n"
  },
  {
    "path": "bridges/SkimfeedBridge.php",
    "content": "<?php\n\nclass SkimfeedBridge extends BridgeAbstract\n{\n    const CONTEXT_NEWS_BOX = 'News box';\n    const CONTEXT_HOT_TOPICS = 'Hot topics';\n    const CONTEXT_TECH_NEWS = 'Tech news';\n    const CONTEXT_CUSTOM = 'Custom feed';\n\n    const NAME = 'Skimfeed';\n    const URI = 'https://skimfeed.com';\n    const DESCRIPTION = 'Returns feeds from Skimfeed, also supports custom feeds!';\n    const MAINTAINER = 'logmanoriginal';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [\n        self::CONTEXT_NEWS_BOX => [ // auto-generated (see below)\n            'box_channel' => [\n                'name' => 'Channel',\n                'type' => 'list',\n                'title' => 'Select your channel',\n                'values' => [\n                    'Hacker News' => '/news/hacker-news.html',\n                    'QZ' => '/news/qz.html',\n                    'The Verge' => '/news/the-verge.html',\n                    'Slashdot' => '/news/slashdot.html',\n                    'Lifehacker' => '/news/lifehacker.html',\n                    'Gizmag' => '/news/gizmag.html',\n                    'Fast Company' => '/news/fast-company.html',\n                    'Engadget' => '/news/engadget.html',\n                    'Wired' => '/news/wired.html',\n                    'MakeUseOf' => '/news/makeuseof.html',\n                    'Techcrunch' => '/news/techcrunch.html',\n                    'Apple Insider' => '/news/apple-insider.html',\n                    'ArsTechnica' => '/news/arstechnica.html',\n                    'Tech in Asia' => '/news/tech-in-asia.html',\n                    'FastCoExist' => '/news/fastcoexist.html',\n                    'Digital Trends' => '/news/digital-trends.html',\n                    'AnandTech' => '/news/anandtech.html',\n                    'How to Geek' => '/news/how-to-geek.html',\n                    'Geek' => '/news/geek.html',\n                    'BBC Technology' => '/news/bbc-technology.html',\n                    'Extreme Tech' => '/news/extreme-tech.html',\n                    'Packet Storm Sec' => '/news/packet-storm-sec.html',\n                    'MedGadget' => '/news/medgadget.html',\n                    'Design' => '/news/design.html',\n                    'The Next Web' => '/news/the-next-web.html',\n                    'Bit-Tech' => '/news/bit-tech.html',\n                    'Next Big Future' => '/news/next-big-future.html',\n                    'A VC' => '/news/a-vc.html',\n                    'Copyblogger' => '/news/copyblogger.html',\n                    'Smashing Mag' => '/news/smashing-mag.html',\n                    'Continuations' => '/news/continuations.html',\n                    'Cult of Mac' => '/news/cult-of-mac.html',\n                    'SecuriTeam' => '/news/securiteam.html',\n                    'The Tech Block' => '/news/the-tech-block.html',\n                    'BetaBeat' => '/news/betabeat.html',\n                    'PC Mag' => '/news/pc-mag.html',\n                    'Venture Beat' => '/news/venture-beat.html',\n                    'ReadWriteWeb' => '/news/readwriteweb.html',\n                    'High Scalability' => '/news/high-scalability.html',\n                ]\n            ]\n        ],\n        self::CONTEXT_HOT_TOPICS => [],\n        self::CONTEXT_TECH_NEWS => [ // auto-generated (see below)\n            'tech_channel' => [\n                'name' => 'Tech channel',\n                'type' => 'list',\n                'title' => 'Select your tech channel',\n                'values' => [\n                    'Agg' => [\n                        'Reddit' => '/news/reddit.html',\n                        'Tech Insider' => '/news/tech-insider.html',\n                        'Digg' => '/news/digg.html',\n                        'Meta Filter' => '/news/meta-filter.html',\n                        'Fark' => '/news/fark.html',\n                        'Mashable' => '/news/mashable.html',\n                        'Ad Week' => '/news/ad-week.html',\n                        'The Chive' => '/news/the-chive.html',\n                        'BoingBoing' => '/news/boingboing.html',\n                        'Vice' => '/news/vice.html',\n                        'ClientsFromHell' => '/news/clientsfromhell.html',\n                        'How Stuff Works' => '/news/how-stuff-works.html',\n                        'Buzzfeed' => '/news/buzzfeed.html',\n                        'BoingBoing' => '/news/boingboing.html',\n                        'Cracked' => '/news/cracked.html',\n                        'Weird News' => '/news/weird-news.html',\n                        'ITOTD' => '/news/itotd.html',\n                        'Metafilter' => '/news/metafilter.html',\n                        'TheOnion' => '/news/theonion.html',\n                    ],\n                    'Cars' => [\n                        'Reddit Cars' => '/news/reddit-cars.html',\n                        'NYT Auto' => '/news/nyt-auto.html',\n                        'Truth About Cars' => '/news/truth-about-cars.html',\n                        'AutoBlog' => '/news/autoblog.html',\n                        'AutoSpies' => '/news/autospies.html',\n                        'Autoweek' => '/news/autoweek.html',\n                        'The Garage' => '/news/the-garage.html',\n                        'Car and Driver' => '/news/car-and-driver.html',\n                        'EGM Car Tech' => '/news/egm-car-tech.html',\n                        'Top Gear' => '/news/top-gear.html',\n                        'eGarage' => '/news/egarage.html',\n                    ],\n                    'Comics' => [\n                        'Penny Arcade' => '/news/penny-arcade.html',\n                        'XKCD' => '/news/xkcd.html',\n                        'Channelate' => '/news/channelate.html',\n                        'Savage Chicken' => '/news/savage-chicken.html',\n                        'Dinosaur Comics' => '/news/dinosaur-comics.html',\n                        'Explosm' => '/news/explosm.html',\n                        'PoorlyDLines' => '/news/poorlydlines.html',\n                        'Moonbeard' => '/news/moonbeard.html',\n                        'Nedroid' => '/news/nedroid.html',\n                    ],\n                    'Design' => [\n                        'FastCoCreate' => '/news/fastcocreate.html',\n                        'Dezeen' => '/news/dezeen.html',\n                        'Design Boom' => '/news/design-boom.html',\n                        'Mmminimal' => '/news/mmminimal.html',\n                        'We Heart' => '/news/we-heart.html',\n                        'CreativeBloq' => '/news/creativebloq.html',\n                        'TheDSGNblog' => '/news/thedsgnblog.html',\n                        'Grainedit' => '/news/grainedit.html',\n                    ],\n                    'Football' => [\n                        'Mail Football' => '/news/mail-football.html',\n                        'Yahoo Football' => '/news/yahoo-football.html',\n                        'FourFourTwo' => '/news/fourfourtwo.html',\n                        'Goal' => '/news/goal.html',\n                        'BBC Football' => '/news/bbc-football.html',\n                        'TalkSport' => '/news/talksport.html',\n                        '101 Great Goals' => '/news/101-great-goals.html',\n                        'Who Scored' => '/news/who-scored.html',\n                        'Football365 Champ' => '/news/football365-champ.html',\n                        'Football365 Premier' => '/news/football365-premier.html',\n                        'BleacherReport' => '/news/bleacherreport.html',\n                    ],\n                    'Gaming' => [\n                        'Polygon' => '/news/polygon.html',\n                        'Gamespot' => '/news/gamespot.html',\n                        'RockPaperShotgun' => '/news/rockpapershotgun.html',\n                        'VG247' => '/news/vg247.html',\n                        'IGN' => '/news/ign.html',\n                        'Reddit Games' => '/news/reddit-games.html',\n                        'TouchArcade' => '/news/toucharcade.html',\n                        'GamesRadar' => '/news/gamesradar.html',\n                        'Siliconera' => '/news/siliconera.html',\n                        'Reddit GameDeals' => '/news/reddit-gamedeals.html',\n                        'Joystiq' => '/news/joystiq.html',\n                        'GameInformer' => '/news/gameinformer.html',\n                        'PSN Blog' => '/news/psn-blog.html',\n                        'Reddit GamerNews' => '/news/reddit-gamernews.html',\n                        'Steam' => '/news/steam.html',\n                        'DualShockers' => '/news/dualshockers.html',\n                        'ShackNews' => '/news/shacknews.html',\n                        'CheapAssGamer' => '/news/cheapassgamer.html',\n                        'Eurogamer' => '/news/eurogamer.html',\n                        'Major Nelson' => '/news/major-nelson.html',\n                        'Reddit Truegaming' => '/news/reddit-truegaming.html',\n                        'GameTrailers' => '/news/gametrailers.html',\n                        'GamaSutra' => '/news/gamasutra.html',\n                        'USGamer' => '/news/usgamer.html',\n                        'Shoryuken' => '/news/shoryuken.html',\n                        'Destructoid' => '/news/destructoid.html',\n                        'ArsGaming' => '/news/arsgaming.html',\n                        'XBOX Blog' => '/news/xbox-blog.html',\n                        'GiantBomb' => '/news/giantbomb.html',\n                        'VideoGamer' => '/news/videogamer.html',\n                        'Pocket Tactics' => '/news/pocket-tactics.html',\n                        'WiredGaming' => '/news/wiredgaming.html',\n                        'AllGamesBeta' => '/news/allgamesbeta.html',\n                        'OnGamers' => '/news/ongamers.html',\n                        'Reddit GameBundles' => '/news/reddit-gamebundles.html',\n                        'Kotaku' => '/news/kotaku.html',\n                        'PCGamer' => '/news/pcgamer.html',\n                    ],\n                    'Investing' => [\n                        'Seeking Alpha' => '/news/seeking-alpha.html',\n                        'BBC Business' => '/news/bbc-business.html',\n                        'Harvard Biz' => '/news/harvard-biz.html',\n                        'Market Watch' => '/news/market-watch.html',\n                        'Investor Place' => '/news/investor-place.html',\n                        'Money Week' => '/news/money-week.html',\n                        'Moneybeat' => '/news/moneybeat.html',\n                        'Dealbook' => '/news/dealbook.html',\n                        'Economist Business' => '/news/economist-business.html',\n                        'Economist' => '/news/economist.html',\n                        'Economist CN' => '/news/economist-cn.html',\n                    ],\n                    'Long' => [\n                        'The Atlantic' => '/news/the-atlantic.html',\n                        'Reddit Long' => '/news/reddit-long.html',\n                        'Paris Review' => '/news/paris-review.html',\n                        'New Yorker' => '/news/new-yorker.html',\n                        'LongForm' => '/news/longform.html',\n                        'LongReads' => '/news/longreads.html',\n                        'The Browser' => '/news/the-browser.html',\n                        'The Feature' => '/news/the-feature.html',\n                    ],\n                    'MMA' => [\n                        'MMA Weekly' => '/news/mma-weekly.html',\n                        'MMAFighting' => '/news/mmafighting.html',\n                        'Reddit MMA' => '/news/reddit-mma.html',\n                        'Sherdog Articles' => '/news/sherdog-articles.html',\n                        'FightLand Vice' => '/news/fightland-vice.html',\n                        'Sherdog Forum' => '/news/sherdog-forum.html',\n                        'MMA Junkie' => '/news/mma-junkie.html',\n                        'Sherdog MMA Video' => '/news/sherdog-mma-video.html',\n                        'BloodyElbow' => '/news/bloodyelbow.html',\n                        'CageWriter' => '/news/cagewriter.html',\n                        'Sherdog News' => '/news/sherdog-news.html',\n                        'MMAForum' => '/news/mmaforum.html',\n                        'MMA Junkie Radio' => '/news/mma-junkie-radio.html',\n                        'UFC News' => '/news/ufc-news.html',\n                        'FightLinker' => '/news/fightlinker.html',\n                        'Bodybuilding MMA' => '/news/bodybuilding-mma.html',\n                        'BleacherReport MMA' => '/news/bleacherreport-mma.html',\n                        'FiveOuncesofPain' => '/news/fiveouncesofpain.html',\n                        'Sherdog Pictures' => '/news/sherdog-pictures.html',\n                        'CagePotato' => '/news/cagepotato.html',\n                        'Sherdog Radio' => '/news/sherdog-radio.html',\n                        'ProMMARadio' => '/news/prommaradio.html',\n                    ],\n                    'Mobile' => [\n                        'Macrumors' => '/news/macrumors.html',\n                        'Android Police' => '/news/android-police.html',\n                        'GSM Arena' => '/news/gsm-arena.html',\n                        'DigiTrend Mobile' => '/news/digitrend-mobile.html',\n                        'Mobile Nation' => '/news/mobile-nation.html',\n                        'TechRadar' => '/news/techradar.html',\n                        'ZDNET Mobile' => '/news/zdnet-mobile.html',\n                        'MacWorld' => '/news/macworld.html',\n                        'Android Dev Blog' => '/news/android-dev-blog.html',\n                    ],\n                    'News' => [\n                        'Daily Mail' => '/news/daily-mail.html',\n                        'Business Insider' => '/news/business-insider.html',\n                        'The Guardian' => '/news/the-guardian.html',\n                        'Fox' => '/news/fox.html',\n                        'BBC World' => '/news/bbc-world.html',\n                        'MSNBC' => '/news/msnbc.html',\n                        'ABC News' => '/news/abc-news.html',\n                        'Al Jazeera' => '/news/al-jazeera.html',\n                        'Business Insider India' => '/news/business-insider-india.html',\n                        'Observer' => '/news/observer.html',\n                        'NYT Tech' => '/news/nyt-tech.html',\n                        'NYT World' => '/news/nyt-world.html',\n                        'CNN' => '/news/cnn.html',\n                        'Japan Times' => '/news/japan-times.html',\n                        'WorldCrunch' => '/news/worldcrunch.html',\n                        'Pro publica' => '/news/pro-publica.html',\n                        'OZY' => '/news/ozy.html',\n                        'Times of India' => '/news/times-of-india.html',\n                        'The Australian' => '/news/the-australian.html',\n                        'Harpers' => '/news/harpers.html',\n                        'Moscow Times' => '/news/moscow-times.html',\n                        'The Times' => '/news/the-times.html',\n                        'Reuters Tech' => '/news/reuters-tech.html',\n                    ],\n                    'Politics' => [\n                        'FreeRepublic' => '/news/freerepublic.html',\n                        'Salon' => '/news/salon.html',\n                        'DrudgeReport' => '/news/drudgereport.html',\n                        'TheHill' => '/news/thehill.html',\n                        'TheBlaze' => '/news/theblaze.html',\n                        'InfoWars' => '/news/infowars.html',\n                        'New Republic' => '/news/new-republic.html',\n                        'WashTimes' => '/news/washtimes.html',\n                        'RealCleanPol' => '/news/realcleanpol.html',\n                        'Fact Check' => '/news/fact-check.html',\n                        'DailyKos' => '/news/dailykos.html',\n                        'NewsMax' => '/news/newsmax.html',\n                        'Politico' => '/news/politico.html',\n                        'Michelle Malkin' => '/news/michelle-malkin.html',\n                    ],\n                    'Reddit' => [\n                        'R Movies' => '/news/r-movies.html',\n                        'R News' => '/news/r-news.html',\n                        'Futurology' => '/news/futurology.html',\n                        'R All' => '/news/r-all.html',\n                        'R Music' => '/news/r-music.html',\n                        'R Askscience' => '/news/r-askscience.html',\n                        'R Technology' => '/news/r-technology.html',\n                        'R Bestof' => '/news/r-bestof.html',\n                        'R Askreddit' => '/news/r-askreddit.html',\n                        'R Worldnews' => '/news/r-worldnews.html',\n                        'R Explainlikeimfive' => '/news/r-explainlikeimfive.html',\n                        'R Iama' => '/news/r-iama.html',\n                    ],\n                    'Science' => [\n                        'PhysOrg' => '/news/physorg.html',\n                        'Hack-a-day' => '/news/hack-a-day.html',\n                        'Reddit Science' => '/news/reddit-science.html',\n                        'Stats Blog' => '/news/stats-blog.html',\n                        'Flowing Data' => '/news/flowing-data.html',\n                        'Eureka Alert' => '/news/eureka-alert.html',\n                        'Robotics BizRev' => '/news/robotics-bizrev.html',\n                        'Planet big Data' => '/news/planet-big-data.html',\n                        'Makezine' => '/news/makezine.html',\n                        'MIT Tech' => '/news/mit-tech.html',\n                        'R Bloggers' => '/news/r-bloggers.html',\n                        'DataIsBeautiful' => '/news/dataisbeautiful.html',\n                        'Ted Videos' => '/news/ted-videos.html',\n                        'Advanced Science' => '/news/advanced-science.html',\n                        'Robotiq' => '/news/robotiq.html',\n                        'Science Daily' => '/news/science-daily.html',\n                        'IEEE Robotics' => '/news/ieee-robotics.html',\n                        'PSFK' => '/news/psfk.html',\n                        'Discover Magazine' => '/news/discover-magazine.html',\n                        'DataTau' => '/news/datatau.html',\n                        'RoboHub' => '/news/robohub.html',\n                        'Discovery' => '/news/discovery.html',\n                        'Smart Data' => '/news/smart-data.html',\n                        'Whats Big Data' => '/news/whats-big-data.html',\n                    ],\n                    'Tech' => [\n                        'Hacker News' => '/news/hacker-news.html',\n                        'The Verge' => '/news/the-verge.html',\n                        'Lifehacker' => '/news/lifehacker.html',\n                        'Fast Company' => '/news/fast-company.html',\n                        'ArsTechnica' => '/news/arstechnica.html',\n                        'MakeUseOf' => '/news/makeuseof.html',\n                        'FastCoExist' => '/news/fastcoexist.html',\n                        'How to Geek' => '/news/how-to-geek.html',\n                        'The Next Web' => '/news/the-next-web.html',\n                        'Engadget' => '/news/engadget.html',\n                        'Gizmag' => '/news/gizmag.html',\n                        'QZ' => '/news/qz.html',\n                        'Wired' => '/news/wired.html',\n                        'Techcrunch' => '/news/techcrunch.html',\n                        'Slashdot' => '/news/slashdot.html',\n                        'Extreme Tech' => '/news/extreme-tech.html',\n                        'AnandTech' => '/news/anandtech.html',\n                        'Digital Trends' => '/news/digital-trends.html',\n                        'Next Big Future' => '/news/next-big-future.html',\n                        'Apple Insider' => '/news/apple-insider.html',\n                        'Geek' => '/news/geek.html',\n                        'BBC Technology' => '/news/bbc-technology.html',\n                        'Bit-Tech' => '/news/bit-tech.html',\n                        'Packet Storm Sec' => '/news/packet-storm-sec.html',\n                        'Design' => '/news/design.html',\n                        'High Scalability' => '/news/high-scalability.html',\n                        'Smashing Mag' => '/news/smashing-mag.html',\n                        'The Tech Block' => '/news/the-tech-block.html',\n                        'A VC' => '/news/a-vc.html',\n                        'Tech in Asia' => '/news/tech-in-asia.html',\n                        'ReadWriteWeb' => '/news/readwriteweb.html',\n                        'PC Mag' => '/news/pc-mag.html',\n                        'Continuations' => '/news/continuations.html',\n                        'Copyblogger' => '/news/copyblogger.html',\n                        'Cult of Mac' => '/news/cult-of-mac.html',\n                        'BetaBeat' => '/news/betabeat.html',\n                        'MedGadget' => '/news/medgadget.html',\n                        'SecuriTeam' => '/news/securiteam.html',\n                        'Venture Beat' => '/news/venture-beat.html',\n                    ],\n                    'Trend' => [\n                        'Trend Hunter' => '/news/trend-hunter.html',\n                        'ApartmentT' => '/news/apartmentt.html',\n                        'GQ' => '/news/gq.html',\n                        'Digital Trends' => '/news/digital-trends.html',\n                        'Cool Hunting' => '/news/cool-hunting.html',\n                        'FastCoDesign' => '/news/fastcodesign.html',\n                        'TC Startups' => '/news/tc-startups.html',\n                        'Killer Startups' => '/news/killer-startups.html',\n                        'DigiInfo' => '/news/digiinfo.html',\n                        'New Startups' => '/news/new-startups.html',\n                        'DigiTrends' => '/news/digitrends.html',\n                    ],\n                    'Watches' => [\n                        'Hodinkee' => '/news/hodinkee.html',\n                        'Quill and Pad' => '/news/quill-and-pad.html',\n                        'Monochrome' => '/news/monochrome.html',\n                        'Deployant' => '/news/deployant.html',\n                        'Watches by SJX' => '/news/watches-by-sjx.html',\n                        'Fratello Watches' => '/news/fratello-watches.html',\n                        'A Blog to Watch' => '/news/a-blog-to-watch.html',\n                        'Wound for Life' => '/news/wound-for-life.html',\n                        'Watch Paper' => '/news/watch-paper.html',\n                        'Watch Report' => '/news/watch-report.html',\n                        'Perpetuelle' => '/news/perpetuelle.html',\n                    ],\n                    'Youtube' => [\n                        'LinusTechTips' => '/news/linustechtips.html',\n                        'MetalJesusRocks' => '/news/metaljesusrocks.html',\n                        'TotalBiscuit' => '/news/totalbiscuit.html',\n                        'DexBonus' => '/news/dexbonus.html',\n                        'Lon Siedman' => '/news/lon-siedman.html',\n                        'MKBHD' => '/news/mkbhd.html',\n                        'Terry A Davis' => '/news/terry-a-davis.html',\n                        'HappyConsole' => '/news/happyconsole.html',\n                        'Austin Evans' => '/news/austin-evans.html',\n                        'NCIX' => '/news/ncix.html',\n                    ],\n                ]\n            ],\n        ],\n        self::CONTEXT_CUSTOM => [\n            'config' => [\n                'name' => 'Configuration',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Enter feed numbers from Skimfeed! e.g: 5,8,2,l,p,9,23',\n                'exampleValue' => '5'\n            ]\n        ],\n        'global' => [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'title' => 'Limits the number of returned items in the feed',\n                'exampleValue' => 10\n            ]\n        ]\n    ];\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_NEWS_BOX:\n                $channel = $this->getInput('box_channel');\n\n                if ($channel) {\n                    return static::URI . $channel;\n                }\n\n                break;\n\n            case self::CONTEXT_HOT_TOPICS:\n                return static::URI;\n\n            case self::CONTEXT_TECH_NEWS:\n                $channel = $this->getInput('tech_channel');\n\n                if ($channel) {\n                    return static::URI . $channel;\n                }\n\n                break;\n\n            case self::CONTEXT_CUSTOM:\n                $config = $this->getInput('config');\n\n                return static::URI . '/custom.php?f=' . urlencode($config);\n        }\n\n        return parent::getURI();\n    }\n\n    public function detectParameters($url)\n    {\n        if (0 !== strpos($url, static::URI)) {\n            return null;\n        }\n\n        foreach (self::PARAMETERS as $context => $channels) {\n            foreach ($channels as $box_name => $box) {\n                foreach ($box['values'] as $name => $channel_url) {\n                    if (static::URI . $channel_url === $url) {\n                        return [\n                            'context' => $context,\n                            $box_name => $name,\n                        ];\n                    }\n                }\n            }\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_NEWS_BOX:\n                $channel = $this->getInput('box_channel');\n\n                $title = array_search(\n                    $channel,\n                    static::PARAMETERS[self::CONTEXT_NEWS_BOX]['box_channel']['values']\n                );\n\n                return $title . ' - ' . static::NAME;\n\n            case self::CONTEXT_HOT_TOPICS:\n                return 'Hot topics - ' . static::NAME;\n\n            case self::CONTEXT_TECH_NEWS:\n                $channel = $this->getInput('tech_channel');\n\n                $titles = [];\n\n                foreach (static::PARAMETERS[self::CONTEXT_TECH_NEWS]['tech_channel']['values'] as $ch) {\n                    $titles = array_merge($titles, $ch);\n                }\n\n                $title = array_search($channel, $titles);\n\n                return $title . ' - ' . static::NAME;\n\n            case self::CONTEXT_CUSTOM:\n                return 'Custom - ' . static::NAME;\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        // enable to export parameter lists\n        // $this->exportBoxChannels(); die;\n        // $this->exportTechChannels(); die;\n\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        defaultLinkTo($html, static::URI);\n\n        switch ($this->queriedContext) {\n            case self::CONTEXT_NEWS_BOX:\n                $author = array_search(\n                    $this->getInput('box_channel'),\n                    static::PARAMETERS[self::CONTEXT_NEWS_BOX]['box_channel']['values']\n                );\n\n                $author = '<a href=\"'\n                . $this->getURI()\n                . '\">'\n                . $author\n                . '</a>';\n\n                $this->extractFeed($html, $author);\n                break;\n\n            case self::CONTEXT_HOT_TOPICS:\n                $this->extractHotTopics($html);\n                break;\n\n            case self::CONTEXT_TECH_NEWS:\n                $authors = [];\n\n                foreach (static::PARAMETERS[self::CONTEXT_TECH_NEWS]['tech_channel']['values'] as $ch) {\n                    $authors = array_merge($authors, $ch);\n                }\n\n                $author = '<a href=\"'\n                . $this->getURI()\n                . '\">'\n                . array_search($this->getInput('tech_channel'), $authors)\n                . '</a>';\n\n                $this->extractFeed($html, $author);\n                break;\n\n            case self::CONTEXT_CUSTOM:\n                $this->extractCustomFeed($html);\n                break;\n        }\n    }\n\n    private function extractFeed($html, $author)\n    {\n        $articles = $html->find('li')\n            or throwServerException('Could not find articles!');\n\n        if (\n            count($articles) === 1\n            && stristr($articles[0]->plaintext, 'Nothing new in the last 48 hours')\n        ) {\n            return; // Nothing to show\n        }\n\n        $limit = $this->getInput('limit') ?: -1;\n\n        foreach ($articles as $article) {\n            $anchor = $article->find('a', 0)\n                or throwServerException('Could not find anchor!');\n\n            $item = [];\n\n            $item['uri'] = $this->getTarget($anchor);\n            $item['title'] = trim($anchor->plaintext);\n\n            // The timestamp is encoded as relative time (max. the last 48 hours)\n            // like this: \"- 7 hours\". It should always be at the end of the article:\n            $age = substr($article->plaintext, strrpos($article->plaintext, '-'));\n\n            $item['timestamp'] = strtotime($age);\n            $item['author'] = $author;\n\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                return;\n            }\n        }\n    }\n\n    private function extractHotTopics($html)\n    {\n        $topics = $html->find('#popbox ul li')\n            or throwServerException('Could not find topics!');\n\n        $limit = $this->getInput('limit') ?: -1;\n\n        foreach ($topics as $topic) {\n            $anchor = $topic->find('a', 0)\n                or throwServerException('Could not find anchor!');\n\n            $item = [];\n\n            $item['uri'] = $this->getTarget($anchor);\n            $item['title'] = $anchor->title;\n\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                return;\n            }\n        }\n    }\n\n    private function extractCustomFeed($html)\n    {\n        $boxes = $html->find('#boxx .boxes')\n            or throwServerException('Could not find boxes!');\n\n        foreach ($boxes as $box) {\n            $anchor = $box->find('span.boxtitles a', 0)\n                or throwServerException('Could not find box anchor!');\n\n            $author = '<a href=\"' . $anchor->href . '\">' . trim($anchor->plaintext) . '</a>';\n            $uri    = $anchor->href;\n\n            $box_html = getSimpleHTMLDOM($uri);\n\n            $this->extractFeed($box_html, $author);\n        }\n    }\n\n    private function getTarget($anchor)\n    {\n        // Anchors are linked to Skimfeed, luckily the target URI is encoded\n        // in that URI via '&u=<URI>':\n        $query = parse_url($anchor->href, PHP_URL_QUERY);\n\n        foreach (explode('&', $query) as $parameter) {\n            [$key, $value] = explode('=', $parameter);\n\n            if ($key !== 'u') {\n                continue;\n            }\n\n            return urldecode($value);\n        }\n    }\n\n    /**\n     * dev-mode!\n     * Requires '&format=Html'\n     *\n     * Returns the 'box' array from the source site\n     */\n    private function exportBoxChannels()\n    {\n        $html = getSimpleHTMLDOMCached(static::URI);\n\n        if (!$this->isCompatible($html)) {\n            throwServerException('Skimfeed version is not compatible!');\n        }\n\n        $boxes = $html->find('#boxx .boxes')\n            or throwServerException('Could not find boxes!');\n\n        // begin of 'channel' list\n        $message = <<<EOD\n'box_channel' => array(\n\t'name' => 'Channel',\n\t'type' => 'list',\n\t'required' => true,\n\t'title' => 'Select your channel',\n\t'values' => array(\n\nEOD;\n\n        foreach ($boxes as $box) {\n            $anchor = $box->find('span.boxtitles a', 0)\n                or throwServerException('Could not find box anchor!');\n\n            $title  = trim($anchor->plaintext);\n            $uri    = $anchor->href;\n\n            // add value\n            $message .= \"\\t\\t'{$title}' => '{$uri}', \\n\";\n        }\n\n        // end of 'box' list\n        $message .= <<<EOD\n\t)\n),\nEOD;\n\n        echo <<<EOD\n<!DOCTYPE html>\n\n<html>\n\t<body>\n\t\t<code style=\"white-space: pre-wrap;\">{$message}</code>\n\t</body>\n</html>\nEOD;\n    }\n\n    /**\n     * dev-mode!\n     * Requires '&format=Html'\n     *\n     * Returns the 'techs' array from the source site\n     */\n    private function exportTechChannels()\n    {\n        $html = getSimpleHTMLDOMCached(static::URI);\n\n        if (!$this->isCompatible($html)) {\n            throwServerException('Skimfeed version is not compatible!');\n        }\n\n        $channels = $html->find('#menubar a')\n            or throwServerException('Could not find channels!');\n\n        // begin of 'tech_channel' list\n        $message = <<<EOD\n'tech_channel' => array(\n\t'name' => 'Tech channel',\n\t'type' => 'list',\n\t'required' => true,\n\t'title' => 'Select your tech channel',\n\t'values' => array(\n\nEOD;\n\n        foreach ($channels as $channel) {\n            if (\n                $channel->href === '#'\n                || $channel->class === 'homelink'\n                || $channel->plaintext === 'Twitter'\n                || $channel->plaintext === 'Weather'\n                || $channel->plaintext === '+Custom'\n            ) {\n                continue;\n            }\n\n            $title  = trim($channel->plaintext);\n            $uri    = '/' . $channel->href;\n\n            $message .= \"\\t\\t'{$title}' => array(\\n\";\n\n            $channel_html = getSimpleHTMLDOMCached(static::URI . $uri);\n\n            $boxes = $channel_html->find('#boxx .boxes')\n                or throwServerException('Could not find boxes!');\n\n            foreach ($boxes as $box) {\n                $anchor = $box->find('span.boxtitles a', 0)\n                    or throwServerException('Could not find box anchor!');\n\n                $boxtitle   = trim($anchor->plaintext);\n                $boxuri     = $anchor->href;\n\n                $message .= \"\\t\\t\\t'{$boxtitle}' => '{$boxuri}', \\n\";\n            }\n\n            $message .= \"\\t\\t),\\n\";\n        }\n\n        // end of 'box' list\n        $message .= <<<EOD\n\t)\n),\nEOD;\n\n        echo <<<EOD\n<!DOCTYPE html>\n\n<html>\n\t<body>\n\t\t<code style=\"white-space: pre-wrap;\">{$message}</code>\n\t</body>\n</html>\nEOD;\n    }\n\n    /**\n     * Checks if the reported skimfeed version is compatible\n     */\n    private function isCompatible($html)\n    {\n        $title = $html->find('title', 0);\n\n        if (!$title) {\n            return false;\n        }\n\n        if ($title->plaintext === 'Skimfeed V5.5 - Tech News') {\n            return true;\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "bridges/SkyArteBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass SkyArteBridge extends BridgeAbstract\n{\n    const NAME          = 'Sky Arte | Mostre ed eventi';\n    const URI           = 'https://arte.sky.it';\n    const MAINTAINER    = 'tillcash';\n    const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours\n    const MAX_ARTICLES  = 5;\n\n    public function collectData()\n    {\n        $urls = get_sitemap('https://arte.sky.it/sitemap-mostre-eventi.xml');\n\n        $count = 0;\n        foreach ($urls as $url) {\n            $loc = $url['loc'];\n\n            if (!$loc) {\n                continue;\n            }\n\n            $json = $this->getJson($loc);\n            if (!$json) {\n                continue;\n            }\n\n            $event = $this->parseEventData($json);\n\n            $this->items[] = [\n                'title'      => $event['title'],\n                'uri'        => $loc,\n                'uid'        => $loc,\n                'timestamp'  => $url['lastmod'],\n                'content'    => $event['content'],\n                'categories' => $event['categories'],\n                'enclosures' => $event['enclosures'],\n            ];\n\n            if (++$count >= self::MAX_ARTICLES) {\n                break;\n            }\n        }\n    }\n\n    private function getJson(string $url): ?array\n    {\n        $html = getSimpleHTMLDOMCached($url, 259200); // 3 days cache\n        if (!$html) {\n            return null;\n        }\n\n        $script = $html->find('script#__NEXT_DATA__', 0);\n        if (!$script) {\n            return null;\n        }\n\n        $decoded = json_decode($script->innertext, true);\n\n        return is_array($decoded) ? $decoded : null;\n    }\n\n    private function parseEventData(array $json): array\n    {\n        $props = $json['props']['pageProps']['data'] ?? [];\n        $card  = $props['card'] ?? [];\n        $info  = $props['info'] ?? [];\n\n        $event = [\n            'title' => $card['title']['typography']['text'] ?? '(untitled)',\n            'content' => '',\n            'categories' => [],\n            'enclosures' => [],\n        ];\n\n        // Artist & Curators\n        $artist = $info['artist']['text'] ?? '';\n\n        $curators = [];\n        if (!empty($info['curators']) && is_array($info['curators'])) {\n            foreach ($info['curators'] as $c) {\n                $curators[] = $c['text'] ?? '';\n            }\n        }\n\n        // Location, Dates, Categories\n        $location = '';\n        $dates = '';\n\n        if (!empty($card['informations']) && is_array($card['informations'])) {\n            foreach ($card['informations'] as $block) {\n                $icon = $block['iconRight']['Icon'] ?? '';\n                if ($icon === 'SvgLocation') {\n                    $location = $block['textRight']['text'] ?? '';\n                }\n\n                if ($icon === 'SvgEventEmpty') {\n                    $dates = $block['textRight']['text'] ?? '';\n                }\n\n                if (!empty($block['badge']['label']['text'])) {\n                    $event['categories'][] = $block['badge']['label']['text'];\n                }\n            }\n        }\n\n        // Enclosure (image)\n        if (!empty($card['image']['src'])) {\n            $event['enclosures'][] = $card['image']['src'];\n        }\n\n        // HTML content building\n        $content = '';\n\n        if ($artist) {\n            $content .= '<p><strong>Artista:</strong> ' . htmlspecialchars($artist) . '</p>';\n        }\n\n        if ($curators) {\n            $content .= '<p><strong>Curatori:</strong> ' . htmlspecialchars(implode(', ', $curators)) . '</p>';\n        }\n\n        if ($location) {\n            $content .= '<p><strong>Luogo:</strong> ' . htmlspecialchars($location) . '</p>';\n        }\n\n        if ($dates) {\n            $content .= '<p><strong>Periodo:</strong> ' . htmlspecialchars($dates) . '</p>';\n        }\n\n        $description = $props['description'] ?? '';\n        if ($description) {\n            $description = preg_replace('~<h2>(.*?)</h2>~i', '<strong>$1</strong>', $description);\n            $description = nl2br($description);\n            $content .= '<br><hr><br><p>' . $description . '</p>';\n        }\n\n        $event['content'] = $content;\n\n        return $event;\n    }\n}\n"
  },
  {
    "path": "bridges/SleeperFantasyFootballBridge.php",
    "content": "<?php\n\nclass SleeperFantasyFootballBridge extends BridgeAbstract\n{\n    const NAME = 'Sleeper.com Alerts';\n    const URI = 'https://sleeper.com/topics/170000000000000000';\n    const DESCRIPTION = 'Fantasy Football Alerts from Sleeper.com';\n    const MAINTAINER = 'piyushpaliwal';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached(self::URI, self::CACHE_TIMEOUT);\n        foreach ($html->find('div.content > div.latest-topics > a') as $index => $a) {\n            $content = $a->find('div.title > p', 0)->innertext;\n            $meta = $this->processString($a->find('div.desc > div.username', 0)->innertext);\n            $item['title'] = $content;\n            $item['content'] = $content;\n            $item['categories'] = $a->find('div.title div.tag', 0)->innertext;\n            $item['timestamp'] = $meta['timestamp'];\n            $item['author'] = $meta['author'];\n            $item['enclosures'] = $a->find('div.player-photo amp-img', 0)->src;\n            $this->items[] = $item;\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    protected function processString($inputString)\n    {\n        $decodedString = str_replace(['&nbsp;', '&#8226;'], [' ', '|'], $inputString);\n        $splitArray = explode(' | ', $decodedString);\n        $author = trim($splitArray[0]);\n        $timeString = trim($splitArray[1]);\n        $timestamp = strtotime($timeString);\n        return [\n            'author' => $author,\n            'timestamp' => $timestamp\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/SlusheBridge.php",
    "content": "<?php\n\nclass SlusheBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'quickwick';\n    const NAME = 'Slushe';\n    const URI = 'https://slushe.com';\n    const DESCRIPTION = 'Returns latest posts from Slushe';\n\n    const PARAMETERS = [\n        'Artist' => [\n            'artist_name' => [\n                'name' => 'Artist name',\n                'required' => true,\n                'exampleValue' => 'lexx228',\n                'title' => 'Enter an artist name'\n            ]\n        ],\n        'Category' => [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'defaultValue' => 'Safe for Work',\n                'title' => 'Choose a category',\n                'values' => [\n                    '2D' => '29',\n                    '3DX' => '58',\n                    'Animation' => '60',\n                    'Anime Fan Art' => '46',\n                    'BDSM' => '47',\n                    'Big Butt' => '73',\n                    'Big Dick' => '52',\n                    'Bit Tits' => '49',\n                    'Bisexual' => '69',\n                    'Comic' => '51',\n                    'Couple' => '3',\n                    'Dickgirl/Futanari' => '56',\n                    'Feet' => '75',\n                    'Game Fan Art' => '63',\n                    'Gay' => '36',\n                    'GIF' => '42',\n                    'Group Sex/ Orgy' => '62',\n                    'Lesbian' => '67',\n                    'Mature' => '72',\n                    'Misc. Fan Art' => '68',\n                    'Monster' => '64',\n                    'Pin-Up' => '28',\n                    'Safe for Work' => '71',\n                    'SFM' => '70',\n                    'Solo' => '66',\n                    'Threesome' => '38',\n                    'TV & Film Fan Art' => '34',\n                    'Western Fan Art' => '33'\n                ]\n            ]\n        ],\n        'Search' => [\n            'search_term' => [\n                'name' => 'Search term(s)',\n                'required' => true,\n                'exampleValue' => 'pole dance',\n                'title' => 'Enter one or more search terms, separated by spaces'\n            ]\n        ]\n    ];\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Artist':\n                return 'Slushe Artist: ' . $this->getInput('artist_name');\n                break;\n            case 'Category':\n                return 'Slushe Category: ' . $this->getInput('category');\n                break;\n            case 'Search':\n                return 'Slushe Search: ' . $this->getInput('search_term');\n                break;\n            default:\n                return self::NAME;\n        }\n    }\n\n    public function collectData()\n    {\n        switch ($this->queriedContext) {\n            case 'Artist':\n                $uri = self::URI . '/' . $this->getInput('artist_name');\n                break;\n            case 'Category':\n                $uri = self::URI . '/search/posts/channels?niche=' .\n                    $this->getInput('category');\n                break;\n            case 'Search':\n                $uri = self::URI . '/search/posts/' . $this->getInput('search_term') .\n                    '?s=1';\n                break;\n        }\n\n        $headers = [\n            'Authority : slushe.com',\n            'Cookie: age-verify=1;',\n            'sec-ch-ua: \"Chromium\";v=\"100\", \" Not A;Brand\";v=\"99\"',\n            'sec-ch-ua-mobile: ?0',\n            'sec-ch-ua-platform: \"Windows\"',\n            'sec-fetch-dest: document',\n            'sec-fetch-mode: navigate',\n            'sec-fetch-site: same-origin',\n            'sec-fetch-user: ?1',\n            'upgrade-insecure-requests: 1'\n        ];\n        // Add user-agent string to headers with implode, due to line length limit\n        $user_agent_string = [\n            'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/',\n            '537.36(KHTML, like Gecko) Chrome/100.0.4896.147 Safari/537.36'\n        ];\n        $headers[] = implode('', $user_agent_string);\n\n        $html = getSimpleHTMLDOM($uri, $headers);\n\n        //Loop on each entry\n        foreach ($html->find('div.blog-item') as $element) {\n            $title = $element->find('h3.title', 0)->first_child()->innertext;\n            $article_uri = $element->find('h3.title', 0)->first_child()->href;\n            $timestamp = $element->find('div.publication-date', 0)->innertext;\n            $author = $element->find('div.artist', 0)->\n                first_child()->first_child()->innertext;\n\n            // Create & populate item\n            $item = [];\n            $item['uri'] = $article_uri;\n            $item['id'] = $item['uri'];\n            $item['timestamp'] = $timestamp;\n            $item['title'] = $title;\n            $item['author'] = $author;\n\n            $media_html = '';\n\n            // Look for image thumbnails\n            $media_uris = $element->find('div.thumb', 0);\n            if (isset($media_uris)) {\n                // Add gallery image count, if it exists\n                $gallery_count = $media_uris->find('span.count', 0);\n                if (isset($gallery_count)) {\n                    $media_html .= '<p>Gallery count: ' .\n                        $gallery_count->first_child()->innertext . '</p>';\n                }\n                // Add image thumbnail(s)\n                foreach ($media_uris->find('img') as $media_uri) {\n                    $media_html .= '<a href=\"' . $article_uri . '\">' . $media_uri . '</a>';\n                    $item['enclosures'][] = str_replace(' ', '%20', $media_uri->src);\n                }\n            }\n\n            // Look for video thumbnails\n            $media_uris = $element->find('div.thumb-holder', 0);\n            // Add video thumbnail(s)\n            if (isset($media_uris)) {\n                foreach ($media_uris->find('img') as $media_uri) {\n                    $media_html .= '<p>Video:</p><a href=\"' .\n                        $article_uri . '\">' . $media_uri . '</a>';\n\n                    $item['enclosures'][] = $media_uri->src;\n                }\n            }\n            $item['content'] = $media_html;\n\n            if (isset($item['title'])) {\n                $this->items[] = $item;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SongkickBridge.php",
    "content": "<?php\n\nclass SongkickBridge extends BridgeAbstract\n{\n    const NAME = 'Songkick';\n    const URI = 'https://songkick.com/';\n    const DESCRIPTION = 'Fetches the concerts of an artist';\n    const MAINTAINER = 'joaomqc';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [ [\n        'artistid' => [\n            'name' => 'Artist ID',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => '2506696-imagine-dragons',\n        ]\n    ] ];\n\n    const ARTIST_URI = 'https://www.songkick.com/artists/%s/';\n    const CALENDAR_URI = self::ARTIST_URI . 'calendar';\n\n    private $name = '';\n\n    public function getURI()\n    {\n        return sprintf(self::ARTIST_URI, $this->getInput('artistid'));\n    }\n\n    public function getName()\n    {\n        if (!empty($this->name)) {\n            return $this->name . ' - ' . parent::getName();\n        }\n        return parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return 'https://assets.sk-static.com/images/nw/furniture/songkick-logo.svg';\n    }\n\n    public function collectData()\n    {\n        $url = sprintf(self::CALENDAR_URI, $this->getInput('artistid'));\n\n        $dom = getSimpleHTMLDOM($url);\n\n        $jsonscript = $dom->find('div.microformat > script', 0);\n\n        if (empty($this->name) && $jsonscript) {\n            $this->name = json_decode($jsonscript->innertext)[0]->name;\n        }\n\n        $dom = $dom->find('div.container > div.row > div.primary', 0);\n\n        if (!$dom) {\n            throw new Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n\n        foreach ($dom->find('div[@id=\"calendar-summary\"] > ol > li') as $article) {\n            $detailsobj = json_decode($article->find('div.microformat > script', 0)->innertext)[0];\n\n            $a = $article->find('a', 0);\n\n            $details = $a->find('div.event-details', 0);\n            $title = $details->find('.secondary-detail', 0)->plaintext;\n            $city = $details->find('.primary-detail', 0)->plaintext;\n            $event = $detailsobj->location->name;\n\n            $content = 'City: ' . $city . '<br>Event: ' . $event . '<br>Date: ' . $article->title;\n\n            $categories = [];\n            if ($details->hasClass('concert')) {\n                $categories[] = 'concert';\n            }\n            if ($details->hasClass('festival')) {\n                $categories[] = 'festival';\n            }\n            if (!is_null($details->find('.outdoor', 0))) {\n                $categories[] = 'outdoor';\n            }\n\n            $this->items[] = [\n                'title' => $title,\n                'uri' => $a->href,\n                'content' => $content,\n                'categories' => $categories,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SoundcloudBridge.php",
    "content": "<?php\n\nclass SoundCloudBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'kranack, Roliga';\n    const NAME = 'Soundcloud';\n    const URI = 'https://soundcloud.com/';\n    const CACHE_TIMEOUT = 600; // 10min\n    const DESCRIPTION = 'Returns 10 newest music from user profile';\n\n    const PARAMETERS = [[\n        'u' => [\n            'name' => 'username',\n            'exampleValue' => 'thekidlaroi',\n            'required' => true\n        ],\n        't' => [\n            'name' => 'Content',\n            'type' => 'list',\n            'defaultValue' => 'tracks',\n            'values' => [\n                'All (except likes)' => 'all',\n                'Tracks' => 'tracks',\n                'Albums' => 'albums',\n                'Playlists' => 'playlists',\n                'Reposts' => 'reposts',\n                'Likes' => 'likes'\n            ]\n        ]\n    ]];\n\n    private $apiUrl = 'https://api-v2.soundcloud.com/';\n    // Without url=http, player URL returns a 404\n    private $playerUrl = 'https://w.soundcloud.com/player/?url=http';\n    private $widgetUrl = 'https://widget.sndcdn.com/';\n\n    private $feedTitle = null;\n    private $feedIcon = null;\n\n    private $clientIdRegex = '/client_id.*?\"(.+?)\"/';\n    private $widgetRegex = '/widget-.+?\\.js/';\n\n    public function collectData()\n    {\n        $res = $this->getUser($this->getInput('u'));\n\n        $this->feedTitle = $res->username;\n        $this->feedIcon = $res->avatar_url;\n\n        $apiItems = $this->getUserItems($res->id, $this->getInput('t'))\n            or throwServerException('No results for ' . $this->getInput('t'));\n\n        $hasTrackObject = ['all', 'reposts', 'likes'];\n\n        foreach ($apiItems->collection as $index => $apiItem) {\n            if (in_array($this->getInput('t'), $hasTrackObject) === true) {\n                $apiItem = $apiItem->playlist ?? $apiItem->track;\n            }\n\n            $item = [];\n            $item['author'] = $apiItem->user->username;\n            $item['title'] = $apiItem->user->username . ' - ' . $apiItem->title;\n            $item['timestamp'] = strtotime($apiItem->created_at);\n            $description = nl2br($apiItem->description ?? '');\n\n            $item['content'] = <<<HTML\n\t\t\t\t<p>{$description}</p>\nHTML;\n\n            if (isset($apiItem->tracks) && $apiItem->track_count > 0) {\n                $list = $this->getTrackList($apiItem->tracks);\n\n                $item['content'] .= <<<HTML\n\t\t\t\t\t<p><strong>Tracks ({$apiItem->track_count})</strong></p>\n\t\t\t\t\t{$list}\nHTML;\n            }\n\n            $item['enclosures'][] = $apiItem->artwork_url;\n            $item['id'] = $apiItem->permalink_url;\n            $item['uri'] = $apiItem->permalink_url;\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    public function getIcon()\n    {\n        if ($this->feedIcon) {\n            return $this->feedIcon;\n        }\n\n        return parent::getIcon();\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('u')) {\n            return self::URI . $this->getInput('u') . '/' . $this->getInput('t');\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if ($this->feedTitle) {\n            return $this->feedTitle . ' - ' . ucfirst($this->getInput('t')) . ' - ' . self::NAME;\n        }\n\n        return parent::getName();\n    }\n\n    private function getClientID()\n    {\n        $clientID = $this->cache->get('SoundCloudBridge_client_id');\n\n        if (!$clientID) {\n            return $this->refreshClientID();\n        } else {\n            return $clientID;\n        }\n    }\n\n    private function refreshClientID()\n    {\n        $playerHTML = getContents($this->playerUrl);\n\n        // Extract widget JS filenames from player page\n        if (preg_match_all($this->widgetRegex, $playerHTML, $matches) == false) {\n            throwServerException('Unable to find widget JS URL.');\n        }\n\n        $clientID = '';\n\n        // Loop widget js files and extract client ID\n        foreach ($matches[0] as $widgetFile) {\n            $widgetURL = $this->widgetUrl . $widgetFile;\n\n            $widgetJS = getContents($widgetURL);\n\n            if (preg_match($this->clientIdRegex, $widgetJS, $matches)) {\n                $clientID = $matches[1];\n                $this->cache->set('SoundCloudBridge_client_id', $clientID);\n                return $clientID;\n            }\n        }\n\n        if (empty($clientID)) {\n            throwServerException('Unable to find client ID.');\n        }\n    }\n\n    private function buildApiUrl($endpoint, $parameters)\n    {\n        return $this->apiUrl\n            . $endpoint\n            . '?'\n            . http_build_query($parameters);\n    }\n\n    private function getUser($username)\n    {\n        $parameters = ['url' => self::URI . $username];\n\n        return $this->getApi('resolve', $parameters);\n    }\n\n    private function getUserItems($userId, $type)\n    {\n        $parameters = ['limit' => 10];\n        $endpoint = 'users/' . $userId . '/' . $type;\n\n        if ($type === 'playlists') {\n            $endpoint = 'users/' . $userId . '/playlists_without_albums';\n        }\n\n        if ($type === 'all') {\n            $endpoint = 'stream/users/' . $userId;\n        }\n\n        if ($type === 'reposts') {\n            $endpoint = 'stream/users/' . $userId . '/' . $type;\n        }\n\n        return $this->getApi($endpoint, $parameters);\n    }\n\n    private function getApi($endpoint, $parameters)\n    {\n        $parameters['client_id'] = $this->getClientID();\n        $url = $this->buildApiUrl($endpoint, $parameters);\n\n        try {\n            return json_decode(getContents($url));\n        } catch (Exception $e) {\n            // Retry once with refreshed client ID\n            $parameters['client_id'] = $this->refreshClientID();\n            $url = $this->buildApiUrl($endpoint, $parameters);\n\n            return json_decode(getContents($url));\n        }\n    }\n\n    private function getTrackList($tracks)\n    {\n        $trackids = '';\n\n        foreach ($tracks as $track) {\n            $trackids .= $track->id . ',';\n        }\n\n        $apiItems = $this->getApi(\n            'tracks',\n            ['ids' => $trackids]\n        );\n\n        $list = '';\n        foreach ($apiItems as $track) {\n            $list .= <<<HTML\n\t\t\t\t<li>{$track->user->username} — <a href=\"{$track->permalink_url}\">{$track->title}</a></li>\nHTML;\n        }\n\n        $html = <<<HTML\n\t\t\t<ul>{$list}</ul>\nHTML;\n\n        return $html;\n    }\n}\n"
  },
  {
    "path": "bridges/SplCenterBridge.php",
    "content": "<?php\n\nclass SplCenterBridge extends FeedExpander\n{\n    const NAME = 'Southern Poverty Law Center';\n    const URI = 'https://www.splcenter.org';\n    const DESCRIPTION = 'Returns the newest posts from the Southern Poverty Law Center';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n            'content' => [\n                'name' => 'Content',\n                'type' => 'list',\n                'values' => [\n                    'News' => 'news',\n                    'Hatewatch' => 'hatewatch',\n                ],\n                'defaultValue' => 'news',\n            ]\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    public function collectData()\n    {\n        $url = $this->getURI() . '/rss.xml';\n        $this->collectExpandableDatas($url);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articleHtml = getSimpleHTMLDOMCached($item['uri']);\n\n        foreach ($articleHtml->find('.file') as $index => $media) {\n            $articleHtml->find('div.file', $index)->outertext = '<em>' . $media->outertext . '</em>';\n        }\n\n        $item['content'] = $articleHtml->find('div#group-content-container', 0)->innertext;\n        $item['enclosures'][] = $articleHtml->find('meta[name=\"twitter:image\"]', 0)->content;\n\n        return $item;\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('content'))) {\n            return self::URI . '/' . $this->getInput('content');\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('content'))) {\n            return $this->getKey('content') . ' - Southern Poverty Law Center';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/SpotifyBridge.php",
    "content": "<?php\n\nclass SpotifyBridge extends BridgeAbstract\n{\n    const NAME = 'Spotify';\n    const URI = 'https://spotify.com/';\n    const DESCRIPTION = 'Fetches the latest items from one or more artists, playlists or podcasts';\n    const MAINTAINER = 'Paroleen';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        'By Spotify URIs' => [\n            'clientid' => [\n                'name' => 'Client ID',\n                'type' => 'text',\n                'required' => true\n            ],\n            'clientsecret' => [\n                'name' => 'Client secret',\n                'type' => 'text',\n                'required' => true\n            ],\n            'country' => [\n                'name' => 'Country/Market',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => 'US',\n                'defaultValue' => 'US'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'exampleValue' => 10,\n                'defaultValue' => 10\n            ],\n            'spotifyuri' => [\n                'name' => 'Spotify URIs',\n                'type' => 'text',\n                'required' => true,\n\n                // spotify:playlist:37i9dQZF1DXcBWIGoYBM5M\n                // spotify:show:6ShFMYxeDNMo15COLObDvC\n                'exampleValue' => 'spotify:artist:4lianjyuR1tqf6oUX8kjrZ',\n            ],\n            'albumtype' => [\n                'name' => 'Album type',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => 'album,single,appears_on,compilation',\n                'defaultValue' => 'album,single'\n            ]\n        ],\n        'By Spotify Search' => [\n            'clientid' => [\n                'name' => 'Client ID',\n                'type' => 'text',\n                'required' => true\n            ],\n            'clientsecret' => [\n                'name' => 'Client secret',\n                'type' => 'text',\n                'required' => true\n            ],\n            'market' => [\n                'name' => 'Market',\n                'type' => 'text',\n                'required' => false,\n                'exampleValue' => 'US',\n                'defaultValue' => 'US'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'exampleValue' => 10,\n                'defaultValue' => 10\n            ],\n            'query' => [\n                'name' => 'Search query',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'artist:The Beatles',\n            ],\n            'type' => [\n                'name' => 'Type',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'album,episode',\n                'defaultValue' => 'album,episode'\n            ]\n        ],\n    ];\n\n    private $uri = '';\n    private $name = '';\n    private $token = '';\n\n    public function collectData()\n    {\n        /**\n         * https://developer.spotify.com/documentation/web-api/concepts/rate-limits\n         */\n        $cacheKey = 'spotify_rate_limit';\n\n        try {\n            $this->collectDataInternal();\n        } catch (HttpException $e) {\n            if ($e->getCode() === 429) {\n                $retryAfter = $e->response->getHeader('Retry-After') ?? (60 * 5);\n                $this->cache->set($cacheKey, true, $retryAfter);\n                throwRateLimitException(sprintf('Rate limited by spotify, try again in %s seconds', $retryAfter));\n            }\n            throw $e;\n        }\n    }\n\n    private function collectDataInternal()\n    {\n        $this->fetchAccessToken();\n\n        if ($this->queriedContext === 'By Spotify URIs') {\n            $entries = $this->getEntriesFromURIs();\n        } else {\n            $entries = $this->getEntriesFromQuery();\n        }\n\n        usort($entries, function ($entry1, $entry2) {\n            return $this->getDate($entry2) <=> $this->getDate($entry1);\n        });\n\n        foreach ($entries as $entry) {\n            if (! isset($entry['type'])) {\n                $item = $this->getTrackData($entry);\n            } elseif ($entry['type'] === 'album') {\n                $item = $this->getAlbumData($entry);\n            } elseif ($entry['type'] === 'episode') {\n                $item = $this->getEpisodeData($entry);\n            } else {\n                throw new \\Exception('Spotify URI not supported');\n            }\n\n            $this->items[] = $item;\n\n            if ($this->getInput('limit') > 0 && count($this->items) >= $this->getInput('limit')) {\n                break;\n            }\n        }\n    }\n\n    private function fetchAccessToken()\n    {\n        $cacheKey = sprintf('SpotifyBridge:%s:%s', $this->getInput('clientid'), $this->getInput('clientsecret'));\n\n        $token = $this->cache->get($cacheKey);\n        if ($token) {\n            $this->token = $token;\n        } else {\n            $basicAuth = base64_encode(sprintf('%s:%s', $this->getInput('clientid'), $this->getInput('clientsecret')));\n            $json = getContents('https://accounts.spotify.com/api/token', [\n                \"Authorization: Basic $basicAuth\",\n            ], [\n                CURLOPT_POSTFIELDS => 'grant_type=client_credentials',\n            ]);\n            $data = Json::decode($json);\n            $this->token = $data['access_token'];\n\n            $this->cache->set($cacheKey, $this->token, 3600);\n        }\n    }\n\n    private function getEntriesFromQuery()\n    {\n        $entries = [];\n\n        $types = [\n            'albums',\n            'episodes',\n        ];\n\n        $query = [\n            'q' => $this->getInput('query'),\n            'type' => $this->getInput('type'),\n            'market' => $this->getInput('market'),\n            'limit' => 50,\n        ];\n\n        $hasItems = true;\n        $offset = 0;\n\n        while ($hasItems && $offset < 1000) {\n            $hasItems = false;\n\n            $query['offset'] = $offset;\n            $json = getContents('https://api.spotify.com/v1/search?' . http_build_query($query), ['Authorization: Bearer ' . $this->token]);\n            $partial = Json::decode($json);\n\n            foreach ($types as $type) {\n                if (isset($partial[$type]['items'])) {\n                    $entries = array_merge($entries, $partial[$type]['items']);\n                    $hasItems = true;\n                }\n            }\n\n            $offset += 50;\n        }\n\n        return $entries;\n    }\n\n    private function getEntriesFromURIs()\n    {\n        $entries = [];\n        $uris = explode(',', $this->getInput('spotifyuri'));\n\n        foreach ($uris as $uri) {\n            $type = explode(':', $uri)[1];\n            $spotifyId = explode(':', $uri)[2];\n\n            $types = [\n                'artist' => 'album',\n                'playlist' => 'track',\n                'show' => 'episode',\n            ];\n            if (!isset($types[$type])) {\n                throw new \\Exception(sprintf('Unsupported Spotify URI: %s', $uri));\n            }\n            $entry_type = $types[$type];\n\n            $url = 'https://api.spotify.com/v1/' . $type . 's/' . $spotifyId . '/' . $entry_type . 's';\n            $query = [\n                'limit' => 50,\n            ];\n\n            if ($type === 'artist') {\n                $query['country'] = $this->getInput('country');\n                $query['include_groups'] = $this->getInput('albumtype');\n            } else {\n                $query['market'] = $this->getInput('country');\n            }\n\n            $offset = 0;\n            while (true) {\n                $query['offset'] = $offset;\n                $json = getContents($url . '?' . http_build_query($query), ['Authorization: Bearer ' . $this->token]);\n                $partial = Json::decode($json);\n                if (empty($partial['items'])) {\n                    break;\n                }\n                $entries = array_merge($entries, $partial['items']);\n                $offset += 50;\n            }\n        }\n        return $entries;\n    }\n\n    private function getAlbumData($album)\n    {\n        $item = [];\n        $item['title'] = $album['name'];\n        $item['uri'] = $album['external_urls']['spotify'];\n        $item['timestamp'] = $this->getDate($album);\n        $item['author'] = $album['artists'][0]['name'];\n        $item['categories'] = [$album['album_type']];\n        $item['content'] = '<img style=\"width: 256px\" src=\"' . $album['images'][0]['url'] . '\">';\n        if ($album['total_tracks'] > 1) {\n            $item['content'] .= '<p>Total tracks: ' . $album['total_tracks'] . '</p>';\n        }\n        return $item;\n    }\n\n    private function getTrackData($track)\n    {\n        $item = [];\n        $item['title'] = $track['track']['name'];\n        $item['uri'] = $track['track']['external_urls']['spotify'];\n        $item['timestamp'] = $this->getDate($track);\n        $item['author'] = $track['track']['artists'][0]['name'];\n        $item['categories'] = ['track'];\n        $item['content'] = '<img style=\"width: 256px\" src=\"' . $track['track']['album']['images'][0]['url'] . '\">';\n        return $item;\n    }\n\n    private function getEpisodeData($episode)\n    {\n        $item = [];\n        $item['title'] = $episode['name'];\n        $item['uri'] = $episode['external_urls']['spotify'];\n        $item['timestamp'] = $this->getDate($episode);\n        $item['content'] = '<img style=\"width: 256px\" src=\"' . $episode['images'][0]['url'] . '\">';\n        if (isset($episode['description'])) {\n            $item['content'] = $item['content'] . '<p>' . $episode['description'] . '</p>';\n        }\n        if (isset($episode['audio_preview_url'])) {\n            $item['content'] = $item['content'] . '<audio controls src=\"' . $episode['audio_preview_url'] . '\"></audio>';\n        }\n        return $item;\n    }\n\n    private function getDate($entry)\n    {\n        if (isset($entry['type'])) {\n            $type = 'release_date';\n        } else {\n            $type = 'added_at';\n        }\n\n        $date = $entry[$type];\n\n        if (strlen($date) == 4) {\n            $date .= '-01-01';\n        } elseif (strlen($date) == 7) {\n            $date .= '-01';\n        }\n\n        if (strlen($date) > 10) {\n            return DateTime::createFromFormat('Y-m-d\\TH:i:s\\Z', $date)->getTimestamp();\n        }\n\n        return DateTime::createFromFormat('Y-m-d', $date)->getTimestamp();\n    }\n\n    public function getURI()\n    {\n        if (empty($this->uri)) {\n            $this->getFirstEntry();\n        }\n\n        return $this->uri;\n    }\n\n    public function getName()\n    {\n        if (empty($this->name)) {\n            $this->getFirstEntry();\n        }\n\n        return $this->name;\n    }\n\n    private function getFirstEntry()\n    {\n        $spotifyUri = $this->getInput('spotifyuri');\n\n        if (!is_null($spotifyUri) && strpos($spotifyUri, ',') === false) {\n            $uris = explode(',', $spotifyUri);\n            $firstUri = $uris[0];\n            $type = explode(':', $firstUri)[1];\n            $spotifyId = explode(':', $firstUri)[2];\n\n            $uri = 'https://api.spotify.com/v1/' . $type . 's/' . $spotifyId;\n            $query = [];\n            if ($type === 'show') {\n                $query['market'] = $this->getInput('country');\n            }\n\n            $json = getContents($uri . '?' . http_build_query($query), ['Authorization: Bearer ' . $this->token]);\n            $item = Json::decode($json);\n\n            $this->uri = $item['external_urls']['spotify'];\n            $this->name = $item['name'] . ' - Spotify';\n        } else {\n            $this->uri = parent::getURI();\n            $this->name = parent::getName();\n        }\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.scdn.co/i/_global/favicon.png';\n    }\n}"
  },
  {
    "path": "bridges/SpottschauBridge.php",
    "content": "<?php\n\nclass SpottschauBridge extends BridgeAbstract\n{\n    const NAME = 'Härringers Spottschau';\n    const URI = 'https://spottschau.com/';\n    const DESCRIPTION = 'Der Fußball-Comic';\n    const MAINTAINER = 'sal0max';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $item = [];\n        $item['uri'] = urljoin(self::URI, $html->find('div.strip>a', 0)->attr['href']);\n        $item['title'] = $html->find('div.text>h2', 0)->innertext;\n\n        $date = preg_replace('/.*, /', '', $item['title']);\n        $date = preg_replace('/\\\\d\\\\d\\\\.\\\\//', '', $date);\n        try {\n            $item['timestamp'] = DateTime::createFromFormat('d.m.y', $date)\n                ->setTimezone(new DateTimeZone('Europe/Berlin'))\n                ->setTime(0, 0)\n                ->getTimestamp();\n        } catch (Throwable $ignored) {\n            $item['timestamp'] = null;\n        }\n\n        $image = $html->find('div.strip>a>img', 0);\n        $imageUrl = urljoin(self::URI, $image->attr['src']);\n        $imageAlt = $image->attr['alt'];\n\n        $item['content'] = <<<EOD\n<img src=\"{$imageUrl}\" alt=\"{$imageAlt}\"/>\n<br/>\nEOD;\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/StanfordSIRbookreviewBridge.php",
    "content": "<?php\n\nclass StanfordSIRbookreviewBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Kidman1670';\n    const NAME = 'StanfordSIRbookreviewBridge';\n    const URI = 'https://ssir.org/books/';\n    const CACHE_TIMEOUT = 21600;\n    const DESCRIPTION = 'Return results from SSIR book review.';\n    const PARAMETERS = [ [\n             'style' => [\n                'name' => 'style',\n                'type' => 'list',\n                'values' => [\n                    'reviews' => 'reviews',\n                    'excerpts' => 'excerpts',\n                ]\n             ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        switch ($this->getInput('style')) {\n            case 'reviews':\n                $url = self::URI . 'reviews';\n                break;\n            case 'excerpts':\n                $url = self::URI . 'excerpts';\n                break;\n        }\n\n        $html = getSimpleHTMLDOM($url);\n        foreach ($html->find('article') as $element) {\n            $item = [];\n            $item['title'] = $element->find('div > h4 > a', 0)->plaintext;\n            $item['uri'] = $element->find('div > h4 > a', 0)->href;\n            $item['content'] = $element->find('div > div.article-entry > p', 2)->plaintext;\n            $item['author'] = $element->find('div > div > p', 0)->plaintext;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SteamAppNewsBridge.php",
    "content": "<?php\n\nclass SteamAppNewsBridge extends BridgeAbstract\n{\n    const NAME = 'Steam App News';\n    const URI = 'https://www.steamcommunity.com';\n    const DESCRIPTION = 'Get the latest news for a game on Steam.';\n    const MAINTAINER = 'otakuf';\n    const CACHE_TIMEOUT = 3600; // 1h\n\n    const PARAMETERS = [ [\n        'appid' => [\n            'name' => 'App ID',\n            'title' => 'App ID (only digits). Find your App ID with steamdb.info',\n            'type' => 'number',\n            'exampleValue' => '730',\n            'required' => true\n        ],\n        'maxlength' => [\n            'name' => 'Max Length',\n            'title' => 'Maximum length for the content to return, 0 for full content',\n            'type' => 'number',\n            'defaultValue' => 0\n        ],\n        'count' => [\n            'name' => 'Count',\n            'title' => '# of posts to retrieve (default 20)',\n            'type' => 'number',\n            'defaultValue' => 20\n        ],\n        'tags' => [\n            'name' => 'Tag Filter',\n            'title' => 'Comma-separated list of tags to filter by',\n            'type' => 'text',\n            'exampleValue' => 'patchnotes'\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $apiTarget = 'https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/';\n        // Example with params: https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/?appid=730&maxlength=0&count=20\n        // More info at dev docs https://partner.steamgames.com/doc/webapi/ISteamNews\n        $url = $apiTarget\n            . '?appid=' . $this->getInput('appid')\n            . '&maxlength=' . $this->getInput('maxlength')\n            . '&count=' . $this->getInput('count')\n            . '&tags=' . $this->getInput('tags');\n\n        // Get the JSON content\n        $json = getContents($url);\n        $json_list = json_decode($json, true);\n\n        foreach ($json_list['appnews']['newsitems'] as $json_item) {\n            $this->items[] = $this->collectArticle($json_item);\n        }\n    }\n\n    private function collectArticle($json_item)\n    {\n        $item = [];\n        $item['uri'] = preg_replace('[ ]', '%20', $json_item['url']);\n        $item['title'] = $json_item['title'];\n        $item['timestamp'] = $json_item['date'];\n        $item['author'] = $json_item['author'];\n\n        # Fix /n\n        if (str_contains($item['uri'], 'steam_community_announcements')) {\n            $item['content'] = $this->replaceBBcodes($json_item['contents']);\n        } else {\n            $item['content'] = $json_item['contents'];\n        }\n        $item['uid'] = $json_item['gid'];\n        return $item;\n    }\n\n    private function replaceBBcodes($text)\n    {\n        //$text = strip_tags($text);\n        $text = nl2br($text);\n        // BBcode array, all list available: https://steamcommunity.com/comment/ForumTopic/formattinghelp\n        $find = [\n            '~\\[h1\\](.*?)\\[/h1\\]~s',\n            '~\\[h2\\](.*?)\\[/h2\\]~s',\n            '~\\[h3\\](.*?)\\[/h3\\]~s',\n            '~\\[list\\](.*?)\\[/list\\]~s',\n            '~\\[olist\\](.*?)\\[/olist\\]~s',\n            '~\\[\\*\\]~s',\n            '~\\[b\\](.*?)\\[/b\\]~s',\n            '~\\[i\\](.*?)\\[/i\\]~s',\n            '~\\[u\\](.*?)\\[/u\\]~s',\n            '~\\[strike\\](.*?)\\[/strike\\]~s',\n            '~\\[spoiler\\](.*?)\\[/spoiler\\]~s',\n            '~\\[noparse\\](.*?)\\[/noparse\\]~s',\n            '~\\[hr\\]~s',\n            '~\\[quote\\](.*?)\\[/quote\\]~s',\n            '~\\[code\\](.*?)\\[/code\\]~s',\n            '~\\{STEAM_CLAN_IMAGE\\}~s',\n            '~\\[url=([^\"><]*?)\\](.*?)\\[/url\\]~s',\n            '~\\[img\\](https?://[^\"><]*?\\.(?:jpg|jpeg|gif|png|bmp))\\[/img\\]~s'\n        ];\n        // HTML tags to replace BBcode\n        $replace = [\n            '<h1>$1</h1>',\n            '<h2>$1</h2>',\n            '<h3>$1</h3>',\n            '<ul>$1</ul>',\n            '<ol>$1</ol>',\n            '<li>',\n            '<b>$1</b>',\n            '<i>$1</i>',\n            '<u>$1</u>',\n            '<s>$1</s>',\n            '$1', // Just remove spoiler\n            '$1', // Just remove noparse\n            '<hr>',\n            '<blockquote>$1</blockquote>',\n            '<code>$1</code>',\n            'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/clans',\n            '<a href=\"$1\">$2</a>',\n            '<img src=\"$1\" alt=\"\" />'\n        ];\n        // Replacing the BBcodes with corresponding HTML tags\n        return preg_replace($find, $replace, $text);\n    }\n}\n"
  },
  {
    "path": "bridges/SteamBridge.php",
    "content": "<?php\n\nclass SteamBridge extends BridgeAbstract\n{\n    const NAME = 'Steam';\n    const URI = 'https://store.steampowered.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Returns apps list';\n    const MAINTAINER = 'jacknumber';\n    const PARAMETERS = [\n        'Wishlist' => [\n            'userid' => [\n                'name' => 'Steamid64 (find it on steamid.io)',\n                'title' => 'User ID (17 digits). Find your user ID with steamid.io or steamidfinder.com',\n                'required' => true,\n                'exampleValue' => '76561198821231205',\n                'pattern' => '[0-9]{17}',\n            ],\n            'only_discount' => [\n                'name' => 'Only discount',\n                'type' => 'checkbox',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $userid = $this->getInput('userid');\n\n        $sourceUrl = self::URI . 'wishlist/profiles/' . $userid . '/wishlistdata?p=0';\n        $sort = [];\n\n        $json = getContents($sourceUrl);\n\n        $appsData = json_decode($json);\n\n        foreach ($appsData as $id => $element) {\n            $appType = $element->type;\n            $appIsBuyable = 0;\n            $appHasDiscount = 0;\n            $appIsFree = 0;\n\n            if ($element->subs) {\n                $appIsBuyable = 1;\n                $priceBlock = str_get_html($element->subs[0]->discount_block);\n                $appPrice = str_replace('--', '00', $priceBlock->find('.discount_final_price', 0)->plaintext);\n\n                if ($element->subs[0]->discount_pct) {\n                    $appHasDiscount = 1;\n                    $discountBlock = str_get_html($element->subs[0]->discount_block);\n                    $appDiscountValue = $discountBlock->find('.discount_pct', 0)->plaintext;\n                    $appOldPrice = $discountBlock->find('.discount_original_price', 0)->plaintext;\n                } else {\n                    if ($this->getInput('only_discount')) {\n                        continue;\n                    }\n                }\n            } else {\n                if ($this->getInput('only_discount')) {\n                    continue;\n                }\n\n                if (isset($element->free) && $element->free = 1) {\n                    $appIsFree = 1;\n                }\n            }\n\n            $coverUrl = str_replace('_292x136', '', strtok($element->capsule, '?'));\n            $picturesPath = pathinfo($coverUrl)['dirname'] . '/';\n\n            $item = [];\n            $item['uri'] = \"http://store.steampowered.com/app/$id/\";\n            $item['title'] = $element->name;\n            $item['type'] = $appType;\n            $item['cover'] = $coverUrl;\n            $item['timestamp'] = $element->added;\n            $item['isBuyable'] = $appIsBuyable;\n            $item['hasDiscount'] = $appHasDiscount;\n            $item['isFree'] = $appIsFree;\n            $item['priority'] = $element->priority;\n\n            if ($appIsBuyable) {\n                $item['price'] = floatval(str_replace(',', '.', $appPrice));\n                $item['content'] = $appPrice;\n            }\n\n            if ($appIsFree) {\n                $item['content'] = 'Free';\n            }\n\n            if ($appHasDiscount) {\n                $item['discount']['value'] = $appDiscountValue;\n                $item['discount']['oldPrice'] = $appOldPrice;\n                $item['content'] = '<s>' . $appOldPrice . '</s> <b>' . $appPrice . '</b> (' . $appDiscountValue . ')';\n            }\n\n            $item['enclosures'] = [];\n            $item['enclosures'][] = $coverUrl;\n\n            foreach ($element->screenshots as $screenshotFileName) {\n                $item['enclosures'][] = $picturesPath . $screenshotFileName;\n            }\n\n            $sort[$id] = $element->priority;\n\n            $this->items[] = $item;\n        }\n\n        array_multisort($sort, SORT_ASC, $this->items);\n    }\n}\n"
  },
  {
    "path": "bridges/SteamCommunityBridge.php",
    "content": "<?php\n\nclass SteamCommunityBridge extends BridgeAbstract\n{\n    const NAME = 'Steam Community';\n    const URI = 'https://www.steamcommunity.com';\n    const DESCRIPTION = 'Get the latest community updates for a game on Steam.';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 3600; // 1h\n\n    const PARAMETERS = [\n        [\n            'i' => [\n                'name' => 'App ID',\n                'exampleValue' => '730',\n                'required' => true\n            ],\n            'category' => [\n                'name' => 'category',\n                'type' => 'list',\n                'exampleValue' => 'Artwork',\n                'title' => 'Select a category',\n                'values' => [\n                    'Artwork' => 'images',\n                    'Screenshots' => 'screenshots',\n                    'Videos' => 'videos',\n                    'Workshop' => 'workshop'\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return self::URI . '/favicon.ico';\n    }\n\n    protected function getMainPage()\n    {\n        $category = $this->getInput('category');\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        return $html;\n    }\n\n    public function getName()\n    {\n        $category = $this->getInput('category');\n\n        if (is_null('i') || is_null($category)) {\n            return self::NAME;\n        }\n\n        $html = $this->getMainPage();\n\n        $titleItem = $html->find('div.apphub_AppName', 0);\n\n        if (!$titleItem) {\n            return self::NAME;\n        }\n\n        return $titleItem->innertext . ' (' . ucwords($category) . ')';\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('category') === 'workshop') {\n            return self::URI . '/workshop/browse/?appid='\n                . $this->getInput('i') . '&browsesort=mostrecent';\n        }\n\n        return self::URI . '/app/'\n            . $this->getInput('i') . '/'\n            . $this->getInput('category')\n            . '/?p=1&browsefilter=mostrecent';\n    }\n\n    private function collectMedia()\n    {\n        $category = $this->getInput('category');\n        $html = $this->getMainPage();\n        $cards = $html->find('div.apphub_Card');\n\n        foreach ($cards as $card) {\n            $uri = $card->getAttribute('data-modal-content-url');\n\n            $htmlCard = getSimpleHTMLDOMCached($uri);\n\n            $author = $card->find('div.apphub_CardContentAuthorName', 0)->innertext;\n            $author = strip_tags($author);\n\n            $title = $author . '\\'s screenshot';\n\n            if ($category != 'screenshots') {\n                $title = $htmlCard->find('div.workshopItemTitle', 0)->innertext;\n            }\n\n            $date = $htmlCard->find('div.detailsStatRight', 0)->innertext;\n\n            // create item\n            $item = [];\n            $item['title'] = $title;\n            $item['uri'] = $uri;\n            $item['timestamp'] = strtotime($date);\n            $item['author'] = $author;\n            $item['categories'] = $category;\n\n            $media = $htmlCard->getElementById('ActualMedia');\n            $mediaURI = $media->getAttribute('src');\n            $downloadURI = $mediaURI;\n\n            $content = '';\n\n            if ($category == 'videos') {\n                $content = handleYoutube($mediaURI);\n            }\n\n            $desc = '';\n\n            if ($category == 'screenshots') {\n                $descItem = $htmlCard->find('div.screenshotDescription', 0);\n                if ($descItem) {\n                    $desc = $descItem->innertext;\n                }\n            }\n\n            if ($category == 'images') {\n                $descItem = $htmlCard->find('div.nonScreenshotDescription', 0);\n                if ($descItem) {\n                    $desc = $descItem->innertext;\n                }\n                $downloadURI = $htmlCard->find('a.downloadImage', 0)->href;\n            }\n\n            if (empty($content)) {\n                $content = '<p><a href=\"' . $downloadURI . '\"><img src=\"' . $mediaURI . '\"/></a></p>';\n                $content .= '<p>' . $desc . '</p>';\n            }\n\n            $item['content'] = $content;\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    private function collectWorkshop()\n    {\n        $category = $this->getInput('category');\n        $html = $this->getMainPage();\n        $workShopItems = $html->find('div.workshopItem');\n\n        foreach ($workShopItems as $workShopItem) {\n            $author = $workShopItem->find('div.workshopItemAuthorName', 0)->find('a', 0);\n            $author = $author->innertext;\n\n            $fileRating = $workShopItem->find('img.fileRating', 0);\n\n            $uri = $workShopItem->find('a.ugc', 0)->getAttribute('href');\n\n            $htmlItem = getSimpleHTMLDOMCached($uri);\n\n            $title = $htmlItem->find('div.workshopItemTitle', 0)->innertext;\n            $date = $htmlItem->find('div.detailsStatRight', 0)->innertext;\n            $description = $htmlItem->find('div.workshopItemDescription', 0)->innertext;\n\n            $previewImage = $htmlItem->find('#previewImage', 0);\n\n            $htmlTags = $htmlItem->find('div.workshopTags');\n\n            $tags = '';\n\n            foreach ($htmlTags as $htmlTag) {\n                if ($tags !== '') {\n                    $tags .= ',';\n                }\n\n                $tags .= $htmlTag->find('a', 0)->innertext;\n            }\n\n            // create item\n            $item = [];\n            $item['title'] = $title;\n            $item['uri'] = $uri;\n            $item['timestamp'] = strtotime($date);\n            $item['author'] = $author;\n            $item['categories'] = $category;\n\n            $item['content'] = '<p><a href=\"' . $uri . '\">'\n                . $previewImage . '</a></p><p>' . $fileRating\n                . '</p><p>' . $description . '</p>';\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n\n    public function collectData()\n    {\n        if ($this->getInput('category') === 'workshop') {\n            $this->collectWorkshop();\n        } else {\n            $this->collectMedia();\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SteamGroupAnnouncementsBridge.php",
    "content": "<?php\n\nclass SteamGroupAnnouncementsBridge extends FeedExpander\n{\n    const MAINTAINER = 'Jisagi';\n    const NAME = 'Steam Group Announcements';\n    const URI = 'https://steamcommunity.com/';\n    const DESCRIPTION = 'Returns latest announcements from a steam group.';\n\n    const PARAMETERS = [\n        [\n            'g' => [\n                'name' => 'Group name',\n                'exampleValue' => 'freegamesfinders',\n                'required' => true\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $uri = self::URI . 'groups/' . $this->getInput('g') . '/rss';\n        $this->collectExpandableDatas($uri, 10);\n    }\n}\n"
  },
  {
    "path": "bridges/StockFilingsBridge.php",
    "content": "<?php\n\nclass StockFilingsBridge extends FeedExpander\n{\n    const MAINTAINER = 'captn3m0';\n    const NAME = 'SEC Stock filings';\n    const URI = 'https://www.sec.gov/edgar/searchedgar/companysearch.html';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Tracks SEC Filings for a single company';\n    const SEARCH_URL = 'https://www.sec.gov/cgi-bin/browse-edgar?owner=exclude&action=getcompany&CIK=';\n    const WEBSITE_ROOT = 'https://www.sec.gov';\n\n    const PARAMETERS = [\n        [\n        'ticker' => [\n            'name'          => 'cik',\n            'required'      => true,\n            'exampleValue'  => 'AMD',\n            // https://stackoverflow.com/a/12827734\n            'pattern'       => '[A-Za-z0-9]+',\n        ],\n        ]];\n\n    public function getIcon()\n    {\n        return 'https://www.sec.gov/favicon.ico';\n    }\n\n    /**\n     * Generates search URL\n     */\n    private function getSearchUrl()\n    {\n        return self::SEARCH_URL . $this->getInput('ticker');\n    }\n\n    /**\n     * Returns the Company Name\n     */\n    private function getRssFeed($html)\n    {\n        $links = $html->find('#contentDiv a');\n\n        foreach ($links as $link) {\n            $href = $link->href;\n\n            if (substr($href, 0, 4) !== 'http') {\n                $href = self::WEBSITE_ROOT . $href;\n            }\n            parse_str(html_entity_decode(parse_url($href, PHP_URL_QUERY)), $query);\n\n            if (isset($query['output']) and ($query['output'] == 'atom')) {\n                return $href;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Return \\simple_html_dom object\n     * for the entire html of the product page\n     */\n    private function getHtml()\n    {\n        $uri = $this->getSearchUrl();\n\n        return getSimpleHTMLDOM($uri);\n    }\n\n    /**\n     * Scrape the SEC Stock Filings RSS Feed URL\n     * and redirect there\n     */\n    public function collectData()\n    {\n        $html = $this->getHtml();\n        $rssFeedUrl = $this->getRssFeed($html);\n\n        if ($rssFeedUrl) {\n            parent::collectExpandableDatas($rssFeedUrl);\n        } else {\n            throwClientException('Could not find RSS Feed URL. Are you sure you used a valid CIK?');\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/StorytelBridge.php",
    "content": "<?php\n\nclass StorytelBridge extends BridgeAbstract\n{\n    const NAME = 'Storytel List';\n    const URI = 'https://www.storytel.com/tr';\n    const DESCRIPTION = 'Fetches books from a Storytel list, including title, author, and cover image.';\n    const MAINTAINER = 'Okbaydere';\n    const PARAMETERS = [\n        'List' => [\n            'url' => [\n                'name' => 'Storytel List URL',\n                'required' => true,\n                'exampleValue' => 'https://www.storytel.com/tr/lists/23d09e0bd8fe4d998d1832ddbfa18166',\n            ],\n        ],\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n\n        if (!preg_match('/^https:\\/\\/www\\.storytel\\.com/', $url)) {\n            throwServerException('Invalid URL: Only Storytel URLs are allowed.');\n        }\n\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('li.sc-4615116a-1') as $element) {\n            $item = [];\n\n            $titleElement = $element->find('span.sc-b1963858-0.hoTsmF', 0);\n            $item['title'] = $titleElement ? $titleElement->plaintext : 'No title';\n\n            $authorElement = $element->find('span.sc-b1963858-0.ghYMwH', 0);\n            $item['author'] = $authorElement ? $authorElement->plaintext : 'Unknown author';\n\n            $imgElement = $element->find('img.sc-da400893-5', 0);\n            $coverUrl = $imgElement ? $imgElement->getAttribute('srcset') : '';\n            if ($coverUrl) {\n                $coverUrls = explode(', ', $coverUrl);\n                $bestCoverUrl = trim(end($coverUrls));\n                $item['content'] = '<img src=\"' . preg_replace('/\\?.*/', '', $bestCoverUrl) . '\"/>';\n            }\n\n            $linkElement = $element->find('a', 0);\n            $item['uri'] = $linkElement ? 'https://www.storytel.com' . $linkElement->getAttribute('href') : $url;\n\n            $item['content'] .= '<p>Author: ' . $item['author'] . '</p>';\n            $item['content'] .= '<p><a href=\"' . $item['uri'] . '\">More details</a></p>';\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/StravaBridge.php",
    "content": "<?php\n\nclass StravaBridge extends BridgeAbstract\n{\n    const NAME = 'Strava';\n    const DESCRIPTION = \"Returns an athlete's recent activities\";\n    const URI = 'https://www.strava.com';\n    const PARAMETERS = [\n        [\n            'athleteID' => [\n                'name' => 'athleteID',\n                'required' => true\n            ]\n        ],\n    ];\n\n    public function detectParameters($url)\n    {\n        if (preg_match('/strava\\.com\\/athletes\\/([\\d]+)/', $url, $matches) > 0) {\n            return [\n                'athleteID' => $matches[1]\n            ];\n        }\n        return null;\n    }\n\n    public function collectData()\n    {\n        $athleteID = $this->getInput('athleteID');\n\n        $dom = getSimpleHTMLDOM(self::URI . '/athletes/' . $athleteID);\n        $scriptRegex = \"/data-react-props='(.*?)'/\";\n        preg_match($scriptRegex, $dom, $matches) or throwServerException('Could not find json');\n        $jsonData = json_decode(html_entity_decode($matches[1]));\n        $this->feedName = $jsonData->athlete->name . \"'s Recent Activities\";\n        $this->iconURL = $jsonData->athlete->avatarUrl;\n        foreach ($jsonData->recentActivities as $activity) {\n            $item = [];\n\n            $item['title'] = $activity->name . ' (' . $activity->detailedType . ')';\n            $item['author'] = $jsonData->athlete->name;\n            $item['uri'] = self::URI . '/activities/' . $activity->id;\n            $item['timestamp'] = $activity->startDateLocal;\n\n            $content = '<b>Distance:</b> ' . $activity->distance .\n                       '<br><b>Elev Gain:</b> ' . $activity->elevation .\n                       '<br><b>Time:</b> ' . $activity->movingTime . '<br><br>';\n\n            foreach ($activity->images as $image) {\n                $src = $image->squareSrc;\n                if (empty($src)) {\n                    $src = $image->defaultSrc;\n                }\n                $content .= '<img src=\"' . $src . '\">';\n            }\n            $item['content'] = $content;\n\n            $item['enclosures'][] = $item['uri'] . '/export_gpx';\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (empty($this->feedName)) {\n            return parent::getName();\n        } else {\n            return $this->feedName;\n        }\n    }\n\n    public function getIcon()\n    {\n        if (empty($this->iconURL)) {\n            return parent::getIcon();\n        } else {\n            return $this->iconURL;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/StreamCzBridge.php",
    "content": "<?php\n\nclass StreamCzBridge extends BridgeAbstract\n{\n    const NAME = 'Stream.cz';\n    const URI = 'https://www.stream.cz';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'Return newest videos';\n    const MAINTAINER = 'Stopka';\n\n    const PARAMETERS = [\n        [\n            'url' => [\n                'name' => 'url to the show',\n                'required' => true,\n                'exampleValue' => 'https://www.stream.cz/lajna'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n\n        $validUrl = '/^(https:\\/\\/www.stream.cz\\/[a-z0-9-]+)(\\/[a-z0-9-]+-\\d+)?$/';\n        if (!preg_match($validUrl, $url, $match)) {\n            throwServerException('Invalid url');\n        }\n\n        $fixedUrl = $match[1];\n\n        $html = getSimpleHTMLDOM($fixedUrl);\n\n        $this->feedUri = $fixedUrl;\n\n        $scriptElement = $html->find('body script', -1);\n        if (null === $scriptElement) {\n            throwServerException('Could not find metadata element on the page');\n        }\n        $json = extractFromDelimiters($scriptElement->innertext, 'data : ', 'logs : ');\n        if (false === $json) {\n            throwServerException('Could not extract metadata from the page');\n        }\n        $data = json_decode(trim($json, \",\\t\\n\\r\\0\\x0B\"), true);\n        if (false === $data) {\n            throwServerException('Could not parse metadata on the page');\n        }\n\n        $showData = $data['fetchable']['tag']['show']['data'];\n        if (!is_array($showData)) {\n            throwServerException('Show not found in metadata');\n        }\n        $this->feedName = $showData['name'];\n        $episodes = $showData['allEpisodesConnection']['edges'];\n        if (!is_array($episodes)) {\n            throwServerException('Episodes not found in metadata');\n        }\n        foreach ($episodes as $episode) {\n            if (!$episode['node']) {\n                continue;\n            }\n            $episodeUrl = $episode['node']['urlName'];\n            $imageUrlNode = reset($episode['node']['images']);\n            $item = [\n                'title' => $episode['node']['name'],\n                'uri' => $fixedUrl . '/' . $episodeUrl,\n                'content' => $imageUrlNode ? '<img src=\"' . $imageUrlNode['url'] . '\" />' : '',\n                'timestamp' => $episode['node']['publishTime']['timestamp']\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        return $this->feedUri ?? parent::getURI();\n    }\n\n    public function getName()\n    {\n        return $this->feedName ?? parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/StripeAPIChangeLogBridge.php",
    "content": "<?php\n\nclass StripeAPIChangeLogBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Pierre Mazière';\n    const NAME = 'Stripe API Changelog';\n    const URI = 'https://stripe.com/docs/upgrades';\n    const CACHE_TIMEOUT = 86400; // 24h\n    const DESCRIPTION = 'Returns the changes made to the stripe.com API';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('h3') as $change) {\n            $item = [];\n            $item['title'] = trim($change->plaintext);\n            $item['uri'] = self::URI . '#' . $item['title'];\n            $item['author'] = 'stripe';\n            $item['content'] = $change->nextSibling()->outertext;\n            $item['timestamp'] = strtotime($item['title']);\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SubitoBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass SubitoBridge extends BridgeAbstract\n{\n    const NAME = 'Subito';\n    const URI = 'https://www.subito.it/';\n    const DESCRIPTION = 'Returns ads from search';\n    const MAINTAINER = 'bagnacauda';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [[\n        'url' => [\n            'name' => 'Search URL',\n            'title' => 'The URL from your browser\\'s address bar after searching and filtering',\n            'exampleValue' => 'https://www.subito.it/annunci-lombardia/vendita/elettronica/milano/milano/?q=iphone',\n            'required' => true\n        ],\n        'hideSoldItems' => [\n            'name' => 'Hide sold items',\n            'title' => 'Hide ads of recently sold items (which are normally displayed for a while)',\n            'type' => 'checkbox',\n            'defaultValue' => false,\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $url = $this->getInput('url');\n\n        $headers = [\n                'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/141.0',\n                'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',\n                'Accept-Language: it-IT,it;q=0.8,en-US;q=0.5,en;q=0.3'\n        ];\n\n        $dom = getSimpleHTMLDOMCached($url, self::CACHE_TIMEOUT, $headers);\n\n        $json = $dom->getElementById('__NEXT_DATA__');\n        $data = json_decode($json->innertext());\n\n        foreach ($data->props->pageProps->initialState->items->list as $post) {\n            $post = $post->item;\n\n            $item = [];\n            $item['uri'] = $post->urls->default;\n            $item['title'] = $post->subject;\n            $item['timestamp'] = $post->date;\n            $item['enclosures'] = [];\n            $item['content'] = '';\n            $skip_item = false;\n\n            $features_html = [];\n            $price_html = '';\n            foreach ($post->features as $key => $feature) {\n                $html = $feature->label . ': ';\n                $skip_feature = false;\n\n                foreach ($feature->values as $value) {\n                    if ($this->getInput('hideSoldItems') && $feature->uri == '/transaction_status' && $value->value == 'SOLD') {\n                        $skip_item = true;\n                        break;\n                    }\n\n                    if ($feature->uri == '/price') {\n                        $price_html = '<h2>' . $value->value . '</h2>';\n                        $skip_feature = true;\n                    }\n\n                    $html .= $value->value . ' ';\n                }\n\n                if (!$skip_feature) {\n                    $html .= '<br>';\n                    $features_html[] = $html;\n                }\n            }\n\n            if ($skip_item) {\n                continue;\n            }\n\n            $query_img = '';\n            foreach ($dom->find('script[type=application/ld+json]') as $json) {\n                $ld_json = json_decode($json->innertext());\n                if (property_exists($ld_json, '@graph') && count($ld_json->{'@graph'}) > 0 && property_exists($ld_json->{'@graph'}[0], 'contentUrl')) {\n                    $query_img = explode('?', $ld_json->{'@graph'}[0]->contentUrl)[1]; // i pick the first query string, to use for all images\n                    break;\n                }\n            }\n\n            foreach ($post->images as $image) {\n                $item['enclosures'][] = $image->cdnBaseUrl . '?' . $query_img;\n            }\n\n            if (count($item['enclosures']) > 0) {\n                $item['content'] = '<img src=\"' . $item['enclosures'][0] . '\"><br>';\n            }\n\n            $item['content'] .= $price_html;\n            $item['content'] .= $post->geo->town->value . ' (' . $post->geo->city->shortName . ')<br><br>';\n\n            sort($features_html);\n            $item['content'] .= implode($features_html);\n\n            $item['content'] .= '<br>';\n\n            $item['content'] .= str_replace(\"\\n\", '<br>', $post->body);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SubstackBridge.php",
    "content": "<?php\n\nclass SubstackBridge extends FeedExpander\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Substack';\n    const URI = 'https://substack.com/';\n    const CACHE_TIMEOUT = 3600; //1hour\n    const DESCRIPTION = 'Access Substack. Add full content for paywalled posts if you have a session cookie with an active subscription.';\n\n    const CONFIGURATION = [\n        'sid' => [\n            'required' => false,\n        ]\n    ];\n\n    const PARAMETERS = [\n        '' => [\n            'url' => [\n                'name' => 'Substack RSS URL',\n                'required' => true,\n                'type' => 'text',\n                'defaultValue' => 'https://newsletter.pragmaticengineer.com/feed',\n                'title' => 'Usually https://<blog-url>/feed'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $headers = [];\n        if ($this->getOption('sid')) {\n            $url_parsed = parse_url($this->getInput('url'));\n            $authority = $url_parsed['host'];\n            $cookies = [\n                'ab_experiment_sampled=%22false%22',\n                'substack.sid=' . $this->getOption('sid'),\n                'substack.lli=1',\n                'intro_popup_last_hidden_at=' . (new DateTime())->format('Y-m-d\\TH:i:s.v\\Z')\n            ];\n            $headers = [\n                'Authority: ' . $authority,\n                'Cache-Control: max-age=0',\n                'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',\n                'Cookie: ' . implode('; ', $cookies)\n            ];\n        }\n        $this->collectExpandableDatas($this->getInput('url'), -1, $headers);\n    }\n}\n"
  },
  {
    "path": "bridges/SubstackProfileBridge.php",
    "content": "<?php\n\nclass SubstackProfileBridge extends BridgeAbstract\n{\n    const NAME = 'Substack Profile';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://substack.com/';\n    const DESCRIPTION = 'Returns posts from profiles on Substack';\n    const PARAMETERS = [[\n        'profile' => [\n            'name' => 'Profile name to use',\n            'exampleValue' => 'taliabhatt',\n        ],\n    ]];\n\n    private $name;\n    private $icon;\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI());\n        preg_match('/<script>window\\._preloads\\s*= JSON\\.parse\\(\"(.+?)\"\\)\\s*<\\/script>/', $html, $preg);\n        $json = stripcslashes($preg[1]);\n        $profile = json_decode($json, true)['profile'];\n\n        $this->name = $profile['name'];\n        $this->icon = $profile['photo_url'];\n\n        $id = $profile['id'];\n        $json = getContents(parent::getURI() . \"api/v1/reader/feed/profile/$id\");\n        foreach (json_decode($json, true)['items'] as $element) {\n            $this->items[] = $this->processAttachment($element);\n        }\n    }\n\n    private function processAttachment(array $element)\n    {\n        $item = [];\n        switch ($element['type']) {\n            case 'comment':\n                $element = $element['comment'];\n                $item['author'] = $element['name'] ?? $element['user']['name'];\n                $item['content'] = '';\n                if (isset($element['body_json'])) {\n                    $item['content'] = $this->processBodyJson($element['body_json']);\n                }\n                $item['timestamp'] = $element['date'];\n                $item['title'] = 'Comment by ' . $item['author'];\n                $item['uri'] = $this->getURI() . '/note/c-' . $element['id'];\n                break;\n            case 'post':\n                $item['content'] = $element['postSelection']['text'] ?? '';\n                $element = $element['post'];\n                $item['author'] = $element['publishedBylines'][0]['name'];\n                $item['content'] .= $this->fetchPost($element['id']);\n                $item['timestamp'] = $element['post_date'];\n                $item['title'] = $element['title'];\n                $item['uri'] = parent::getURI() . 'home/post/p-' . $element['id'];\n                break;\n            case 'link':\n                $element = $element['linkMetadata'];\n                $item['author'] = $element['host'];\n                $item['content'] = $element['description'];\n                $item['title'] = $element['title'];\n                $item['uri'] = $element['url'];\n                break;\n            case 'image':\n                $item['uri'] = $element['imageUrl'];\n                break;\n            default:\n                throw new Exception('Invalid Substack entry type: ' . $element['type']);\n        }\n\n        $item['enclosures'] = [\n            $element['audio_items'][0]['audio_url'] ?? null,\n            $element['audio_items'][1]['audio_url'] ?? null,\n            $element['cover_image'] ?? null,\n            $element['image'] ?? null,\n            $element['imageUrl'] ?? null,\n        ];\n\n        $item['categories'] = array_map(fn($tag) => $tag['name'], $element['postTags'] ?? []);\n        $item['comments'] = $item['uri'] . '/restacks/notes';\n\n        if (isset($element['attachments'])) {\n            foreach ($element['attachments'] as $attachment) {\n                $attachment = $this->processAttachment($attachment);\n                $item['categories'] = array_merge($item['categories'], $attachment['categories']);\n                $item['enclosures'] = array_merge($item['enclosures'], $attachment['enclosures']);\n                if (isset($attachment['title'])) { // Nothing to quote for images\n                    $item['content'] .= $this->quoteAttachment($attachment);\n                }\n            }\n        }\n\n        return $item;\n    }\n\n    private function fetchPost(string $id)\n    {\n        $json = getContents(parent::getURI() . \"api/v1/posts/by-id/$id\");\n        $json = json_decode($json, true)['post'];\n        $html = str_get_html($json['body_html']);\n        $body = $html->root;\n        $block = $html->createElement('div');\n        $block->appendChild($html->createElement('hr'));\n        $block->appendChild($html->createElement('h4', 'Full text:'));\n        $block->appendChild($body);\n        return $block->innertext();\n    }\n\n    private function quoteAttachment(array $attachment)\n    {\n        $html = new simple_html_dom();\n        $body = $html->createElement('div');\n        $body->appendChild($html->createElement('hr'));\n        $link = $html->createElement('a');\n        $link->href = $attachment['uri'];\n        $link->appendChild($html->createElement('h3', $attachment['title']));\n        $body->appendChild($link);\n        if ($attachment['content'] != '') {\n            $body->appendChild($html->createElement('h4', 'Qouting ' . $attachment['author'] . ':'));\n            $body->appendChild($html->createElement('blockquote', $attachment['content']));\n        }\n        return $body->innertext();\n    }\n\n    private function processBodyJson(array $json)\n    {\n        $html = new simple_html_dom();\n        $body = $html->createElement('div');\n        foreach ($json['content'] as $block) {\n            if (isset($block['content'])) {\n                $content = $this->processBodyJson($block);\n            }\n            switch ($block['type']) {\n                case 'blockquote':\n                    $content->tag = 'blockquote';\n                    $body->appendChild($content);\n                    break;\n                case 'paragraph':\n                    $content->tag = 'p';\n                    $body->appendChild($content);\n                    break;\n                case 'text':\n                    $text = $html->createTextNode($block['text']);\n                    if (isset($block['marks'])) {\n                        foreach ($block['marks'] as $mark) {\n                            switch ($mark['type']) {\n                                case 'bold':\n                                    $marked = $html->createElement('strong');\n                                    $marked->appendChild($text);\n                                    $text = $marked;\n                                    break;\n                                case 'italic':\n                                    $marked = $html->createElement('em');\n                                    $marked->appendChild($text);\n                                    $text = $marked;\n                                    break;\n                                case 'link':\n                                    $marked = $html->createElement('a');\n                                    $marked->href = $mark['attrs']['href'];\n                                    $marked->appendChild($text);\n                                    $text = $marked;\n                                    break;\n                                default:\n                                    throw new Exception('Invalid text mark type: ' . $mark['type']);\n                            }\n                        }\n                    }\n                    $body->appendChild($text);\n                    break;\n                case 'substack_mention':\n                    $link = $html->createElement('a');\n                    $link->href = parent::getURI() . 'profile/' . $block['attrs']['id'];\n                    $link->appendChild($html->createTextNode($block['attrs']['label']));\n                    $body->appendChild($link);\n                    break;\n                default:\n                    throw new Exception('Invalid body type: ' . $block['type']);\n            }\n        }\n        return $body;\n    }\n\n    public function getName()\n    {\n        $name = parent::getName();\n        if (isset($this->name)) {\n            $name .= \" - $this->name\";\n        }\n        return $name;\n    }\n\n    public function getIcon()\n    {\n        if (isset($this->icon)) {\n            return $this->icon;\n        }\n        return parent::getIcon();\n    }\n\n    public function getURI()\n    {\n        if ($this->getInput('profile') != null) {\n            return parent::getURI() . '@' . $this->getInput('profile');\n        }\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/SummitsOnTheAirBridge.php",
    "content": "<?php\n\nclass SummitsOnTheAirBridge extends BridgeAbstract\n{\n    const MAINTAINER = 's0lesurviv0r';\n    const NAME = 'Summits On The Air Spots';\n    const URI = 'https://api2.sota.org.uk/api/spots/';\n    const CACHE_TIMEOUT = 60; // 1m\n    const DESCRIPTION = 'Summits On The Air Activator Spots';\n\n    const PARAMETERS = [\n        'Count' => [\n            'c' => [\n                'name' => 'count',\n                'required' => true,\n                'defaultValue' => 10\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $header = [\n            'Content-type:application/json',\n        ];\n        $opts = [\n            CURLOPT_HTTPGET => 1,\n        ];\n        $json = getContents($this->getURI() . $this->getInput('c'), $header, $opts);\n\n        $spots = json_decode($json, true);\n\n        foreach ($spots as $spot) {\n            $summit = $spot['associationCode'] . '/' . $spot['summitCode'];\n\n            $title = $spot['activatorCallsign'] . ' @ ' . $summit . ' ' .\n                $spot['frequency'] . ' MHz';\n\n            $content = <<<EOL\n\t\t\t<a href=\"http://summits.sota.org.uk/summit/{$summit}\">\n\t\t\t{$summit}, {$spot['summitDetails']}</a><br />\n\t\t\tFrequency: {$spot['frequency']} MHz<br />\n\t\t\tMode: {$spot['mode']}<br />\n\t\t\tComments: {$spot['comments']}\nEOL;\n\n            $this->items[] = [\n                'uri' => 'https://sotawatch.sota.org.uk/en/',\n                'title' => $title,\n                'content' => $content,\n                'timestamp' => $spot['timeStamp']\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SuperSmashBlogBridge.php",
    "content": "<?php\n\nclass SuperSmashBlogBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'Super Smash Blog';\n    const URI = 'https://www.smashbros.com/en_US/blog/index.html';\n    const CACHE_TIMEOUT = 7200; // 2h\n    const DESCRIPTION = 'Latest articles from the Super Smash Blog blog';\n\n    public function collectData()\n    {\n        $dlUrl = 'https://www.smashbros.com/data/bs/en_US/json/en_US.json';\n\n        $jsonString = getContents($dlUrl);\n        $json = json_decode($jsonString, true);\n\n        foreach ($json as $article) {\n            // Build content\n            $picture = $article['acf']['image1']['url'];\n            if (strlen($picture) != 0) {\n                $picture = str_get_html('<img src=\"https://www.smashbros.com/' . substr($picture, 8) . '\"/>');\n            } else {\n                $picture = '';\n            }\n\n            $video = $article['acf']['link_url'];\n            if (strlen($video) != 0) {\n                $video = str_get_html('<a href=\"' . $video . '\">Youtube video</a>');\n            } else {\n                $video = '';\n            }\n            $text = str_get_html($article['acf']['editor']);\n            $content = $picture . $video . $text;\n\n            // Build final item\n            $item = [];\n            $item['title'] = $article['title']['rendered'];\n            $item['timestamp'] = strtotime($article['date']);\n            $item['content'] = $content;\n            $item['uri'] = self::URI . '?post=' . $article['id'];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/SymfonyCastsBridge.php",
    "content": "<?php\n\nclass SymfonyCastsBridge extends BridgeAbstract\n{\n    const NAME = 'SymfonyCasts';\n    const URI = 'https://symfonycasts.com/';\n    const DESCRIPTION = 'Follow new updates on symfonycasts.com';\n    const MAINTAINER = 'Park0';\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $url = 'https://symfonycasts.com/updates/find';\n        $html = getSimpleHTMLDOM($url);\n\n        /** @var simple_html_dom_node[] $dives */\n        $dives = $html->find('div.user-notification-not-viewed');\n\n        foreach ($dives as $div) {\n            $type = $div->find('h5', 0);\n            $title = $div->find('a', 0);\n            $dateString = $div->find('h5.font-gray', 0);\n            $href = $div->find('a', 0);\n            $hrefAttribute = $href->getAttribute('href');\n            $url = 'https://symfonycasts.com' . $hrefAttribute;\n\n            $item = [];\n            $item['uid'] = $div->getAttribute('data-mark-update-update-url-value');\n            $item['title'] = $title->innertext;\n\n            // this natural language date string does not work\n            $item['timestamp'] = $dateString->innertext;\n\n            $item['content'] = $type->plaintext . '<a href=\"' . $url . '\">' . $title . '</a>';\n            $item['uri'] = $url;\n            $this->items[] = $item; // Add item to the list\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TCBScansBridge.php",
    "content": "<?php\n\nclass TCBScansBridge extends BridgeAbstract\n{\n    const NAME = 'TCB Scans';\n    const URI = 'https://tcbscans.me/';\n    const DESCRIPTION = 'Returns the latest chapter from a TCB Scans project';\n    const MAINTAINER = 'osvfj';\n    const PARAMETERS = [[\n        'manga' => [\n            'name' => 'Manga',\n            'title' => 'Select your prefered manga',\n            'exampleValue' => 'One Piece',\n            'type' => 'list',\n            'values' => [\n                'Ace Novel - Manga Adaptation' => 'mangas/1/ace-novel-manga-adaptation',\n                'Attack on Titan' => 'mangas/8/attack-on-titan',\n                'Black Clover' => 'mangas/3/black-clover',\n                'Black Clover Gaiden: Quartet Knights' => 'mangas/24/black-clover-gaiden-quartet-knights',\n                'Bleach' => 'mangas/2/bleach',\n                'Build King' => 'mangas/9/build-king',\n                'Chainsaw Man' => 'mangas/13/chainsaw-man',\n                'Demon Slayer: Kimetsu no Yaiba ' => 'mangas/19/demon-slayer-kimetsu-no-yaiba',\n                'Haikyuu!! (New Special!)' => 'mangas/11/haikyu-special',\n                'Hunter X Hunter' => 'mangas/15/hunter-x-hunter',\n                'Jujutsu Kaisen' => 'mangas/4/jujutsu-kaisen',\n                'My Hero Academia' => 'mangas/6/my-hero-academia',\n                \"My Hero Academia One-Shot: You're Next!!\" => 'mangas/25/my-hero-academia-one-shot-you-re-next',\n                'One Piece ' => 'mangas/5/one-piece',\n                'One Piece - Nami vs Kalifa by Boichi' => 'mangas/12/one-piece-nami-vs-kalifa-by-boichi',\n                'One-Punch Man' => 'mangas/10/one-punch-man',\n                'Spy X Family' => 'mangas/23/spy-x-family',\n            ],\n        ],\n        'full_chapter' => [\n            'name' => 'Load images in the item',\n            'type' => 'checkbox',\n            'title' => 'Activate to always load the full chapter',\n            'defaultValue' => 'checked'\n        ],\n        'hide_title' => [\n            'name' => 'Hide title of the chapter',\n            'type' => 'checkbox',\n            'title' => 'Activate to hide the title of the chapter and just show the number'\n        ]\n    ]];\n    const CACHE_TIMEOUT = 60 * 15;\n\n    public function collectData()\n    {\n        $manga = $this->getInput('manga');\n        $html = getSimpleHTMLDOMCached($this->getURI() . $manga);\n        $html = defaultLinkTo($html, $this->getURI());\n        $full_chapter = $this->getInput('full_chapter');\n\n        $chapter = $html->find('a.block.border.border-border.bg-card.mb-3.p-3.rounded', 0);\n\n        $item = [];\n        $item['title'] = $this->getInput('hide_title') ? $chapter->find('div.text-lg.font-bold', 0)->plaintext : $chapter->find('div.text-gray-500', 0)->plaintext;\n        $item['uri'] = $chapter->href;\n        $item['uid'] = $chapter->href;\n\n\n        if ($full_chapter) {\n            $item['content'] = $this->getFullChapter($item['uri']);\n        } else {\n            $item['content'] = <<<EOD\n                <a href=\"{$item['uri']}\" rel=\"nofollow noreferrer\">Read chapter</a>\n            EOD;\n            ;\n        }\n        $this->items[] = $item;\n    }\n\n    private function getFullChapter($uri)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n        $pictures = $html->find('picture.fixed-ratio');\n        $img_html = '';\n\n        foreach ($pictures as $picture) {\n            $img_html .= <<<EOD\n                <img src=\"{$picture->find('img.fixed-ratio-content', 0)->src}\">\n            EOD;\n        }\n        return $img_html;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getKey('manga'))) {\n            return $this->getKey('manga') . ' | ' . self::NAME;\n        }\n\n        return self::NAME;\n    }\n\n    public function getIcon()\n    {\n        return $this->getURI() . '/files/favicon-32x32.png';\n    }\n}"
  },
  {
    "path": "bridges/TagesspiegelBridge.php",
    "content": "<?php\n\nclass TagesspiegelBridge extends FeedExpander\n{\n    const MAINTAINER = 'AlexanderS';\n    const NAME = 'Tagesspiegel';\n    const URI = 'https://www.tagesspiegel.de/';\n    const CACHE_TIMEOUT = 3600; // 60min\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'Startseite'\n                => 'https://tagesspiegel.de/contentexport/feed',\n                'Plus'\n                => 'https://tagesspiegel.de/contentexport/feed/plus/',\n                'Politik'\n                => 'https://tagesspiegel.de/contentexport/feed/politik/',\n                'Internationales'\n                => 'https://tagesspiegel.de/contentexport/feed/internationales/',\n                'Berlin'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/',\n                'Berlin - Bezirke'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/bezirke/',\n                'Berlin - Berliner Wirtschaft'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/berliner-wirtschaft/',\n                'Berlin - Berliner Sport'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/berliner_sport/',\n                'Berlin - Polizei & Justiz'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/polizei-justiz/',\n                'Berlin - Stadtleben'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/stadtleben/',\n                'Berlin - Schule'\n                => 'https://tagesspiegel.de/contentexport/feed/berlin/schule/',\n                'Gesellschaft'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/',\n                'Gesellschaft - Liebe & Partnerschaft'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/liebe-partnerschaft/',\n                'Gesellschaft - Queer'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/queerspiegel/',\n                'Gesellschaft - Panorama'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/panorama/',\n                'Gesellschaft - Medien'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/medien/',\n                'Gesellschaft - Geschichte'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/geschichte/',\n                'Gesellschaft - Reise'\n                => 'https://tagesspiegel.de/contentexport/feed/gesellschaft/reise/',\n                'Wirtschaft'\n                => 'https://tagesspiegel.de/contentexport/feed/wirtschaft/',\n                'Wirtschaft - Immobilien'\n                => 'https://tagesspiegel.de/contentexport/feed/wirtschaft/immobilien/',\n                'Wirtschaft - Jobs & Karriere'\n                => 'https://tagesspiegel.de/contentexport/feed/wirtschaft/karriere/',\n                'Wirtschaft - Finanzen'\n                => 'https://tagesspiegel.de/contentexport/feed/wirtschaft/finanzen/',\n                'Wirtschaft - Mobilität'\n                => 'https://tagesspiegel.de/contentexport/feed/wirtschaft/mobilitaet/',\n                'Kultur'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/',\n                'Kultur - Literatur'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/literatur/',\n                'Kultur - Comics'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/comics/',\n                'Kultur - Kino'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/kino/',\n                'Kultur - Pop'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/pop/',\n                'Kultur - Ausstellungen'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/ausstellungen/',\n                'Kultur - Bühne'\n                => 'https://tagesspiegel.de/contentexport/feed/kultur/buehne/',\n                'Wissen'\n                => 'https://tagesspiegel.de/contentexport/feed/wissen/',\n                'Gesundheit'\n                => 'https://tagesspiegel.de/contentexport/feed/gesundheit/',\n                'Sport'\n                => 'https://tagesspiegel.de/contentexport/feed/sport/',\n                'Meinung'\n                => 'https://tagesspiegel.de/contentexport/feed/meinung/',\n                'Meinung - Kolumnen'\n                => 'https://tagesspiegel.de/contentexport/feed/meinung/kolumnen/',\n                'Meinung - Lesermeinung'\n                => 'https://tagesspiegel.de/contentexport/feed/meinung/lesermeinung/',\n                'Potsdam'\n                => 'https://tagesspiegel.de/contentexport/feed/potsdam/',\n                'Potsdam - Landeshauptstadt'\n                => 'https://tagesspiegel.de/contentexport/feed/potsdam/landeshauptstadt/',\n                'Potsdam - Potsdam-Mittelmark'\n                => 'https://tagesspiegel.de/contentexport/feed/potsdam/potsdam-mittelmark/',\n                'Potsdam - Brandenburg'\n                => 'https://tagesspiegel.de/contentexport/feed/potsdam/brandenburg/',\n                'Potsdam - Kultur'\n                => 'https://tagesspiegel.de/contentexport/feed/potsdam/potsdam-kultur/',\n                'Podcasts'\n                => 'https://tagesspiegel.de/contentexport/feed/podcasts/',\n            ]\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 5\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = $this->getInput('category');\n        $limit = $this->getInput('limit') ?: 5;\n\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['enclosures'] = [];\n\n        $article = getSimpleHTMLDOM($item['uri']);\n        $item = $this->parseArticle($item, $article);\n\n        return $item;\n    }\n\n    private function parseArticle($item, $article)\n    {\n        $item['categories'] = [];\n\n        // Add tag for articles only available with \"Tagesspiegel Plus\"\n        $plusicon = $article->find('span[data-ob=\"plus\"]', 0);\n        if ($plusicon) {\n            $item['categories'][] = 'Tagesspiegel Plus';\n        }\n\n        // Add section from breadcrumbs as tags\n        $breadcrumbs = $article->find('ol[property=\"breadcrumb\"]', 0);\n        $names = $breadcrumbs->find('span[property=\"name\"]');\n        $names = array_slice($names, 1, -1);\n        foreach ($names as $name) {\n            $item['categories'][] = trim($name->plaintext);\n        }\n\n        // Get categories from article\n        $home_link = $article->find('a[data-gtm-class=\"article-home-link\"]', 0);\n        if ($home_link) {\n            $tag_container = $home_link->parent->nextSibling();\n            if ($tag_container) {\n                $tags = $tag_container->find('li');\n\n                if ($tags) {\n                    foreach ($tags as $tag) {\n                        $item['categories'][] = trim($tag->plaintext);\n                    }\n                }\n            }\n        }\n\n        $article = $article->find('article', 0);\n\n        // Remove known bad elements\n        foreach (\n            $article->find(\n                'script, aside, nav, dl.debug-piano, .link--external svg, time, a[data-gtm-class=\"article-home-link\"]'\n            ) as $bad\n        ) {\n            $bad->remove();\n        }\n\n        // Remove references to external content (requires javascript for consent)\n        foreach ($article->find('p') as $par) {\n            if ($par->plaintext == 'Empfohlener redaktioneller Inhalt') {\n                $par->parent->parent->parent->parent->remove();\n            }\n        }\n\n        // Reload html, as remove() is buggy\n        $article = str_get_html($article->outertext);\n\n\n        // Clean article content\n        $elements = $article->find('h3, p, figure, blockquote');\n        foreach ($elements as $i => $element) {\n            foreach ($element->find('img, picture source') as $img) {\n                // Add URI to src\n                if ($img->hasAttribute('src')) {\n                    if (str_starts_with($img->attr['src'], '/')) {\n                        $img->attr['src'] = urljoin(self::URI, $img->attr['src']);\n                    }\n                }\n\n                // Add URI to srcset\n                if ($img->hasAttribute('srcset')) {\n                    $srcsets = explode(',', $img->attr['srcset']);\n                    foreach ($srcsets as &$srcset) {\n                        $parts = explode(' ', trim($srcset));\n                        if (count($parts) > 0) {\n                            if (str_starts_with($parts[0], '/')) {\n                                $parts[0] = urljoin(self::URI, $parts[0]);\n                            }\n                        }\n                        $srcset = implode(' ', $parts);\n                    }\n                    $img->attr['srcset'] = implode(', ', $srcsets);\n                }\n            }\n\n            // Remove paragraphs that are already included in other elements\n            if ($element->tag == 'p') {\n                if ($element->parent->tag == 'blockquote' || $element->parent->tag == 'figure') {\n                    unset($elements[$i]);\n                }\n            }\n        }\n        $item['content'] = implode('', $elements);\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TapasBridge.php",
    "content": "<?php\n\nclass TapasBridge extends FeedExpander\n{\n    const NAME            = 'Tapas.io';\n    const URI            = 'https://tapas.io/';\n    const DESCRIPTION    = 'Return new chapters from standart Tapas RSS';\n    const MAINTAINER    = 'Ololbu';\n    const CACHE_TIMEOUT    = 3600;\n    const PARAMETERS    = [\n        [\n            'title' => [\n                'name' => 'URL\\'s title / ID',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert title from URL (tapas.io/series/THIS_TITLE/info) or title ID',\n            ],\n            'extend_content' => [\n                'name' => 'Include on-site content',\n                'type' => 'checkbox',\n                'title' => 'Activate to include images or chapter text',\n            ],\n//            'force_title' => [\n//                'name' => 'Force title use',\n//                'type' => 'checkbox',\n//                'title' => 'If you have trouble with feed getting, try this option.',\n//            ],\n        ]\n    ];\n\n    protected $id;\n\n    public function collectData()\n    {\n        if (preg_match('/^[\\d]+$/', $this->getInput('title'))) {\n            $this->id = $this->getInput('title');\n        }\n        if ($this->getInput('force_title') || !$this->id) {\n            $html = getSimpleHTMLDOM($this->getURI());\n            $this->id = $html->find('meta[property$=\":url\"]', 0)->content;\n            $this->id = str_ireplace(['tapastic://series/', '/info'], '', $this->id);\n        }\n        $this->collectExpandableDatas($this->getURI(), 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n//        $namespaces = $feedItem->getNamespaces(true);\n//        if (isset($namespaces['content'])) {\n//            $description = $feedItem->children($namespaces['content']);\n//            if (isset($description->encoded)) {\n//                $item['content'] = (string)$description->encoded;\n//            }\n//        }\n\n        $item['content'] ??= '';\n        if ($this->getInput('extend_content')) {\n            $html = getSimpleHTMLDOM($item['uri']);\n            $item['content'] = $item['content'] ?? '';\n\n            if ($html->find('article.main__body', 0)) {\n                foreach ($html->find('article', 0)->find('img') as $line) {\n                    $item['content'] .= '<img src=\"' . $line->{'data-src'} . '\">';\n                }\n            } elseif ($html->find('article.main__body--book', 0)) {\n                $item['content'] .= $html->find('article.viewer__body', 0)->innertext;\n            } else {\n                $item['content'] .= '<h1 style=\"font-size:24px;text-align:center;\">Locked episode</h1>';\n                $item['content'] .= '<h5 style=\"text-align:center;\">' . $html->find('div.js-viewer-filter h5', 0)->plaintext . '</h5>';\n            }\n        }\n\n        return $item;\n    }\n\n    public function getURI()\n    {\n        if ($this->id) {\n            return self::URI . 'rss/series/' . $this->id;\n        }\n        return self::URI . 'series/' . $this->getInput('title') . '/info/';\n    }\n}\n"
  },
  {
    "path": "bridges/TarnkappeBridge.php",
    "content": "<?php\n\nclass TarnkappeBridge extends FeedExpander\n{\n    const MAINTAINER = 'Tone866';\n    const NAME = 'tarnkappe';\n    const URI = 'https://tarnkappe.info/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'required' => false,\n            'title' => <<<'TITLE'\n                If you only want to subscribe to a specific category\n                you can enter it here.\n                If not, leave it blank to subscribe to everything.\n                TITLE,\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 10\n        ]\n    ]];\n    const LIMIT = 10;\n\n    public function collectData()\n    {\n        if (empty($this->getInput('category'))) {\n            $category = 'https://tarnkappe.info/feed';\n        } else {\n            $category = 'https://tarnkappe.info/artikel/' . $this->getInput('category') . '/feed';\n        }\n\n        $this->collectExpandableDatas(\n            $category,\n            $this->getInput('limit') ?: static::LIMIT\n        );\n    }\n\n    protected function parseItem(array $item)\n    {\n        if (strpos($item['uri'], 'https://tarnkappe.info/') !== 0) {\n            return $item;\n        }\n\n        $article = getSimpleHTMLDOMCached($item['uri']);\n\n        if ($article) {\n            $article = defaultLinkTo($article, $item['uri']);\n            $item = $this->addArticleToItem($item, $article);\n        }\n\n        return $item;\n    }\n\n    private function addArticleToItem($item, $article)\n    {\n        $item['content'] = $article->find('a.image-header', 0);\n\n        $article = $article->find('main#article article div.card-content div.content.entry-content', 0);\n\n        // remove unwanted stuff\n        foreach (\n            $article->find('section, div.menu, p[style]') as $element\n        ) {\n            $element->remove();\n        }\n\n        // reload html, as remove() is buggy\n        $article = str_get_html($article->outertext);\n\n        $item['content'] .= $article;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TbibBridge.php",
    "content": "<?php\n\nclass TbibBridge extends GelbooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Tbib';\n    const URI = 'https://tbib.org/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    protected function buildThumbnailURI($element)\n    {\n        $regex = '/\\.\\w+$/';\n        return $this->getURI() . 'thumbnails/' . $element->directory\n        . '/thumbnail_' . preg_replace($regex, '.jpg', $element->image);\n    }\n}\n"
  },
  {
    "path": "bridges/TebeoBridge.php",
    "content": "<?php\n\nclass TebeoBridge extends FeedExpander\n{\n    const NAME = 'Tébéo';\n    const URI = 'http://www.tebeo.bzh/';\n    const CACHE_TIMEOUT = 21600; //6h\n    const DESCRIPTION = 'Returns the newest Tébéo videos by category';\n    const MAINTAINER = 'Mitsukarenai';\n\n    const PARAMETERS = [ [\n        'cat' => [\n            'name' => 'Catégorie',\n            'type' => 'list',\n            'values' => [\n                'Toutes les vidéos' => '/',\n                'Actualité' => '/14-actualite',\n                'Sport' => '/3-sport',\n                'Culture-Loisirs' => '/5-culture-loisirs',\n                'Société' => '/15-societe',\n                'Langue Bretonne' => '/9-langue-bretonne'\n            ]\n        ]\n    ]];\n\n    public function getIcon()\n    {\n        return self::URI . 'images/header_logo.png';\n    }\n\n    public function collectData()\n    {\n        $url = self::URI . '/le-replay/' . $this->getInput('cat');\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('div[id=items_replay] div.replay') as $element) {\n            $item = [];\n            $item['uri'] = $element->find('a', 0)->href;\n            $item['title'] = $element->find('h3', 0)->plaintext;\n            $item['timestamp'] = strtotime($element->find('p.moment-format-day', 0)->plaintext);\n            $item['content'] = '<a href=\"' . $item['uri'] . '\"><img alt=\"\" src=\"' . $element->find('img', 0)->src . '\"></a>';\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TeefuryBridge.php",
    "content": "<?php\n\nclass TeefuryBridge extends BridgeAbstract\n{\n    const NAME = 'Teefury';\n    const URI = 'https://www.teefury.com';\n    const DESCRIPTION = 'Returns the daily designs';\n    const MAINTAINER = 'Bockiii';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 60 * 60 * 3; // 3 hours\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n        $html = defaultLinkTo($html, self::URI);\n\n        foreach ($html->find('div.odad-card__wrapper') as $element) {\n            $titletext = $element->find('p', 0)->innertext;\n            $title = trim(explode('<br>', $titletext)[0]);\n            $today = date('m/d/Y');\n            $shirtinfo = $element->find('div[id*=\"img-color-art\"]', 0);\n            $uri = self::URI . $shirtinfo->attr['data-link'];\n            $item = [];\n            $item['uri'] = $uri;\n            $item['title'] = $title;\n            $item['uid'] = $title;\n            $item['timestamp'] = $today;\n            $item['content'] = $element->find('p', 0)\n            . '<br><a href=\"'\n            . $uri\n            . '\"><img src=\"'\n            . $shirtinfo->find('img', 0)->attr['src']\n            . '\" /></a>';\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TelegramBridge.php",
    "content": "<?php\n\nclass TelegramBridge extends BridgeAbstract\n{\n    const NAME = 'Telegram';\n    const URI = 'https://t.me';\n    const DESCRIPTION = 'Returns newest posts from a *public* Telegram channel';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n            'username' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '@rssbridge',\n            ]\n        ]\n    ];\n\n    const CONFIGURATION = [\n        'max_pages' => [\n            'required'      => false,\n            'defaultValue'  => 1,\n        ],\n    ];\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://t.me/s/rssbridge' => ['username' => 'rssbridge'],\n        'https://t.me/rssbridge' => ['username' => 'rssbridge'],\n        'http://t.me/rssbridge' => ['username' => 'rssbridge'],\n        'https://telegram.me/s/rssbridge' => ['username' => 'rssbridge'],\n        'https://telegram.me/rssbridge' => ['username' => 'rssbridge'],\n        'http://telegram.me/rssbridge' => ['username' => 'rssbridge'],\n        'http://rssbridge.t.me/' => ['username' => 'rssbridge'],\n        'https://rssbridge.t.me/' => ['username' => 'rssbridge'],\n    ];\n\n    const CACHE_TIMEOUT = 60 * 60; // 1h\n    private $feedName = '';\n    private $feedIcon = '';\n\n    private $enclosures = [];\n    private $itemTitle = '';\n\n    private const BACKGROUND_IMAGE_REGEX = \"/background-image:url\\('(.*)'\\)/\";\n\n    public function collectData()\n    {\n        $pages = 0;\n        $url = 'https://t.me/s/' . $this->normalizeUsername();\n\n        $max_pages = $this->getOption('max_pages');\n\n        // Hard-coded upper bound of 100 loops\n        while ($pages < $max_pages && $pages < 100) {\n            $pages++;\n\n            $dom = getSimpleHTMLDOM($url);\n\n            if (!$this->feedName) {\n                $channelTitle = $dom->find('div.tgme_channel_info_header_title span', 0)->plaintext ?? '';\n                $channelTitle = htmlspecialchars_decode($channelTitle, ENT_QUOTES);\n                $this->feedName = $channelTitle . ' (@' . $this->normalizeUsername() . ')';\n            }\n\n            if (!$this->feedIcon) {\n                $photoImg = $dom->find('i.tgme_page_photo_image img', 0);\n                if ($photoImg) {\n                    $this->feedIcon = $photoImg->src;\n                }\n            }\n\n            $messages = $dom->find('div.tgme_widget_message_wrap.js-widget_message_wrap');\n            if (!$channelTitle && !$messages) {\n                throwClientException('Unable to find channel. The channel is non-existing or non-public.');\n            }\n\n            foreach (array_reverse($messages) as $message) {\n                $this->itemTitle = '';\n                $this->enclosures = [];\n\n                $item = [];\n\n                $item['uri'] = $message->find('a.tgme_widget_message_date', 0)->href;\n                $item['content'] = $this->processContent($message);\n                $item['title'] = $this->itemTitle;\n                $item['timestamp'] = $message->find('span.tgme_widget_message_meta', 0)->find('time', 0)->datetime;\n                $item['enclosures'] = $this->enclosures;\n\n                $messageOwner = $message->find('a.tgme_widget_message_owner_name', 0);\n                if ($messageOwner) {\n                    $item['author'] = html_entity_decode(trim($messageOwner->plaintext), ENT_QUOTES);\n                }\n\n                array_unshift($this->items, $item);\n            }\n\n            $more = $dom->find('> div.tgme_widget_message_centered.js-messages_more_wrap a', 0);\n            if ($more && str_contains($more->href, 'before')) {\n                $url = 'https://t.me/' . $more->href;\n            } else {\n                break;\n            }\n        }\n\n        $this->logger->debug(sprintf('Fetched %s messages from %s pages (%s)', count($this->items), $pages, $url));\n\n        $this->items = array_reverse($this->items);\n    }\n\n    private function processContent($messageDiv)\n    {\n        $message = '';\n\n        $notSupported = $messageDiv->find('div.message_media_not_supported_wrap', 0);\n        if ($notSupported) {\n            // For unknown reasons, the telegram preview page omits the content of this post\n            $message = (string) $notSupported->innertext;\n        }\n\n        if ($messageDiv->find('div.tgme_widget_message_forwarded_from', 0)) {\n            $message .= $messageDiv->find('div.tgme_widget_message_forwarded_from', 0)->innertext . '<br><br>';\n        }\n\n        if ($messageDiv->find('a.tgme_widget_message_reply', 0)) {\n            $message .= $this->processReply($messageDiv);\n        }\n\n        if ($messageDiv->find('div.tgme_widget_message_sticker_wrap', 0)) {\n            $message .= $this->processSticker($messageDiv);\n        }\n\n        if ($messageDiv->find('div.tgme_widget_message_poll', 0)) {\n            $message .= $this->processPoll($messageDiv);\n        }\n\n        if ($messageDiv->find('video', 0)) {\n            $message .= $this->processVideo($messageDiv);\n        }\n\n        if ($messageDiv->find('a.tgme_widget_message_photo_wrap', 0)) {\n            $message .= $this->processPhoto($messageDiv);\n        }\n\n        if ($messageDiv->find('a.not_supported', 0)) {\n            $message .= $this->processNotSupported($messageDiv);\n        }\n\n        if ($messageDiv->find('div.tgme_widget_message_text.js-message_text', 0)) {\n            $message .= $messageDiv->find('div.tgme_widget_message_text.js-message_text', 0);\n\n            $this->itemTitle = $this->ellipsisTitle(\n                $messageDiv->find('div.tgme_widget_message_text.js-message_text', 0)->plaintext\n            );\n        }\n\n        if ($messageDiv->find('div.tgme_widget_message_document', 0)) {\n            $message .= $this->processAttachment($messageDiv);\n        }\n\n        if ($messageDiv->find('a.tgme_widget_message_link_preview', 0)) {\n            $message .= $this->processLinkPreview($messageDiv);\n        }\n\n        if ($messageDiv->find('a.tgme_widget_message_location_wrap', 0)) {\n            $message .= $this->processLocation($messageDiv);\n        }\n\n        return $message;\n    }\n\n    public function getURI()\n    {\n        $username = $this->getInput('username');\n        if ($username) {\n            return self::URI . '/s/' . $this->normalizeUsername();\n        }\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if ($this->feedName) {\n            return $this->feedName . ' - Telegram';\n        }\n        return parent::getName();\n    }\n\n    public function getIcon()\n    {\n        if ($this->feedIcon) {\n            return $this->feedIcon;\n        }\n        return parent::getIcon();\n    }\n\n    private function processReply($messageDiv)\n    {\n        $reply = $messageDiv->find('a.tgme_widget_message_reply', 0);\n        $author = $reply->find('span.tgme_widget_message_author_name', 0)->plaintext;\n        $text = '';\n\n        if ($reply->find('div.tgme_widget_message_metatext', 0)) {\n            $text = $reply->find('div.tgme_widget_message_metatext', 0)->innertext;\n        }\n\n        if ($reply->find('div.tgme_widget_message_text', 0)) {\n            $text = $reply->find('div.tgme_widget_message_text', 0)->innertext;\n        }\n\n        return <<<EOD\n<blockquote>{$author}<br>\n{$text}\n<a href=\"{$reply->href}\">{$reply->href}</a></blockquote><hr>\nEOD;\n    }\n\n    private function processSticker($messageDiv)\n    {\n        if (!$this->itemTitle) {\n            $this->itemTitle = '@' . $this->normalizeUsername() . ' posted a sticker';\n        }\n\n        $stickerDiv = $messageDiv->find('div.tgme_widget_message_sticker_wrap', 0);\n\n        if ($stickerDiv->find('picture', 0)) {\n            $stickerDiv->find('picture', 0)->find('div', 0)->style = '';\n            $stickerDiv->find('picture', 0)->style = '';\n\n            return $stickerDiv;\n        }\n\n        $var = $stickerDiv->find('i', 0);\n        if ($var) {\n            $style = $var->style;\n            if (preg_match(self::BACKGROUND_IMAGE_REGEX, $style, $sticker)) {\n                return <<<EOD\n\t\t\t\t<a href=\"{$stickerDiv->children(0)->herf}\"><img src=\"{$sticker[1]}\"></a>\nEOD;\n            }\n        }\n\n        return '';\n    }\n\n    private function processPoll($messageDiv)\n    {\n        $poll = $messageDiv->find('div.tgme_widget_message_poll', 0);\n\n        $title = $poll->find('div.tgme_widget_message_poll_question', 0)->plaintext;\n        $type = $poll->find('div.tgme_widget_message_poll_type', 0)->plaintext;\n\n        if (!$this->itemTitle) {\n            $this->itemTitle = $title;\n        }\n\n        $pollOptions = '<ul>';\n\n        foreach ($poll->find('div.tgme_widget_message_poll_option') as $option) {\n            $pollOptions .= '<li>' . $option->children(0)->plaintext . ' - ' .\n                $option->find('div.tgme_widget_message_poll_option_text', 0)->plaintext . '</li>';\n        }\n        $pollOptions .= '</ul>';\n\n        return <<<EOD\n\t\t\t{$title}<br><small>$type</small><br>{$pollOptions}\nEOD;\n    }\n\n    private function processLinkPreview($messageDiv)\n    {\n        $image = '';\n        $title = '';\n        $site = '';\n        $description = '';\n\n        $preview = $messageDiv->find('a.tgme_widget_message_link_preview', 0);\n\n        if (trim($preview->innertext) === '') {\n            return '';\n        }\n\n        if (\n            $preview->find('i', 0) &&\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $preview->find('i', 0)->style, $photo)\n        ) {\n            $image = '<img src=\"' . $photo[1] . '\"/>';\n        }\n\n        if ($preview->find('div.link_preview_title', 0)) {\n            $title = $preview->find('div.link_preview_title', 0)->plaintext;\n        }\n\n        if ($preview->find('div.link_preview_site_name', 0)) {\n            $site = $preview->find('div.link_preview_site_name', 0)->plaintext;\n        }\n\n        if ($preview->find('div.link_preview_description', 0)) {\n            $description = $preview->find('div.link_preview_description', 0)->plaintext;\n        }\n\n        return <<<EOD\n<blockquote><a href=\"{$preview->href}\">{$image}</a><br><a href=\"{$preview->href}\">\n{$title} - {$site}</a><br>{$description}</blockquote>\nEOD;\n    }\n\n    private function processVideo($messageDiv)\n    {\n        if (!$this->itemTitle) {\n            $this->itemTitle = '@' . $this->normalizeUsername() . ' posted a video';\n        }\n\n        if ($messageDiv->find('i.tgme_widget_message_video_thumb')) {\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $messageDiv->find('i.tgme_widget_message_video_thumb', 0)->style, $photo);\n        } elseif ($messageDiv->find('i.link_preview_video_thumb')) {\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $messageDiv->find('i.link_preview_video_thumb', 0)->style, $photo);\n        } elseif ($messageDiv->find('i.tgme_widget_message_roundvideo_thumb')) {\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $messageDiv->find('i.tgme_widget_message_roundvideo_thumb', 0)->style, $photo);\n        } else {\n            // Not all videos have a poster image\n            $photo = [null, null];\n        }\n\n        $this->enclosures[] = $photo[1];\n\n        // Intentionally omitting preload=\"none\" on <video>\n        return <<<EOD\n<video controls=\"\" poster=\"{$photo[1]}\" style=\"max-width:100%;\">\n\t<source src=\"{$messageDiv->find('video', 0)->src}\" type=\"video/mp4\">\n</video>\nEOD;\n    }\n\n    private function processPhoto($messageDiv)\n    {\n        if (!$this->itemTitle) {\n            $this->itemTitle = '@' . $this->normalizeUsername() . ' posted a photo';\n        }\n\n        $photos = '';\n\n        foreach ($messageDiv->find('a.tgme_widget_message_photo_wrap') as $photoWrap) {\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $photoWrap->style, $photo);\n\n            $photos .= <<<EOD\n<a href=\"{$photoWrap->href}\"><img src=\"{$photo[1]}\"/></a><br>\nEOD;\n        }\n        return $photos;\n    }\n\n    private function processNotSupported($messageDiv)\n    {\n        if (!$this->itemTitle) {\n            $this->itemTitle = '@' . $this->normalizeUsername() . ' posted a video';\n        }\n\n        if ($messageDiv->find('i.tgme_widget_message_video_thumb')) {\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $messageDiv->find('i.tgme_widget_message_video_thumb', 0)->style, $photo);\n        } elseif ($messageDiv->find('i.link_preview_video_thumb')) {\n            preg_match(self::BACKGROUND_IMAGE_REGEX, $messageDiv->find('i.link_preview_video_thumb', 0)->style, $photo);\n        } else {\n            // Unsupported content ususally don't have a preview image\n            $photo = [null, null];\n        }\n\n        return <<<EOD\n<a href=\"{$messageDiv->find('a.not_supported', 0)->href}\">\n{$messageDiv->find('div.message_media_not_supported_label', 0)->innertext}<br><br>\n{$messageDiv->find('span.message_media_view_in_telegram', 0)->innertext}<br><br>\n<img src=\"{$photo[1]}\"/></a>\nEOD;\n    }\n\n    private function processAttachment($messageDiv)\n    {\n        $attachments = 'File attachments:<br>';\n\n        if (!$this->itemTitle) {\n            $this->itemTitle = '@' . $this->normalizeUsername() . ' posted an attachment';\n        }\n\n        foreach ($messageDiv->find('div.tgme_widget_message_document') as $document) {\n            $attachments .= <<<EOD\n{$document->find('div.tgme_widget_message_document_title', 0)->plaintext} -\n{$document->find('div.tgme_widget_message_document_extra', 0)->plaintext}<br>\nEOD;\n        }\n\n        return $attachments;\n    }\n\n    private function processLocation($messageDiv)\n    {\n        if (!$this->itemTitle) {\n            $this->itemTitle = '@' . $this->normalizeUsername() . ' posted a location';\n        }\n\n        preg_match(self::BACKGROUND_IMAGE_REGEX, $messageDiv->find('div.tgme_widget_message_location', 0)->style, $image);\n\n        $link = $messageDiv->find('a.tgme_widget_message_location_wrap', 0)->href;\n\n        return <<<EOD\n\t\t\t<a href=\"{$link}\"><img src=\"{$image[1]}\"></a>\nEOD;\n    }\n\n    // todo: extract truncate\n    private function ellipsisTitle($text)\n    {\n        $length = 100;\n        if (strlen($text) > $length) {\n            $text = explode('<br>', wordwrap($text, $length, '<br>'));\n            return $text[0] . '...';\n        }\n        return $text;\n    }\n\n    private function normalizeUsername()\n    {\n        $username = trim($this->getInput('username'));\n\n        return ltrim($username, '@');\n    }\n\n    public function detectParameters($url)\n    {\n        $detectParamsRegex = '/^https?:\\/\\/(?:(?:t|telegram)\\.me\\/(?:s\\/)?([\\w]+)|([\\w]+)\\.t\\.me\\/?)$/';\n        $params = [];\n        if (preg_match($detectParamsRegex, $url, $matches) > 0) {\n            if ($matches[1] !== '') {\n                $params['username'] = $matches[1];\n            }\n\n            if (isset($matches[2]) && $matches[2] !== '') {\n                $params['username'] = $matches[2];\n            }\n\n            return $params;\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/TestFaktaBridge.php",
    "content": "<?PHP\n\nclass TestFaktaBridge extends BridgeAbstract\n{\n    const NAME = 'Testfakta';\n    const URI = 'https://www.testfakta.se';\n    const DESCRIPTION = 'Letest independent tests by Testfakta';\n    const MAINTAINER = 'ajain-93';\n\n    public function getIcon()\n    {\n        return self::URI . '/themes/testfakta/favicon.ico';\n    }\n\n    private function parseSwedishDates($dateString)\n    {\n        // Mapping of Swedish month names to English month names\n        $months = [\n            'Jan' => 'Jan',\n            'Feb' => 'Feb',\n            'Mar' => 'Mar',\n            'Apr' => 'Apr',\n            'Maj' => 'May',\n            'Jun' => 'Jun',\n            'Jul' => 'Jul',\n            'Aug' => 'Aug',\n            'Sep' => 'Sep',\n            'Okt' => 'Oct',\n            'Nov' => 'Nov',\n            'Dec' => 'Dec'\n        ];\n\n        // Replace Swedish month names with English\n        $dateString = preg_replace_callback(\n            '/\\b(' . implode('|', array_keys($months)) . ')\\b/',\n            function ($matches) use ($months) {\n                return $months[$matches[0]];\n            },\n            $dateString\n        );\n\n        // Create DateTime object\n        $dateValue = DateTime::createFromFormat(\n            'd M, Y',\n            trim($dateString),\n            new DateTimeZone('Europe/Stockholm')\n        );\n        if ($dateValue) {\n            $dateValue->setTime(0, 0); // Set time to 00:00\n            return $dateValue->getTimestamp();\n        }\n\n        return $dateValue ? $dateValue->getTimestamp() : false;\n    }\n\n    public function collectData()\n    {\n        $NEWSURL = self::URI . '/sv';\n        $html = getSimpleHTMLDOMCached($NEWSURL, 18000);\n\n        foreach ($html->find('.row-container') as $element) {\n            // Debug::log($element);\n\n            $title = $element->find('h2', 0)->plaintext;\n            $category = trim($element->find('.red-label', 0)->plaintext);\n            $url = self::URI . $element->find('a', 0)->getAttribute('href');\n            $figure = $element->find('img', 0);\n            $preamble = trim($element->find('.text', 0)->plaintext);\n\n            $article_html = getSimpleHTMLDOMCached($url, 18000);\n            $article_content = $article_html->find('div.content', 0);\n            $article_text = $article_html->find('article', 0);\n\n            $requestor = $article_html->find('div.uppdrag', 0)->plaintext;\n            $author = trim($article_html->find('span.name', 0)->plaintext);\n            $published = $this->parseSwedishDates(\n                str_replace(\n                    'Publicerad: ',\n                    '',\n                    trim($article_html->find('span.created', 0)->plaintext)\n                )\n            );\n\n            $content = $figure . '<br/>';\n            $content .= '<b>' . strtoupper($category) . '</b>  ' . $requestor . '<br/><br/>';\n            $content .= '<b><i>' . $preamble . '</i></b><br/><br/>';\n            $content .= $article_text;\n\n            $this->items[] = [\n                'uri' => $url,\n                'title' => $title,\n                'author' => $author,\n                'timestamp' => $published,\n                'content' => trim($content),\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TheDriveBridge.php",
    "content": "<?php\n\nclass TheDriveBridge extends FeedExpander\n{\n    const NAME = 'The Drive';\n    const URI = 'https://www.thedrive.com/';\n    const DESCRIPTION = 'Car news from thedrive.com';\n    const MAINTAINER = 't0stiman';\n    const DONATION_URI = 'https://ko-fi.com/tostiman';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://www.thedrive.com/feed', 20);\n    }\n\n    protected function parseItem($feedItem)\n    {\n        $item = parent::parseItem($feedItem);\n\n        //remove warzone articles\n        if (str_contains($item['uri'], 'the-war-zone')) {\n            return null;\n        }\n\n        //the first image in the article is an attachment for some reason\n        foreach ($item['enclosures'] as $attachment) {\n            $item['content'] = '<img src=\"' . $attachment . '\">' . $item['content'];\n        }\n        $item['enclosures'] = [];\n\n        //make youtube videos clickable\n        $html = str_get_html($item['content']);\n\n        foreach ($html->find('div.lazied-youtube-frame') as $youtubeVideoDiv) {\n            $videoID = $youtubeVideoDiv->getAttribute('data-video-id');\n\n            //place <a> around the <div>\n            $youtubeVideoDiv->outertext = handleYoutube($videoID);\n        }\n\n        $item['content'] = $html;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TheFarSideBridge.php",
    "content": "<?php\n\nclass TheFarSideBridge extends BridgeAbstract\n{\n    const NAME = 'The Far Side';\n    const URI = 'https://www.thefarside.com';\n    const DESCRIPTION = 'Returns the daily dose';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $div = $html->find('div.tfs-page-container__cows', 0);\n\n        $item = [];\n        $item['uri'] = $html->find('meta[property=\"og:url\"]', 0)->content;\n        $item['title'] = $div->find('h3', 0)->innertext;\n        $item['timestamp'] = $div->find('h3', 0)->innertext;\n        $item['content'] = '';\n\n        foreach ($div->find('div.card-body') as $index => $card) {\n            $image = $card->find('img', 0);\n            $imageUrl = $image->attr['data-src'];\n\n            $caption = '';\n\n            if ($card->find('figcaption', 0)) {\n                $caption = $card->find('figcaption', 0)->innertext;\n            }\n\n            $item['enclosures'][] = $imageUrl;\n            $item['content'] .= <<<EOD\n<figure>\n\t<img title=\"{$caption}\" src=\"{$imageUrl}\"/>\n\t<figcaption>{$caption}</figcaption>\n</figure>\n<br/>\nEOD;\n        }\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TheGuardianBridge.php",
    "content": "<?php\n\nclass TheGuardianBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'The Guardian';\n    const URI = 'https://www.theguardian.com/';\n    const CACHE_TIMEOUT = 600; // This is a news site, so don't cache for more than 10 mins\n    const DESCRIPTION = 'RSS feed for The Guardian';\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed',\n            'type' => 'list',\n            'values' => [\n                'World News' => 'world/rss',\n                'US News' => '/us-news/rss',\n                'UK News' => '/uk-news/rss',\n                'Australia News' => '/australia-news/rss',\n                'Europe News' => '/world/europe-news/rss',\n                'Asia News' => '/world/asia/rss',\n                'Tech' => '/uk/technology/rss',\n                'Business News' => '/uk/business/rss',\n                'Opinion' => '/uk/commentisfree/rss',\n                'Lifestyle' => '/uk/lifeandstyle/rss',\n                'Culture' => '/uk/culture/rss',\n                'Sports' => '/uk/sport/rss'\n            ]\n        ]\n\n        /*\n\n        Topicwise Links\n\n        You can find the base feed for any topic by appending /rss to the url.\n\n        Example:\n\n        https://feeds.theguardian.com/theguardian/uk-news/rss\n        https://feeds.theguardian.com/theguardian/us-news/rss\n\n        Or simply\n\n        https://www.theguardian.com/world/rss\n\n        Just add that topic as a value in the PARAMETERS const.\n\n        */\n\n\n    ]];\n\n    public function collectData()\n    {\n        $feed = $this->getInput('feed');\n        $url = 'https://feeds.theguardian.com/theguardian/' . $feed;\n        $this->collectExpandableDatas($url, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOM($item['uri']);\n        // figure contain's the main article image\n        $article = $articlePage->find('figure', 0);\n        // content__article-body has the actual article\n        foreach ($articlePage->find('#maincontent') as $element) {\n            $article = $article . $element;\n        }\n\n        // --- Fixing ugly elements ---\n\n        // Replace the image viewer and BS with the image itself\n        foreach ($articlePage->find('a.article__img-container') as $uslElementLoc) {\n            $main_img = $uslElementLoc->find('img', 0);\n            $article = str_replace($uslElementLoc, $main_img, $article);\n        }\n\n        // List of all the crap in the article\n        $uselessElements = [\n            'span > figcaption',\n            '#show-caption',\n            '.element-atom',\n            '.submeta',\n            'youtube-media-atom',\n            'svg',\n            '#the-checkbox',\n        ];\n\n        // Remove the listed crap\n        foreach ($uselessElements as $uslElement) {\n            foreach ($articlePage->find($uslElement) as $uslElementLoc) {\n                $article = str_replace($uslElementLoc, '', $article);\n            }\n        }\n\n        $item['content'] = $article;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TheHackerNewsBridge.php",
    "content": "<?php\n\nclass TheHackerNewsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'The Hacker News';\n    const URI = 'https://thehackernews.com/';\n    const DESCRIPTION = 'Cyber Security, Hacking, Technology News.';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $html = convertLazyLoading($html);\n        $html = defaultLinkTo($html, $this->getURI());\n        $limit = 0;\n\n        foreach ($html->find('div.body-post') as $element) {\n            if ($limit >= 5) {\n                break;\n            }\n\n            // Author (not present on home page)\n            $article_author = null;\n\n            // Title\n            $article_title = $element->find('h2.home-title', 0)->plaintext;\n\n            // Date\n            $article_timestamp = time();\n            $calendar = $element->find('i.icon-calendar', 0);\n            if ($calendar) {\n                $article_timestamp = strtotime(\n                    extractFromDelimiters(\n                        $calendar->parent()->outertext,\n                        '</i>',\n                        '</span>'\n                    )\n                );\n            }\n\n            // Thumbnail\n            $article_thumbnail = [];\n            if (is_object($element->find('img', 0))) {\n                $article_thumbnail = [ $element->find('img', 0)->src ];\n            }\n\n            // Content (truncated)\n            $article_content = $element->find('div.home-desc', 0)->plaintext;\n\n            // Now try expanding article\n            $article_url = $element->find('a.story-link', 0)->href;\n            $article_html = getSimpleHTMLDOMCached($article_url);\n            if ($article_html) {\n                // Content (expanded and cleaned)\n                $article_body = $article_html->find('div.articlebody', 0);\n                if ($article_body) {\n                    $article_body = convertLazyLoading($article_body);\n                    $article_body = defaultLinkTo($article_body, $article_url);\n                    $header_img = $article_body->find('img', 0);\n                    if ($header_img) {\n                        $header_img->parent->style = '';\n                    }\n                    foreach ($article_body->find('center.cf') as $center_ad) {\n                        $center_ad->outertext = '';\n                    }\n                    $article_content = $article_body->innertext;\n                }\n                // Author\n                $spans_author = $article_html->find('span.author');\n                if (count($spans_author) > 0) {\n                    $article_author = $spans_author[array_key_last($spans_author)]->plaintext;\n                }\n            }\n\n            $item = [];\n            $item['uri'] = $article_url;\n            $item['title'] = $article_title;\n            if (!empty($article_author)) {\n                $item['author'] = $article_author;\n            }\n            $item['enclosures'] = $article_thumbnail;\n            $item['timestamp'] = $article_timestamp;\n            $item['content'] = trim($article_content);\n            $this->items[] = $item;\n            $limit++;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TheOatmealBridge.php",
    "content": "<?php\n\nclass TheOatmealBridge extends FeedExpander\n{\n    const NAME = 'The Oatmeal';\n    const URI = 'https://theoatmeal.com/';\n    const DESCRIPTION = 'Fetch the entire comic image';\n    const MAINTAINER = 't0stiman';\n    const DONATION_URI = 'https://ko-fi.com/tostiman';\n\n    public function collectData()\n    {\n        $url = self::URI . 'feed/rss';\n        $this->collectExpandableDatas($url, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $page = getSimpleHTMLDOMCached($item['uri']);\n        $comicImage = $page->find('#comic > p > img', 0);\n        $item['content'] = $comicImage;\n\n        return $item;\n    }\n}"
  },
  {
    "path": "bridges/ThePirateBayBridge.php",
    "content": "<?php\n\n/**\n * Much of the logic here is copied from https://thepiratebay.org/static/main.js\n */\nclass ThePirateBayBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dvikan';\n    const NAME = 'The Pirate Bay';\n    const URI = 'https://thepiratebay.org';\n    const DESCRIPTION = 'Returns results for the keywords. You can put several\n list of keywords by separating them with a semicolon (e.g. \"one show;another\n show\"). Category based search needs the category number as input. User based\n search takes the Uploader name. Search can be done in a specified category';\n\n    const PARAMETERS = [ [\n        'q' => [\n            'name' => 'keywords/username/category, separated by semicolons',\n            'exampleValue' => 'simpsons',\n            'required' => true\n        ],\n        'crit' => [\n            'type' => 'list',\n            'name' => 'Search type',\n            'values' => [\n                'search' => 'search',\n                'category' => 'cat',\n                'user' => 'usr',\n            ]\n        ],\n        'catCheck' => [\n            'type' => 'checkbox',\n            'name' => 'Specify category for keyword search ?',\n        ],\n        'cat' => [\n            'name' => 'Category number',\n            'exampleValue' => '100, 200… See TPB for category number'\n        ],\n        'trusted' => [\n            'type' => 'checkbox',\n            'name' => 'Only get results from Trusted or VIP users ?',\n        ],\n    ]];\n\n    const STATIC_SERVER = 'https://torrindex.net';\n\n    const CATEGORIES = [\n        '1' => 'Audio',\n        '2' => 'Video',\n        '3' => 'Applications',\n        '4' => 'Games',\n        '5' => 'Porn',\n        '6' => 'Other',\n        '101' => 'Music',\n        '102' => 'Audio Books',\n        '103' => 'Sound clips',\n        '104' => 'FLAC',\n        '199' => 'Other',\n        '201' => 'Movies',\n        '202' => 'Movies DVDR',\n        '203' => 'Music videos',\n        '204' => 'Movie Clips',\n        '205' => 'TV-Shows',\n        '206' => 'Handheld',\n        '207' => 'HD Movies',\n        '208' => 'HD TV-Shows',\n        '209' => '3D',\n        '210' => 'CAM/TS',\n        '211' => 'UHD/4k Movies',\n        '212' => 'UHD/4k TV-Shows',\n        '299' => 'Other',\n        '301' => 'Windows',\n        '302' => 'Mac/Apple',\n        '303' => 'UNIX',\n        '304' => 'Handheld',\n        '305' => 'IOS(iPad/iPhone)',\n        '306' => 'Android',\n        '399' => 'Other OS',\n        '401' => 'PC',\n        '402' => 'Mac/Apple',\n        '403' => 'PSx',\n        '404' => 'XBOX360',\n        '405' => 'Wii',\n        '406' => 'Handheld',\n        '407' => 'IOS(iPad/iPhone)',\n        '408' => 'Android',\n        '499' => 'Other OS',\n        '501' => 'Movies',\n        '502' => 'Movies DVDR',\n        '503' => 'Pictures',\n        '504' => 'Games',\n        '505' => 'HD-Movies',\n        '506' => 'Movie Clips',\n        '507' => 'UHD/4k Movies',\n        '599' => 'Other',\n        '601' => 'E-books',\n        '602' => 'Comics',\n        '603' => 'Pictures',\n        '604' => 'Covers',\n        '605' => 'Physibles',\n        '699' => 'Other',\n    ];\n\n    public function collectData()\n    {\n        $keywords = explode(';', $this->getInput('q'));\n\n        foreach ($keywords as $keyword) {\n            $this->processKeyword($keyword);\n        }\n    }\n\n    private function processKeyword($keyword)\n    {\n        $keyword = trim($keyword);\n        switch ($this->getInput('crit')) {\n            case 'search':\n                $catCheck = $this->getInput('catCheck');\n                if ($catCheck) {\n                    $categories = $this->getInput('cat');\n                    $query = sprintf(\n                        '/q.php?q=%s&cat=%s',\n                        rawurlencode($keyword),\n                        rawurlencode($categories)\n                    );\n                } else {\n                    $query = sprintf('/q.php?q=%s', rawurlencode($keyword));\n                }\n                break;\n            case 'cat':\n                $query = sprintf('/q.php?q=category:%s', rawurlencode($keyword));\n                break;\n            case 'usr':\n                $query = sprintf('/q.php?q=user:%s', rawurlencode($keyword));\n                break;\n            default:\n                throwClientException('Impossible');\n        }\n        $api = 'https://apibay.org';\n        $json = getContents($api . $query);\n        $result = json_decode($json);\n\n        if ($result[0]->name === 'No results returned') {\n            return;\n        }\n        foreach ($result as $torrent) {\n            // This is the check for whether to include results from Trusted or VIP users\n            if (\n                $this->getInput('trusted')\n                && !in_array($torrent->status, ['vip', 'trusted'])\n            ) {\n                continue;\n            }\n            $this->processTorrent($torrent);\n        }\n    }\n\n    private function processTorrent($torrent)\n    {\n        // Extracted these trackers from the magnet links on thepiratebay.org\n        $trackers = [\n            'udp://tracker.coppersurfer.tk:6969/announce',\n            'udp://tracker.openbittorrent.com:6969/announce',\n            'udp://9.rarbg.to:2710/announce',\n            'udp://9.rarbg.me:2780/announce',\n            'udp://9.rarbg.to:2730/announce',\n            'udp://tracker.opentrackr.org:1337',\n            'http://p4p.arenabg.com:1337/announce',\n            'udp://tracker.torrent.eu.org:451/announce',\n            'udp://tracker.tiny-vps.com:6969/announce',\n            'udp://open.stealth.si:80/announce',\n        ];\n\n        $magnetLink = sprintf(\n            'magnet:?xt=urn:btih:%s&dn=%s',\n            $torrent->info_hash,\n            rawurlencode($torrent->name)\n        );\n        foreach ($trackers as $tracker) {\n            // Build magnet link manually instead of using http_build_query because it\n            // creates undesirable query such as ?tr[0]=foo&tr[1]=bar&tr[2]=baz\n            $magnetLink .= '&tr=' . rawurlencode($tracker);\n        }\n\n        $item = [];\n\n        $item['title'] = $torrent->name;\n        // This uri should be a magnet link so that feed readers can easily pick it up.\n        // However, rss-bridge only allows http or https schemes\n        $item['uri'] = sprintf('%s/description.php?id=%s', self::URI, $torrent->id);\n        $item['timestamp'] = $torrent->added;\n        $item['author'] = $torrent->username;\n\n        $content  = '<b>Type:</b> '\n            . $this->renderCategory($torrent->category) . '<br>';\n        $content .= \"<b>Files:</b> $torrent->num_files<br>\";\n        $content .= '<b>Size:</b> ' . $this->renderSize($torrent->size) . '<br><br>';\n\n        $content .= '<b>Uploaded:</b> '\n            . $this->renderUploadDate($torrent->added) . '<br>';\n        $content .= '<b>By:</b> ' . $this->renderUser($torrent) . '<br>';\n\n        $content .= \"<b>Seeders:</b> {$torrent->seeders}<br>\";\n        $content .= \"<b>Leechers:</b> {$torrent->leechers}<br>\";\n        $content .= \"<b>Info hash:</b> {$torrent->info_hash}<br><br>\";\n\n        if ($torrent->imdb) {\n            $content .= '<b>Imdb:</b> '\n                . $this->renderImdbLink($torrent->imdb) . '<br><br>';\n        }\n\n        $html = <<<HTML\n<a href=\"%s\">\n\t<img src=\"%s/images/icon-magnet.gif\"> GET THIS TORRENT\n</a>\n<br>\nHTML;\n        $content .= sprintf($html, $magnetLink, self::STATIC_SERVER);\n\n        $item['content'] = $content;\n\n        $this->items[] = $item;\n    }\n\n    private function renderSize($size)\n    {\n        if ($size < 1024) {\n            return $size . ' B';\n        }\n        if ($size < pow(1024, 2)) {\n            return round($size / 1024, 2) . ' KB';\n        }\n        if ($size < pow(1024, 3)) {\n            return round($size / pow(1024, 2), 2) . ' MB';\n        }\n        if ($size < pow(1024, 4)) {\n            return round($size / pow(1024, 3), 2) . ' GB';\n        }\n\n        return round($size / pow(1024, 4), 2) . ' TB';\n    }\n\n    private function renderUploadDate($added)\n    {\n        return date('Y-m-d', $added ?: time());\n    }\n\n    private function renderCategory($category)\n    {\n        $mainCategory = sprintf(\n            '<a href=\"%s/search.php?q=category:%s\">%s</a>',\n            self::URI,\n            $category[0] . '00',\n            self::CATEGORIES[$category[0]]\n        );\n\n        $subCategory = sprintf(\n            '<a href=\"%s/search.php?q=category:%s\">%s</a>',\n            self::URI,\n            $category,\n            self::CATEGORIES[$category]\n        );\n\n        return sprintf('%s > %s', $mainCategory, $subCategory);\n    }\n\n    private function renderUser($torrent)\n    {\n        if ($torrent->username === 'Anonymous') {\n            return $torrent->username . ' ' . $this->renderStatusImage($torrent->status);\n        }\n        return sprintf(\n            '<a href=\"%s/search.php?q=user:%s\">%s %s</a>',\n            self::URI,\n            $torrent->username,\n            $torrent->username,\n            $this->renderStatusImage($torrent->status)\n        );\n    }\n\n    private function renderStatusImage($status)\n    {\n        if ($status == 'trusted') {\n            return sprintf(\n                '<img src=\"%s/images/trusted.png\" title=\"Trusted\"/>',\n                self::STATIC_SERVER\n            );\n        }\n        if ($status == 'vip') {\n            return sprintf(\n                '<img src=\"%s/images/vip.gif\" title=\"VIP\"/>',\n                self::STATIC_SERVER\n            );\n        }\n        if ($status == 'helper') {\n            return sprintf(\n                '<img src=\"%s/images/helper.png\" title=\"Helper\"/>',\n                self::STATIC_SERVER\n            );\n        }\n        if ($status == 'moderator') {\n            return sprintf(\n                '<img src=\"%s/images/moderator.gif\" title=\"Moderator\"/>',\n                self::STATIC_SERVER\n            );\n        }\n        if ($status == 'supermod') {\n            return sprintf(\n                '<img src=\"%s/images/supermod.png\" title=\"Super Mod\"/>',\n                self::STATIC_SERVER\n            );\n        }\n        if ($status == 'admin') {\n            return sprintf(\n                '<img src=\"%s/images/admin.gif\" title=\"Admin\"/>',\n                self::STATIC_SERVER\n            );\n        }\n\n        return '';\n    }\n\n    private function renderImdbLink($imdb)\n    {\n        return sprintf(\n            '<a href=\"%s\">%s</a>',\n            \"https://www.imdb.com/title/$imdb\",\n            \"https://www.imdb.com/title/$imdb\"\n        );\n    }\n}\n"
  },
  {
    "path": "bridges/TheRedHandFilesBridge.php",
    "content": "<?php\n\nclass TheRedHandFilesBridge extends BridgeAbstract\n{\n    const NAME = 'The Red Hand Files';\n    const URI = 'https://www.theredhandfiles.com';\n    const DESCRIPTION = 'The Red Hand Files, a Q&A blog by Nick Cave';\n    const MAINTAINER = 'somini';\n    /* The feed was available here: 'https://www.theredhandfiles.com/feed/'; */\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('#main article.posts__article') as $element) {\n            $item = [];\n\n            $html_title = $element->find('h2', 0);\n            $html_subtitle = $element->find('h3', 0);\n            $html_image = $element->find('.posts__article-img', 0);\n\n            $item['title'] = $html_subtitle->plaintext;\n            $item['uri'] = $html_title->find('a', 0)->href;\n            $item['content'] = $html_image->innertext . $html_title->plaintext;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TheWhiteboardBridge.php",
    "content": "<?php\n\nclass TheWhiteboardBridge extends BridgeAbstract\n{\n    const NAME = 'The Whiteboard';\n    const URI = 'https://www.the-whiteboard.com/';\n    const DESCRIPTION = 'Get the latest comic from The Whiteboard';\n    const MAINTAINER = 'CyberJacob';\n\n    public function collectData()\n    {\n        $item = [];\n\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $image = $html->find('center', 1)->find('img', 0);\n        $image->src = self::URI . '/' . $image->src;\n\n        $item['title'] = explode(\"\\r\\n\", $html->find('center', 1)->plaintext)[0];\n        $item['content'] = $image;\n        $item['timestamp'] = explode(\"\\r\\n\", $html->find('center', 1)->plaintext)[0];\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TheYeteeBridge.php",
    "content": "<?php\n\nclass TheYeteeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Monsieur Poutounours';\n    const NAME = 'TheYetee';\n    const URI = 'https://theyetee.com';\n    const CACHE_TIMEOUT = 14400; // 4 h\n    const DESCRIPTION = 'Fetch daily shirts from The Yetee';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        $div = $html->find('.module_timed-item.is--full');\n        foreach ($div as $element) {\n            $item = [];\n            $item['enclosures'] = [];\n\n            $title = $element->find('h2', 0)->plaintext;\n            $item['title'] = $title;\n\n            $author = trim($element->find('.module_timed-item--artist a', 0)->plaintext);\n            $item['author'] = $author;\n\n            $item['uri'] = static::URI;\n\n            $content = '<p>' . $title . ' by ' . $author . '</p>';\n            $photos = $element->find('a.img');\n            foreach ($photos as $photo) {\n                $content = $content . \"<br /><img src='$photo->href' />\";\n                $item['enclosures'][] = $photo->src;\n            }\n            $item['content'] = $content;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ThreadsBridge.php",
    "content": "<?php\n\nclass ThreadsBridge extends BridgeAbstract\n{\n    const NAME = 'Threads';\n    const URI = 'https://www.threads.net/';\n    const DESCRIPTION = 'Say more with Threads &#x2014; Instagram&#039;s new text app.';\n    const MAINTAINER = 'mdemoss';\n    const CACHE_TIMEOUT = 3600;\n\n    const PARAMETERS = [\n        'By username' => [\n            'u' => [\n                'name' => 'username',\n                'required' => true,\n                'exampleValue' => 'zuck',\n                'title' => 'Insert a user name'\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify number of posts to fetch',\n                'defaultValue' => 5\n            ]\n        ]\n    ];\n\n    protected $feedName = self::NAME;\n    public function getName()\n    {\n        return $this->feedName;\n    }\n\n    public function detectParameters($url)\n    {\n        // By username\n        $regex = '/^(https?:\\/\\/)?(www\\.)?threads\\.net\\/(@)?([^\\/?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By username';\n            $params['u'] = urldecode($matches[3]);\n            return $params;\n        }\n        return null;\n    }\n\n    public function getURI()\n    {\n        return self::URI . '@' . $this->getInput('u');\n    }\n\n    // https://stackoverflow.com/a/3975706/421140\n    // Found this in FlaschenpostBridge, modified to return an array and take an object.\n    private function recursiveFind($haystack, $needle)\n    {\n        $found = [];\n        $iterator = new \\RecursiveArrayIterator($haystack);\n        $recursive = new \\RecursiveIteratorIterator(\n            $iterator,\n            \\RecursiveIteratorIterator::SELF_FIRST\n        );\n        foreach ($recursive as $key => $value) {\n            if ($key === $needle) {\n                $found[] = $value;\n            }\n        }\n        return $found;\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached($this->getURI(), static::CACHE_TIMEOUT);\n\n        $jsonBlobs = $html->find('script[type=\"application/json\"]');\n\n        $gatheredCodes = [];\n        $limit = $this->getInput('limit');\n        foreach ($jsonBlobs as $jsonBlob) {\n            // The structure of the JSON document is likely to change, but we're looking for a \"code\" inside a \"post\"\n            foreach ($this->recursiveFind($this->recursiveFind(json_decode($jsonBlob->innertext), 'post'), 'code') as $candidateCode) {\n                // code should be like CzZk4-USq1O or Cy3m1VnRiwP or Cywjyrdv9T6 or CzZk4-USq1O\n                if (grapheme_strlen($candidateCode) == 11 and !in_array($candidateCode, $gatheredCodes)) {\n                    $gatheredCodes[] = $candidateCode;\n                    if (count($gatheredCodes) >= $limit) {\n                        break 2;\n                    }\n                }\n            }\n        }\n\n        $this->feedName = html_entity_decode($html->find('meta[property=og:title]', 0)->content);\n        // todo: meta[property=og:description] could populate the feed description\n\n        foreach ($gatheredCodes as $postCode) {\n            $item = [];\n            // post URL is like: https://www.threads.net/@zuck/post/Czrr520PZfh\n            $item['uri'] = $this->getURI() . '/post/' . $postCode;\n            $articleHtml = getSimpleHTMLDOMCached($item['uri'], 15778800); // cache time: six months\n\n            // Relying on meta tags ought to be more reliable.\n            if ($articleHtml->find('meta[property=og:type]', 0)->content != 'article') {\n                continue;\n            }\n            $item['title'] = $articleHtml->find('meta[property=og:description]', 0)->content;\n            $item['content'] = $articleHtml->find('meta[property=og:description]', 0)->content;\n            $item['author'] = html_entity_decode($articleHtml->find('meta[property=og:title]', 0)->content);\n\n            $imageUrl = $articleHtml->find('meta[property=og:image]', 0);\n            if ($imageUrl) {\n                $item['enclosures'][] = html_entity_decode($imageUrl->content);\n            }\n\n            // todo: parse hashtags out of content for $item['categories']\n            // todo: try to scrape out a timestamp for $item['timestamp'], it's not in the meta tags\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TicketioBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass TicketioBridge extends BridgeAbstract\n{\n    const NAME = 'Ticket.io';\n    const URI = 'https://www.ticket.io';\n    const DESCRIPTION = 'Provides updates for available events in a specific ticketshop on ticket.io';\n    const MAINTAINER = 'SebLaus';\n    const CACHE_TIMEOUT = 60 * 60 * 12; // 12 hours\n    const PARAMETERS = [\n        [\n            'Link' => [\n                'name'          => 'Link to Ticketpage',\n                'required'      => true,\n                'exampleValue'  => 'https://LOCATION.ticket.io'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getInput('Link'));\n\n        if (!$html) {\n            throwServerException('Could not retrieve website content.');\n        }\n\n        // Find all event rows\n        $eventRows = $html->find('tr.container');\n\n        foreach ($eventRows as $eventRow) {\n            // Get the event name\n            $eventName = $eventRow->find('a.a-eventlink', 0)->plaintext;\n\n            // Reduce eventName length if too long\n            if (strlen($eventName) > 35) {\n                $eventName = substr($eventName, 0, 35);\n            }\n\n            // Find the list item containing the date\n            $dateElement = $eventRow->find('ul.fa-ul li span', 2); // Third <span> inside the list item\n\n            // Check if the date element is found\n            if ($dateElement) {\n                $eventDate = $dateElement->plaintext;\n            } else {\n                $eventDate = 'Date not found';\n            }\n\n            // Get Picture\n            $imageElement = $eventRow->find('img', 0);\n            if ($imageElement) {\n                $image = $imageElement->src;\n            } else {\n                $image = '';\n            }\n\n\n            // Build title out of Name and Date\n            $eventTitle = $eventName . ' - ' . $eventDate;\n\n            // Link to the event page\n            $eventLink = $this->getInput('Link') . $eventRow->find('a.a-eventlink', 0)->href;\n\n            // Create a feed item with the title and link\n            $item = [];\n            $item['title'] = $eventTitle;\n            $item['uri'] = $eventLink;\n            $item['content'] = \"\n            <p><a href='$eventLink'>\n            <img src='$image'>\n            </a></p>\n            <p><a href='$eventLink'>More details</a></p>\n            \";\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TikTokBridge.php",
    "content": "<?php\n\nclass TikTokBridge extends BridgeAbstract\n{\n    const NAME = 'TikTok';\n    const URI = 'https://www.tiktok.com';\n    const DESCRIPTION = 'Returns posts';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'By user' => [\n            'username' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '@tiktok',\n            ]\n        ]];\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://www.tiktok.com/@tiktok' => [\n            'context' => 'By user',\n            'username' => '@tiktok',\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 60 * 60; // 1h\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached('https://www.tiktok.com/embed/' . $this->processUsername());\n        $authorProfilePicture = $html->find('img[data-e2e=creator-profile-userInfo-Avatar]', 0)->src ?? '';\n\n        $videos = $html->find('div[data-e2e=common-videoList-VideoContainer]');\n\n        foreach ($videos as $video) {\n            $item = [];\n\n            // Omit query string (remove tracking parameters)\n            $a = $video->find('a', 0);\n            $href = $a->href;\n            $parsedUrl = parse_url($href);\n            $url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . '/' . ltrim($parsedUrl['path'], '/');\n\n            $json = null;\n\n            // Sometimes the API fails to return data for a second, so try a few times\n            $attempts = 0;\n            do {\n                try {\n                    // Fetch the video embed data from the OEmbed API\n                    $json = getContents('https://www.tiktok.com/oembed?url=' . $url);\n                } catch (HttpException $e) {\n                    $attempts++;\n                    // Sleep 0.1s\n                    usleep(100000);\n                    continue;\n                }\n                break;\n            } while ($attempts < 3);\n\n            if ($json) {\n                $videoEmbedData = json_decode($json);\n            } else {\n                $videoEmbedData = new \\stdClass();\n                $videoEmbedData->title = $url;\n                $videoEmbedData->thumbnail_url = '';\n                $videoEmbedData->author_unique_id = '';\n            }\n\n            $title = $videoEmbedData->title;\n            $image = $videoEmbedData->thumbnail_url;\n            $views = $video->find('div[data-e2e=common-Video-Count]', 0)->plaintext;\n\n            $enclosures = [$image, $authorProfilePicture];\n\n            $item['uri'] = $url;\n            $item['title'] = $title;\n            $item['author'] = '@' . $videoEmbedData->author_unique_id;\n            $item['enclosures'] = $enclosures;\n            $item['content'] = <<<EOD\n<p>$title</p>\n<a href=\"{$url}\"><img src=\"{$image}\"/></a>\n<p>{$views} views<p><br/>\nEOD;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'By user':\n                return self::URI . '/' . $this->processUsername();\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By user':\n                return  $this->processUsername() . ' - TikTok';\n            default:\n                return parent::getName();\n        }\n    }\n\n    private function processUsername()\n    {\n        $username = trim($this->getInput('username'));\n        if (preg_match('#^https?://www\\.tiktok\\.com/@(.*)$#', $username, $m)) {\n            return '@' . $m[1];\n        }\n        if (substr($username, 0, 1) !== '@') {\n            return '@' . $username;\n        }\n        return $username;\n    }\n\n    public function detectParameters($url)\n    {\n        if (preg_match('/tiktok\\.com\\/(@[\\w]+)/', $url, $matches) > 0) {\n            return [\n                'context' => 'By user',\n                'username' => $matches[1]\n            ];\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/TinyLetterBridge.php",
    "content": "<?php\n\nclass TinyLetterBridge extends BridgeAbstract\n{\n    const NAME = 'Tiny Letter';\n    const URI = 'https://tinyletter.com/';\n    const DESCRIPTION = 'Tiny Letter is a mailing list service';\n    const MAINTAINER = 'somini';\n    const PARAMETERS = [\n        [\n            'username' => [\n                'name' => 'User Name',\n                'required' => true,\n                'exampleValue' => 'forwards',\n            ]\n        ]\n    ];\n\n    public function getName()\n    {\n        $username = $this->getInput('username');\n        if (!is_null($username)) {\n            return static::NAME . ' | ' . $username;\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        $username = $this->getInput('username');\n        if (!is_null($username)) {\n            return static::URI . urlencode($username);\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $archives = $this->getURI() . '/archive';\n        $html = getSimpleHTMLDOMCached($archives);\n\n        foreach ($html->find('.message-list li') as $element) {\n            $item = [];\n\n            $snippet = $element->find('p.message-snippet', 0);\n            $link = $element->find('.message-link', 0);\n\n            $item['title'] = $link->plaintext;\n            $item['content'] = $snippet->innertext;\n            $item['uri'] = $link->href;\n            $item['timestamp'] = strtotime($element->find('.message-date', 0)->plaintext);\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TldrTechBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass TldrTechBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'TLDR Tech Newsletter';\n    const URI = 'https://tldr.tech/';\n    const DESCRIPTION = 'Return newsletter articles from TLDR Tech';\n\n    const PARAMETERS = [\n        '' => [\n            'limit' => [\n                'name' => 'Maximum number of articles to return',\n                'type' => 'number',\n                'required' => true,\n                'defaultValue' => 10\n            ],\n            'topic' => [\n                'name' => 'Topic',\n                'type' => 'list',\n                'values' => [\n                    'Tech' => 'tech',\n                    'Dev' => 'dev',\n                    'AI' => 'ai',\n                    'Information Security' => 'infosec',\n                    'Product Management' => 'product',\n                    'DevOps' => 'devops',\n                    'Crypto' => 'crypto',\n                    'Design' => 'design',\n                    'Marketing' => 'marketing',\n                    'Founders' => 'founders',\n                    'Fintech' => 'fintech',\n                    'Data' => 'data',\n                    'IT' => 'it',\n                    'Hardware' => 'hardware',\n                ],\n                'defaultValue' => 'tech'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $topic = $this->getInput('topic');\n        $limit = $this->getInput('limit');\n\n        $url = self::URI . 'api/latest/' . $topic;\n        $response = getContents($url, [], [], true);\n        $location = $response->getHeader('Location');\n        $locationUrl = Url::fromString($location);\n\n        $this->extractItem($locationUrl);\n\n        $archives_url = self::URI . $topic . '/archives';\n        $archives_html = getSimpleHTMLDOM($archives_url);\n        $entries_root = $archives_html->find('div.content-center.mt-5', 0);\n        foreach ($entries_root->children() as $child) {\n            if ($child->tag != 'a') {\n                continue;\n            }\n            $itemUrl = Url::fromString(self::URI . ltrim($child->href, '/'));\n            if ($itemUrl == $locationUrl) {\n                continue;\n            }\n            $this->extractItem($itemUrl);\n            if (count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    private function extractItem(Url $url)\n    {\n        $pathParts = explode('/', $url->getPath());\n        $date = strtotime(end($pathParts));\n        try {\n            [$content, $title] = $this->extractContent($url);\n\n            $this->items[] = [\n                'uri'       => (string) $url,\n                'title'     => $title,\n                'timestamp' => $date,\n                'content'   => $content,\n            ];\n        } catch (HttpException $e) {\n            // archive occasionally returns broken URLs\n            return;\n        }\n    }\n\n    private function extractContent($url)\n    {\n        $html = getSimpleHTMLDOMCached($url);\n        $content = $html->find('div.content-center.mt-5', 0);\n        if (!$content) {\n            throw new \\Exception('Could not find content');\n        }\n        $subscribe_form = $content->find('div.mt-5 > div > form', 0);\n        if ($subscribe_form) {\n            $content->removeChild($subscribe_form->parent->parent);\n        }\n        $privacy_link = $content->find(\"a[href='/privacy']\", 0);\n        if ($privacy_link) {\n            $content->removeChild($privacy_link->parent->parent);\n        }\n        $headers = $content->find('h6.text-center.font-bold');\n        foreach ($headers as $header) {\n            $elem = $html->createElement('h3', $header->parent->plaintext);\n            $elem->style = 'margin-top: 1.2em; margin-bottom: 0.5em;';\n            $header_root = $header->parent;\n            foreach ($header_root->children() as $child) {\n                $header_root->removeChild($child);\n            }\n            $header_root->appendChild($elem);\n        }\n\n        foreach ($content->find('a.font-bold') as $a) {\n            $a->removeAttribute('class');\n            $elem = $html->createElement('b', $a->plaintext);\n            $a->removeChild($a->firstChild());\n            $a->appendChild($elem);\n        }\n        foreach ($content->children() as $child) {\n            if ($child->tag != 'div') {\n                continue;\n            }\n            foreach ($child->children() as $grandchild) {\n                if ($grandchild->tag == 'div') {\n                    $grandchild->style = 'margin-bottom: 12px;';\n                }\n            }\n        }\n        foreach ($content->find('section') as $section) {\n            if (count($section->children()) == 0) {\n                $content->removeChild($section);\n            }\n        }\n        $title = $content->find('h2', 0);\n        return [$content->innertext, $title->plaintext];\n    }\n}\n"
  },
  {
    "path": "bridges/TomsToucheBridge.php",
    "content": "<?php\n\nclass TomsToucheBridge extends BridgeAbstract\n{\n    const NAME = 'Toms Touché';\n    const URI = 'https://taz.de/#!tom=tomdestages';\n    const DESCRIPTION = 'Your daily dose of Toms Touche.';\n    const MAINTAINER = 'latz';\n    const CACHE_TIMEOUT = 3600; // 1h\n\n    public function collectData()\n    {\n        $url = 'https://taz.de/';\n        $html = getSimpleHTMLDOM($url); // Docs: https://simplehtmldom.sourceforge.io/docs/1.9/index.html\n        $date = $html->find('p[x-ref]');\n        $date = trim($date[0]->innertext);\n        [$day, $month, $year] = explode('.', $date);\n        $image = $html->find('img[alt=\"tom des tages\"]');\n\n        $item = [];\n        $item['title'] = \"Toms Touché - $date\";\n        $item['uri'] = 'https://taz.de/#!tom=tomdestages';\n        $item['timestamp'] = mktime(0, 0, 0, $month, $day, $year);\n        $item['content'] = $image[0] . '</img>'; // This isn't good HTML style, but at least syntactically correct\n        $item['uid'] = $image[0]->getAttribute('src');\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/TorrentGalaxyBridge.php",
    "content": "<?php\n\nclass TorrentGalaxyBridge extends BridgeAbstract\n{\n    const NAME = 'Torrent Galaxy';\n    const URI = 'https://torrentgalaxy.to';\n    const DESCRIPTION = 'Returns latest torrents';\n    const MAINTAINER = 'GregThib';\n    const CACHE_TIMEOUT = 14400; // 24h = 86400s\n\n    const PARAMETERS = [\n        [\n            'search' => [\n                'name' => 'search',\n                'required' => true,\n                'exampleValue' => 'simpsons',\n                'title' => 'Type your query'\n            ],\n            'lang' => [\n                'name' => 'language',\n                'type' => 'list',\n                'exampleValue' => 'All languages',\n                'title' => 'Select your language',\n                'values' => [\n                    'All languages' => '0',\n                    'English' => '1',\n                    'French' => '2',\n                    'German' => '3',\n                    'Italian' => '4',\n                    'Japanese' => '5',\n                    'Spanish' => '6',\n                    'Russian' => '7',\n                    'Hindi' => '8',\n                    'Other / Multiple' => '9',\n                    'Korean' => '10',\n                    'Danish' => '11',\n                    'Norwegian' => '12',\n                    'Dutch' => '13',\n                    'Manderin' => '14',\n                    'Portuguese' => '15',\n                    'Bengali' => '16',\n                    'Polish' => '17',\n                    'Turkish' => '18',\n                    'Telugu' => '19',\n                    'Urdu' => '20',\n                    'Arabic' => '21',\n                    'Swedish' => '22',\n                    'Romanian' => '23'\n                ]\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $url = self::URI\n            . '/torrents.php?search=' . urlencode($this->getInput('search'))\n            . '&lang=' . $this->getInput('lang')\n            . '&sort=id&order=desc';\n        $html = getSimpleHTMLDOM($url);\n\n        foreach ($html->find('div.tgxtablerow') as $result) {\n            $identity = $result->find('div.tgxtablecell', 3)->find('div a', 0);\n            $authorid = $result->find('div.tgxtablecell', 6)->find('a', 0);\n            $creadate = $result->find('div.tgxtablecell', 11)->plaintext;\n            $glxlinks = $result->find('div.tgxtablecell', 4);\n\n            $item = [];\n            $item['uri'] = self::URI . $identity->href;\n            $item['title'] = $identity->plaintext;\n\n            // todo: parse date strings such as '1Hr ago' etc.\n            $createdAt = DateTime::createFromFormat('d/m/y H:i', $creadate);\n            if ($createdAt) {\n                $item['timestamp'] = $createdAt->format('U');\n            }\n\n            $item['author'] = $authorid->plaintext;\n            $item['content'] = <<<HTML\n<h1>{$identity->plaintext}</h1>\n<h2>Links</h2>\n<p><a href=\"{$glxlinks->find('a', 1)->href}\" title=\"magnet link\">magnet</a></p>\n<p><a href=\"{$glxlinks->find('a', 0)->href}\" title=\"torrent link\">torrent</a></p>\n<h2>Infos</h2>\n<p>Size: {$result->find('div.tgxtablecell', 7)->plaintext}</p>\n<p>Added by: <a href=\"{$authorid->href}\" title=\"author profile\">{$authorid->plaintext}</a></p>\n<p>Upload time: {$creadate}</p>\nHTML;\n            $item['enclosures'] = [$glxlinks->find('a', 0)->href];\n            $item['categories'] = [$result->find('div.tgxtablecell', 0)->plaintext];\n            if (preg_match('#/torrent/([^/]+)/#', self::URI . $identity->href, $torrentid)) {\n                $item['uid'] = $torrentid[1];\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('search'))) {\n            return $this->getInput('search') . ' : ' . self::NAME;\n        }\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('search'))) {\n            return self::URI\n                . '/torrents.php?search=' . urlencode($this->getInput('search'))\n                . '&lang=' . $this->getInput('lang');\n        }\n        return parent::getURI();\n    }\n\n    public function getDescription()\n    {\n        if (!is_null($this->getInput('search'))) {\n            return 'Latest torrents for \"' . $this->getInput('search') . '\"';\n        }\n        return parent::getDescription();\n    }\n\n    public function getIcon()\n    {\n        if (!is_null($this->getInput('search'))) {\n            return self::URI . '/common/favicon/favicon.ico';\n        }\n        return parent::getIcon();\n    }\n}\n"
  },
  {
    "path": "bridges/TraktBridge.php",
    "content": "<?php\n\nclass TraktBridge extends BridgeAbstract\n{\n    const NAME = 'Trakt';\n    const DESCRIPTION = \"Returns a user's watch history\";\n    const URI = 'https://www.trakt.tv';\n\n    const PARAMETERS = [\n        [\n            'username' => [\n                'name' => 'username',\n                'required' => true\n            ],\n            'hide_shows' => [\n                'name' => 'Hide shows',\n                'type' => 'checkbox',\n                'title' => 'Hide shows',\n            ],\n\n        ],\n    ];\n\n    public function detectParameters($url)\n    {\n        if (preg_match('/trakt\\.tv\\/users\\/(.*?)\\//', $url, $matches) > 0) {\n            return [\n                'username' => $matches[1]\n            ];\n        }\n        return null;\n    }\n\n    public function collectData()\n    {\n        $username = $this->getInput('username');\n        $dom = getSimpleHTMLDOMCached(self::URI . '/users/' . $username . '/history');\n        $this->feedName = $dom->find('#avatar-wrapper h1 a', 0)->plaintext;\n        $this->iconURL = $dom->find('img.avatar', 0)->{'src'};\n\n        foreach ($dom->find('#history-items .posters', 0)->find('div.grid-item') as $div) {\n            if ($this->getInput('hide_shows') && $div->{'data-type'} != 'movie') {\n                continue;\n            }\n            $item = [];\n            $item['author'] = $this->feedName;\n            $item['title'] = $div->find('img.real', 0)->{'title'};\n            $item['timestamp'] = $div->find('.format-date', 0)->plaintext;\n            $item['content'] = '<img src=\"' . $div->find('img.real', 0)->{'data-original'} . '\">';\n            $item['uri'] = self::URI . $div->{'data-url'};\n            $this->items[] = $item;\n        }\n    }\n    public function getName()\n    {\n        if (empty($this->feedName)) {\n            return parent::getName();\n        } else {\n            return $this->feedName;\n        }\n    }\n    public function getIcon()\n    {\n        if (empty($this->iconURL)) {\n            return parent::getIcon();\n        } else {\n            return $this->iconURL;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TrelloBridge.php",
    "content": "<?php\n\nclass TrelloBridge extends BridgeAbstract\n{\n    const NAME = 'Trello';\n    const URI = 'https://trello.com/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Returns activity on Trello boards or cards';\n    const MAINTAINER = 'Roliga';\n    const PARAMETERS = [\n        'Board' => [\n            'b' => [\n                'name' => 'Board ID',\n                'required' => true,\n                'exampleValue' => 'g9mdhdzg',\n                'title' => 'Taken from Trello URL, e.g. trello.com/b/[Board ID]'\n            ]\n        ],\n        'Card' => [\n            'c' => [\n                'name' => 'Card ID',\n                'required' => true,\n                'exampleValue' => '8vddc9pE',\n                'title' => 'Taken from Trello URL, e.g. trello.com/c/[Card ID]'\n            ]\n        ]\n    ];\n\n    /*\n     * This was extracted from webpack on a Trello page, e.g. trello.com/b/g9mdhdzg\n     * In the browser's inspector/debugger go to the Debugger (Firefox) or\n     * Sources (Chromium) tab, these values can be found at:\n     * webpack:///resources/strings/actions/en.json\n     */\n    const ACTION_TEXTS = [\n        'action_accept_enterprise_join_request'\n            => '{memberCreator} added team {organization} to the enterprise {enterprise}',\n        'action_add_attachment_to_card'\n            => '{memberCreator} attached {attachment} to {card} {attachmentPreview}',\n        'action_add_attachment_to_card@card'\n            => '{memberCreator} attached {attachment} to this card {attachmentPreview}',\n        'action_add_checklist_to_card'\n            => '{memberCreator} added {checklist} to {card}',\n        'action_add_checklist_to_card@card'\n            => '{memberCreator} added {checklist} to this card',\n        'action_add_label_to_card'\n            => '{memberCreator} added the {label} label to {card}',\n        'action_add_label_to_card@card'\n            => '{memberCreator} added the {label} label to this card',\n        'action_add_organization_to_enterprise'\n            => '{memberCreator} added team {organization} to the enterprise {enterprise}',\n        'action_add_to_organization_board'\n            => '{memberCreator} added {board} to {organization}',\n        'action_add_to_organization_board@board'\n            => '{memberCreator} added this board to {organization}',\n        'action_added_a_due_date'\n            => '{memberCreator} set {card} to be due {date}',\n        'action_added_a_due_date@card'\n            => '{memberCreator} set this card to be due {date}',\n        'action_added_list_to_board'\n            => '{memberCreator} added list {list} to {board}',\n        'action_added_list_to_board@board'\n            => '{memberCreator} added {list} to this board',\n        'action_added_member_to_board'\n            => '{memberCreator} added {member} to {board}',\n        'action_added_member_to_board@board'\n            => '{memberCreator} added {member} to this board',\n        'action_added_member_to_board_as_admin'\n            => '{memberCreator} added {member} to {board} as an admin',\n        'action_added_member_to_board_as_admin@board'\n            => '{memberCreator} added {member} to this board as an admin',\n        'action_added_member_to_board_as_observer'\n            => '{memberCreator} added {member} to {board} as an observer',\n        'action_added_member_to_board_as_observer@board'\n            => '{memberCreator} added {member} to this board as an observer',\n        'action_added_member_to_card'\n            => '{memberCreator} added {member} to {card}',\n        'action_added_member_to_card@card'\n            => '{memberCreator} added {member} to this card',\n        'action_added_member_to_organization'\n            => '{memberCreator} added {member} to {organization}',\n        'action_added_member_to_organization_as_admin'\n            => '{memberCreator} added {member} to {organization} as an admin',\n        'action_admins_visibility'\n            => 'its admins',\n        'action_another_board'\n            => 'another board',\n        'action_archived_card'\n            => '{memberCreator} archived {card}',\n        'action_archived_card@card'\n            => '{memberCreator} archived this card',\n        'action_archived_list'\n            => '{memberCreator} archived list {list}',\n        'action_became_a_normal_user_in_organization'\n            => '{memberCreator} became a normal user in {organization}',\n        'action_became_a_normal_user_on'\n            => '{memberCreator} became a normal user on {board}',\n        'action_became_a_normal_user_on@board'\n            => '{memberCreator} became a normal user on this board',\n        'action_became_an_admin_of_organization'\n            => '{memberCreator} became an admin of {organization}',\n        'action_board_perm_level'\n            => '{memberCreator} made {board} visible to {level}',\n        'action_board_perm_level@board'\n            => '{memberCreator} made this board visible to {level}',\n        'action_calendar'\n            => 'calendar',\n        'action_cardAging'\n            => 'card aging',\n        'action_changed_a_due_date'\n            => '{memberCreator} changed the due date of {card} to {date}',\n        'action_changed_a_due_date@card'\n            => '{memberCreator} changed the due date of this card to {date}',\n        'action_changed_board_background'\n            => '{memberCreator} changed the background of {board}',\n        'action_changed_board_background@board'\n            => '{memberCreator} changed the background of this board',\n        'action_changed_description_of_card'\n            => '{memberCreator} changed description of {card}',\n        'action_changed_description_of_card@card'\n            => '{memberCreator} changed description of this card',\n        'action_changed_description_of_organization'\n            => '{memberCreator} changed description of {organization}',\n        'action_changed_display_name_of_organization'\n            => '{memberCreator} changed display name of {organization}',\n        'action_changed_name_of_organization'\n            => '{memberCreator} changed name of {organization}',\n        'action_changed_website_of_organization'\n            => '{memberCreator} changed website of {organization}',\n        'action_closed_board'\n            => '{memberCreator} closed {board}',\n        'action_closed_board@board'\n            => '{memberCreator} closed this board',\n        'action_comment_on_card'\n            => '{memberCreator} {contextOn} {card} {comment}',\n        'action_comment_on_card@card'\n            => '{memberCreator} {comment}',\n        'action_completed_checkitem'\n            => '{memberCreator} completed {checkitem} on {card}',\n        'action_completed_checkitem@card'\n            => '{memberCreator} completed {checkitem} on this card',\n        'action_convert_to_card_from_checkitem'\n            => '{memberCreator} converted {card} from a checklist item on {cardSource}',\n        'action_convert_to_card_from_checkitem@card'\n            => '{memberCreator} converted this card from a checklist item on {cardSource}',\n        'action_convert_to_card_from_checkitem@cardSource'\n            => '{memberCreator} converted {card} from a checklist item on this card',\n        'action_copy_board'\n            => '{memberCreator} copied this board from {board}',\n        'action_copy_card'\n            => '{memberCreator} copied {card} from {cardSource} in list {list}',\n        'action_copy_card@card'\n            => '{memberCreator} copied this card from {cardSource} in list {list}',\n        'action_copy_comment_from_card'\n            => '{memberCreator} copied comment by {member} from card {card} {comment}',\n        'action_create_board'\n            => '{memberCreator} created {board}',\n        'action_create_board@board'\n            => '{memberCreator} created this board',\n        'action_create_card'\n            => '{memberCreator} added {card} to {list}',\n        'action_create_card@card'\n            => '{memberCreator} added this card to {list}',\n        'action_create_custom_field'\n            => '{memberCreator} created the {customField} custom field on {board}',\n        'action_create_custom_field@board'\n            => '{memberCreator} created the {customField} custom field on this board',\n        'action_create_enterprise_join_request'\n            => '{memberCreator} requested to add team {organization} to the enterprise {enterprise}',\n        'action_created_an_invitation_to_board'\n            => '{memberCreator} created an invitation to {board}',\n        'action_created_an_invitation_to_board@board'\n            => '{memberCreator} created an invitation to this board',\n        'action_created_an_invitation_to_organization'\n            => '{memberCreator} created an invitation to {organization}',\n        'action_created_checklist_on_board'\n            => '{memberCreator} created {checklist} on {board}',\n        'action_created_checklist_on_board@board'\n            => '{memberCreator} created {checklist} on this board',\n        'action_created_organization'\n            => '{memberCreator} created {organization}',\n        'action_decline_enterprise_join_request'\n            => '{memberCreator} declined the request to add team {organization} to the enterprise {enterprise}',\n        'action_delete_attachment_from_card'\n            => '{memberCreator} deleted the {attachment} attachment from {card}',\n        'action_delete_attachment_from_card@card'\n            => '{memberCreator} deleted the {attachment} attachment from this card',\n        'action_delete_card'\n            => '{memberCreator} deleted card #{idCard} from {list}',\n        'action_delete_custom_field'\n            => '{memberCreator} deleted the {customField} custom field from {board}',\n        'action_delete_custom_field@board'\n            => '{memberCreator} deleted the {customField} custom field from this board',\n        'action_deleted_account'\n            => '[deleted account]',\n        'action_deleted_an_invitation_to_board'\n            => '{memberCreator} deleted an invitation to {board}',\n        'action_deleted_an_invitation_to_board@board'\n            => '{memberCreator} deleted an invitation to this board',\n        'action_deleted_an_invitation_to_organization'\n            => '{memberCreator} deleted an invitation to {organization}',\n        'action_deleted_checkitem'\n            => '{memberCreator} deleted task {checkitem} on {checklist}',\n        'action_disabled_calendar_feed'\n            => '{memberCreator} disabled the iCalendar feed on {board}',\n        'action_disabled_calendar_feed@board'\n            => '{memberCreator} disabled the iCalendar feed on this board',\n        'action_disabled_card_covers'\n            => '{memberCreator} disabled card cover images on {board}',\n        'action_disabled_card_covers@board'\n            => '{memberCreator} disabled card cover images on this board',\n        'action_disabled_commenting'\n            => '{memberCreator} disabled commenting on {board}',\n        'action_disabled_commenting@board'\n            => '{memberCreator} disabled commenting on this board',\n        'action_disabled_inviting'\n            => '{memberCreator} disabled inviting on {board}',\n        'action_disabled_inviting@board'\n            => '{memberCreator} disabled inviting on this board',\n        'action_disabled_plugin'\n            => '{memberCreator} disabled the {plugin} Power-Up',\n        'action_disabled_powerup'\n            => '{memberCreator} disabled the {powerup} Power-Up',\n        'action_disabled_self_join'\n            => '{memberCreator} disabled self join on {board}',\n        'action_disabled_self_join@board'\n            => '{memberCreator} disabled self join on this board',\n        'action_disabled_voting'\n            => '{memberCreator} disabled voting on {board}',\n        'action_disabled_voting@board'\n            => '{memberCreator} disabled voting on this board',\n        'action_due_date_change'\n            => '{memberCreator}',\n        'action_email_card'\n            => '{memberCreator} emailed {card} to {list}',\n        'action_email_card@card'\n            => '{memberCreator} emailed this card to {list}',\n        'action_email_card_from'\n            => '{memberCreator} emailed {card} to {list} from {from}',\n        'action_email_card_from@card'\n            => '{memberCreator} emailed this card to {list} from {from}',\n        'action_enabled_calendar_feed'\n            => '{memberCreator} enabled the iCalendar feed on {board}',\n        'action_enabled_calendar_feed@board'\n            => '{memberCreator} enabled the iCalendar feed on this board',\n        'action_enabled_card_covers'\n            => '{memberCreator} enabled card cover images on {board}',\n        'action_enabled_card_covers@board'\n            => '{memberCreator} enabled card cover images on this board',\n        'action_enabled_plugin'\n            => '{memberCreator} enabled the {plugin} Power-Up',\n        'action_enabled_powerup'\n            => '{memberCreator} enabled the {powerup} Power-Up',\n        'action_enabled_self_join'\n            => '{memberCreator} enabled self join on {board}',\n        'action_enabled_self_join@board'\n            => '{memberCreator} enabled self join on this board',\n        'action_hid_board'\n            => '{memberCreator} hid {board}',\n        'action_hid_board@board'\n            => '{memberCreator} hid this board',\n        'action_invited_an_unconfirmed_member_to_board'\n            => '{memberCreator} invited an unconfirmed member to {board}',\n        'action_invited_an_unconfirmed_member_to_board@board'\n            => '{memberCreator} invited an unconfirmed member to this board',\n        'action_invited_an_unconfirmed_member_to_organization'\n            => '{memberCreator} invited an unconfirmed member to {organization}',\n        'action_joined_board'\n            => '{memberCreator} joined {board}',\n        'action_joined_board@board'\n            => '{memberCreator} joined this board',\n        'action_joined_board_by_invitation_link'\n            => '{memberCreator} joined {board} with an invitation link from {memberInviter}',\n        'action_joined_board_by_invitation_link@board'\n            => '{memberCreator} joined this board with an invitation link from {memberInviter}',\n        'action_joined_organization'\n            => '{memberCreator} joined {organization}',\n        'action_joined_organization_by_invitation_link'\n            => '{memberCreator} joined {organization} with an invitation link from {memberInviter}',\n        'action_left_board'\n            => '{memberCreator} left {board}',\n        'action_left_board@board'\n            => '{memberCreator} left this board',\n        'action_left_organization'\n            => '{memberCreator} left {organization}',\n        'action_made_a_normal_user_in_organization'\n            => '{memberCreator} made {member} a normal user in {organization}',\n        'action_made_a_normal_user_on'\n            => '{memberCreator} made {member} a normal user on {board}',\n        'action_made_a_normal_user_on@board'\n            => '{memberCreator} made {member} a normal user on this board',\n        'action_made_admin_of_board'\n            => '{memberCreator} made {member} an admin of {board}',\n        'action_made_admin_of_board@board'\n            => '{memberCreator} made {member} an admin of this board',\n        'action_made_an_admin_of_organization'\n            => '{memberCreator} made {member} an admin of {organization}',\n        'action_made_commenting_on'\n            => '{memberCreator} made commenting on {board} available to {level}',\n        'action_made_commenting_on@board'\n            => '{memberCreator} made commenting on this board available to {level}',\n        'action_made_inviting_on'\n            => '{memberCreator} made inviting on {board} available to {level}',\n        'action_made_inviting_on@board'\n            => '{memberCreator} made inviting on this board available to {level}',\n        'action_made_observer_of_board'\n            => '{memberCreator} made {member} an observer of {board}',\n        'action_made_observer_of_board@board'\n            => '{memberCreator} made {member} an observer of this board',\n        'action_made_self_admin_of_board'\n            => '{memberCreator} made themselves an admin of {board}',\n        'action_made_self_admin_of_board@board'\n            => '{memberCreator} made themselves an admin of this board',\n        'action_made_self_observer_of_board'\n            => '{memberCreator} became an observer of {board}',\n        'action_made_self_observer_of_board@board'\n            => '{memberCreator} became an observer of this board',\n        'action_made_voting_on'\n            => '{memberCreator} made voting on {board} available to {level}',\n        'action_made_voting_on@board'\n            => '{memberCreator} made voting on this board available to {level}',\n        'action_marked_checkitem_incomplete'\n            => '{memberCreator} marked {checkitem} incomplete on {card}',\n        'action_marked_checkitem_incomplete@card'\n            => '{memberCreator} marked {checkitem} incomplete on this card',\n        'action_marked_the_due_date_complete'\n            => '{memberCreator} marked the due date on {card} complete',\n        'action_marked_the_due_date_complete@card'\n            => '{memberCreator} marked the due date complete',\n        'action_marked_the_due_date_incomplete'\n            => '{memberCreator} marked the due date on {card} incomplete',\n        'action_marked_the_due_date_incomplete@card'\n            => '{memberCreator} marked the due date incomplete',\n        'action_member_joined_card'\n            => '{memberCreator} joined {card}',\n        'action_member_joined_card@card'\n            => '{memberCreator} joined this card',\n        'action_member_left_card'\n            => '{memberCreator} left {card}',\n        'action_member_left_card@card'\n            => '{memberCreator} left this card',\n        'action_members_visibility'\n            => 'its members',\n        'action_move_card_from_board'\n            => '{memberCreator} transferred {card} to {board}',\n        'action_move_card_from_board@card'\n            => '{memberCreator} transferred this card to {board}',\n        'action_move_card_from_list_to_list'\n            => '{memberCreator} moved {card} from {listBefore} to {listAfter}',\n        'action_move_card_from_list_to_list@card'\n            => '{memberCreator} moved this card from {listBefore} to {listAfter}',\n        'action_move_card_to_board'\n            => '{memberCreator} transferred {card} from {board}',\n        'action_move_card_to_board@card'\n            => '{memberCreator} transferred this card from {board}',\n        'action_move_list_from_board'\n            => '{memberCreator} transferred {list} to {board}',\n        'action_move_list_to_board'\n            => '{memberCreator} transferred {list} from {board}',\n        'action_moved_card_higher'\n            => '{memberCreator} moved {card} higher',\n        'action_moved_card_higher@card'\n            => '{memberCreator} moved this card higher',\n        'action_moved_card_lower'\n            => '{memberCreator} moved {card} lower',\n        'action_moved_card_lower@card'\n            => '{memberCreator} moved this card lower',\n        'action_moved_checkitem_higher'\n            => '{memberCreator} moved {checkitem} higher in the checklist {checklist}',\n        'action_moved_checkitem_lower'\n            => '{memberCreator} moved {checkitem} higher in the checklist {checklist}',\n        'action_moved_list_left'\n            => '{memberCreator} moved list {list} left on {board}',\n        'action_moved_list_left@board'\n            => '{memberCreator} moved {list} left on this board',\n        'action_moved_list_right'\n            => '{memberCreator} moved list {list} right on {board}',\n        'action_moved_list_right@board'\n            => '{memberCreator} moved {list} right on this board',\n        'action_observers_visibility'\n            => 'members and observers',\n        'action_on'\n            => 'on',\n        'action_org_visibility'\n            => 'members of its team',\n        'action_public_visibility'\n            => 'the public',\n        'action_remove_checklist_from_card'\n            => '{memberCreator} removed {checklist} from {card}',\n        'action_remove_checklist_from_card@card'\n            => '{memberCreator} removed {checklist} from this card',\n        'action_remove_from_organization_board'\n            => '{memberCreator} removed {board} from {organization}',\n        'action_remove_from_organization_board@board'\n            => '{memberCreator} removed this board from {organization}',\n        'action_remove_label_from_card'\n            => '{memberCreator} removed the {label} label from {card}',\n        'action_remove_label_from_card@card'\n            => '{memberCreator} removed the {label} label from this card',\n        'action_remove_organization_from_enterprise'\n            => '{memberCreator} removed team {organization} from the enterprise {enterprise}',\n        'action_removed_a_due_date'\n            => '{memberCreator} removed the due date from {card}',\n        'action_removed_a_due_date@card'\n            => '{memberCreator} removed the due date from this card',\n        'action_removed_from_board'\n            => '{memberCreator} removed {member} from {board}',\n        'action_removed_from_board@board'\n            => '{memberCreator} removed {member} from this board',\n        'action_removed_member_from_card'\n            => '{memberCreator} removed {member} from {card}',\n        'action_removed_member_from_card@card'\n            => '{memberCreator} removed {member} from this card',\n        'action_removed_member_from_organization'\n            => '{memberCreator} removed {member} from {organization}',\n        'action_removed_vote_for_card'\n            => '{memberCreator} removed vote for {card}',\n        'action_removed_vote_for_card@card'\n            => '{memberCreator} removed vote for this card',\n        'action_rename_custom_field'\n            => '{memberCreator} renamed the {customField} custom field on {board} (from {name})',\n        'action_rename_custom_field@board'\n            => '{memberCreator} renamed the {customField} custom field on this board (from {name})',\n        'action_renamed_card'\n            => '{memberCreator} renamed {card} (from {name})',\n        'action_renamed_card@card'\n            => '{memberCreator} renamed this card (from {name})',\n        'action_renamed_checkitem'\n            => '{memberCreator} renamed {checkitem} (from {name})',\n        'action_renamed_checklist'\n            => '{memberCreator} renamed {checklist} (from {name})',\n        'action_renamed_list'\n            => '{memberCreator} renamed list {list} (from {name})',\n        'action_reopened_board'\n            => '{memberCreator} re-opened {board}',\n        'action_reopened_board@board'\n            => '{memberCreator} re-opened this board',\n        'action_sent_card_to_board'\n            => '{memberCreator} sent {card} to the board',\n        'action_sent_card_to_board@card'\n            => '{memberCreator} sent this card to the board',\n        'action_sent_list_to_board'\n            => '{memberCreator} sent list {list} to the board',\n        'action_set_card_aging_mode_pirate'\n            => '{memberCreator} changed card aging to pirate mode',\n        'action_set_card_aging_mode_regular'\n            => '{memberCreator} changed card aging to regular mode',\n        'action_update_board_desc'\n            => '{memberCreator} changed description of {board}',\n        'action_update_board_desc@board'\n            => '{memberCreator} changed description of this board',\n        'action_update_board_name'\n            => '{memberCreator} renamed {board} (from {name})',\n        'action_update_board_name@board'\n            => '{memberCreator} renamed this board (from {name})',\n        'action_update_custom_field'\n            => '{memberCreator} updated the {customField} custom field on {board}',\n        'action_update_custom_field@board'\n            => '{memberCreator} updated the {customField} custom field on this board',\n        'action_update_custom_field_item'\n            => '{memberCreator} updated the value for the {customFieldItem} custom field on {card}',\n        'action_update_custom_field_item@card'\n            => '{memberCreator} updated the value for the {customFieldItem} custom field on this card',\n        'action_updated_their_bio'\n            => '{memberCreator} updated their bio',\n        'action_updated_their_display_name'\n            => '{memberCreator} updated their display name',\n        'action_updated_their_initials'\n            => '{memberCreator} updated their initials',\n        'action_updated_their_username'\n            => '{memberCreator} updated their username',\n        'action_vote_on_card'\n            => '{memberCreator} voted for {card}',\n        'action_vote_on_card@card'\n            => '{memberCreator} voted for this card',\n        'action_voting'\n            => 'voting',\n        'action_withdraw_enterprise_join_request'\n            => '{memberCreator} withdrew a request to add team {organization} to the enterprise {enterprise}'\n        ];\n\n    const REQUEST_ACTIONS_BOARDS = [\n        'addAttachmentToCard',\n        'addChecklistToCard',\n        'addMemberToCard',\n        'commentCard',\n        'copyCommentCard',\n        'convertToCardFromCheckItem',\n        'createCard',\n        'copyCard',\n        'deleteAttachmentFromCard',\n        'emailCard',\n        'moveCardFromBoard',\n        'moveCardToBoard',\n        'removeChecklistFromCard',\n        'removeMemberFromCard',\n        'updateCard:idList',\n        'updateCard:closed',\n        'updateCard:due',\n        'updateCard:dueComplete',\n        'updateCheckItemStateOnCard',\n        'updateCustomFieldItem',\n        'addMemberToBoard',\n        'addToOrganizationBoard',\n        'copyBoard',\n        'createBoard',\n        'createCustomField',\n        'createList',\n        'deleteCard',\n        'deleteCustomField',\n        'disablePlugin',\n        'disablePowerUp',\n        'enablePlugin',\n        'enablePowerUp',\n        'makeAdminOfBoard',\n        'makeNormalMemberOfBoard',\n        'makeObserverOfBoard',\n        'moveListFromBoard',\n        'moveListToBoard',\n        'removeFromOrganizationBoard',\n        'unconfirmedBoardInvitation',\n        'unconfirmedOrganizationInvitation',\n        'updateBoard',\n        'updateCustomField',\n        'updateList:closed'\n    ];\n\n    const REQUEST_ACTIONS_CARDS = [\n        'addAttachmentToCard',\n        'addChecklistToCard',\n        'addMemberToCard',\n        'commentCard',\n        'copyCommentCard',\n        'convertToCardFromCheckItem',\n        'createCard',\n        'copyCard',\n        'deleteAttachmentFromCard',\n        'emailCard',\n        'moveCardFromBoard',\n        'moveCardToBoard',\n        'removeChecklistFromCard',\n        'removeMemberFromCard',\n        'updateCard:idList',\n        'updateCard:closed',\n        'updateCard:due',\n        'updateCard:dueComplete',\n        'updateCheckItemStateOnCard',\n        'updateCustomFieldItem'\n    ];\n\n    private $feedName = '';\n    private $feedURI = '';\n\n    private function queryAPI($path, $params = [])\n    {\n        $url = 'https://trello.com/1/' . $path . '?' . http_build_query($params);\n        $data = json_decode(getContents($url));\n        return $data;\n    }\n\n    private function renderAction($action, $textOnly = false)\n    {\n        if (!array_key_exists($action->display->translationKey, self::ACTION_TEXTS)) {\n            return '';\n        }\n\n        $strings = [];\n        $entities = (array)$action->display->entities;\n\n        foreach ($entities as $entity_name => $entity) {\n            $type = $entity->type;\n            if (\n                $type === 'attachmentPreview'\n                && !$textOnly\n                && isset($entity->originalUrl)\n            ) {\n                $string = sprintf(\n                    '<p><a href=\"%s\"><img src=\"%s\"></a></p>',\n                    $entity->originalUrl,\n                    $entity->previewUrl ?? ''\n                );\n            } elseif ($type === 'card' && !$textOnly) {\n                $string = sprintf('<a href=\"https://trello.com/c/%s\">%s</a>', $entity->shortLink, $entity->text);\n            } elseif ($type === 'member' && !$textOnly) {\n                $string = sprintf('<a href=\"https://trello.com/%s\">%s</a>', $entity->username, $entity->text);\n            } elseif ($type === 'date') {\n                $string = gmdate('M j, Y \\a\\t g:i A T', strtotime($entity->date));\n            } elseif ($type === 'translatable') {\n                $string = self::ACTION_TEXTS[$entity->translationKey];\n            } else {\n                $string = $entity->text ?? '';\n            }\n            $strings['{' . $entity_name . '}'] = $string;\n        }\n\n        return str_replace(\n            array_keys($strings),\n            array_values($strings),\n            self::ACTION_TEXTS[$action->display->translationKey]\n        );\n    }\n\n    public function collectData()\n    {\n        $apiParams = [\n            'actions_display' => 'true',\n            'fields' => 'name,url'\n        ];\n        switch ($this->queriedContext) {\n            case 'Board':\n                $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_BOARDS);\n                $data = $this->queryAPI('boards/' . $this->getInput('b'), $apiParams);\n                break;\n            case 'Card':\n                $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_CARDS);\n                $data = $this->queryAPI('cards/' . $this->getInput('c'), $apiParams);\n                break;\n            default:\n                throwClientException('Invalid context');\n        }\n\n        $this->feedName = $data->name;\n        $this->feedURI = $data->url;\n\n        foreach ($data->actions as $action) {\n            $item = [];\n\n            $item['title'] = $this->renderAction($action, true);\n            $item['timestamp'] = strtotime($action->date);\n            $item['author'] = $action->memberCreator->fullName;\n            $item['categories'] = [\n                'trello',\n                $action->data->board->name,\n                $action->type\n            ];\n            if (isset($action->data->card)) {\n                $item['categories'][] = $action->data->card->name ?? $action->data->card->id;\n                $item['uri'] = 'https://trello.com/c/'\n                    . $action->data->card->shortLink\n                    . '#action-'\n                    . $action->id;\n            } else {\n                $item['uri'] = 'https://trello.com/b/'\n                    . $action->data->board->shortLink;\n            }\n            $item['content'] = $this->renderAction($action, false);\n            if (isset($action->data->attachment->url)) {\n                $item['enclosures'] = [$action->data->attachment->url];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function detectParameters($url)\n    {\n        $regex = '/^(https?:\\/\\/)?trello\\.com\\/([bc])\\/([^\\/?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            if ($matches[2] == 'b') {\n                $context = 'Board';\n            } else if ($matches[2] == 'c') {\n                $context = 'Card';\n            }\n            return [\n                'context' => $context,\n                $matches[2] => $matches[3]\n            ];\n        } else {\n            return null;\n        }\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'Board':\n            case 'Card':\n                return $this->feedURI;\n            default:\n                return parent::getURI();\n        }\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Board':\n            case 'Card':\n                return $this->feedName;\n            default:\n                return parent::getName();\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/TriabolosNewsBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass TriabolosNewsBridge extends BridgeAbstract\n{\n    const CATEGORIES      = [\n        'Alle' => 'stories',\n        'Vereinsnachrichten' => 'stories/category/vereinsnachrichten',\n        'Eilmeldungen'   => 'stories/category/eilmeldungen',\n        'Neue Mitglieder'      => 'stories/category/neue%20mitglieder',\n        'Rennberichte'   => 'stories/category/rennberichte',\n        'Trainingslager'   => 'stories/category/trainingslager',\n        'Regionalliga'   => 'stories/category/regionalliga',\n        'Landesliga'   => 'stories/category/landesliga',\n        'Kinderschwimmen'   => 'stories/category/kinderschwimmen',\n        'Jugendsparte'   => 'stories/category/jugendsparte',\n    ];\n    const NAME = 'Triabolos News';\n    const URI = 'https://www.triabolos.de';\n    const DESCRIPTION = 'News feed of Hamburg Triathlon club Triabolos';\n    const MAINTAINER = 't3sec';\n    const CACHE_TIMEOUT = 3600; // seconds\n    const PARAMETERS    = [\n        [\n            'category' => [\n                'name' => 'Triabolos news category',\n                'type' => 'list',\n                'values' => self::CATEGORIES,\n                'defaultValue' => 'stories',\n                'title' => 'Choose one of the available news categories',\n            ],\n        ],\n    ];\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('category'))) {\n            return sprintf('%s/news/%s', static::URI, $this->getInput('category'));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getDescription()\n    {\n        if (!is_null($this->getInput('category'))) {\n            return sprintf('%s - %s', static::DESCRIPTION, array_search($this->getInput('category'), self::CATEGORIES));\n        }\n\n        return parent::getDescription();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('category'))) {\n            return sprintf('%s - %s', static::NAME, array_search($this->getInput('category'), self::CATEGORIES));\n        }\n\n        return parent::getName();\n    }\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOMCached($this->getURI(), self::CACHE_TIMEOUT);\n        foreach ($dom->find('.blog-listing .blog-item') as $li) {\n            $a = $li->find('.blog-content .blog-header .blog-title a', 0);\n            $time = $li->find('.blog-content .blog-header .blog-intro time', 0);\n            $category = $li->find('.blog-content .blog-header .blog-intro .category-name a', 0);\n            $content = $li->find('.blog-content .blog-text p', 0);\n            $enclosure = $li->find('.img-blog a img', 0);\n            $this->items[] = [\n                'title' => $a->plaintext,\n                'content' => $content->plaintext,\n                'timestamp' => $time->datetime,\n                'categories' => [$category->plaintext],\n                'enclosure' => is_null($enclosure) ? [] : [self::URI . $enclosure->src],\n                'uri' => self::URI . $a->href,\n            ];\n        }\n    }\n}"
  },
  {
    "path": "bridges/TwitScoopBridge.php",
    "content": "<?php\n\nclass TwitScoopBridge extends BridgeAbstract\n{\n    const NAME = 'TwitScoop';\n    const URI = 'https://www.twitscoop.com';\n    const DESCRIPTION = 'Returns trending Twitter topics by country';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        [\n            'country' => [\n                'name' => 'Country',\n                'type' => 'list',\n                'values' => [\n                    'Worldwide' => 'worldwide',\n                    'Algeria' => 'algeria',\n                    'Argentina' => 'argentina',\n                    'Australia' => 'australia',\n                    'Austria' => 'austria',\n                    'Bahrain' => 'bahrain',\n                    'Belarus' => 'belarus',\n                    'Belgium' => 'belgium',\n                    'Brazil' => 'brazil',\n                    'Canada' => 'canada',\n                    'Chile' => 'chile',\n                    'Colombia' => 'colombia',\n                    'Denmark' => 'denmark',\n                    'Dominican Republic' => 'dominican-republic',\n                    'Ecuador' => 'ecuador',\n                    'Egypt' => 'egypt',\n                    'France' => 'france',\n                    'Germany' => 'germany',\n                    'Ghana' => 'ghana',\n                    'Greece' => 'greece',\n                    'Guatemala' => 'guatemala',\n                    'India' => 'india',\n                    'Indonesia' => 'indonesia',\n                    'Ireland' => 'ireland',\n                    'Israel' => 'israel',\n                    'Italy' => 'italy',\n                    'Japan' => 'japan',\n                    'Jordan' => 'jordan',\n                    'Kenya' => 'kenya',\n                    'Korea' => 'korea',\n                    'Kuwait' => 'kuwait',\n                    'Latvia' => 'latvia',\n                    'Lebanon' => 'lebanon',\n                    'Malaysia' => 'malaysia',\n                    'Mexico' => 'mexico',\n                    'Netherlands' => 'netherlands',\n                    'New Zealand' => 'new-zealand',\n                    'Nigeria' => 'nigeria',\n                    'Norway' => 'norway',\n                    'Oman' => 'oman',\n                    'Pakistan' => 'pakistan',\n                    'Panama' => 'panama',\n                    'Peru' => 'peru',\n                    'Philippines' => 'philippines',\n                    'Poland' => 'poland',\n                    'Portugal' => 'portugal',\n                    'Puerto Rico' => 'puerto-rico',\n                    'Qatar' => 'qatar',\n                    'Russia' => 'russia',\n                    'Saudi Arabia' => 'saudi-arabia',\n                    'Singapore' => 'singapore',\n                    'South Africa' => 'south-africa',\n                    'Spain' => 'spain',\n                    'Sweden' => 'sweden',\n                    'Switzerland' => 'switzerland',\n                    'Thailand' => 'thailand',\n                    'Turkey' => 'turkey',\n                    'Ukraine' => 'ukraine',\n                    'United Arab Emirates' => 'united-arab-emirates',\n                    'United Kingdom' => 'united-kingdom',\n                    'United States' => 'united-states',\n                    'Venezuela' => 'venezuela',\n                    'Vietnam' => 'vietnam',\n                ]\n            ],\n            'limit' => [\n                'name' => 'Topics',\n                'type' => 'number',\n                'title' => 'Number of trending topics to return. Max 50',\n                'defaultValue' => 20,\n            ]\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 900; // 15 mins\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $updated = $html->find('time', 0)->datetime;\n        $trends = $html->find('div.trends', 0);\n\n        $limit = $this->getInput('limit');\n\n        if ($limit > 50 || $limit < 1) {\n            $limit = 50;\n        }\n\n        foreach ($trends->find('ol.items > li') as $index => $li) {\n            $number = $index + 1;\n\n            $item = [];\n\n            $name = rtrim($li->find('span.trend.name', 0)->plaintext, '&nbsp');\n            $tweets = str_replace(' tweets', '', $li->find('span.tweets', 0)->plaintext);\n            $tweets = str_replace('<', '', $tweets);\n\n            $item['title'] = '#' . $number . ' - ' . $name . ' (' . $tweets . ' tweets)';\n            $item['uri'] = 'https://twitter.com/search?q=' . rawurlencode($name);\n\n            if ($tweets === '10K') {\n                $tweets = 'less than 10K';\n            }\n\n            $item['content'] = <<<EOD\n<strong>Rank</strong><br>\n<p>{$number}</p>\n<Strong>Topic</strong><br>\n<p>{$name}</p>\n<Strong>Tweets</strong><br>\n<p>{$tweets}</p>\nEOD;\n            $item['timestamp'] = $updated;\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('country'))) {\n            return self::URI . '/' . $this->getInput('country');\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('country'))) {\n            return $this->getKey('country') . ' - TwitScoop';\n        }\n\n        return parent::getName();\n    }\n}\n"
  },
  {
    "path": "bridges/TwitchBridge.php",
    "content": "<?php\n\nclass TwitchBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'Roliga';\n    const NAME = 'Twitch';\n    const URI = 'https://twitch.tv/';\n    const CACHE_TIMEOUT = 300; // 5min\n    const DESCRIPTION = 'Twitch channel videos';\n    const PARAMETERS = [ [\n        'channel' => [\n            'name' => 'Channel',\n            'type' => 'text',\n            'required' => true,\n            'exampleValue' => 'criticalrole',\n            'title' => 'Lowercase channel name as seen in channel URL'\n        ],\n        'type' => [\n            'name' => 'Type',\n            'type' => 'list',\n            'values' => [\n                'All' => 'all',\n                'Archive' => 'archive',\n                'Highlights' => 'highlight',\n                'Uploads' => 'upload',\n                'Past Premieres' => 'past_premiere',\n                'Premiere Uploads' => 'premiere_upload'\n            ],\n            'defaultValue' => 'archive'\n        ]\n    ]];\n\n    const BROADCAST_TYPES = [\n        'all' => [\n            'ARCHIVE',\n            'HIGHLIGHT',\n            'UPLOAD',\n            'PAST_PREMIERE',\n            'PREMIERE_UPLOAD'\n        ],\n        'archive' => 'ARCHIVE',\n        'highlight' => 'HIGHLIGHT',\n        'upload' => 'UPLOAD',\n        'past_premiere' => 'PAST_PREMIERE',\n        'premiere_upload' => 'PREMIERE_UPLOAD'\n    ];\n\n    public function collectData()\n    {\n        $query = <<<'EOD'\nquery VODList($channel: String!, $types: [BroadcastType!]) {\n  user(login: $channel) {\n    displayName\n    videos(types: $types, sort: TIME) {\n      edges {\n        node {\n          id\n          title\n          publishedAt\n          lengthSeconds\n          viewCount\n          thumbnailURLs(width: 640, height: 360)\n          previewThumbnailURL(width: 640, height: 360)\n          description\n          tags\n          contentTags {\n            isLanguageTag\n            localizedName\n          }\n          game {\n            displayName\n          }\n          moments(momentRequestType: VIDEO_CHAPTER_MARKERS) {\n            edges {\n              node {\n                description\n                positionMilliseconds\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}\nEOD;\n        $channel = $this->getInput('channel');\n        $type = $this->getInput('type');\n        $variables = [\n            'channel' => $channel,\n            'types' => self::BROADCAST_TYPES[$type]\n        ];\n        $response = $this->apiRequest($query, $variables);\n        $data = $response->data;\n        if ($data->user === null) {\n            throwClientException(sprintf('Unable to find channel `%s`', $channel));\n        }\n\n        $user = $data->user;\n        if ($user->videos === null) {\n            // twitch regularly does this for unknown reasons\n            $this->logger->debug('Twitch returned empty set of videos', ['data' => $data]);\n            return;\n        }\n\n        foreach ($user->videos->edges as $edge) {\n            $video = $edge->node;\n\n            $url = 'https://www.twitch.tv/videos/' . $video->id;\n\n            $item = [\n                'uri' => $url,\n                'title' => $video->title,\n                'timestamp' => $video->publishedAt,\n                'author' => $user->displayName,\n            ];\n\n            // Add categories for tags and played game\n            $item['categories'] = $video->tags;\n            if (!is_null($video->game)) {\n                $item['categories'][] = $video->game->displayName;\n            }\n\n            $contentTags = $video->contentTags ?? [];\n            foreach ($contentTags as $tag) {\n                if (!$tag->isLanguageTag) {\n                    $item['categories'][] = $tag->localizedName;\n                }\n            }\n\n            // Add enclosures for thumbnails from a few points in the video\n            // Thumbnail list has duplicate entries sometimes so remove those\n            $item['enclosures'] = array_unique($video->thumbnailURLs);\n\n            /*\n             * Content format example:\n             *\n             * [Preview Image]\n             *\n             * Some optional video description.\n             *\n             * Duration: 1:23:45\n             * Views: 123\n             *\n             * Played games:\n             * * 00:00:00 Game 1\n             * * 00:12:34 Game 2\n             *\n             */\n            $item['content'] = '<p><a href=\"'\n                . $url\n                . '\"><img src=\"'\n                . $video->previewThumbnailURL\n                . '\" /></a></p><p>'\n                . $video->description // in markdown format\n                . '</p><p><b>Duration:</b> '\n                . $this->formatTimestampTime($video->lengthSeconds)\n                . '<br/><b>Views:</b> '\n                . $video->viewCount\n                . '</p>';\n\n            // Add played games list to content\n            $item['content'] .= '<p><b>Played games:</b><ul>';\n\n            $momentEdges = $video->moments->edges ?? [];\n            if (count($momentEdges) > 0) {\n                foreach ($momentEdges as $momentEdge) {\n                    $moment = $momentEdge->node;\n\n                    $item['categories'][] = $moment->description;\n                    $item['content'] .= '<li><a href=\"'\n                        . $url\n                        . '?t='\n                        . $this->formatQueryTime($moment->positionMilliseconds / 1000)\n                        . '\">'\n                        . $this->formatTimestampTime($moment->positionMilliseconds / 1000)\n                        . '</a> - '\n                        . $moment->description\n                        . '</li>';\n                }\n            } else {\n                $item['content'] .= '<li><a href=\"'\n                    . $url\n                    . '\">00:00:00</a> - '\n                    . ($video->game ? $video->game->displayName : 'No Game')\n                    . '</li>';\n            }\n            $item['content'] .= '</ul></p>';\n\n            $item['categories'] = array_unique($item['categories']);\n\n            $this->items[] = $item;\n        }\n    }\n\n    // e.g. 01:53:27\n    private function formatTimestampTime($seconds)\n    {\n        $floor = floor($seconds / 3600);\n        $i = intval($seconds / 60) % 60;\n        $i1 = $seconds % 60;\n\n        return sprintf('%02d:%02d:%02d', $floor, $i, $i1);\n    }\n\n    // e.g. 01h53m27s\n    private function formatQueryTime($seconds)\n    {\n        $floor = floor($seconds / 3600);\n        $i = intval($seconds / 60) % 60;\n        $i1 = $seconds % 60;\n\n        return sprintf('%02dh%02dm%02ds', $floor, $i, $i1);\n    }\n\n    /**\n     * GraphQL: https://graphql.org/\n     * Tool for developing/testing queries: https://github.com/skevy/graphiql-app\n     *\n     * Official instructions for obtaining your own client ID can be found here:\n     * https://dev.twitch.tv/docs/v5/#getting-a-client-id\n     */\n    private function apiRequest($query, $variables)\n    {\n        $request = [\n            'query'     => $query,\n            'variables' => $variables,\n        ];\n        $headers = [\n            'Client-ID: kimne78kx3ncx6brgo4mv6wki5h1ko',\n        ];\n        $opts = [\n            CURLOPT_CUSTOMREQUEST => 'POST',\n            CURLOPT_POSTFIELDS => json_encode($request),\n        ];\n        $json = getContents('https://gql.twitch.tv/gql', $headers, $opts);\n        $result = Json::decode($json, false);\n        return $result;\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('channel'))) {\n            return $this->getInput('channel') . ' twitch videos';\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('channel'))) {\n            return self::URI . $this->getInput('channel');\n        }\n\n        return parent::getURI();\n    }\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // Matches e.g. https://www.twitch.tv/someuser/videos?filter=archives\n        $regex = '/^(https?:\\/\\/)?\n\t\t\t(www\\.)?\n\t\t\ttwitch\\.tv\\/\n\t\t\t([^\\/&?\\n]+)\n\t\t\t\\/videos\\?.*filter=\n\t\t\t(all|archive|highlight|upload)/x';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['channel'] = urldecode($matches[3]);\n            $params['type'] = $matches[4];\n            return $params;\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/TwitterBridge.php",
    "content": "<?php\n\nclass TwitterBridge extends BridgeAbstract\n{\n    const NAME = 'Twitter';\n    const URI = 'https://twitter.com/';\n    const API_URI = 'https://api.twitter.com';\n    const GUEST_TOKEN_USES = 100;\n    const GUEST_TOKEN_EXPIRY = 10800; // 3hrs\n    const CACHE_TIMEOUT = 60 * 15; // 15min\n    const DESCRIPTION = 'returns tweets';\n    const MAINTAINER = 'arnd-s';\n    const PARAMETERS = [\n        'global' => [\n            'nopic' => [\n                'name' => 'Hide profile pictures',\n                'type' => 'checkbox',\n                'title' => 'Activate to hide profile pictures in content'\n            ],\n            'noimg' => [\n                'name' => 'Hide images in tweets',\n                'type' => 'checkbox',\n                'title' => 'Activate to hide images in tweets'\n            ],\n            'noimgscaling' => [\n                'name' => 'Disable image scaling',\n                'type' => 'checkbox',\n                'title' => 'Activate to disable image scaling in tweets (keeps original image)'\n            ]\n        ],\n        'By keyword or hashtag' => [\n            'q' => [\n                'name' => 'Keyword or #hashtag',\n                'required' => true,\n                'exampleValue' => 'rss-bridge OR rssbridge',\n                'title' => <<<EOD\n* To search for multiple words (must contain all of these words), put a space between them.\n\nExample: `rss-bridge release`.\n\n* To search for multiple words (contains any of these words), put \"OR\" between them.\n\nExample: `rss-bridge OR rssbridge`.\n\n* To search for an exact phrase (including whitespace), put double-quotes around them.\n\nExample: `\"rss-bridge release\"`\n\n* If you want to search for anything **but** a specific word, put a hyphen before it.\n\nExample: `rss-bridge -release` (ignores \"release\")\n\n* Of course, this also works for hashtags.\n\nExample: `#rss-bridge OR #rssbridge`\n\n* And you can combine them in any shape or form you like.\n\nExample: `#rss-bridge OR #rssbridge -release`\nEOD\n            ]\n        ],\n        'By username' => [\n            'u' => [\n                'name' => 'username',\n                'required' => true,\n                'exampleValue' => 'sebsauvage',\n                'title' => 'Insert a user name'\n            ],\n            'norep' => [\n                'name' => 'Without replies',\n                'type' => 'checkbox',\n                'title' => 'Only return initial tweets'\n            ],\n            'noretweet' => [\n                'name' => 'Without retweets',\n                'required' => false,\n                'type' => 'checkbox',\n                'title' => 'Hide retweets'\n            ],\n            'nopinned' => [\n                'name' => 'Without pinned tweet',\n                'required' => false,\n                'type' => 'checkbox',\n                'title' => 'Hide pinned tweet'\n            ]\n        ],\n        'By list' => [\n            'user' => [\n                'name' => 'User',\n                'required' => true,\n                'exampleValue' => 'Scobleizer',\n                'title' => 'Insert a user name'\n            ],\n            'list' => [\n                'name' => 'List',\n                'required' => true,\n                'exampleValue' => 'Tech-News',\n                'title' => 'Insert the list name'\n            ],\n            'filter' => [\n                'name' => 'Filter',\n                'exampleValue' => '#rss-bridge',\n                'required' => false,\n                'title' => 'Specify term to search for'\n            ]\n        ],\n        'By list ID' => [\n            'listid' => [\n                'name' => 'List ID',\n                'exampleValue' => '31748',\n                'required' => true,\n                'title' => 'Insert the list id'\n            ],\n            'filter' => [\n                'name' => 'Filter',\n                'exampleValue' => '#rss-bridge',\n                'required' => false,\n                'title' => 'Specify term to search for'\n            ]\n        ]\n    ];\n\n    private $apiKey     = null;\n    private $guestToken = null;\n    private $authHeaders = [];\n    private ?string $feedIconUrl = null;\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        // By keyword or hashtag (search)\n        $regex = '/^(https?:\\/\\/)?(www\\.)?twitter\\.com\\/search.*(\\?|&)q=([^\\/&?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By keyword or hashtag';\n            $params['q'] = urldecode($matches[4]);\n            return $params;\n        }\n\n        // By hashtag\n        $regex = '/^(https?:\\/\\/)?(www\\.)?twitter\\.com\\/hashtag\\/([^\\/?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By keyword or hashtag';\n            $params['q'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        // By list\n        $regex = '/^(https?:\\/\\/)?(www\\.)?twitter\\.com\\/([^\\/?\\n]+)\\/lists\\/([^\\/?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By list';\n            $params['user'] = urldecode($matches[3]);\n            $params['list'] = urldecode($matches[4]);\n            return $params;\n        }\n\n        // By username\n        $regex = '/^(https?:\\/\\/)?(www\\.)?twitter\\.com\\/([^\\/?\\n]+)/';\n        if (preg_match($regex, $url, $matches) > 0) {\n            $params['context'] = 'By username';\n            $params['u'] = urldecode($matches[3]);\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By keyword or hashtag':\n                $specific = 'search ';\n                $param = 'q';\n                break;\n            case 'By username':\n                $specific = '@';\n                $param = 'u';\n                break;\n            case 'By list':\n                return $this->getInput('list') . ' - Twitter list by ' . $this->getInput('user');\n            case 'By list ID':\n                return 'Twitter List #' . $this->getInput('listid');\n            default:\n                return parent::getName();\n        }\n        return 'Twitter ' . $specific . $this->getInput($param);\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case 'By keyword or hashtag':\n                return self::URI\n            . 'search?q='\n            . urlencode($this->getInput('q'))\n            . '&f=tweets';\n            case 'By username':\n                return self::URI\n            . urlencode($this->getInput('u'));\n            // Always return without replies!\n            // . ($this->getInput('norep') ? '' : '/with_replies');\n            case 'By list':\n                return self::URI\n            . urlencode($this->getInput('user'))\n            . '/lists/'\n            . str_replace(' ', '-', strtolower($this->getInput('list')));\n            case 'By list ID':\n                return self::URI\n            . 'i/lists/'\n            . urlencode($this->getInput('listid'));\n            default:\n                return parent::getURI();\n        }\n    }\n\n    private function getFullText($id)\n    {\n        $url = sprintf(\n            'https://cdn.syndication.twimg.com/tweet-result?id=%s&lang=en&token=449yf2pc4g',\n            $id\n        );\n\n        return json_decode(getContents($url), false);\n    }\n\n    public function collectData()\n    {\n        // $data will contain an array of all found tweets (unfiltered)\n        $data = null;\n        // Contains user data (when in by username context)\n        $user = null;\n        // Array of all found tweets\n        $tweets = [];\n\n        // Get authentication information\n        $api = new TwitterClient($this->cache);\n        // Try to get all tweets\n        switch ($this->queriedContext) {\n            case 'By username':\n                $screenName = $this->getInput('u');\n                $screenName = trim($screenName);\n                $screenName = ltrim($screenName, '@');\n\n                $data = $api->fetchUserTweets($screenName);\n\n                break;\n\n            case 'By keyword or hashtag':\n                // Does not work with the recent twitter changes\n                $params = [\n                    'q'                 => urlencode($this->getInput('q')),\n                    'tweet_mode'        => 'extended',\n                    'tweet_search_mode' => 'live',\n                ];\n\n                $tweets = $api->search($params)->statuses;\n                $data = (object) [\n                    'tweets' => $tweets\n                ];\n                break;\n\n            case 'By list':\n                // Does not work with the recent twitter changes\n                // $params = [\n                // 'slug'              => strtolower($this->getInput('list')),\n                // 'owner_screen_name' => strtolower($this->getInput('user')),\n                // 'tweet_mode'        => 'extended',\n                // ];\n                $query = [\n                    'screenName' => strtolower($this->getInput('user')),\n                    'listSlug' => strtolower($this->getInput('list'))\n                ];\n\n                $data = $api->fetchListTweets($query, $this->queriedContext);\n                break;\n\n            case 'By list ID':\n                // Does not work with the recent twitter changes\n                // $params = [\n                // 'list_id'           => $this->getInput('listid'),\n                // 'tweet_mode'        => 'extended',\n                // ];\n\n                $query = [\n                    'listId' => $this->getInput('listid')\n                ];\n\n                $data = $api->fetchListTweets($query, $this->queriedContext);\n                break;\n            default:\n                throwServerException('Invalid query context !');\n        }\n\n        if (!$data) {\n            switch ($this->queriedContext) {\n                case 'By keyword or hashtag':\n                    throwServerException('twitter: No results for this query.');\n                    // fall-through\n                case 'By username':\n                    throwServerException('Requested username can\\'t be found.');\n                    // fall-through\n                case 'By list':\n                    throwServerException('Requested username or list can\\'t be found');\n            }\n        }\n\n        $hidePictures = $this->getInput('nopic');\n\n        $hidePinned = $this->getInput('nopinned');\n        if ($hidePinned) {\n            $pinnedTweetId = null;\n            if ($data->user_info && $data->user_info->legacy->pinned_tweet_ids_str) {\n                $pinnedTweetId = $data->user_info->legacy->pinned_tweet_ids_str[0];\n            }\n        }\n\n        // Array of Tweet IDs\n        $tweetIds = [];\n        // Filter out unwanted tweets\n        foreach ($data->tweets as $tweet) {\n            if (!$tweet) {\n                continue;\n            }\n\n            if (isset($tweet->legacy)) {\n                $legacy_info = $tweet->legacy;\n            } else {\n                $legacy_info = $tweet;\n            }\n\n            // Filter out retweets to remove possible duplicates of original tweet\n            switch ($this->queriedContext) {\n                case 'By keyword or hashtag':\n\t\t    // phpcs:ignore\n                    if ((isset($legacy_info->retweeted_status) || isset($legacy_info->retweeted_status_result)) && substr($legacy_info->full_text, 0, 4) === 'RT @') {\n                        continue 2;\n                    }\n                    break;\n            }\n\n            // Skip own Retweets...\n            if (isset($legacy_info->retweeted_status) && $legacy_info->retweeted_status->user->id_str === $tweet->user->id_str) {\n                continue;\n            // phpcs:ignore\n            } elseif (isset($legacy_info->retweeted_status_result) && $tweet->retweeted_status_result->result->legacy->user_id_str === $legacy_info->user_id_str) {\n                continue;\n            }\n\n            $tweetId = (isset($legacy_info->id_str) ? $legacy_info->id_str : $tweet->rest_id);\n            // Skip pinned tweet\n            if ($hidePinned && ($tweetId === $pinnedTweetId)) {\n                continue;\n            }\n\n            if (isset($tweet->rest_id)) {\n                $tweetIds[] = $tweetId;\n            }\n            $rtweet = $legacy_info;\n            $tweets[] = $rtweet;\n        }\n\n        if ($this->queriedContext === 'By username') {\n            $this->feedIconUrl = $data->user_info->legacy->profile_image_url_https ?? null;\n        }\n\n        $i = 0;\n        foreach ($tweets as $tweet) {\n            $item = [];\n\n            $realtweet = $tweet;\n            $tweetId = (isset($tweetIds[$i]) ? $tweetIds[$i] : $realtweet->conversation_id_str);\n            if (isset($tweet->retweeted_status)) {\n                // Tweet is a Retweet, so set author based on original tweet and set realtweet for reference to the right content\n                $realtweet = $tweet->retweeted_status;\n            } elseif (isset($tweet->retweeted_status_result)) {\n                $tweetId = $tweet->retweeted_status_result->result->rest_id;\n                $realtweet = $tweet->retweeted_status_result->result->legacy;\n            }\n\n            if (isset($realtweet->truncated) && $realtweet->truncated) {\n                try {\n                    $realtweet = $this->getFullText($realtweet->id_str);\n                } catch (HttpException $e) {\n                    $realtweet = $tweet;\n                }\n            }\n\n            if (!$realtweet) {\n                $realtweet = $tweet;\n            }\n\n            switch ($this->queriedContext) {\n                case 'By username':\n                    if ($this->getInput('norep') && isset($tweet->in_reply_to_status_id)) {\n                        continue 2;\n                    }\n                    $item['username']  = $data->user_info->legacy->screen_name;\n                    $item['fullname']  = $data->user_info->legacy->name;\n                    $item['avatar']    = $data->user_info->legacy->profile_image_url_https;\n                    $item['id']        = (isset($realtweet->id_str) ? $realtweet->id_str : $tweetId);\n                    break;\n                case 'By list':\n                case 'By list ID':\n                    $item['username']  = $data->userIds[$i]->legacy->screen_name;\n                    $item['fullname']  = $data->userIds[$i]->legacy->name;\n                    $item['avatar']    = $data->userIds[$i]->legacy->profile_image_url_https;\n                    $item['id']        = $realtweet->conversation_id_str;\n                    break;\n                case 'By keyword or hashtag':\n                    $item['username']  = $realtweet->user->screen_name;\n                    $item['fullname']  = $realtweet->user->name;\n                    $item['avatar']    = $realtweet->user->profile_image_url_https;\n                    $item['id']        = $realtweet->id_str;\n                    break;\n            }\n\n            $item['timestamp'] = $realtweet->created_at;\n            $item['uri']       = self::URI . $item['username'] . '/status/' . $item['id'];\n            $item['author']    = ((isset($tweet->retweeted_status) || (isset($tweet->retweeted_status_result))) ? 'RT: ' : '')\n                         . $item['fullname']\n                         . ' (@'\n                         . $item['username'] . ')';\n\n            // Convert plain text URLs into HTML hyperlinks\n            if (isset($realtweet->full_text)) {\n                $fulltext = $realtweet->full_text;\n            } else {\n                $fulltext = $realtweet->text;\n            }\n            $cleanedTweet = $fulltext;\n\n            $foundUrls = false;\n\n            if (substr($cleanedTweet, 0, 4) === 'RT @') {\n                $cleanedTweet = substr($cleanedTweet, 3);\n            }\n\n            if (isset($realtweet->entities->media)) {\n                foreach ($realtweet->entities->media as $media) {\n                    $cleanedTweet = str_replace(\n                        $media->url,\n                        '<a href=\"' . $media->expanded_url . '\">' . $media->display_url . '</a>',\n                        $cleanedTweet\n                    );\n                    $foundUrls = true;\n                }\n            }\n            if (isset($realtweet->entities->urls)) {\n                foreach ($realtweet->entities->urls as $url) {\n                    $cleanedTweet = str_replace(\n                        $url->url,\n                        '<a href=\"' . $url->expanded_url . '\">' . $url->display_url . '</a>',\n                        $cleanedTweet\n                    );\n                    $foundUrls = true;\n                }\n            }\n            if ($foundUrls === false) {\n                // fallback to regex'es\n                $reg_ex = '/(http|https|ftp|ftps)\\:\\/\\/[a-zA-Z0-9\\-\\.]+\\.[a-zA-Z]{2,3}(\\/\\S*)?/';\n                if (preg_match($reg_ex, $fulltext, $url)) {\n                    $cleanedTweet = preg_replace(\n                        $reg_ex,\n                        \"<a href='{$url[0]}' target='_blank'>{$url[0]}</a> \",\n                        $cleanedTweet\n                    );\n                }\n            }\n            // generate the title\n            $item['title'] = strip_tags($cleanedTweet);\n\n            // Add avatar\n            $picture_html = '';\n            if (!$hidePictures) {\n                $picture_html = <<<EOD\n<a href=\"https://twitter.com/{$item['username']}\">\n<img\n\tstyle=\"align:top; width:75px; border:1px solid black;\"\n\talt=\"{$item['username']}\"\n\tsrc=\"{$item['avatar']}\"\n\ttitle=\"{$item['fullname']}\" />\n</a>\nEOD;\n            }\n\n            $medias = [];\n            if (isset($realtweet->extended_entities->media)) {\n                $medias = $realtweet->extended_entities->media;\n            } else if (isset($realtweet->mediaDetails)) {\n                $medias = $realtweet->mediaDetails;\n            }\n\n            // Get images\n            $media_html = '';\n            if (!$this->getInput('noimg')) {\n                foreach ($medias as $media) {\n                    switch ($media->type) {\n                        case 'photo':\n                            $image = $media->media_url_https . '?name=orig';\n                            $display_image = $media->media_url_https;\n                            // add enclosures\n                            $item['enclosures'][] = $image;\n\n                            $media_html .= <<<EOD\n<a href=\"{$image}\">\n<img\n\tstyle=\"align:top; max-width:558px; border:1px solid black;\"\n\treferrerpolicy=\"no-referrer\"\n\tsrc=\"{$display_image}\" />\n</a>\nEOD;\n                            break;\n                        case 'video':\n                        case 'animated_gif':\n                            if (isset($media->video_info)) {\n                                $link = $media->expanded_url;\n                                $poster = $media->media_url_https;\n                                $video = null;\n                                $maxBitrate = -1;\n                                foreach ($media->video_info->variants as $variant) {\n                                    $bitRate = $variant->bitrate ?? -100;\n                                    if ($bitRate > $maxBitrate) {\n                                        $maxBitrate = $bitRate;\n                                        $video = $variant->url;\n                                    }\n                                }\n                                if (!is_null($video)) {\n                                    // add enclosures\n                                    $item['enclosures'][] = $video;\n                                    $item['enclosures'][] = $poster;\n\n                                    $media_html .= <<<EOD\n<a href=\"{$link}\">Video</a>\n<video\n\tstyle=\"align:top; max-width:558px; border:1px solid black;\"\n\treferrerpolicy=\"no-referrer\"\n\tsrc=\"{$video}\" poster=\"{$poster}\" />\nEOD;\n                                }\n                            }\n                            break;\n                        default:\n                            break;\n                    }\n                }\n            }\n\n            switch ($this->queriedContext) {\n                case 'By list':\n                case 'By list ID':\n                    // Check if filter applies to list (using raw content)\n                    if ($this->getInput('filter')) {\n                        if (stripos($cleanedTweet, $this->getInput('filter')) === false) {\n                            continue 2; // switch + for-loop!\n                        }\n                    }\n                    break;\n                case 'By username':\n                    if ($this->getInput('noretweet') && strtolower($item['username']) != strtolower($this->getInput('u'))) {\n                        continue 2; // switch + for-loop!\n                    }\n                    break;\n                default:\n            }\n\n            $item['content'] = <<<EOD\n<div style=\"display: inline-block; vertical-align: top;\">\n\t{$picture_html}\n</div>\n<div style=\"display: inline-block; vertical-align: top;\">\n\t<blockquote>{$cleanedTweet}</blockquote>\n</div>\n<div style=\"display: block; vertical-align: top;\">\n\t<blockquote>{$media_html}</blockquote>\n</div>\nEOD;\n\n            // put out\n            $i++;\n            $this->items[] = $item;\n        }\n\n        usort($this->items, ['TwitterBridge', 'compareTweetId']);\n    }\n\n    public function getIcon()\n    {\n        return $this->feedIconUrl ?? parent::getIcon();\n    }\n\n    private static function compareTweetId($tweet1, $tweet2)\n    {\n        return (intval($tweet1['id']) < intval($tweet2['id']) ? 1 : -1);\n    }\n}\n"
  },
  {
    "path": "bridges/TwitterEngineeringBridge.php",
    "content": "<?php\n\nclass TwitterEngineeringBridge extends FeedExpander\n{\n    const MAINTAINER = 'corenting';\n    const NAME = 'Twitter Engineering Blog';\n    const URI = 'https://blog.twitter.com/engineering/';\n    const DESCRIPTION = 'Returns the newest articles.';\n    const CACHE_TIMEOUT = 21600; // 6h\n\n    public function collectData()\n    {\n        $url = 'https://blog.twitter.com/engineering/en_us/blog.rss';\n        $this->collectExpandableDatas($url);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOMCached($item['uri']);\n        if (!$dom) {\n            $item['content'] .= '<p><em>Could not request ' . $this->getName() . ': ' . $item['uri'] . '</em></p>';\n            return $item;\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n\n        $article_body = $dom->find('div.column.column-6', 0);\n\n        // Remove elements that are not part of article content\n        $unwanted_selector = 'div.bl02-blog-post-text-masthead, div.tweet-error-text, div.bl13-tweet-template';\n        foreach ($article_body->find($unwanted_selector) as $found) {\n            $found->outertext = '';\n        }\n\n        // Set src for images\n        foreach ($article_body->find('img') as $found) {\n            $found->setAttribute('src', $found->getAttribute('data-src'));\n        }\n\n        $item['content'] = $article_body;\n        $item['timestamp'] = strtotime($dom->find('span.b02-blog-post-no-masthead__date', 0)->innertext);\n        $item['categories'] = self::getCategoriesFromTags($dom);\n\n        return $item;\n    }\n\n    private static function getCategoriesFromTags($article_html)\n    {\n        $tags_list_items = [$article_html->find('.post__tags > ul > li')];\n        $categories = [];\n\n        foreach ($tags_list_items as $tag_list_item) {\n            foreach ($tag_list_item as $tag) {\n                $categories[] = trim($tag->plaintext);\n            }\n        }\n\n        return $categories;\n    }\n\n    public function getName()\n    {\n        // Else the original feed returns \"English (US)\" as the title\n        return 'Twitter Engineering Blog';\n    }\n}\n"
  },
  {
    "path": "bridges/TwitterV2Bridge.php",
    "content": "<?php\n\n/**\n * TwitterV2Bridge leverages Twitter API v2, and requires\n * a unique API Bearer Token, which requires creation of\n * a Twitter Dev account. Link to instructions in DESCRIPTION.\n */\nclass TwitterV2Bridge extends BridgeAbstract\n{\n    const NAME = 'Twitter V2';\n    const URI = 'https://twitter.com/';\n    const API_URI = 'https://api.twitter.com/2';\n    const DESCRIPTION = 'Returns tweets (using Twitter API v2). See the \n\t<a href=\"https://rss-bridge.github.io/rss-bridge/Bridge_Specific/TwitterV2.html\">\n\tConfiguration Instructions</a>.';\n    const MAINTAINER = 'quickwick';\n    const CONFIGURATION = [\n        'twitterv2apitoken' => [\n            'required' => true,\n        ]\n    ];\n    const PARAMETERS = [\n        'global' => [\n            'filter' => [\n                'name' => 'Filter',\n                'exampleValue' => 'rss-bridge',\n                'required' => false,\n                'title' => 'Specify a single term to search for'\n            ],\n            'norep' => [\n                'name' => 'Without replies',\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude reply tweets'\n            ],\n            'noretweet' => [\n                'name' => 'Without retweets',\n                'required' => false,\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude retweets'\n            ],\n            'nopinned' => [\n                'name' => 'Without pinned tweet',\n                'required' => false,\n                'type' => 'checkbox',\n                'title' => 'Activate to exclude pinned tweets'\n            ],\n            'maxresults' => [\n                'name' => 'Maximum results',\n                'required' => false,\n                'exampleValue' => '20',\n                'title' => 'Maximum number of tweets to retrieve (limit is 100)'\n            ],\n            'imgonly' => [\n                'name' => 'Only media tweets',\n                'type' => 'checkbox',\n                'title' => 'Activate to show only tweets with media (photo/video)'\n            ],\n            'nopic' => [\n                'name' => 'Hide profile pictures',\n                'type' => 'checkbox',\n                'title' => 'Activate to hide profile pictures in content'\n            ],\n            'noimg' => [\n                'name' => 'Hide images in tweets',\n                'type' => 'checkbox',\n                'title' => 'Activate to hide images in tweets'\n            ],\n            'noimgscaling' => [\n                'name' => 'Disable image scaling',\n                'type' => 'checkbox',\n                'title' => 'Activate to display original sized images (no thumbnails)'\n            ],\n            'noexternallink' => [\n                'name' => 'Hide external link from content html',\n                'type' => 'checkbox',\n                'title' => 'Activate to hide the links from the content html field'\n            ],\n            'idastitle' => [\n                'name' => 'Use tweet id as title',\n                'type' => 'checkbox',\n                'title' => 'Activate to use tweet id as title (instead of tweet text)'\n            ]\n        ],\n        'By username' => [\n            'u' => [\n                'name' => 'username',\n                'required' => true,\n                'exampleValue' => 'sebsauvage',\n                'title' => 'Insert a user name'\n            ]\n        ],\n        'By keyword or hashtag' => [\n            'query' => [\n                'name' => 'Keyword or #hashtag',\n                'required' => true,\n                'exampleValue' => 'rss-bridge OR #rss-bridge',\n                'title' => <<<EOD\n* To search for multiple words (must contain all of these words), put a space between them.\n\nExample: `rss-bridge release`.\n\n* To search for multiple words (contains any of these words), put \"OR\" between them.\n\nExample: `rss-bridge OR rssbridge`.\n\n* To search for an exact phrase (including whitespace), put double-quotes around them.\n\nExample: `\"rss-bridge release\"`\n\n* If you want to search for anything **but** a specific word, put a hyphen before it.\n\nExample: `rss-bridge -release` (ignores \"release\")\n\n* Of course, this also works for hashtags.\n\nExample: `#rss-bridge OR #rssbridge`\n\n* And you can combine them in any shape or form you like.\n\nExample: `#rss-bridge OR #rssbridge -release`\nEOD\n            ]\n        ],\n        'By list ID' => [\n            'listid' => [\n                'name' => 'List ID',\n                'exampleValue' => '31748',\n                'required' => true,\n                'title' => 'Enter a list id'\n            ]\n        ]\n    ];\n\n    // $Item variable needs to be accessible from multiple functions without passing\n    private $item = [];\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By keyword or hashtag':\n                $specific = 'search ';\n                $param = 'query';\n                break;\n            case 'By username':\n                $specific = '@';\n                $param = 'u';\n                break;\n            case 'By list ID':\n                return 'Twitter List #' . $this->getInput('listid');\n            default:\n                return parent::getName();\n        }\n        return 'Twitter ' . $specific . $this->getInput($param);\n    }\n\n    public function collectData()\n    {\n        // $data will contain an array of all found tweets\n        $data = null;\n        // Contains user data (when in by username context)\n        $user = null;\n        // Array of all found tweets\n        $tweets = [];\n\n        $hideProfilePic = $this->getInput('nopic');\n        $hideImages = $this->getInput('noimg');\n        $hideReplies = $this->getInput('norep');\n        $hideRetweets = $this->getInput('noretweet');\n        $hidePinned = $this->getInput('nopinned');\n        $tweetFilter = $this->getInput('filter');\n        $maxResults = $this->getInput('maxresults');\n        if ($maxResults > 100) {\n            $maxResults = 100;\n        }\n        $idAsTitle = $this->getInput('idastitle');\n        $onlyMediaTweets = $this->getInput('imgonly');\n\n        // Read API token from config.ini.php, put into Header\n        $apiToken = $this->getOption('twitterv2apitoken');\n        $authHeaders = [\n            'authorization: Bearer ' . $apiToken,\n        ];\n\n        // Try to get all tweets\n        switch ($this->queriedContext) {\n            case 'By username':\n                //Get id from username\n                $params = [\n                'user.fields'   => 'pinned_tweet_id,profile_image_url'\n                ];\n                $user = $this->makeApiCall('/users/by/username/'\n                . $this->getInput('u'), $authHeaders, $params);\n\n                if (isset($user->errors)) {\n                    throwServerException('Requested username can\\'t be found.');\n                }\n\n                // Set default params\n                $params = [\n                'max_results'   => (empty($maxResults) ? '10' : $maxResults),\n                'tweet.fields'\n                => 'created_at,referenced_tweets,entities,attachments',\n                'user.fields'   => 'pinned_tweet_id',\n                'expansions'\n                => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys',\n                'media.fields'  => 'type,url,preview_image_url'\n                ];\n\n                // Set params to filter out replies and/or retweets\n                if ($hideReplies && $hideRetweets) {\n                    $params['exclude'] = 'replies,retweets';\n                } elseif ($hideReplies) {\n                    $params['exclude'] = 'replies';\n                } elseif ($hideRetweets) {\n                    $params['exclude'] = 'retweets';\n                }\n\n                // Get the tweets\n                $data = $this->makeApiCall('/users/' . $user->data->id\n                . '/tweets', $authHeaders, $params);\n                break;\n\n            case 'By keyword or hashtag':\n                $params = [\n                'query'         => $this->getInput('query'),\n                'max_results'   => (empty($maxResults) ? '10' : $maxResults),\n                'tweet.fields'\n                => 'created_at,referenced_tweets,entities,attachments',\n                'expansions'\n                => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys',\n                'media.fields'  => 'type,url,preview_image_url'\n                ];\n\n                // Set params to filter out replies and/or retweets\n                if ($hideReplies) {\n                    $params['query'] = $params['query'] . ' -is:reply';\n                }\n                if ($hideRetweets) {\n                    $params['query'] = $params['query'] . ' -is:retweet';\n                }\n\n                $data = $this->makeApiCall('/tweets/search/recent', $authHeaders, $params);\n                break;\n\n            case 'By list ID':\n                // Set default params\n                $params = [\n                'max_results' => (empty($maxResults) ? '10' : $maxResults),\n                'tweet.fields'\n                => 'created_at,referenced_tweets,entities,attachments',\n                'expansions'\n                => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys',\n                'media.fields'  => 'type,url,preview_image_url'\n                ];\n\n                $data = $this->makeApiCall('/lists/' . $this->getInput('listid') .\n                '/tweets', $authHeaders, $params);\n                break;\n\n            default:\n                throwServerException('Invalid query context !');\n        }\n\n        if (\n            (isset($data->errors) && !isset($data->data)) ||\n            (isset($data->meta) && $data->meta->result_count === 0)\n        ) {\n            switch ($this->queriedContext) {\n                case 'By keyword or hashtag':\n                    throwServerException('No results for this query.');\n                    // fall-through\n                case 'By username':\n                    throwServerException('Requested username cannnot be found.');\n                    // fall-through\n                case 'By list ID':\n                    throwServerException('Requested list cannnot be found');\n                    // fall-through\n            }\n        }\n\n        // figure out the Pinned Tweet Id\n        if ($hidePinned) {\n            $pinnedTweetId = null;\n            if (isset($user) && isset($user->data->pinned_tweet_id)) {\n                $pinnedTweetId = $user->data->pinned_tweet_id;\n            }\n        }\n\n        // Extract Media data into array\n        isset($data->includes->media) ? $includesMedia = $data->includes->media : $includesMedia = null;\n\n        // Extract additional Users data into array\n        isset($data->includes->users) ? $includesUsers = $data->includes->users : $includesUsers = null;\n\n        // Extract additional Tweets data into array\n        isset($data->includes->tweets) ? $includesTweets = $data->includes->tweets : $includesTweets = null;\n\n        // Extract main Tweets data into array\n        $tweets = $data->data;\n\n        // Make another API call to get user and media info for retweets\n        // Is there some way to get this info included in original API call?\n        $retweetedData = null;\n        $retweetedMedia = null;\n        $retweetedUsers = null;\n        if (!$hideImages && isset($includesTweets)) {\n            // There has to be a better PHP way to extract the tweet Ids?\n            $includesTweetsIds = [];\n            foreach ($includesTweets as $includesTweet) {\n                $includesTweetsIds[] = $includesTweet->id;\n            }\n\n            // Set default params for API query\n            $params = [\n                'ids'           => join(',', $includesTweetsIds),\n                'tweet.fields'  => 'entities,attachments',\n                'expansions'    => 'author_id,attachments.media_keys',\n                'media.fields'  => 'type,url,preview_image_url',\n                'user.fields'   => 'id,profile_image_url'\n            ];\n\n            // Get the retweeted tweets\n            $retweetedData = $this->makeApiCall('/tweets', $authHeaders, $params);\n\n            // Extract retweets Media data into array\n            isset($retweetedData->includes->media) ? $retweetedMedia\n            = $retweetedData->includes->media : $retweetedMedia = null;\n\n            // Extract retweets additional Users data into array\n            isset($retweetedData->includes->users) ? $retweetedUsers\n            = $retweetedData->includes->users : $retweetedUsers = null;\n        }\n\n        // Create output array with all required elements for each tweet\n        foreach ($tweets as $tweet) {\n            // Skip pinned tweet (if selected)\n            if ($hidePinned && $tweet->id === $pinnedTweetId) {\n                continue;\n            }\n\n            // Check if tweet is Retweet, Quote or Reply\n            $isRetweet = false;\n            $isReply = false;\n            $isQuote = false;\n\n            if (isset($tweet->referenced_tweets)) {\n                switch ($tweet->referenced_tweets[0]->type) {\n                    case 'retweeted':\n                        $isRetweet = true;\n                        break;\n                    case 'quoted':\n                        $isQuote = true;\n                        break;\n                    case 'replied_to':\n                        $isReply = true;\n                        break;\n                }\n            }\n\n            // Skip replies and/or retweets (if selected). This check is primarily for lists\n            // These should already be pre-filtered for username and keyword queries\n            if (($hideRetweets && $isRetweet) || ($hideReplies && $isReply)) {\n                continue;\n            }\n\n            // Initialize empty array to hold feed item values\n            $this->item = [];\n\n            // Start getting and setting values needed for HTML output\n            $quotedTweet = null;\n            $cleanedQuotedTweet = null;\n            $quotedUser = null;\n            if ($isQuote) {\n                foreach ($includesTweets as $includesTweet) {\n                    if ($includesTweet->id === $tweet->referenced_tweets[0]->id) {\n                        $quotedTweet = $includesTweet;\n                        $cleanedQuotedTweet = nl2br($quotedTweet->text);\n                        break;\n                    }\n                }\n\n                $quotedUser = $this->getTweetUser($quotedTweet, $retweetedUsers, $includesUsers);\n            }\n            if ($isRetweet || is_null($user)) {\n                // Replace tweet object with original retweeted object\n                if ($isRetweet) {\n                    foreach ($includesTweets as $includesTweet) {\n                        if ($includesTweet->id === $tweet->referenced_tweets[0]->id) {\n                            $tweet = $includesTweet;\n                            break;\n                        }\n                    }\n                }\n\n                // Skip self-Retweets (can cause duplicate entries in output)\n                if (isset($user) && $tweet->author_id === $user->data->id) {\n                    continue;\n                }\n\n                // Get user object for retweeted tweet\n                $originalUser = $this->getTweetUser($tweet, $retweetedUsers, $includesUsers);\n\n                $this->item['username']  = $originalUser->username;\n                $this->item['fullname']  = $originalUser->name;\n                if (isset($originalUser->profile_image_url)) {\n                    $this->item['avatar']    = $originalUser->profile_image_url;\n                } else {\n                    $this->item['avatar'] = null;\n                }\n            } else {\n                $this->item['username']  = $user->data->username;\n                $this->item['fullname']  = $user->data->name;\n                $this->item['avatar']    = $user->data->profile_image_url;\n            }\n            $this->item['id']        = $tweet->id;\n            $this->item['timestamp'] = $tweet->created_at;\n            $this->item['uri']\n            = self::URI . $this->item['username'] . '/status/' . $this->item['id'];\n            $this->item['author']    = ($isRetweet ? 'RT: ' : '')\n                         . $this->item['fullname']\n                         . ' (@'\n                         . $this->item['username'] . ')';\n\n            $cleanedTweet = nl2br($tweet->text);\n\n            // Perform optional keyword filtering (only keep tweet if keyword is found)\n            if (! empty($tweetFilter)) {\n                if (stripos($cleanedTweet, $this->getInput('filter')) === false) {\n                    continue;\n                }\n            }\n\n            // Perform optional non-media tweet skip\n            // This check must wait until after retweets are identified\n            if (\n                $onlyMediaTweets && !isset($tweet->attachments->media_keys) &&\n                (($isQuote && !isset($quotedTweet->attachments->media_keys)) || !$isQuote)\n            ) {\n                // There is no media in current tweet or quoted tweet, skip to next\n                continue;\n            }\n\n            // Search for and replace URLs in Tweet text\n            $cleanedTweet = $this->replaceTweetURLs($tweet, $cleanedTweet);\n            if (isset($cleanedQuotedTweet)) {\n                $cleanedQuotedTweet = $this->replaceTweetURLs($quotedTweet, $cleanedQuotedTweet);\n            }\n\n            // Generate Title text\n            if ($idAsTitle) {\n                $titleText = $tweet->id;\n            } else {\n                $titleText = strip_tags($cleanedTweet);\n            }\n\n            if ($isRetweet) {\n                if (substr($titleText, 0, 4) === 'RT @') {\n                    $titleText = substr_replace($titleText, ':', 2, 0);\n                } else {\n                    $titleText = 'RT: @' . $this->item['username'] . ': ' . $titleText;\n                }\n            } elseif ($isReply  && !$idAsTitle) {\n                $titleText = 'R: ' . $titleText;\n            }\n\n            $this->item['title'] = $titleText;\n\n            // Get external link info\n            $extURL = null;\n            if (isset($tweet->entities->urls) && strpos($tweet->entities->urls[0]->expanded_url, 'twitter.com') === false) {\n                $extURL = $tweet->entities->urls[0]->expanded_url;\n                $extDisplayURL = $tweet->entities->urls[0]->display_url;\n                $extTitle = $tweet->entities->urls[0]->title;\n                $extDesc = $tweet->entities->urls[0]->description;\n                if (isset($tweet->entities->urls[0]->images)) {\n                    $extMediaOrig = $tweet->entities->urls[0]->images[0]->url;\n                    $extMediaScaled = $tweet->entities->urls[0]->images[1]->url;\n                } else {\n                    $extMediaOrig = '';\n                    $extMediaScaled = '';\n                }\n            }\n\n            // Generate Avatar HTML block\n            $picture_html = '';\n            if (!$hideProfilePic && isset($this->item['avatar'])) {\n                $picture_html = <<<EOD\n<a href=\"https://twitter.com/{$this->item['username']}\">\n<img\n\tstyle=\"margin-right: 10px; margin-bottom: 10px;\"\n\talt=\"{$this->item['username']}\"\n\tsrc=\"{$this->item['avatar']}\"\n\ttitle=\"{$this->item['fullname']}\" />\n</a>\nEOD;\n            }\n\n            // Generate media HTML block\n            $media_html = '';\n            $quoted_media_html = '';\n            $ext_media_html = '';\n            if (!$hideImages) {\n                if (isset($tweet->attachments->media_keys)) {\n                    $media_html = $this->createTweetMediaHTML($tweet, $includesMedia, $retweetedMedia);\n                }\n                if (isset($quotedTweet->attachments->media_keys)) {\n                    $quoted_media_html = $this->createTweetMediaHTML($quotedTweet, $includesMedia, $retweetedMedia);\n                }\n                if (isset($extURL)) {\n                    if ($this->getInput('noimgscaling')) {\n                        $extMediaURL = $extMediaOrig;\n                    } else {\n                        $extMediaURL = $extMediaScaled;\n                    }\n                    $ext_media_html = <<<EOD\n<a href=\"$extURL\"><img referrerpolicy=\"no-referrer\" src=\"$extMediaURL\" /></a>\nEOD;\n                }\n            }\n\n            // Generate the HTML for Item content\n            $this->item['content'] = <<<EOD\n<div style=\"float: left;\">\n\t{$picture_html}\n</div>\n<div style=\"display: table;\">\n\t{$cleanedTweet}\n</div>\n<div style=\"display: block; margin-top: 16px;\">\n\t{$media_html}\nEOD;\n\n            // Add Quoted Tweet HTML, if relevant\n            if (isset($quotedTweet)) {\n                $quotedTweetURI = self::URI . $quotedUser->username . '/status/' . $quotedTweet->id;\n                $quote_html = <<<QUOTE\n<div style=\"display: table; border-style: solid; border-width: 1px; border-radius: 5px; padding: 5px;\">\n\t\t\t\t\t\t\t\t\t\n\t<p><b>$quotedUser->name</b> @$quotedUser->username · \n\t<a href=\"$quotedTweetURI\">$quotedTweet->created_at</a></p>\n\t$cleanedQuotedTweet\n\t$quoted_media_html\n</div>\nQUOTE;\n                $this->item['content'] .= $quote_html;\n            }\n\n            // Add External Link HTML, if relevant\n            if (isset($extURL) && !$this->getInput('noexternallink')) {\n                $ext_html = <<<EXTERNAL\n<div style=\"display: table; border-style: solid; border-width: 1px; border-radius: 5px; padding: 5px;\">\n    $ext_media_html<br>\n    <a href=\"$extURL\">$extDisplayURL</a><br>\n    <b>$extTitle</b><br>\n    $extDesc\n</div>\nEXTERNAL;\n                $this->item['content'] .= $ext_html;\n            }\n\n            $this->item['content'] = htmlspecialchars_decode($this->item['content'], ENT_QUOTES);\n\n            // Add current Item to Items array\n            $this->items[] = $this->item;\n        }\n\n        // Sort all tweets in array by date\n        usort($this->items, ['TwitterV2Bridge', 'compareTweetDate']);\n    }\n\n    private static function compareTweetDate($tweet1, $tweet2)\n    {\n        return (strtotime($tweet1['timestamp']) < strtotime($tweet2['timestamp']) ? 1 : -1);\n    }\n\n    /**\n     * Tries to make an API call to Twitter.\n     * @param $api string API entry point\n     * @param $params array additional URI parmaeters\n     * @return object json data\n     */\n    private function makeApiCall($api, $authHeaders, $params)\n    {\n        $uri = self::API_URI . $api . '?' . http_build_query($params);\n        $result = getContents($uri, $authHeaders);\n        $data = json_decode($result);\n        return $data;\n    }\n\n    /**\n     * Change format of URLs in tweet text\n     * @param $tweetObject object current Tweet JSON\n     * @param $tweetText string current Tweet text\n     * @return string modified tweet text\n     */\n    private function replaceTweetURLs($tweetObject, $tweetText)\n    {\n        $foundUrls = false;\n        // Rewrite URL links, based on URL list in tweet object\n        if (isset($tweetObject->entities->urls)) {\n            foreach ($tweetObject->entities->urls as $url) {\n                $tweetText = str_replace(\n                    $url->url,\n                    '<a href=\"' . $url->expanded_url\n                    . '\">' . $url->display_url . '</a>',\n                    $tweetText\n                );\n            }\n            $foundUrls = true;\n        }\n        // Regex fallback for rewriting URL links. Should never trigger?\n        if ($foundUrls === false) {\n            $reg_ex = '/(http|https|ftp|ftps)\\:\\/\\/[a-zA-Z0-9\\-\\.]+\\.[a-zA-Z]{2,3}(\\/\\S*)?/';\n            if (preg_match($reg_ex, $tweetText, $url)) {\n                $tweetText = preg_replace(\n                    $reg_ex,\n                    \"<a href='{$url[0]}' target='_blank'>{$url[0]}</a> \",\n                    $tweetText\n                );\n            }\n        }\n        // Fix back-to-back URLs by adding a <br>\n        $reg_ex = '/\\/a>\\s*<a/';\n        $tweetText = preg_replace($reg_ex, '/a><br><a', $tweetText);\n\n        return $tweetText;\n    }\n\n    /**\n     * Find User object for Retweeted/Quoted tweet\n     * @param $tweetObject object current Tweet JSON\n     * @param $retweetedUsers\n     * @param $includesUsers\n     * @return object found User\n     */\n    private function getTweetUser($tweetObject, $retweetedUsers, $includesUsers)\n    {\n        $originalUser = new stdClass(); // make the linters stop complaining\n        if (isset($retweetedUsers)) {\n            foreach ($retweetedUsers as $retweetedUser) {\n                if ($retweetedUser->id === $tweetObject->author_id) {\n                    $matchedUser = $retweetedUser;\n                    break;\n                }\n            }\n        }\n        if (!isset($matchedUser->username) && isset($includesUsers)) {\n            foreach ($includesUsers as $includesUser) {\n                if ($includesUser->id === $tweetObject->author_id) {\n                    $matchedUser = $includesUser;\n\n                    break;\n                }\n            }\n        }\n        return $matchedUser;\n    }\n\n    /**\n     * Generates HTML for embedded media\n     * @param $tweetObject object current Tweet JSON\n     * @param $includesMedia\n     * @param $retweetedMedia\n     * @return string modified tweet text\n     */\n    private function createTweetMediaHTML($tweetObject, $includesMedia, $retweetedMedia)\n    {\n        $media_html = '';\n        // Match media_keys in tweet to media list from, put matches into new array\n        $tweetMedia = [];\n        // Start by checking the original list of tweet Media includes\n        if (isset($includesMedia)) {\n            foreach ($includesMedia as $includesMedium) {\n                if (\n                    in_array(\n                        $includesMedium->media_key,\n                        $tweetObject->attachments->media_keys\n                    )\n                ) {\n                    $tweetMedia[] = $includesMedium;\n                }\n            }\n        }\n        // If no matches found, check the retweet Media includes\n        if (empty($tweetMedia) && isset($retweetedMedia)) {\n            foreach ($retweetedMedia as $retweetedMedium) {\n                if (\n                    in_array(\n                        $retweetedMedium->media_key,\n                        $tweetObject->attachments->media_keys\n                    )\n                ) {\n                    $tweetMedia[] = $retweetedMedium;\n                }\n            }\n        }\n\n        foreach ($tweetMedia as $media) {\n            switch ($media->type) {\n                case 'photo':\n                    if ($this->getInput('noimgscaling')) {\n                        $image = $media->url;\n                        $display_image = $media->url;\n                    } else {\n                        $image = $media->url . '?name=orig';\n                        $display_image = $media->url;\n                    }\n                    // add enclosures\n                    $this->item['enclosures'][] = $image;\n\n                    $media_html .= <<<EOD\n<a href=\"{$image}\">\n<img\nreferrerpolicy=\"no-referrer\"\nsrc=\"{$display_image}\" />\n</a>\nEOD;\n                    break;\n                case 'video':\n                    // To Do: Is there a way to easily match this\n                    // to a direct Video URL?\n                    $display_image = $media->preview_image_url;\n\n                    $media_html .= <<<EOD\n<p>Video:</p><a href=\"{$this->item['uri']}\">\n<img referrerpolicy=\"no-referrer\" src=\"{$display_image}\" /></a>\nEOD;\n                    break;\n                case 'animated_gif':\n                    // To Do: Is there a way to easily match this to a\n                    // direct animated Gif URL?\n                    $display_image = $media->preview_image_url;\n\n                    $media_html .= <<<EOD\n<p>Animated Gif:</p><a href=\"{$this->item['uri']}\">\n<img referrerpolicy=\"no-referrer\" src=\"{$display_image}\" /></a>\nEOD;\n                    break;\n                default:\n                    break;\n            }\n        }\n\n        return $media_html;\n    }\n}\n"
  },
  {
    "path": "bridges/UberNewsroomBridge.php",
    "content": "<?php\n\nclass UberNewsroomBridge extends BridgeAbstract\n{\n    const NAME = 'Uber Newsroom';\n    const URI = 'https://www.uber.com';\n    const URI_API_DATA = 'https://newsroomapi.uber.com/wp-json/newsroom/v1/data?locale=';\n    const URI_API_POST = 'https://newsroomapi.uber.com/wp-json/wp/v2/posts/';\n    const DESCRIPTION = 'Returns news posts';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [[\n        'region' => [\n            'name' => 'Region',\n            'type' => 'list',\n            'values' => [\n                'Africa' => [\n                    'Egypt' => 'ar-EG',\n                    'Ghana' => 'en-GH',\n                    'Kenya' => 'en-KE',\n                    'Morocco' => 'fr-MA',\n                    'Nigeria' => 'en-NG',\n                    'South Africa' => 'en-ZA',\n                    'Tanzania' => 'en-TZ',\n                    'Uganda' => 'en-UG',\n                ],\n                'Asia' => [\n                    'Bangladesh' => 'en-BD',\n                    'Cambodia' => 'km-KH',\n                    'China' => 'zh-CN',\n                    'Hong Kong' => 'zh-HK',\n                    'India' => 'en-IN',\n                    'Indonesia' => 'en-ID',\n                    'Japan' => 'ja-JP',\n                    'Korea' => 'ko-KR',\n                    'Macau' => 'zh-MO',\n                    'Malaysia' => 'en-MY',\n                    'Myanmar' => 'en-MM',\n                    'Philippines' => 'en-PH',\n                    'Singapore' => 'en-SG',\n                    'Sri Lanka' => 'en-LK',\n                    'Taiwan' => 'zh-TW',\n                    'Thailand' => 'th-TH',\n                    'Vietnam' => 'vi-VN',\n                ],\n                'Central America' => [\n                    'Costa Rica' => 'es-CR',\n                    'Dominican Republic' => 'es-DO',\n                    'El Salvador' => 'es-SV',\n                    'Guatemala' => 'es-GT',\n                    'Honduras' => 'es-HN',\n                    'Mexico' => 'es-MX',\n                    'Nicaragua' => 'es-NI',\n                    'Panama' => 'es-PA',\n                    'Puerto Rico' => 'es-PR',\n                ],\n                'Europe' => [\n                    'Austria' => 'de-AT',\n                    'Azerbaijan' => 'az',\n                    'Belarus' => 'ru-BY',\n                    'Belgium' => 'fr-BE',\n                    'Bulgaria' => 'bg',\n                    'Croatia' => 'hr',\n                    'Czech Republic' => 'cs-CZ',\n                    'Denmark' => 'da-DK',\n                    'Estonia' => 'et-EE',\n                    'Finland' => 'fi',\n                    'France' => 'fr',\n                    'Germany' => 'de',\n                    'Greece' => 'el-GR',\n                    'Hungary' => 'hu',\n                    'Ireland' => 'en-IE',\n                    'Italy' => 'it',\n                    'Kazakhstan' => 'ru-KZ',\n                    'Lithuania' => 'lt',\n                    'Netherlands' => 'nl',\n                    'Norway' => 'nb-NO',\n                    'Poland' => 'pl',\n                    'Portugal' => 'pt',\n                    'Romania' => 'ro',\n                    'Russia' => 'ru',\n                    'Slovakia' => 'sk',\n                    'Spain' => 'es-ES',\n                    'Sweden' => 'sv-SE',\n                    'Switzerland' => 'fr-CH',\n                    'Turkey' => 'tr',\n                    'Ukraine' => 'uk-UA',\n                    'United Kingdom' => 'en-GB',\n                ],\n                'Middle East' => [\n                    'Bahrain' => 'en-BH',\n                    'Israel' => 'he-IL',\n                    'Jordan' => 'en-JO',\n                    'Kuwait' => 'en-KW',\n                    'Lebanon' => 'en-LB',\n                    'Pakistan' => 'en-PK',\n                    'Qatar' => 'en-QA',\n                    'Saudi Arabia' => 'ar-SA',\n                    'United Arab Emirates' => 'en-AE',\n                ],\n                'North America' => [\n                    'Canada' => 'en-CA',\n                    'United States' => 'en-US',\n                ],\n                'Pacific' => [\n                    'Australia' => 'en-AU',\n                    'New Zealand' => 'en-NZ',\n                ],\n                'South America' => [\n                    'Argentina' => 'es-AR',\n                    'Bolivia' => 'es-BO',\n                    'Brazil' => 'pt-BR',\n                    'Chile' => 'es-CL',\n                    'Colombia' => 'es-CO',\n                    'Ecuador' => 'es-EC',\n                    'Paraguay' => 'es-PY',\n                    'Peru' => 'es-PE',\n                    'Trinidad & Tobago' => 'en-TT',\n                    'Uruguay' => 'es-UY',\n                    'Venezuela' => 'es-VE',\n                ],\n            ],\n            'defaultValue' => 'en-US',\n        ]\n    ]];\n\n    const CACHE_TIMEOUT = 3600;\n\n    private $regionName = '';\n\n    public function collectData()\n    {\n        $json = getContents(self::URI_API_DATA . $this->getInput('region'));\n        $data = json_decode($json);\n\n        $this->regionName = $data->region->name;\n\n        foreach ($data->articles as $article) {\n            $json = getContents(self::URI_API_POST . $article->id);\n            $post = json_decode($json);\n\n            $item = [];\n            $item['title'] = $post->title->rendered;\n            $item['timestamp'] = $post->date;\n            $item['uri'] = $post->link;\n            $item['content'] = $this->formatContent($post->content->rendered);\n            $item['enclosures'][] = $article->image_full;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (is_null($this->getInput('region')) === false) {\n            return self::URI . '/' . $this->getInput('region') . '/newsroom';\n        }\n\n        return parent::getURI() . '/newsroom';\n    }\n\n    public function getName()\n    {\n        if (is_null($this->getInput('region')) === false) {\n            return $this->regionName . ' - Uber Newsroom';\n        }\n\n        return parent::getName();\n    }\n\n    private function formatContent($html)\n    {\n        $html = str_get_html($html);\n\n        foreach ($html->find('div.wp-video') as $div) {\n            $div->style = '';\n        }\n\n        foreach ($html->find('video') as $video) {\n            $video->width = '100%';\n            $video->height = '';\n        }\n\n        return $html;\n    }\n}\n"
  },
  {
    "path": "bridges/UniverseTodayBridge.php",
    "content": "<?php\n\nclass UniverseTodayBridge extends FeedExpander\n{\n    const MAINTAINER = 'sqrtminusone';\n    const NAME = 'Universe Today';\n    const URI = 'https://www.universetoday.com/';\n    const DESCRIPTION = 'Returns the latest articles from Universe Today.';\n\n    const PARAMETERS = [\n        '' => [\n            'limit' => [\n                'name' => 'Feed Item Limit',\n                'required' => true,\n                'type' => 'number',\n                'defaultValue' => 10,\n                'title' => 'Maximum number of returned feed items. Default 10'\n            ],\n        ],\n    ];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(self::URI . 'feed', (int)$this->getInput('limit'));\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOMCached($item['uri'], 7 * 24 * 60 * 60);\n        $article_main = $dom->find('main > article', 0);\n\n        // Mostly YouTube videos\n        $iframes = $article_main->find('iframe');\n        foreach ($iframes as $iframe) {\n            $iframe->outertext = '<a href=\"' . $iframe->src . '\">' . $iframe->src . '</a>';\n        }\n        $article_main = defaultLinkTo($article_main, self::URI);\n\n        $author_bio = $article_main->find('div.author-bio', 0);\n        if ($author_bio) {\n            $author_bio->parent->removeChild($author_bio);\n        }\n        $article_nav = $article_main->find('nav.article-navigation', 0);\n        if ($article_nav) {\n            $article_nav->parent->removeChild($article_nav);\n        }\n\n        $item['content'] = $article_main->innertext;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/UnogsBridge.php",
    "content": "<?php\n\nclass UnogsBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'csisoap';\n    const NAME = 'uNoGS';\n    const URI = 'https://unogs.com';\n    const DESCRIPTION = 'Return what\\'s new or removal on Netflix';\n\n    const PARAMETERS = [\n        'global' => [\n            'feed' => [\n                'name' => 'feed',\n                'type' => 'list',\n                'title' => 'Choose whether you want latest movies or removal on Netflix',\n                'values' => [\n                    'What\\'s New' => 'new last 7 days',\n                    'Expiring' => 'expiring'\n                ]\n            ],\n            'limit' => self::LIMIT,\n        ],\n        'Global' => [],\n        'Country' => [\n            'country_code' => [\n                'name' => 'Country',\n                'type' => 'list',\n                'title' => 'Choose your preferred country',\n                'values' => [\n                    'Argentina' => 21,\n                    'Australia' => 23,\n                    'Belgium' => 26,\n                    'Brazil' => 29,\n                    'Canada' => 33,\n                    'Colombia' => 36,\n                    'Czech Republic' => 307,\n                    'France' => 45,\n                    'Germany' => 39,\n                    'Greece' => 327,\n                    'Hong Kong' => 331,\n                    'Hungary' => 334,\n                    'Iceland' => 265,\n                    'India' => 337,\n                    'Israel' => 336,\n                    'Italy' => 269,\n                    'Japan' => 267,\n                    'Lithuania' => 357,\n                    'Malaysia' => 378,\n                    'Mexico' => 65,\n                    'Netherlands' => 67,\n                    'Philippines' => 390,\n                    'Poland' => 392,\n                    'Portugal' => 268,\n                    'Romania' => 400,\n                    'Russia' => 402,\n                    'Singapore' => 408,\n                    'Slovakia' => 412,\n                    'South Africa' => 447,\n                    'South Korea' => 348,\n                    'Spain' => 270,\n                    'Sweden' => 73,\n                    'Switzerland' => 34,\n                    'Thailand' => 425,\n                    'Turkey' => 432,\n                    'Ukraine' => 436,\n                    'United Kingdom' => 46,\n                    'United States' => 78\n                ]\n            ]\n        ]\n    ];\n\n    public function getName()\n    {\n        $feedName = '';\n        if ($this->queriedContext == 'Global') {\n            $feedName .= 'Netflix Global - ';\n        } elseif ($this->queriedContext == 'Country') {\n            $feedName .= 'Netflix ' . $this->getKey('country_code') . ' - ';\n        }\n        if ($this->getInput('feed') == 'expiring') {\n            $feedName .= 'Expiring title';\n        } elseif ($this->getInput('feed') == 'new last 7 days') {\n            $feedName .= 'What\\'s New';\n        } else {\n            $feedName = self::NAME;\n        }\n        return $feedName;\n    }\n\n    private function getJSON($url)\n    {\n        $header = [\n            'Referer: https://unogs.com/',\n            'referrer: http://unogs.com',\n        ];\n\n        $raw = getContents($url, $header);\n        return json_decode($raw, true);\n    }\n\n    private function getImage($nfid)\n    {\n        $url = self::URI . '/api/title/bgimages?netflixid=' . $nfid;\n        $json = $this->getJSON($url);\n        $image_wrapper = '';\n        if (isset($json['bo1280x448'])) {\n            $image_wrapper = 'bo1280x448';\n        } else {\n            $image_wrapper = 'bo665x375';\n        }\n        end($json[$image_wrapper]);\n        $position = key($json[$image_wrapper]);\n        $image_link = $json[$image_wrapper][$position]['url'];\n        return $image_link;\n    }\n\n    private function handleData($data)\n    {\n        $item = [];\n        $item['title'] = $data['title'] . ' - ' . $data['year'];\n        $item['timestamp'] = $data['titledate'];\n        $netflix_id = $data['nfid'];\n        $item['uri'] = 'https://www.netflix.com/title/' . $netflix_id;\n        $image_url = $this->getImage($netflix_id);\n        $netflix_synopsis = $data['synopsis'];\n        $expired_warning = '';\n        if (isset($data['expires'])) {\n            $expired_warning .= '<p><b>Expired on: ' . $data['expires'] . '</b></p>';\n            $item['timestamp'] = $data['expires'];\n        }\n        $unogs_url = self::URI . '/title/' . $netflix_id;\n\n        $item['content'] = <<<EOD\n<img src={$image_url}>\n$expired_warning\n<p>$netflix_synopsis</p>\n<p>Details: <a href={$unogs_url}>$unogs_url</a></p>\nEOD;\n        $this->items[] = $item;\n    }\n\n    public function collectData()\n    {\n        $feed = $this->getInput('feed');\n        $is_global = false;\n        $country_code = '';\n\n        switch ($this->queriedContext) {\n            case 'Country':\n                $country_code = $this->getInput('country_code');\n                break;\n        }\n\n        $limit = $this->getInput('limit') ?? 30;\n\n        // https://rapidapi.com/unogs/api/unogsng/details\n        $api_url = sprintf(\n            '%s/api/search?query=%s%s&limit=%s',\n            self::URI,\n            urlencode($feed),\n            $country_code ? '&countrylist=' . $country_code : '',\n            $limit\n        );\n\n        $json_data = $this->getJSON($api_url);\n        $movies = $json_data['results'];\n\n        if ($this->getInput('feed') == 'expiring') {\n            /*  uNoGS API returns movies/series that going to remove\n            *   today according to the day you fetch the data.\n            *   They put items that going to remove in the future on the last\n            *   so I reverse this to get those items, not to bothers those that already removed today.\n            */\n            $movies = array_reverse($movies);\n        }\n\n        foreach ($movies as $movie) {\n            $this->handleData($movie);\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/UnraidCommunityApplicationsBridge.php",
    "content": "<?php\n\nclass UnraidCommunityApplicationsBridge extends BridgeAbstract\n{\n    const NAME = 'Unraid Community Applications';\n    const URI = 'https://forums.unraid.net/topic/38582-plug-in-community-applications/';\n    const DESCRIPTION = 'Fetches the latest fifteen new apps/plugins from Unraid Community Applications';\n    const MAINTAINER = 'Paroleen';\n    const CACHE_TIMEOUT = 3600;\n\n    const APPSURI = 'https://raw.githubusercontent.com/Squidly271/AppFeed/master/applicationFeed.json';\n\n    private $apps = [];\n\n    private function fetchApps()\n    {\n        $this->apps = getContents(self::APPSURI);\n        $this->apps = json_decode($this->apps, true)['applist'];\n    }\n\n    private function sortApps()\n    {\n        usort($this->apps, function ($app1, $app2) {\n            return $app1['FirstSeen'] < $app2['FirstSeen'] ? 1 : -1;\n        });\n    }\n\n    public function collectData()\n    {\n        $this->fetchApps();\n        $this->sortApps();\n        foreach ($this->apps as $app) {\n            if (array_key_exists('Language', $app)) {\n                continue;\n            }\n            $item = [];\n            $item['title'] = $app['Name'];\n            $item['timestamp'] = $app['FirstSeen'];\n            $item['author'] = explode('\\'', $app['Repo'])[0];\n            $item['content'] = '';\n\n            if (isset($app['CategoryList'])) {\n                $item['categories'] = $app['CategoryList'];\n            }\n\n            if (array_key_exists('Icon', $app)) {\n                $item['content'] .= '<img style=\"width: 64px\" src=\"'\n                    . $app['Icon']\n                    . '\">';\n            }\n\n            if (array_key_exists('Overview', $app)) {\n                $item['content'] .= '<p>'\n                    . $app['Overview']\n                    . '</p>';\n            }\n\n            if (array_key_exists('Project', $app)) {\n                $item['uri'] = $app['Project'];\n            }\n\n            if (array_key_exists('Registry', $app)) {\n                $item['content'] .= '<br><a href=\"'\n                    . $app['Registry']\n                    . '\">Docker Hub</a>';\n            }\n\n            if (array_key_exists('Support', $app)) {\n                $item['content'] .= '<br><a href=\"'\n                    . $app['Support']\n                    . '\">Support</a>';\n            }\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 150) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/UnsplashBridge.php",
    "content": "<?php\n\nclass UnsplashBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'nel50n, langfingaz';\n    const NAME = 'Unsplash';\n    const URI = 'https://unsplash.com/';\n    const CACHE_TIMEOUT = 43200; // 12h\n    const DESCRIPTION = 'Returns the latest photos from Unsplash';\n\n    const PARAMETERS = [[\n        'u' => [\n            'name' => 'Filter by username (optional)',\n            'type' => 'text',\n            'defaultValue' => 'unsplash'\n        ],\n        'm' => [\n            'name' => 'Max number of photos',\n            'type' => 'number',\n            'defaultValue' => 20,\n            'required' => true\n        ],\n        'prev_q' => [\n            'name' => 'Preview quality',\n            'type' => 'list',\n            'values' => [\n                'full' => 'full',\n                'regular' => 'regular',\n                'small' => 'small',\n                'thumb' => 'thumb',\n            ],\n            'defaultValue' => 'regular'\n        ],\n        'w' => [\n            'name' => 'Max download width (optional)',\n            'exampleValue' => 1920,\n            'type' => 'number',\n            'defaultValue' => 1920,\n        ],\n        'jpg_q' => [\n            'name' => 'Max JPEG quality (optional)',\n            'exampleValue' => 75,\n            'type' => 'number',\n            'defaultValue' => 75,\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $filteredUser = $this->getInput('u');\n        $width = $this->getInput('w');\n        $max = $this->getInput('m');\n        $previewQuality = $this->getInput('prev_q');\n        $jpgQuality = $this->getInput('jpg_q');\n\n        $url = 'https://unsplash.com/napi';\n        if (strlen($filteredUser) > 0) {\n            $url .= '/users/' . $filteredUser;\n        }\n        $url .= '/photos?page=1&per_page=' . $max;\n        $api_response = getContents($url);\n\n        $json = json_decode($api_response, true);\n\n        foreach ($json as $json_item) {\n            $item = [];\n\n            // Get image URI\n            $uri = $json_item['urls']['raw'] . '&fm=jpg';\n            if ($jpgQuality > 0) {\n                $uri .= '&q=' . $jpgQuality;\n            }\n            if ($width > 0) {\n                $uri .= '&w=' . $width . '&fit=max';\n            }\n            $uri .= '.jpg'; // only for format hint\n            $item['uri'] = $uri;\n\n            // Get title from description\n            if (is_null($json_item['description'])) {\n                $item['title'] = 'Unsplash picture from ' . $json_item['user']['name'];\n            } else {\n                $item['title'] = $json_item['description'];\n            }\n\n            $item['timestamp'] = $json_item['created_at'];\n            $content = 'User: <a href=\"'\n                . $json_item['user']['links']['html']\n                . '\">@'\n                . $json_item['user']['username']\n                . '</a>';\n            if (isset($json_item['location']['name'])) {\n                $content .= ' | Location: ' . $json_item['location']['name'];\n            }\n            $content .= ' | Image on <a href=\"'\n                . $json_item['links']['html']\n                . '\">Unsplash</a><br><a href=\"'\n                . $uri\n                . '\"><img src=\"'\n                . $json_item['urls'][$previewQuality]\n                . '\" alt=\"Image from '\n                . $filteredUser\n                . '\" /></a>';\n            $item['content'] = $content;\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getName()\n    {\n        $filteredUser = $this->getInput('u') ?? '';\n        if (strlen($filteredUser) > 0) {\n            return $filteredUser . ' - ' . self::NAME;\n        } else {\n            return self::NAME;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/UrlebirdBridge.php",
    "content": "<?php\n\nclass UrlebirdBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dotter-ak';\n    const NAME = 'urlebird.com';\n    const URI = 'https://urlebird.com/';\n    const DESCRIPTION = 'Bridge for urlebird.com';\n    const CACHE_TIMEOUT = 60 * 5;\n    const PARAMETERS = [\n        [\n            'query' => [\n                'name' => '@username or #hashtag',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => '@willsmith',\n                'title' => '@username or #hashtag'\n            ]\n        ]\n    ];\n\n    private $title;\n\n    public function collectData()\n    {\n        switch ($this->getInput('query')[0]) {\n            case '@':\n                $url = 'https://urlebird.com/user/' . substr($this->getInput('query'), 1) . '/';\n                break;\n            case '#':\n                $url = 'https://urlebird.com/hash/' . substr($this->getInput('query'), 1) . '/';\n                break;\n            default:\n                throwServerException('Please, enter valid username or hashtag!');\n                break;\n        }\n\n        $html = getSimpleHTMLDOM($url);\n        $limit = 10;\n\n        $this->title = $html->find('title', 0)->innertext;\n        $articles = $html->find('div.thumb');\n        $articles = array_slice($articles, 0, $limit);\n        foreach ($articles as $article) {\n            $item = [];\n            $itemUrl = $article->find('a', 2)->href;\n            $item['uri'] = $this->encodePathSegments($itemUrl);\n\n            $dom = getSimpleHTMLDOM($item['uri']);\n            $videoDiv = $dom->find('div.video', 0);\n\n            // timestamp\n            $timestampH6 = $videoDiv->find('h6', 0);\n            $datetimeString = str_replace('Posted ', '', $timestampH6->plaintext);\n            $item['timestamp'] = $datetimeString;\n\n            $innertext = $dom->find('a.user-video', 1)->innertext;\n            $alt = $article->find('img', 0)->alt;\n            $item['author'] = $alt . ' (' . $innertext . ')';\n\n            $item['title'] = $dom->find('title', 0)->innertext;\n            $item['enclosures'][] = $dom->find('video', 0)->poster;\n\n            $video = $dom->find('video', 0);\n            $video->autoplay = null;\n\n            $item['content'] = $video->outertext . '<br>' .\n                $dom->find('div.music', 0) . '<br>' .\n                $dom->find('div.info2', 0)->innertext .\n                '<br><br><a href=\"' . $dom->find('video', 0)->src .\n                '\">Direct video link</a><br><br><a href=\"' . $item['uri'] .\n                '\">Post link</a><br><br>';\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function encodePathSegments($url)\n    {\n        $path = parse_url($url, PHP_URL_PATH);\n        $pathSegments = explode('/', $path);\n        $encodedPathSegments = array_map('urlencode', $pathSegments);\n        $encodedPath = implode('/', $encodedPathSegments);\n        $result = str_replace($path, $encodedPath, $url);\n        return $result;\n    }\n\n    public function getName()\n    {\n        return $this->title ?: parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return 'https://urlebird.com/favicon.ico';\n    }\n}\n"
  },
  {
    "path": "bridges/UsbekEtRicaBridge.php",
    "content": "<?php\n\nclass UsbekEtRicaBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Usbek & Rica';\n    const URI = 'https://usbeketrica.com';\n    const DESCRIPTION = 'Returns latest articles from the front page';\n\n    const PARAMETERS = [\n        [\n            'limit' => [\n                'name' => 'Number of articles to return',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specifies the maximum number of articles to return',\n                'defaultValue' => -1\n            ],\n            'fullarticle' => [\n                'name' => 'Load full article',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Activate to load full articles',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit');\n        $fullarticle = $this->getInput('fullarticle');\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $articles = $html->find('article');\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $title = $article->find('h2', 0);\n            if ($title) {\n                $item['title'] = $title->plaintext;\n            } else {\n                // Sometimes we get rubbish, ignore.\n                continue;\n            }\n\n            $author = $article->find('div.author span', 0);\n            if ($author) {\n                $item['author'] = $author->plaintext;\n            }\n\n            $content = null;\n\n            $u = $article->find('a.card-img', 0);\n            if ($u) {\n                $uri = $u->href;\n                if (substr($uri, 0, 1) === 'h') {\n                    // absolute uri\n                    $item['uri'] = $uri;\n                } else {\n                    // relative uri\n                    $item['uri'] = $this->getURI() . $uri;\n                }\n                if ($fullarticle) {\n                    $content = $this->loadFullArticle($item['uri']);\n                }\n            }\n\n            if ($fullarticle && $content) {\n                $item['content'] = $content;\n            } else {\n                $excerpt = $article->find('div.card-excerpt', 0);\n                if ($excerpt) {\n                    $item['content'] = $excerpt->plaintext;\n                }\n            }\n\n            $image = $article->find('div.card-img img', 0);\n            if ($image) {\n                $item['enclosures'] = [\n                    $image->src\n                ];\n            }\n\n            $this->items[] = $item;\n\n            if ($limit > 0 && count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n\n    /**\n    * Loads the full article and returns the contents\n    * @param $uri The article URI\n    * @return The article content\n    */\n    private function loadFullArticle($uri)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n\n        $content = $html->find('div.rich-text', 1);\n        if ($content) {\n            return $this->replaceUriInHtmlElement($content);\n        }\n\n        return null;\n    }\n\n    /**\n    * Replaces all relative URIs with absolute ones\n    * @param $element A simplehtmldom element\n    * @return The $element->innertext with all URIs replaced\n    */\n    private function replaceUriInHtmlElement($element)\n    {\n        return str_replace('href=\"/', 'href=\"' . $this->getURI() . '/', $element->innertext);\n    }\n}\n"
  },
  {
    "path": "bridges/UsenixBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nfinal class UsenixBridge extends BridgeAbstract\n{\n    const NAME = 'USENIX';\n    const URI = 'https://www.usenix.org/publications';\n    const DESCRIPTION = 'Digital publications from USENIX (usenix.org)';\n    const MAINTAINER = 'dvikan';\n    const PARAMETERS = [\n        'USENIX ;login:' => [\n        ],\n    ];\n\n    public function collectData()\n    {\n        if ($this->queriedContext === 'USENIX ;login:') {\n            $this->collectLoginOnlineItems();\n            return;\n        }\n        throwClientException('Illegal Context');\n    }\n\n    private function collectLoginOnlineItems(): void\n    {\n        $url = 'https://www.usenix.org/publications/loginonline';\n        $dom = getSimpleHTMLDOMCached($url);\n        $items = $dom->find('div.view-content > div');\n\n        foreach ($items as $item) {\n            $title = $item->find('.views-field-title > span', 0);\n            $author = $item->find('.views-field-pseudo-author-list > span.field-content', 0);\n            $relativeUrl = $item->find('.views-field-nothing-1 > span > a', 0);\n            $uri = sprintf('https://www.usenix.org%s', $relativeUrl->href);\n            // June 2, 2022\n            $createdAt = $item->find('div.views-field-field-lv2-publication-date > div > span', 0);\n\n            $item = [\n                'title' => $title->innertext,\n                'author' => strstr($author->plaintext, ',', true) ?: $author->plaintext,\n                'uri' => $uri,\n                'timestamp' => $createdAt->innertext,\n            ];\n\n            $this->items[] = array_merge($item, $this->getItemContent($uri));\n        }\n    }\n\n    private function getItemContent(string $uri): array\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n        $content = $html->find('.paragraphs-items-full', 0)->innertext;\n        $extra = $html->find('fieldset', 0);\n        if (!empty($extra)) {\n            $content .= $extra->innertext;\n        }\n\n        $tags = [];\n        foreach ($html->find('.field-name-field-lv2-tags div.field-item') as $tag) {\n            $tags[] = $tag->plaintext;\n        }\n\n        return [\n            'content' => $content,\n            'categories' => $tags\n        ];\n    }\n}\n"
  },
  {
    "path": "bridges/UsesTechBridge.php",
    "content": "<?php\n\nclass UsesTechbridge extends BridgeAbstract\n{\n    const NAME        = '/uses';\n    const URI         = 'https://uses.tech/';\n    const DESCRIPTION = 'RSS feed for /uses';\n    const MAINTAINER  = 'jummo4@yahoo.de';\n    const MAX_ITEM      = 100; # Maximum items to loop through which works fast enough on my computer\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n\n        foreach ($html->find('div[class=PersonInner]') as $index => $a) {\n            $item = []; // Create an empty item\n            $articlePath = $a->find('a[class=displayLink]', 0)->href;\n            $item['title'] = $a->find('img', 0)->getAttribute('alt');\n            $item['author'] = $a->find('img', 0)->getAttribute('alt');\n            $item['uri'] = $articlePath;\n            $item['content'] = $a->find('p', 0)->innertext;\n\n            $this->items[] = $item; // Add item to the list\n            if (count($this->items) >= self::MAX_ITEM) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/VarietyBridge.php",
    "content": "<?php\n\nclass VarietyBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'Variety';\n    const URI = 'https://variety.com';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'RSS feed for Variety';\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('https://feeds.feedburner.com/variety/headlines', 15);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOM($item['uri']);\n\n        // Remove Script tags\n        foreach ($articlePage->find('script') as $script_tag) {\n            $script_tag->remove();\n        }\n        $article = $articlePage->find('div.c-featured-media', 0);\n        $article = $article . $articlePage->find('.c-content', 0);\n\n        $item['content'] = $article;\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ViadeoCompanyBridge.php",
    "content": "<?php\n\nclass ViadeoCompanyBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'regisenguehard';\n    const NAME = 'Viadeo Company';\n    const URI = 'https://www.viadeo.com/';\n    const CACHE_TIMEOUT = 21600; // 6h\n    const DESCRIPTION = 'Returns most recent actus from Company on Viadeo.\n (http://www.viadeo.com/fr/company/<strong style=\"font-weight:bold;\">apple</strong>)';\n\n    const PARAMETERS = [ [\n        'c' => [\n            'name' => 'Company name',\n            'exampleValue' => 'apple',\n            'required' => true\n        ]\n    ]];\n\n    public function collectData()\n    {\n        // Redirects to https://emploi.lefigaro.fr/recherche/entreprises\n        $url = sprintf('%sfr/company/%s', self::URI, $this->getInput('c'));\n\n        $html = getSimpleHTMLDOM($url);\n\n        // TODO: Fix broken xpath selector\n        $elements = $html->find('//*[@id=\"company-newsfeed\"]/ul/li');\n\n        foreach ($elements as $element) {\n            $title = $element->find('p', 0)->innertext;\n            if (!$title) {\n                continue;\n            }\n            $item = [];\n            $item['uri'] = $url;\n            $item['title'] = mb_substr($element->find('p', 0)->innertext, 0, 100);\n            $item['content'] = $element->find('p', 0)->innertext;\n            ;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ViceBridge.php",
    "content": "<?php\n\nclass ViceBridge extends FeedExpander\n{\n    const MAINTAINER = 'IceWreck';\n    const NAME = 'Vice';\n    const URI = 'https://www.vice.com/';\n    const CACHE_TIMEOUT = 3600;\n    const DESCRIPTION = 'RSS feed for vice publications like Vice News, Munchies, Motherboard, etc.';\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed',\n            'type' => 'list',\n            'values' => [\n                'Vice News' => 'rss',\n                'Motherboard - Tech' => 'en_us/rss/topic/tech',\n                'Entertainment' => 'en_us/rss/topic/entertainment',\n                'Noisey - Music' => 'en_us/rss/topic/music',\n                'Munchies - Food' => 'en_us/rss/topic/food'\n            ]\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $feed = $this->getInput('feed');\n        if ($feed === 'rss') {\n            // They changed url in Sep 2023\n            $feed = 'en/rss';\n        }\n        $feedURL = 'https://www.vice.com/' . $feed;\n        $this->collectExpandableDatas($feedURL, 10);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $articlePage = getSimpleHTMLDOM($item['uri']);\n        // text and embedded content\n        $article = $articlePage->find('.article__body', 0);\n        $item['content'] = $article ?? '';\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/VideoCardzBridge.php",
    "content": "<?php\n\nclass VideoCardzBridge extends BridgeAbstract\n{\n    const NAME = 'VideoCardz';\n    const URI = 'https://videocardz.com/';\n    const DESCRIPTION = 'Returns news from VideoCardz.com';\n    const MAINTAINER = 'rmscoelho';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        [\n            'feed' => [\n                'name' => 'News Feed',\n                'type' => 'list',\n                'title' => 'Feeds from VideoCardz.com',\n                'values' => [\n                    'News' => 'sections/news',\n                    'Featured' => 'sections/featured',\n                    'Leaks' => 'sections/leaks',\n                    'Press Releases' => 'sections/press-releases',\n                    'Preview Roundup' => 'sections/review-roundup',\n                    'Rumour' => 'sections/rumor',\n                ]\n            ]\n        ]\n    ];\n\n    public function getIcon()\n    {\n        return 'https://videocardz.com/favicon-32x32.png?x66580';\n    }\n\n    public function getName()\n    {\n        return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME;\n    }\n\n    public function getURI()\n    {\n        return self::URI . $this->getInput('feed');\n    }\n\n    public function collectData()\n    {\n        $url = sprintf('https://videocardz.com/%s', $this->getInput('feed'));\n        $dom = getSimpleHTMLDOM($url);\n        $dom = $dom->find('.subcategory-news', 0);\n        if (!$dom) {\n            throw new \\Exception(sprintf('Unable to find css selector on `%s`', $url));\n        }\n        $dom = defaultLinkTo($dom, $this->getURI());\n\n        foreach ($dom->find('article') as $article) {\n            $title = preg_replace('/\\(PR\\) /i', '', $article->find('h2', 0)->plaintext);\n            //Get thumbnail\n            $image = $article->style;\n            $image = preg_replace('/background-image:url\\(/i', '', $image);\n            $image = substr_replace($image, '', -3);\n            //Get date and time of publishing\n            $datetime = date_parse($article->find('.main-index-article-datetitle-date > a', 0)->plaintext);\n            $year = $datetime['year'];\n            $month = $datetime['month'];\n            $day = $datetime['day'];\n            $hour = $datetime['hour'];\n            $minute = $datetime['minute'];\n            $timestamp = mktime($hour, $minute, 0, $month, $day, $year);\n            $content = '<img src=\"' . $image . '\" alt=\"' . $article->find('h2', 0)->plaintext . ' thumbnail\" />';\n            $this->items[] = [\n                'title' => $title,\n                'uri' => $article->find('p.main-index-article-datetitle-date > a', 0)->href,\n                'content' => $content,\n                'timestamp' => $timestamp,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/VieDeMerdeBridge.php",
    "content": "<?php\n\nclass VieDeMerdeBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'floviolleau';\n    const NAME = 'VieDeMerde';\n    const URI = 'https://www.viedemerde.fr';\n    const DESCRIPTION = 'Returns latest quotes from VieDeMerde.';\n    const CACHE_TIMEOUT = 7200;\n\n    const PARAMETERS = [[\n            'item_limit' => [\n            'name' => 'Limit number of returned items',\n            'type' => 'number',\n            'defaultValue' => 20\n            ]\n    ]];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('item_limit');\n\n        if ($limit < 1) {\n            $limit = 20;\n        }\n\n        $html = getSimpleHTMLDOM(self::URI, []);\n        $quotes = $html->find('article.bg-white');\n        if (count($quotes) === 0) {\n            return;\n        }\n\n        foreach ($quotes as $quote) {\n            $item = [];\n            $item['uri'] = self::URI . $quote->find('a', 0)->href;\n            $titleContent = $quote->find('h2', 0);\n\n            if ($titleContent) {\n                $item['title'] = html_entity_decode($titleContent->plaintext, ENT_QUOTES);\n            } else {\n                continue;\n            }\n\n            $quoteText = $quote->find('a', 1)->plaintext;\n            $isAVDM = $quote->find('.vote-btn', 0)->plaintext;\n            $isNotAVDM = $quote->find('.vote-btn', 1)->plaintext;\n            $item['content'] = $quoteText . '<br>' . $isAVDM . '<br>' . $isNotAVDM;\n            $item['author'] = $quote->find('p', 0)->plaintext;\n            $item['uid'] = hash('sha256', $item['title']);\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= $limit) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/VimeoBridge.php",
    "content": "<?php\n\nclass VimeoBridge extends BridgeAbstract\n{\n    const NAME = 'Vimeo';\n    const URI = 'https://vimeo.com/';\n    const DESCRIPTION = 'Returns search results from Vimeo';\n    const MAINTAINER = 'logmanoriginal';\n\n    const PARAMETERS = [\n        [\n            'q' => [\n                'name' => 'Search Query',\n                'type' => 'text',\n                'exampleValue' => 'birds',\n                'required' => true\n            ],\n            'type' => [\n                'name' => 'Show results for',\n                'type' => 'list',\n                'defaultValue' => 'Videos',\n                'values' => [\n                    'Videos' => 'search',\n                    'On Demand' => 'search/ondemand',\n                    'People' => 'search/people',\n                    'Channels' => 'search/channels',\n                    'Groups' => 'search/groups'\n                ]\n            ]\n        ]\n    ];\n\n    public function getURI()\n    {\n        if (\n            ($query = $this->getInput('q'))\n            && ($type = $this->getInput('type'))\n        ) {\n            return self::URI . $type . '/sort:latest?q=' . $query;\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(\n            $this->getURI(),\n            $header = [],\n            $opts = [],\n            $lowercase = true,\n            $forceTagsClosed = true,\n            $target_charset = DEFAULT_TARGET_CHARSET,\n            $stripRN = false, // We want to keep newline characters\n            $defaultBRText = DEFAULT_BR_TEXT,\n            $defaultSpanText = DEFAULT_SPAN_TEXT\n        );\n\n        $json = null; // Holds the JSON data\n\n        /**\n         * Search results are included as JSON formatted string inside a script\n         * tag that has the variable 'vimeo.config'. The data is condensed into\n         * a single line of code, so we can just search for the newline.\n         *\n         * Everything after \"vimeo.config = _extend((vimeo.config || {}), \" is\n         * the JSON formatted string.\n         */\n        foreach ($html->find('script') as $script) {\n            foreach (explode(\"\\n\", $script) as $line) {\n                $line = trim($line);\n\n                if (strpos($line, 'vimeo.config') !== 0) {\n                    continue;\n                }\n\n                // 45 = strlen(\"vimeo.config = _extend((vimeo.config || {}), \");\n                // 47 = 45 + 2, because we don't want the final \");\"\n                $json = json_decode(substr($line, 45, strlen($line) - 47));\n            }\n        }\n\n        if (is_null($json)) {\n            throwClientException('No results for this query!');\n        }\n\n        foreach ($json->api->initial_json->data as $element) {\n            switch ($element->type) {\n                case 'clip':\n                    $this->addClip($element);\n                    break;\n                case 'ondemand':\n                    $this->addOnDemand($element);\n                    break;\n                case 'people':\n                    $this->addPeople($element);\n                    break;\n                case 'channel':\n                    $this->addChannel($element);\n                    break;\n                case 'group':\n                    $this->addGroup($element);\n                    break;\n\n                default:\n                    throwServerException('Unknown type: ' . $element->type);\n            }\n        }\n    }\n\n    private function addClip($element)\n    {\n        $item = [];\n\n        $item['uri'] = $element->clip->link;\n        $item['title'] = $element->clip->name;\n        $item['author'] = $element->clip->user->name;\n        $item['timestamp'] = strtotime($element->clip->created_time);\n\n        $item['enclosures'] = [\n            end($element->clip->pictures->sizes)->link\n        ];\n\n        $item['content'] = \"<img src={$item['enclosures'][0]} />\";\n\n        $this->items[] = $item;\n    }\n\n    private function addOnDemand($element)\n    {\n        $item = [];\n\n        $item['uri'] = $element->ondemand->link;\n        $item['title'] = $element->ondemand->name;\n\n        // Only for films\n        if (isset($element->ondemand->film)) {\n            $item['timestamp'] = strtotime($element->ondemand->film->release_time);\n        }\n\n        $item['enclosures'] = [\n            end($element->ondemand->pictures->sizes)->link\n        ];\n\n        $item['content'] = \"<img src={$item['enclosures'][0]} />\";\n\n        $this->items[] = $item;\n    }\n\n    private function addPeople($element)\n    {\n        $item = [];\n\n        $item['uri'] = $element->people->link;\n        $item['title'] = $element->people->name;\n\n        $item['enclosures'] = [\n            end($element->people->pictures->sizes)->link\n        ];\n\n        $item['content'] = \"<img src={$item['enclosures'][0]} />\";\n\n        $this->items[] = $item;\n    }\n\n    private function addChannel($element)\n    {\n        $item = [];\n\n        $item['uri'] = $element->channel->link;\n        $item['title'] = $element->channel->name;\n\n        $item['enclosures'] = [\n            end($element->channel->pictures->sizes)->link\n        ];\n\n        $item['content'] = \"<img src={$item['enclosures'][0]} />\";\n\n        $this->items[] = $item;\n    }\n\n    private function addGroup($element)\n    {\n        $item = [];\n\n        $item['uri'] = $element->group->link;\n        $item['title'] = $element->group->name;\n\n        $item['enclosures'] = [\n            end($element->group->pictures->sizes)->link\n        ];\n\n        $item['content'] = \"<img src={$item['enclosures'][0]} />\";\n\n        $this->items[] = $item;\n    }\n}\n"
  },
  {
    "path": "bridges/VixenBridge.php",
    "content": "<?php\n\nclass VixenBridge extends BridgeAbstract\n{\n    const NAME = 'Vixen Network';\n    const URI = 'https://www.vixen.com';\n    const DESCRIPTION = 'Latest videos from Vixen Network sites';\n    const MAINTAINER = 'pubak42';\n\n    /**\n     * The pictures on the pages are referenced with temporary links with\n     * limited validity. Greater cache timeout results in invalid links in\n     * the feed\n     */\n    const CACHE_TIMEOUT = 60;\n\n    const PARAMETERS = [\n        [\n            'site' => [\n                'type' => 'list',\n                'name' => 'Site',\n                'title' => 'Choose site of interest',\n                'values' => [\n                    'Blacked' => 'Blacked',\n                    'BlackedRaw' => 'BlackedRaw',\n                    'Tushy' => 'Tushy',\n                    'TushyRaw' => 'TushyRaw',\n                    'Vixen' => 'Vixen',\n                    'Slayed' => 'Slayed',\n                    'Deeper' => 'Deeper'\n                ],\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $videosURL = $this->getURI() . '/videos';\n\n        $website = getSimpleHTMLDOM($videosURL);\n        $json = $website->getElementById('__NEXT_DATA__');\n        $data = json_decode($json->innertext(), true);\n        $nodes = array_column($data['props']['pageProps']['edges'], 'node');\n\n        foreach ($nodes as $n) {\n            $imageURL = $n['images']['listing'][2]['highdpi']['triple'];\n\n            $item = [\n                'title' => $n['title'],\n                'uri' => \"$videosURL/$n[slug]\",\n                'uid' => $n['videoId'],\n                'timestamp' => strtotime($n['releaseDate']),\n                'enclosures' => [ $imageURL ],\n                'author' => implode(' & ', array_column($n['modelsSlugged'], 'name')),\n            ];\n\n            /*\n             * No images retrieved from here. Should be cached for as long as\n             * possible to avoid rate throttling\n             */\n            $target = getSimpleHtmlDOMCached($item['uri'], 86400);\n            $item['content'] = $this->generateContent(\n                $imageURL,\n                $target->find('meta[name=description]', 0)->content,\n                $n['modelsSlugged']\n            );\n\n            $item['categories'] = array_map(\n                'ucwords',\n                explode(',', $target->find('meta[name=keywords]', 0)->content)\n            );\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        $param = $this->getInput('site');\n        return $param ? \"https://www.$param.com\" : self::URI;\n    }\n\n    /**\n     * Return name of the bridge. Default is needed for bridge index list\n     */\n    public function getName()\n    {\n        $param = $this->getInput('site');\n        return $param ? \"$param Bridge\" : self::NAME;\n    }\n\n    private static function makeLink($URI, $text)\n    {\n        return \"<a href=\\\"$URI\\\">$text</a>\";\n    }\n\n    private function generateContent($imageURI, $description, $models)\n    {\n        $content = \"<img src=\\\"$imageURI\\\" referrerpolicy=\\\"no-referrer\\\"/><p>$description</p>\";\n        $modelLinks = array_map(\n            function ($model) {\n                return self::makeLink(\n                    $this->getURI() . \"/models/$model[slugged]\",\n                    $model['name']\n                );\n            },\n            $models\n        );\n        return $content . '<p>Starring: ' . implode(' & ', $modelLinks) . '</p>';\n    }\n}\n"
  },
  {
    "path": "bridges/Vk2Bridge.php",
    "content": "<?php\n\nclass Vk2Bridge extends BridgeAbstract\n{\n    const MAINTAINER = 'em92';\n    const NAME = 'ВКонтакте';\n    const URI = 'https://vk.com';\n    const DESCRIPTION = 'Выводит записи на стене';\n    const CACHE_TIMEOUT = 300; // 5 minutes\n    const PARAMETERS = [\n        [\n            'u' => [\n                'name' => 'Короткое имя группы или профиля (из ссылки)',\n                'exampleValue' => 'goblin_oper_ru',\n                'required' => true\n            ],\n            'hide_reposts' => [\n                'name' => 'Скрыть репосты',\n                'type' => 'checkbox',\n            ]\n        ]\n    ];\n\n    const CONFIGURATION = [\n        'access_token' => [\n            'required' => true,\n        ],\n    ];\n\n    const TEST_DETECT_PARAMETERS = [\n        'https://vk.com/id1' => ['u' => 'id1'],\n        'https://vk.com/groupname' => ['u' => 'groupname'],\n        'https://m.vk.com/groupname' => ['u' => 'groupname'],\n        'https://vk.com/groupname/anythingelse' => ['u' => 'groupname'],\n        'https://vk.com/groupname?w=somethingelse' => ['u' => 'groupname'],\n        'https://vk.com/with_underscore' => ['u' => 'with_underscore'],\n        'https://vk.com/vk.cats' => ['u' => 'vk.cats'],\n    ];\n\n    protected $ownerNames = [];\n    protected $pageName;\n    private $urlRegex = '/vk\\.com\\/([\\w.]+)/';\n    private $rateLimitCacheKey = 'vk2_rate_limit';\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return urljoin(static::URI, urlencode($this->getInput('u')));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if ($this->pageName) {\n            return $this->pageName;\n        }\n\n        return parent::getName();\n    }\n\n    public function detectParameters($url)\n    {\n        if (preg_match($this->urlRegex, $url, $matches)) {\n            return ['u' => $matches[1]];\n        }\n\n        return null;\n    }\n\n    protected function getPostURI($post)\n    {\n        $r = 'https://vk.com/wall' . $post['owner_id'] . '_';\n        if (isset($post['reply_post_id'])) {\n            $r .= $post['reply_post_id'] . '?reply=' . $post['id'] . '&thread=' . $post['parents_stack'][0];\n        } else {\n            $r .= $post['id'];\n        }\n        return $r;\n    }\n\n    // This function is based on SlackCoyote's vkfeed2rss\n    // https://github.com/em92/vkfeed2rss\n    protected function generateContentFromPost($post)\n    {\n        // it's what we will return\n        $ret = $post['text'];\n\n        // html special characters convertion\n        $ret = htmlentities($ret, ENT_QUOTES | ENT_HTML401);\n        // change all linebreak to HTML compatible <br />\n        $ret = nl2br($ret);\n\n        $ret = \"<p>$ret</p>\";\n\n        // find URLs\n        $ret = preg_replace(\n            '/((https?|ftp|gopher)\\:\\/\\/[a-zA-Z0-9\\-\\.]+(:[a-zA-Z0-9]*)?\\/?([@\\w\\-\\+\\.\\?\\,\\'\\/&amp;%\\$#\\=~\\x5C])*)/',\n            \"<a href='$1'>$1</a>\",\n            $ret\n        );\n\n        // find [id1|Pawel Durow] form links\n        $ret = preg_replace('/\\[(\\w+)\\|([^\\]]+)\\]/', \"<a href='https://vk.com/$1'>$2</a>\", $ret);\n\n\n        // attachments\n        if (isset($post['attachments'])) {\n            // level 1\n            foreach ($post['attachments'] as $attachment) {\n                if ($attachment['type'] == 'video') {\n                    // VK videos\n                    $title = e($attachment['video']['title']);\n                    $photo = e($this->getImageURLWithLargestWidth($attachment['video']['image']));\n                    $href = \"https://vk.com/video{$attachment['video']['owner_id']}_{$attachment['video']['id']}\";\n                    $ret .= \"<p><a href='{$href}'><img src='{$photo}' alt='Video: {$title}'><br/>Video: {$title}</a></p>\";\n                } elseif ($attachment['type'] == 'audio') {\n                    // VK audio\n                    $artist = e($attachment['audio']['artist']);\n                    $title = e($attachment['audio']['title']);\n                    $ret .= \"<p>Audio: {$artist} - {$title}</p>\";\n                } elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] != 'gif') {\n                    // any doc apart of gif\n                    $doc_url = e($attachment['doc']['url']);\n                    $title = e($attachment['doc']['title']);\n                    $ret .= \"<p><a href='{$doc_url}'>Документ: {$title}</a></p>\";\n                }\n            }\n            // level 2\n            foreach ($post['attachments'] as $attachment) {\n                if ($attachment['type'] == 'photo') {\n                    // JPEG, PNG photos\n                    // GIF in vk is a document, so, not handled as photo\n                    $photo = e($this->getImageURLWithLargestWidth($attachment['photo']['sizes']));\n                    $text = e($attachment['photo']['text']);\n                    $ret .= \"<p><img src='{$photo}' alt='{$text}'></p>\";\n                } elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] == 'gif') {\n                    // GIF docs\n                    $url = e($attachment['doc']['url']);\n                    $ret .= \"<p><img src='{$url}'></p>\";\n                } elseif ($attachment['type'] == 'link') {\n                    // links\n                    $url = e($attachment['link']['url']);\n                    $url = str_replace('https://m.vk.com', 'https://vk.com', $url);\n                    $title = e($attachment['link']['title']);\n                    if (isset($attachment['link']['photo'])) {\n                        $photo = $this->getImageURLWithLargestWidth($attachment['link']['photo']['sizes']);\n                        $ret .= \"<p><a href='{$url}'><img src='{$photo}' alt='{$title}'><br>{$title}</a></p>\";\n                    } else {\n                        $ret .= \"<p><a href='{$url}'>{$title}</a></p>\";\n                    }\n                } elseif ($attachment['type'] == 'note') {\n                    // notes\n                    $title = e($attachment['note']['title']);\n                    $url = e($attachment['note']['view_url']);\n                    $ret .= \"<p><a href='{$url}'>{$title}</a></p>\";\n                } elseif ($attachment['type'] == 'poll') {\n                    // polls\n                    $question = e($attachment['poll']['question']);\n                    $vote_count = $attachment['poll']['votes'];\n                    $answers = $attachment['poll']['answers'];\n                    $ret .= \"<p>Poll: {$question} ({$vote_count} votes)<br />\";\n                    foreach ($answers as $answer) {\n                        $text = e($answer['text']);\n                        $votes = $answer['votes'];\n                        $rate = $answer['rate'];\n                        $ret .= \"* {$text}: {$votes} ({$rate}%)<br />\";\n                    }\n                    $ret .= '</p>';\n                } elseif ($attachment['type'] == 'album') {\n                    $album = $attachment['album'];\n                    $url = \"https://vk.com/album{$album['owner_id']}_{$album['id']}\";\n                    $title = 'Альбом: ' . $album['title'];\n                    $photo = $this->getImageURLWithLargestWidth($album['thumb']['sizes']);\n                    $ret .= \"<p><a href='{$url}'><img src='{$photo}' alt='{$title}'><br>{$title}</a></p>\";\n                } elseif (!in_array($attachment['type'], ['video', 'audio', 'doc'])) {\n                    $ret .= \"<p>Unknown attachment type: {$attachment['type']}</p>\";\n                }\n            }\n        }\n\n        return $ret;\n    }\n\n    protected function getImageURLWithLargestWidth($items)\n    {\n        usort($items, function ($a, $b) {\n            return $b['width'] - $a['width'];\n        });\n        return $items[0]['url'];\n    }\n\n    public function collectData()\n    {\n        if ($this->cache->get($this->rateLimitCacheKey)) {\n            throwRateLimitException();\n        }\n\n        $u = $this->getInput('u');\n        $ownerId = null;\n\n        // getting ownerId from url\n        $r = preg_match('/^(club|public)(\\d+)$/', $u, $matches);\n        if ($r) {\n            $ownerId = -intval($matches[2]);\n        } else {\n            $r = preg_match('/^(id)(\\d+)$/', $u, $matches);\n            if ($r) {\n                $ownerId = intval($matches[2]);\n            }\n        }\n\n        // getting owner id from API\n        if (is_null($ownerId)) {\n            $r = $this->api('groups.getById', [\n                'group_ids' => $u,\n            ], [100]);\n            if (isset($r['response'][0])) {\n                $ownerId = -$r['response'][0]['id'];\n            } else {\n                $r = $this->api('users.get', [\n                    'user_ids' => $u,\n                ]);\n                if (count($r['response']) > 0) {\n                    $ownerId = $r['response'][0]['id'];\n                }\n            }\n        }\n\n        if (is_null($ownerId)) {\n            throwServerException('Could not detect owner id');\n        }\n\n        $r = $this->api('wall.get', [\n            'owner_id' => $ownerId,\n            'extended' => '1',\n        ]);\n\n        // preparing ownerNames dictionary\n        foreach ($r['response']['profiles'] as $profile) {\n            $this->ownerNames[$profile['id']] = $profile['first_name'] . ' ' . $profile['last_name'];\n        }\n        foreach ($r['response']['groups'] as $group) {\n            $this->ownerNames[-$group['id']] = $group['name'];\n        }\n        $this->generateFeed($r);\n    }\n\n    protected function generateFeed($r)\n    {\n        $ownerId = 0;\n\n        foreach ($r['response']['items'] as $post) {\n            if (!$ownerId) {\n                $ownerId = $post['owner_id'];\n            }\n            $item = [];\n            $content = $this->generateContentFromPost($post);\n            if (isset($post['copy_history'])) {\n                if ($this->getInput('hide_reposts')) {\n                    continue;\n                }\n                $originalPost = $post['copy_history'][0];\n                if ($originalPost['from_id'] < 0) {\n                    $originalPostAuthorScreenName = 'club' . (-$originalPost['owner_id']);\n                } else {\n                    $originalPostAuthorScreenName = 'id' . $originalPost['owner_id'];\n                }\n                $originalPostAuthorURI = 'https://vk.com/' . $originalPostAuthorScreenName;\n                $originalPostAuthorName = $this->ownerNames[$originalPost['from_id']];\n                $originalPostAuthor = \"<a href='$originalPostAuthorURI'>$originalPostAuthorName</a>\";\n                $content .= '<p>Репост (<a href=\"';\n                $content .= $this->getPostURI($originalPost);\n                $content .= '\">Пост</a> от ';\n                $content .= $originalPostAuthor;\n                $content .= '):</p>';\n                $content .= $this->generateContentFromPost($originalPost);\n            }\n            $item['content'] = $content;\n            $item['timestamp'] = $post['date'];\n            $item['author'] = $this->ownerNames[$post['from_id']];\n            $item['title'] = $this->getTitle(strip_tags($content));\n            $item['uri'] = $this->getPostURI($post);\n\n            $this->items[] = $item;\n        }\n\n        $this->pageName = $this->ownerNames[$ownerId];\n    }\n\n    protected function getTitle($content)\n    {\n        $content = explode('<br>', $content)[0];\n        $content = strip_tags($content);\n        preg_match('/^[:\\,\"\\w\\ \\p{L}\\(\\)\\?#«»\\-\\–\\—||&\\.%\\\\₽\\/+\\;\\!]+/mu', htmlspecialchars_decode($content), $result);\n        if (count($result) == 0) {\n            return 'untitled';\n        }\n        return $result[0];\n    }\n\n    protected function api($method, array $params, $expected_error_codes = [])\n    {\n        $access_token = $this->getOption('access_token');\n        if (!$access_token) {\n            throwServerException('You cannot run VK API methods without access_token');\n        }\n        $params['v'] = '5.131';\n        $r = json_decode(\n            getContents(\n                'https://api.vk.com/method/' . $method . '?' . http_build_query($params),\n                ['Authorization: Bearer ' . $access_token]\n            ),\n            true\n        );\n        if (isset($r['error']) && !in_array($r['error']['error_code'], $expected_error_codes)) {\n            if ($r['error']['error_code'] == 6) {\n                $this->cache->set($this->rateLimitCacheKey, true, 5);\n            } else if ($r['error']['error_code'] == 29) {\n                // wall.get has limit of 5000 requests per day\n                // if that limit is hit, VK returns error 29\n                $this->cache->set($this->rateLimitCacheKey, true, 60 * 30);\n            }\n            throwServerException('API returned error: ' . $r['error']['error_msg'] . ' (' . $r['error']['error_code'] . ')');\n        }\n        return $r;\n    }\n}\n"
  },
  {
    "path": "bridges/VkBridge.php",
    "content": "<?php\n\nclass VkBridge extends BridgeAbstract\n{\n    // const MAINTAINER = 'em92';\n    // const MAINTAINER = 'pmaziere';\n    // const MAINTAINER = 'ahiles3005';\n    const NAME = 'VK.com';\n    const URI = 'https://vk.com/';\n    const CACHE_TIMEOUT = 3600; // 1h\n    const DESCRIPTION = 'Does not work anymore';\n    const PARAMETERS = [\n        [\n            'u' => [\n                'name' => 'Group or user name',\n                'exampleValue' => 'elonmusk_tech',\n                'required' => true\n            ],\n            'hide_reposts' => [\n                'name' => 'Hide reposts',\n                'type' => 'checkbox',\n            ]\n        ]\n    ];\n    const TEST_DETECT_PARAMETERS = [\n        'https://vk.com/id1' => ['u' => 'id1'],\n        'https://vk.com/groupname' => ['u' => 'groupname'],\n        'https://m.vk.com/groupname' => ['u' => 'groupname'],\n        'https://vk.com/groupname/anythingelse' => ['u' => 'groupname'],\n        'https://vk.com/groupname?w=somethingelse' => ['u' => 'groupname'],\n        'https://vk.com/with_underscore' => ['u' => 'with_underscore'],\n        'https://vk.com/vk.cats' => ['u' => 'vk.cats'],\n    ];\n\n    protected $pageName;\n    protected $tz = 0;\n    private $urlRegex = '/vk\\.com\\/([\\w.]+)/';\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('u'))) {\n            return static::URI . urlencode($this->getInput('u'));\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if ($this->pageName) {\n            return $this->pageName;\n        }\n\n        return parent::getName();\n    }\n\n    public function detectParameters($url)\n    {\n        if (preg_match($this->urlRegex, $url, $matches)) {\n            return ['u' => $matches[1]];\n        }\n\n        return null;\n    }\n\n    public function collectData()\n    {\n        return;\n        $text_html = $this->getContents();\n\n        $text_html = iconv('windows-1251', 'utf-8//ignore', $text_html);\n\n        $html = str_get_html($text_html);\n        foreach ($html->find('script') as $script) {\n            preg_match('/tz: ([0-9]+)/', $script->outertext, $matches);\n            if (count($matches) > 0) {\n                $this->tz = intval($matches[1]);\n                break;\n            }\n        }\n        $pageName = $html->find('meta[property=\"og:title\"]', 0);\n        if (is_object($pageName)) {\n            $pageName = $pageName->getAttribute('content');\n            $this->pageName = $pageName;\n        }\n        foreach ($html->find('div.replies') as $comment_block) {\n            $comment_block->outertext = '';\n        }\n\n        // expensive operation\n        $save = $html->save();\n        $html->load($save);\n\n        $pinned_post_item = null;\n        $last_post_id = 0;\n\n        foreach ($html->find('.post') as $post) {\n            if ($post->find('.wall_post_text_deleted')) {\n                // repost of deleted post\n                continue;\n            }\n\n            defaultLinkTo($post, self::URI);\n\n            $is_pinned_post = false;\n            if (strpos($post->getAttribute('class'), 'post_fixed') !== false) {\n                $is_pinned_post = true;\n            }\n\n            // Remove 'Show more' button\n            foreach ($post->find('button.PostTextMore') as $junk) {\n                $junk->outertext = '';\n            }\n\n            $content_suffix = '';\n\n            // looking for external links\n            $external_link_selectors = [\n                'a.page_media_link_title',\n                'div.page_media_link_title > a',\n                'div.media_desc > a.lnk',\n            ];\n\n            foreach ($external_link_selectors as $sel) {\n                if (is_object($post->find($sel, 0))) {\n                    $a = $post->find($sel, 0);\n                    $innertext = $a->innertext;\n                    $parsed_url = parse_url($a->getAttribute('href'));\n                    if (strpos($parsed_url['path'], '/away.php') !== 0) {\n                        continue;\n                    }\n                    parse_str($parsed_url['query'], $parsed_query);\n                    $content_suffix .= \"<br>External link: <a href='\" . $parsed_query['to'] . \"'>$innertext</a>\";\n                }\n            }\n\n            // remove external link from content\n            $external_link_selectors_to_remove = [\n                'div.page_media_thumbed_link',\n                'div.page_media_link_desc_wrap',\n                'div.media_desc > a.lnk',\n            ];\n\n            foreach ($external_link_selectors_to_remove as $sel) {\n                if (is_object($post->find($sel, 0))) {\n                    $post->find($sel, 0)->outertext = '';\n                }\n            }\n\n            // looking for article\n            $article = $post->find('a.article_snippet', 0);\n            if (is_object($article)) {\n                if (strpos($article->getAttribute('class'), 'article_snippet_mini') !== false) {\n                    $article_title_selector = 'div.article_snippet_mini_title';\n                    $article_author_selector = 'div.article_snippet_mini_info > .mem_link,\n\t\t\t\t\t\tdiv.article_snippet_mini_info > .group_link';\n                    $article_thumb_selector = 'div.article_snippet_mini_thumb';\n                } else {\n                    $article_title_selector = 'div.article_snippet__title';\n                    $article_author_selector = 'div.article_snippet__author';\n                    $article_thumb_selector = 'div.article_snippet__image';\n                }\n                $article_title = $article->find($article_title_selector, 0)->innertext ?? '';\n                $article_author = $article->find($article_author_selector, 0)->innertext ?? '';\n                $article_link = $article->getAttribute('href');\n                $article_img_element_style = $article->find($article_thumb_selector, 0)->getAttribute('style');\n                preg_match('/background-image: url\\((.*)\\)/', $article_img_element_style, $matches);\n                if (count($matches) > 0) {\n                    $content_suffix .= \"<br><img src='\" . $matches[1] . \"'>\";\n                }\n                $content_suffix .= \"<br>Article: <a href='$article_link'>$article_title ($article_author)</a>\";\n                $article->outertext = '';\n            }\n\n            // get all videos\n            foreach ($post->find('a.page_post_thumb_video') as $a) {\n                $video_title = htmlspecialchars_decode($a->getAttribute('aria-label'));\n                $video_title_split_pos = strrpos($video_title, ' is ');\n                if ($video_title_split_pos !== false) {\n                    $video_title = substr($video_title, 0, $video_title_split_pos);\n                }\n                $video_link = $a->getAttribute('href');\n                $this->appendVideo($video_title, $video_link, backgroundToImg($a), $content_suffix);\n                $a->outertext = '';\n            }\n\n            // get all photos\n            foreach ($post->find('div.wall_text a.page_post_thumb_wrap') as $a) {\n                $result = $this->getPhoto($a);\n                if ($result == null) {\n                    continue;\n                }\n                $a->outertext = '';\n                $content_suffix .= \"<br>$result\";\n            }\n\n            // get albums\n            foreach ($post->find('.page_album_wrap') as $el) {\n                $a = $el->find('.page_album_link', 0);\n                $album_title = $a->find('.page_album_title_text', 0)->getAttribute('title');\n                $album_link = $a->getAttribute('href');\n                $el->outertext = '';\n                $content_suffix .= \"<br>Album: <a href='$album_link'>$album_title</a>\";\n            }\n\n            // get photo documents\n            foreach ($post->find('a.page_doc_photo_href') as $a) {\n                $doc_link = $a->getAttribute('href');\n                $doc_gif_label_element = $a->find('.page_gif_label', 0);\n                $doc_title_element = $a->find('.doc_label', 0);\n\n                if (is_object($doc_gif_label_element)) {\n                    $gif_preview_img = backgroundToImg($a->find('.page_doc_photo', 0));\n                    $content_suffix .= \"<br>Gif: <a href='$doc_link'>$gif_preview_img</a>\";\n                } elseif (is_object($doc_title_element)) {\n                    $doc_title = $doc_title_element->innertext;\n                    $content_suffix .= \"<br>Doc: <a href='$doc_link'>$doc_title</a>\";\n                } else {\n                    continue;\n                }\n\n                $a->outertext = '';\n            }\n\n            // get other documents\n            foreach ($post->find('div.page_doc_row') as $div) {\n                $doc_title_element = $div->find('a.page_doc_title', 0);\n\n                if (is_object($doc_title_element)) {\n                    $doc_title = $doc_title_element->innertext;\n                    $doc_link = $doc_title_element->getAttribute('href');\n                    $content_suffix .= \"<br>Doc: <a href='$doc_link'>$doc_title</a>\";\n                } else {\n                    continue;\n                }\n\n                $div->outertext = '';\n            }\n\n            // get polls\n            foreach ($post->find('div.page_media_poll_wrap') as $div) {\n                $poll_title = $div->find('.page_media_poll_title', 0)->innertext;\n                $content_suffix .= \"<br>Poll: $poll_title\";\n                foreach ($div->find('div.page_poll_text') as $poll_stat_title) {\n                    $content_suffix .= '<br>- ' . $poll_stat_title->innertext;\n                }\n                $div->outertext = '';\n            }\n\n            // get sign / post author\n            $post_author = $pageName;\n            $author_selectors = ['a.wall_signed_by', 'a.author'];\n            foreach ($author_selectors as $author_selector) {\n                $a = $post->find($author_selector, 0);\n                if (is_object($a)) {\n                    $post_author = $a->innertext;\n                    $a->outertext = '';\n                    break;\n                }\n            }\n\n            // fix links and get post hashtags\n            $hashtags = [];\n            foreach ($post->find('a') as $a) {\n                $href = $a->getAttribute('href');\n                $innertext = $a->innertext;\n\n                $hashtag_prefix = '/feed?section=search&q=%23';\n                $hashtag = null;\n\n                if ($href && substr($href, 0, strlen($hashtag_prefix)) === $hashtag_prefix) {\n                    $hashtag = urldecode(substr($href, strlen($hashtag_prefix)));\n                } elseif (substr($innertext, 0, 1) == '#') {\n                    $hashtag = $innertext;\n                }\n\n                if ($hashtag) {\n                    $a->outertext = $innertext;\n                    $hashtags[] = $hashtag;\n                    continue;\n                }\n\n                $parsed_url = parse_url($href);\n\n                if (array_key_exists('path', $parsed_url) === false) {\n                    continue;\n                }\n\n                if (strpos($parsed_url['path'], '/away.php') === 0) {\n                    parse_str($parsed_url['query'], $parsed_query);\n                    $a->setAttribute('href', iconv(\n                        'windows-1251',\n                        'utf-8//ignore',\n                        $parsed_query['to']\n                    ));\n                }\n            }\n\n            $copy_quote = $post->find('div.copy_quote', 0);\n            if (is_object($copy_quote)) {\n                if ($this->getInput('hide_reposts') === true) {\n                    continue;\n                }\n                if ($copy_post_header = $copy_quote->find('div.copy_post_header', 0)) {\n                    $copy_post_header->outertext = '';\n                }\n\n                $second_copy_quote = $copy_quote->find('div.published_sec_quote', 0);\n                if (is_object($second_copy_quote)) {\n                    $second_copy_quote_author = $second_copy_quote->find('a.copy_author', 0)->outertext;\n                    $second_copy_quote_content = $second_copy_quote->find('div.copy_post_date', 0)->outertext;\n                    $second_copy_quote->outertext = \"<br>Reposted ($second_copy_quote_author): $second_copy_quote_content\";\n                }\n                $copy_quote_author = $copy_quote->find('a.copy_author', 0)->outertext;\n                $copy_quote_content = $copy_quote->innertext;\n                $copy_quote->outertext = \"<br>Reposted ($copy_quote_author): <br>$copy_quote_content\";\n            }\n\n            foreach ($post->find('.PrimaryAttachment .PhotoPrimaryAttachment') as $pa) {\n                $img = $pa->find('.PhotoPrimaryAttachment__imageElement', 0);\n                if (is_object($img)) {\n                    $pa->outertext = $img->outertext;\n                }\n            }\n\n            foreach ($post->find('.SecondaryAttachment') as $sa) {\n                $sa_href = $sa->getAttribute('href');\n                if (!$sa_href) {\n                    $sa_href = '';\n                }\n                $sa_task_click = $sa->getAttribute('data-task-click');\n\n                if (str_starts_with($sa_href, 'https://vk.com/doc')) {\n                    // document\n                    $doc_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext;\n                    $doc_size = $sa->find('.SecondaryAttachmentSubhead', 0)->innertext;\n                    $doc_link = $sa_href;\n                    $content_suffix .= \"<br>Doc: <a href='$doc_link'>$doc_title</a> ($doc_size)\";\n                    $sa->outertext = '';\n                } else if (str_starts_with($sa_href, 'https://vk.com/@')) {\n                    // article\n                    $article_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext;\n                    $article_author = explode('Article · from ', $sa->find('.SecondaryAttachmentSubhead', 0)->innertext)[1];\n                    $article_link = $sa_href;\n                    $content_suffix .= \"<br>Article: <a href='$article_link'>$article_title ($article_author)</a>\";\n                    $sa->outertext = '';\n                } else if ($sa_task_click == 'SecondaryAttachment/playAudio') {\n                    // audio\n                    $audio_json = json_decode(html_entity_decode($sa->getAttribute('data-audio')));\n                    $audio_link = $audio_json->url;\n                    $audio_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext;\n                    $audio_author = $sa->find('.SecondaryAttachmentSubhead', 0)->innertext;\n                    $content_suffix .= \"<br>Audio: <a href='$audio_link'>$audio_title ($audio_author)</a>\";\n                    $sa->outertext = '';\n                } else if ($sa_task_click == 'SecondaryAttachment/playPlaylist') {\n                    // playlist link\n                    $playlist_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext;\n                    $playlist_link = $sa->find('.SecondaryAttachment__link', 0)->getAttribute('href');\n                    $content_suffix .= \"<br>Playlist: <a href='$playlist_link'>$playlist_title</a>\";\n                    $sa->outertext = '';\n                }\n            }\n\n            $item = [];\n            $content = strip_tags(backgroundToImg($post->find('div.wall_text', 0)->innertext), '<a><br><img>');\n            $content .= $content_suffix;\n            if (!$content) {\n                $content = '(empty post)';\n            }\n            $content = str_get_html($content);\n            foreach ($content->find('img') as $img) {\n                $parsed_src = parse_url($img->getAttribute('src'));\n\n                // unblur images (case of impf)\n                // get original images instead of thumbnails (case of impg)\n                $imgPrefix = array_reduce(['/impf/', '/impg/'], function ($a, $c) use ($parsed_src) {\n                    if ($a) {\n                        return $a;\n                    }\n                    if (str_starts_with($parsed_src['path'], $c)) {\n                        return $c;\n                    }\n                    return $a;\n                }, '');\n                if ($imgPrefix) {\n                    $new_src = $parsed_src['scheme'] . '://' . $parsed_src['host'];\n                    $new_src .= substr($parsed_src['path'], strlen($imgPrefix) - 1);\n                    $img->setAttribute('src', $new_src);\n                }\n            }\n            $item['content'] = $content->outertext;\n            $item['categories'] = $hashtags;\n\n            // get post link\n            $var = $post->find('a.PostHeaderSubtitle__link', 0);\n            if ($var) {\n                $post_link = $var->getAttribute('href');\n                preg_match('/wall-?\\d+_(\\d+)/', $post_link, $preg_match_result);\n                $item['post_id'] = intval($preg_match_result[1]);\n                $item['uri'] = $post_link;\n            }\n            $item['timestamp'] = $this->getTime($post);\n            $item['title'] = $this->getTitle($item['content']);\n            $item['author'] = $post_author;\n            if ($is_pinned_post) {\n                // do not append it now\n                $pinned_post_item = $item;\n            } else {\n                $last_post_id = $item['post_id'] ?? null;\n                $this->items[] = $item;\n            }\n        }\n\n        if (!is_null($pinned_post_item)) {\n            if (count($this->items) == 0) {\n                $this->items[] = $pinned_post_item;\n            } elseif ($last_post_id < $pinned_post_item['post_id']) {\n                $this->items[] = $pinned_post_item;\n                usort($this->items, function ($item1, $item2) {\n                    return $item2['post_id'] - $item1['post_id'];\n                });\n            }\n        }\n    }\n\n    private function getPhoto($a)\n    {\n        $onclick = $a->getAttribute('onclick');\n        preg_match('/return showPhoto\\(.+?({.*})/', $onclick, $preg_match_result);\n        if (count($preg_match_result) == 0) {\n            return;\n        }\n\n        $arg = htmlspecialchars_decode(str_replace('queue:1', '\"queue\":1', $preg_match_result[1]));\n        $data = json_decode($arg, true);\n        if ($data == null) {\n            return;\n        }\n\n        $thumb = $data['temp']['base'] . $data['temp']['x_'][0];\n        $original = '';\n        foreach (['y_', 'z_', 'w_'] as $key) {\n            if (!isset($data['temp'][$key])) {\n                continue;\n            }\n            if (!isset($data['temp'][$key][0])) {\n                continue;\n            }\n            if (substr($data['temp'][$key][0], 0, 4) == 'http') {\n                $base = '';\n            } else {\n                $base = $data['temp']['base'];\n            }\n            $original = $base . $data['temp'][$key][0];\n        }\n\n        if ($original) {\n            return \"<a href='$original'><img src='$thumb'></a>\";\n        } else {\n            return backgroundToImg($a);\n        }\n    }\n\n    private function getTitle($content)\n    {\n        $content = explode('<br>', $content)[0];\n        $content = strip_tags($content);\n        preg_match('/^[:\\,\"\\w\\ \\p{L}\\(\\)\\?#«»\\-\\–\\—||&\\.%\\\\₽\\/+\\;\\!]+/mu', htmlspecialchars_decode($content), $result);\n        if (count($result) == 0) {\n            return 'untitled';\n        }\n        return $result[0];\n    }\n\n    private function getTime($post)\n    {\n        $accurateDateElement = $post->find('span.rel_date', 0);\n        if ($accurateDateElement) {\n            return $accurateDateElement->getAttribute('time');\n        } else {\n            $strdate = $post->find('time.PostHeaderSubtitle__item', 0)->plaintext ?? null;\n            if (!$strdate) {\n                return 0;\n            }\n            $strdate = preg_replace('/[\\x00-\\x1F\\x7F-\\xFF]/', ' ', $strdate);\n\n            $date = date_parse($strdate);\n            if (!$date['year']) {\n                if (strstr($strdate, 'today') !== false) {\n                    $strdate = date('d-m-Y') . ' ' . $strdate;\n                } elseif (strstr($strdate, 'yesterday ') !== false) {\n                    $time = time() - 60 * 60 * 24;\n                    $strdate = date('d-m-Y', $time) . ' ' . $strdate;\n                } elseif ($date['month'] && intval(date('m')) < $date['month']) {\n                    $strdate = $strdate . ' ' . (date('Y') - 1);\n                } else {\n                    $strdate = $strdate . ' ' . date('Y');\n                }\n\n                $date = date_parse($strdate);\n            } elseif ($date['hour'] === false) {\n                $date['hour'] = $date['minute'] = '00';\n            }\n            return strtotime($date['day'] . '-' . $date['month'] . '-' . $date['year'] . ' ' .\n                $date['hour'] . ':' . $date['minute']) - $this->tz;\n        }\n    }\n\n    private function getContents()\n    {\n        $httpHeaders = [\n            'Accept-language: en',\n            'Cookie: remixlang=3',\n        ];\n        $redirects = 0;\n        $uri = $this->getURI();\n\n        while ($redirects < 2) {\n            $response = getContents($uri, $httpHeaders, [CURLOPT_FOLLOWLOCATION => false], true);\n\n            if (in_array($response->getCode(), [200, 304])) {\n                return $response->getBody();\n            }\n\n            $headers = $response->getHeaders();\n            $uri = urljoin(self::URI, $headers['location'][0]);\n\n            if (str_contains($uri, '/429.html')) {\n                throwRateLimitException();\n            }\n\n            if (!preg_match('#^https?://vk.com/#', $uri)) {\n                throwServerException('Unexpected redirect location: ' . $uri);\n            }\n\n            $redirects++;\n        }\n\n        throwServerException('Too many redirects, while retreving content from VK');\n    }\n\n    protected function appendVideo($video_title, $video_link, $previewImg, &$content_suffix)\n    {\n        if (!$video_title) {\n            $video_title = '(empty)';\n        }\n\n        $content_suffix .= '<br><a href=\"' . htmlspecialchars($video_link) . '\">' . $previewImg;\n        $content_suffix .= 'Video: ' . $video_title . '</a>';\n    }\n}\n"
  },
  {
    "path": "bridges/VproTegenlichtBridge.php",
    "content": "<?php\n\nclass VproTegenlichtBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'vincentvd';\n    const NAME = 'VPRO tegenlicht';\n    const URI = 'https://www.vpro.nl/programmas/tegenlicht/lees/artikelen.html';\n    const CACHE_TIMEOUT = 900; // 15 minutes\n    const DESCRIPTION = 'RSS feed for the VPRO tegenlicht website';\n\n    public function getIcon()\n    {\n        return 'https://www.vpro.nl/.resources/vpro/favicons/vpro/favicon.ico';\n    }\n\n    public function collectData()\n    {\n        $url = sprintf('https://www.vpro.nl/programmas/tegenlicht/lees/artikelen.html');\n        $dom = getSimpleHTMLDOM($url);\n        $dom = $dom->find('ul#browsable-news-overview', 0);\n        $dom = defaultLinkTo($dom, $this->getURI());\n        foreach ($dom->find('li') as $article) {\n            $a = $article->find('a.complex-teaser', 0);\n            $title = $article->find('a.complex-teaser', 0)->title;\n            $url = $article->find('a.complex-teaser', 0)->href;\n            $author = 'VPRO tegenlicht';\n            $content = $article->find('p.complex-teaser-summary', 0)->plaintext;\n            $timestamp = strtotime($article->find('div.complex-teaser-data', 0)->plaintext);\n\n            $item = [\n                'uri' => $url,\n                'author' => $author,\n                'title' => $title,\n                'timestamp' => $timestamp,\n                'content' => $content\n            ];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WKYTNewsBridge.php",
    "content": "<?php\n\nclass WKYTNewsBridge extends BridgeAbstract\n{\n    const NAME = 'WKYT Lexington News';\n    const URI = 'https://www.wkyt.com/news/';\n    const DESCRIPTION = 'Returns the recent articles published on WKYT News (Lexington KY)';\n    const MAINTAINER = 'mattconnell';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n        $html = defaultLinkTo($html, self::URI);\n\n        $articles = $html->find('.card-body');\n\n        foreach ($articles as $article) {\n            $item = [];\n            $url = $article->find('.headline a', 0);\n            $item['uri'] = $url->href;\n            $item['title'] = trim($url->plaintext);\n            $item['author'] = $article->find('.author', 0)->plaintext;\n            $item['content'] = $article->find('.deck', 0)->plaintext;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WYMTNewsBridge.php",
    "content": "<?php\n\nclass WYMTNewsBridge extends BridgeAbstract\n{\n    const NAME = 'WYMT Mountain News';\n    const URI = 'https://www.wymt.com/news/';\n    const DESCRIPTION = 'Returns the recent articles published on WYMT Mountain News (Hazard KY)';\n    const MAINTAINER = 'mattconnell';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM(self::URI);\n        $html = defaultLinkTo($html, self::URI);\n\n        $articles = $html->find('.card-body');\n\n        foreach ($articles as $article) {\n            $item = [];\n            $url = $article->find('.headline a', 0);\n            $item['uri'] = $url->href;\n            $item['title'] = trim($url->plaintext);\n            $item['author'] = $article->find('.author', 0)->plaintext;\n            $item['content'] = $article->find('.deck', 0)->plaintext;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WaggaCouncilBridge.php",
    "content": "<?php\n\nclass WaggaCouncilBridge extends BridgeAbstract\n{\n    const NAME = 'Wagga Wagga Council';\n    const URI = 'https://news.wagga.nsw.gov.au/';\n    const DESCRIPTION = 'Wagga Wagga Council updates';\n    const MAINTAINER = 'Scrub000';\n    const CACHE_TIMEOUT = 3600;\n    const PARAMETERS = [\n        [\n            'section' => [\n                'name' => 'Section',\n                'type' => 'list',\n                'values' => [\n                    'Council' => 'council',\n                    'Community' => 'community',\n                    'Projects & Works' => 'projects-and-works',\n                    'Arts & Culture' => 'arts-and-culture',\n                    'Environment' => 'environment',\n                    'Events & Tourism' => 'events-and-tourism',\n                    'Parks & Recreation' => 'parks-and-recreation',\n                ],\n                'defaultValue' => 'council',\n            ],\n        ]\n    ];\n\n    public function getURI(): string\n    {\n        $section = $this->getInput('section') ?: 'council';\n        return urljoin(self::URI, $section);\n    }\n\n    public function collectData(): void\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('div.container') as $container) {\n            $titleElement = $container->find('h5', 0);\n            $linkElement = $container->find('a', 0);\n            $timeElement = $container->find('small.text-muted', 0);\n\n            if (!$titleElement || !$linkElement || !$timeElement) {\n                continue;\n            }\n\n            $title = trim($titleElement->plaintext);\n            $uri = urljoin(self::URI, $linkElement->href);\n            $timestamp = strtotime(str_replace('Published: ', '', $timeElement->plaintext));\n\n            // Load full article\n            $articleHtml = getSimpleHTMLDOM($uri);\n            $articleContent = '';\n\n            if ($articleHtml) {\n                $article = $articleHtml->find('article.article', 0);\n                if ($article) {\n                    // Remove uneeded content\n                    $selectorsToRemove = [\n                        'button',\n                        'nav',\n                        '.visually-hidden',\n                        '.carousel-control-prev',\n                        '.carousel-control-next',\n                        '.article__heading',\n                        '.article__badge',\n                        'p.text-muted',\n                    ];\n\n                    foreach ($selectorsToRemove as $sel) {\n                        foreach ($article->find($sel) as $el) {\n                            $el->outertext = '';\n                        }\n                    }\n\n                    foreach ($article->find('iframe') as $iframe) {\n                        $src = $iframe->getAttribute('src');\n                        $iframe->outertext = '<p><a href=\"' . htmlspecialchars($src) . '\">Embedded content: ' . htmlspecialchars($src) . '</a></p>';\n                    }\n\n                    // Enhance list rendering\n                    foreach ($article->find('ul') as $ul) {\n                        $ul->style = 'margin-left: 1em; padding-left: 1em;';\n                    }\n                    foreach ($article->find('li') as $li) {\n                        $li->innertext = '• ' . $li->innertext;\n                    }\n\n                    foreach ($article->children() as $node) {\n                        // Skip <p> that contains <figure> to avoid duplication\n                        if ($node->tag === 'p' && $node->find('figure', 0)) {\n                            continue;\n                        }\n                        $articleContent .= $node->outertext;\n                    }\n                }\n            }\n\n            $this->items[] = [\n                'title' => $title,\n                'uri' => $uri,\n                'author' => 'Wagga Wagga City Council',\n                'timestamp' => $timestamp,\n                'content' => $articleContent,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WallmineNewsBridge.php",
    "content": "<?php\n\nclass WallmineNewsBridge extends BridgeAbstract\n{\n    const NAME = 'Wallmine News';\n    const URI = 'https://wallmine.com';\n    const DESCRIPTION = 'Returns financial news';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [];\n\n    const CACHE_TIMEOUT = 900; // 15 mins\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI() . '/news/');\n\n        $html = defaultLinkTo($html, self::URI);\n\n        foreach ($html->find('div.container.news-card') as $div) {\n            $item = [];\n            $item['uri'] = $div->find('a', 0)->href;\n\n            $image = $div->find('img.img-fluid', 0)->src;\n\n            $page = getSimpleHTMLDOMCached($item['uri'], 7200);\n\n            $article = $page->find('div.container.article-container', 0);\n\n            $item['title'] = $article->find('h1', 0)->plaintext;\n\n            $article->find('p.published-on', 0)->children(0)->outertext = '';\n            $article->find('p.published-on', 0)->children(1)->outertext = '';\n            $date = str_replace('at', '', $article->find('p.published-on', 0)->innertext);\n\n            $item['timestamp'] = $date;\n\n            $article->find('h1', 0)->outertext = '';\n            $article->find('p.published-on', 0)->outertext = '';\n\n            $item['content'] = $article->innertext;\n            $item['enclosures'][] = $image;\n\n            $this->items[] = $item;\n\n            if (count($this->items) >= 10) {\n                break;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WallpaperflareBridge.php",
    "content": "<?php\n\nclass WallpaperflareBridge extends XPathAbstract\n{\n    const NAME = 'Wallpaperflare';\n    const URI = 'https://wallpaperflare.com';\n    const DESCRIPTION = 'Wallpaperflare is a provider for Wallpapers on nearly every topic, especially for Anime';\n    const MAINTAINER = 'dhuschde';\n    const PARAMETERS = [\n        '' => [\n            'search' => [\n                'name' => 'Search',\n                'exampleValue' => 'birds',\n                'required' => true\n            ]\n        ]];\n    const CACHE_TIMEOUT = 3600; //1 hour\n    const XPATH_EXPRESSION_ITEM = './/figure';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/img/@title';\n    const XPATH_EXPRESSION_ITEM_CONTENT = '';\n    const XPATH_EXPRESSION_ITEM_URI = './/a[@itemprop=\"url\"]/@href';\n    const XPATH_EXPRESSION_ITEM_AUTHOR = '/html[1]/body[1]/main[1]/section[1]/h1[1]';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = '';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@data-src';\n    const XPATH_EXPRESSION_ITEM_CATEGORIES = './/figcaption[@itemprop=\"caption description\"]';\n    const SETTING_FIX_ENCODING = false;\n\n    protected function getSourceUrl()\n    {\n        return 'https://www.wallpaperflare.com/search?wallpaper=' . $this->getInput('search');\n    }\n\n    public function getIcon()\n    {\n        return 'https://www.google.com/s2/favicons?domain=wallpaperflare.com/';\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('search'))) {\n            return 'Wallpaperflare - ' . $this->getInput('search');\n        } else {\n            return 'Wallpaperflare';\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WarhammerComBridge.php",
    "content": "<?php\n\nclass WarhammerComBridge extends BridgeAbstract\n{\n    const NAME = 'Warhammer Community Blog';\n    const URI = 'https://www.warhammer-community.com';\n    const DESCRIPTION = 'Warhammer Community Blog';\n    const MAINTAINER = 'thefranke';\n    const CACHE_TIMEOUT = 86400;\n\n    public function collectData()\n    {\n        $url = static::URI . '/api/search/news/';\n\n        $header = [\n            'Content-Type: application/json',\n        ];\n\n        $data = '{\"sortBy\":\"date_desc\",\"category\":\"\",\"collections\":[\"articles\"],\"game_systems\":[],\"index\":\"news\",\"locale\":\"en-gb\",\"page\":0,\"perPage\":16,\"topics\":[]}';\n\n        $opts = [\n            CURLOPT_POST => true,\n            CURLOPT_POSTFIELDS => $data,\n            CURLOPT_RETURNTRANSFER => true\n        ];\n\n        $json = getContents($url, $header, $opts);\n        $json = json_decode($json);\n\n        foreach ($json->news as $article) {\n            $articleurl = static::URI . $article->uri;\n\n            $fullarticle = getSimpleHTMLDOMCached($articleurl);\n            $content = $fullarticle->find('.article-content', 0);\n\n            $categories = [];\n            foreach ($article->topics as $topic) {\n                $categories[] = $topic->title;\n            }\n\n            $this->items[] = [\n                'title' => $article->title,\n                'uri' => static::URI . $article->uri,\n                'timestamp' => strtotime($article->date),\n                'content' => $content,\n                'uid' => $article->uuid,\n                'categories' => $categories\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WeLiveSecurityBridge.php",
    "content": "<?php\n\nclass WeLiveSecurityBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'We Live Security';\n    const URI = 'https://www.welivesecurity.com/';\n    const DESCRIPTION = 'Returns the newest articles.';\n    const PARAMETERS = [\n        [\n            'limit' => self::LIMIT,\n        ],\n    ];\n\n    protected function parseItem(array $item)\n    {\n        $html = getSimpleHTMLDOMCached($item['uri']);\n        if (!$html) {\n            $item['content'] .= '<br /><p><em>Could not request ' . $this->getName() . ': ' . $item['uri'] . '</em></p>';\n            return $item;\n        }\n\n        $html = $html->find('.article-page', 0);\n        $content_html = $html->find('.article-body', 0);\n\n        // Remove social media footer\n        foreach ($content_html->find('blockquote') as $blockquote) {\n            if (str_starts_with(trim($blockquote->plaintext), 'Connect with us on')) {\n                $blockquote->outertext = '';\n            }\n        }\n\n        // Headline subtitle\n        $content = $content_html->innertext;\n        $subtitle = $html->find('.sub-title', 0);\n        if ($subtitle) {\n            $content = '<p><b>' . $subtitle->plaintext . '</b></p>' . $content;\n        }\n\n        // Author\n        $author = $html->find('.article-author', 0);\n        if ($author && !isset($item['author'])) {\n            $item['author'] = trim($author->plaintext);\n        }\n\n        $item['content'] = trim($content);\n        return $item;\n    }\n\n    public function collectData()\n    {\n        $feed = static::URI . 'feed/';\n        $limit = $this->getInput('limit') ?? 10;\n        $this->collectExpandableDatas($feed, $limit);\n    }\n}\n"
  },
  {
    "path": "bridges/WebfailBridge.php",
    "content": "<?php\n\nclass WebfailBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const URI = 'https://webfail.com';\n    const NAME = 'Webfail';\n    const DESCRIPTION = 'Returns the latest fails';\n    const PARAMETERS = [\n        'By content type' => [\n            'language' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'title' => 'Select your language',\n                'values' => [\n                    'English' => 'en',\n                    'German' => 'de'\n                ],\n                'defaultValue' => 'English'\n            ],\n            'type' => [\n                'name' => 'Type',\n                'type' => 'list',\n                'title' => 'Select your content type',\n                'values' => [\n                    'None' => '/',\n                    'Facebook' => '/ffdts',\n                    'Images' => '/images',\n                    'Videos' => '/videos',\n                    'Gifs' => '/gifs'\n                ],\n                'defaultValue' => 'None'\n            ]\n        ]\n    ];\n\n    public function getURI()\n    {\n        if (is_null($this->getInput('language'))) {\n            return parent::getURI();\n        }\n\n        // e.g.: https://en.webfail.com\n        return 'https://' . $this->getInput('language') . '.webfail.com';\n    }\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI() . $this->getInput('type'));\n\n        $type = $this->getKey('type');\n\n        switch (strtolower($type)) {\n            case 'facebook':\n            case 'videos':\n                $this->extractNews($html, $type);\n                break;\n            case 'none':\n            case 'images':\n            case 'gifs':\n                $this->extractArticle($html);\n                break;\n            default:\n                throwClientException('Unknown type: ' . $type);\n        }\n    }\n\n    private function extractNews($html, $type)\n    {\n        $news = $html->find('#main', 0)->find('a.wf-list-news');\n        foreach ($news as $element) {\n            $item = [];\n            $item['title'] = $this->fixTitle($element->find('div.wf-news-title', 0)->innertext);\n            $item['uri'] = $this->getURI() . $element->href;\n\n            $img = $element->find('img.wf-image', 0)->src;\n            // Load high resolution image for 'facebook'\n            switch (strtolower($type)) {\n                case 'facebook':\n                    $img = $this->getImageHiResUri($item['uri']);\n                    break;\n                default:\n            }\n\n            $description = '';\n            if (!is_null($element->find('div.wf-news-description', 0))) {\n                $description = $element->find('div.wf-news-description', 0)->innertext;\n            }\n\n            $infoElement = $element->find('div.wf-small', 0);\n            if (!is_null($infoElement)) {\n                if (preg_match('/(\\d{2}\\.\\d{2}\\.\\d{4})/m', $infoElement->innertext, $matches) === 1 && count($matches) == 2) {\n                    $dt = DateTime::createFromFormat('!d.m.Y', $matches[1]);\n                    if ($dt !== false) {\n                        $item['timestamp'] = $dt->getTimestamp();\n                    }\n                }\n            }\n\n            $item['content'] = '<p>'\n            . $description\n            . '</p><br><a href=\"'\n            . $item['uri']\n            . '\"><img src=\"'\n            . $img\n            . '\"></a>';\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function extractArticle($html)\n    {\n        $articles = $html->find('article');\n        foreach ($articles as $article) {\n            $item = [];\n            $item['title'] = $this->fixTitle($article->find('a', 1)->innertext);\n\n            // Images, videos and gifs are provided in their own unique way\n            if (!is_null($article->find('img.wf-image', 0))) { // Image type\n                $item['uri'] = $this->getURI() . $article->find('a', 2)->href;\n                $item['content'] = '<a href=\"'\n                . $item['uri']\n                . '\"><img src=\"'\n                . $article->find('img.wf-image', 0)->src\n                . '\"></a>';\n            } elseif (!is_null($article->find('div.wf-video', 0))) { // Video type\n                $videoId = $this->getVideoId($article->find('div.wf-play', 0)->onclick);\n                $item['uri'] = 'https://www.youtube.com/watch?v=' . $videoId;\n                $item['content'] = handleYoutube($videoId);\n            } elseif (!is_null($article->find('video[id*=gif-]', 0))) { // Gif type\n                $item['uri'] = $this->getURI() . $article->find('a', 2)->href;\n                $item['content'] = '<video controls src=\"'\n                . $article->find('video[id*=gif-]', 0)->src\n                . '\" poster=\"'\n                . $article->find('video[id*=gif-]', 0)->poster\n                . '\"></video>';\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function fixTitle($title)\n    {\n        // This fixes titles that include umlauts (in German language)\n        return html_entity_decode($title, ENT_QUOTES | ENT_HTML401, 'UTF-8');\n    }\n\n    private function getVideoId($onclick)\n    {\n        return substr($onclick, 21, 11);\n    }\n\n    private function getImageHiResUri($url)\n    {\n        // https://de.webfail.com/ef524fae509?tag=ffdt\n        // http://cdn.webfail.com/upl/img/ef524fae509/post2.jpg\n        $id = substr($url, strrpos($url, '/') + 1, strlen($url) - strrpos($url, '?') + 2);\n        return 'http://cdn.webfail.com/upl/img/' . $id . '/post2.jpg';\n    }\n}\n"
  },
  {
    "path": "bridges/WhatsAppBlogBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass WhatsAppBlogBridge extends BridgeAbstract\n{\n    const NAME = 'WhatsApp Blog';\n    const URI = 'https://blog.whatsapp.com/';\n    const DESCRIPTION = 'WhatsApp Blog';\n    const MAINTAINER = 'latz';\n    const PARAMETERS = [[\n        'language' => [\n            'name' => 'Language',\n            'title' => 'ISO 639 language code',\n            'type' => 'text',\n            'required' => false,\n            'defaultValue' => 'en',\n        ],\n    ]];\n    const CACHE_TIMEOUT = 3600; // 1h\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOMCached('https://blog.whatsapp.com/?lang=' . $this->getInput('language'));\n\n        // extract React HTML snippets from JavaScript\n        foreach ($html->find('script') as $script) {\n            $htmlSnippetPattern = '/\\{\"__html\":\".*\"\\}/U';\n            if (preg_match_all($htmlSnippetPattern, $script->innertext, $htmlSnippets)) {\n                foreach ($htmlSnippets[0] as $snippet) {\n                    $decoded = json_decode($snippet, false)->__html;\n                    $parsed = str_get_html($decoded); // this is the parsed HTML snippet\n\n                    $content = $parsed->find('section', 0);\n                    if ($content) {\n                        // remove share buttons\n                        $content->find('._9wj7', 0)->remove();\n                        // remove \"learn more\" link\n                        $content->find('a._ajcm', 0)->remove();\n\n                        $item = [];\n\n                        $timestampStr = $content->find('._aof4 p', 0);\n                        if ($timestampStr) {\n                            $timestamp = strtotime($timestampStr->plaintext);\n                            $item['timestamp'] = $timestamp;\n                        }\n\n                        $title = $content->find('h2', 0);\n                        if ($title) {\n                            $item['title'] = $title->plaintext;\n                        }\n\n                        $links = $content->find('a');\n                        $uri = end($links);\n                        if ($uri) {\n                            $item['uri'] = $uri->href;\n                        }\n\n                        $item['content'] = implode('', array_map(fn($e) => $e->outertext, $content->find('._aofe, picture')));\n\n                        $this->items[] = $item;\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WikiLeaksBridge.php",
    "content": "<?php\n\nclass WikiLeaksBridge extends BridgeAbstract\n{\n    const NAME = 'WikiLeaks';\n    const URI = 'https://wikileaks.org';\n    const DESCRIPTION = 'Returns the latest news or articles from WikiLeaks';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        [\n            'category' => [\n                'name' => 'Category',\n                'type' => 'list',\n                'title' => 'Select your category',\n                'values' => [\n                    'News' => '-News-',\n                    'Leaks' => [\n                        'All' => '-Leaks-',\n                        'Intelligence' => '+-Intelligence-+',\n                        'Global Economy' => '+-Global-Economy-+',\n                        'International Politics' => '+-International-Politics-+',\n                        'Corporations' => '+-Corporations-+',\n                        'Government' => '+-Government-+',\n                        'War & Military' => '+-War-Military-+'\n                    ]\n                ],\n                'defaultValue' => 'news'\n            ],\n            'teaser' => [\n                'name' => 'Show teaser',\n                'type' => 'checkbox',\n                'title' => 'If checked feeds will display the teaser',\n                'defaultValue' => 'checked'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        // News are presented differently\n        switch ($this->getInput('category')) {\n            case '-News-':\n                $this->loadNewsItems($html);\n                break;\n            default:\n                $this->loadLeakItems($html);\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('category'))) {\n            return static::URI . '/' . $this->getInput('category') . '.html';\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!is_null($this->getInput('category'))) {\n            $category = array_search(\n                $this->getInput('category'),\n                static::PARAMETERS[0]['category']['values']\n            );\n\n            if ($category === false) {\n                $category = array_search(\n                    $this->getInput('category'),\n                    static::PARAMETERS[0]['category']['values']['Leaks']\n                );\n            }\n\n            return $category . ' - ' . static::NAME;\n        }\n\n        return parent::getName();\n    }\n\n    private function loadNewsItems($html)\n    {\n        $articles = $html->find('div.news-articles ul li');\n\n        if (is_null($articles) || count($articles) === 0) {\n            return;\n        }\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $item['title'] = $article->find('h3', 0)->plaintext;\n            $item['uri'] = static::URI . $article->find('h3 a', 0)->href;\n            $item['content'] = $article->find('div.introduction', 0)->plaintext;\n            $timestamp = $article->find('div.timestamp', 0);\n            if ($timestamp) {\n                $item['timestamp'] = strtotime($timestamp->plaintext);\n            }\n            $this->items[] = $item;\n        }\n    }\n\n    private function loadLeakItems($html)\n    {\n        $articles = $html->find('li.tile');\n\n        if (is_null($articles) || count($articles) === 0) {\n            return;\n        }\n\n        foreach ($articles as $article) {\n            $item = [];\n\n            $item['title'] = $article->find('h2', 0)->plaintext;\n            $item['uri'] = static::URI . $article->find('a', 0)->href;\n\n            $teaser = static::URI . '/' . $article->find('div.teaser img', 0)->src;\n\n            if ($this->getInput('teaser')) {\n                $item['content'] = '<img src=\"'\n                . $teaser\n                . '\" /><p>'\n                . $article->find('div.intro', 0)->plaintext\n                . '</p>';\n            } else {\n                $item['content'] = $article->find('div.intro', 0)->plaintext;\n            }\n\n            $item['timestamp'] = strtotime($article->find('div.timestamp', 0)->plaintext);\n            $item['enclosures'] = [$teaser];\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WikipediaBridge.php",
    "content": "<?php\n\ndefine('WIKIPEDIA_SUBJECT_TFA', 0); // Today's featured article\ndefine('WIKIPEDIA_SUBJECT_DYK', 1); // Did you know...\n\nclass WikipediaBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'logmanoriginal';\n    const NAME = 'Wikipedia bridge for many languages';\n    const URI = 'https://www.wikipedia.org/';\n    const DESCRIPTION = 'Returns articles for a language of your choice';\n\n    const PARAMETERS = [ [\n        'language' => [\n            'name' => 'Language',\n            'type' => 'list',\n            'title' => 'Select your language',\n            'exampleValue' => 'English',\n            'values' => [\n                'English' => 'en',\n                'Русский' => 'ru',\n                'Dutch' => 'nl',\n                'Esperanto' => 'eo',\n                'French' => 'fr',\n                'German' => 'de',\n            ]\n        ],\n        'subject' => [\n            'name' => 'Subject',\n            'type' => 'list',\n            'title' => 'What subject are you interested in?',\n            'exampleValue' => 'Today\\'s featured article',\n            'values' => [\n                'Today\\'s featured article' => 'tfa',\n                'Did you know…' => 'dyk'\n            ]\n        ],\n        'fullarticle' => [\n            'name' => 'Load full article',\n            'type' => 'checkbox',\n            'title' => 'Activate to always load the full article'\n        ]\n    ]];\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('language'))) {\n            return 'https://'\n            . strtolower($this->getInput('language'))\n            . '.wikipedia.org';\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        switch ($this->getInput('subject')) {\n            case 'tfa':\n                $subject = WIKIPEDIA_SUBJECT_TFA;\n                break;\n            case 'dyk':\n                $subject = WIKIPEDIA_SUBJECT_DYK;\n                break;\n            default:\n                return parent::getName();\n        }\n\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $name = 'Today\\'s featured article from '\n                . strtolower($this->getInput('language'))\n                . '.wikipedia.org';\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $name = 'Did you know? - articles from '\n                . strtolower($this->getInput('language'))\n                . '.wikipedia.org';\n                break;\n            default:\n                $name = 'Articles from '\n                . strtolower($this->getInput('language'))\n                . '.wikipedia.org';\n                break;\n        }\n        return $name;\n    }\n\n    public function collectData()\n    {\n        switch ($this->getInput('subject')) {\n            case 'tfa':\n                $subject = WIKIPEDIA_SUBJECT_TFA;\n                break;\n            case 'dyk':\n                $subject = WIKIPEDIA_SUBJECT_DYK;\n                break;\n            default:\n                $subject = WIKIPEDIA_SUBJECT_TFA;\n                break;\n        }\n\n        $fullArticle = $this->getInput('fullarticle');\n\n        // This will automatically send us to the correct main page in any language (try it!)\n        $html = getSimpleHTMLDOM($this->getURI() . '/wiki');\n\n        /*\n        * Now read content depending on the language (make sure to create one function per language!)\n        * We build the function name automatically, just make sure you create a private function ending\n        * with your desired language code, where the language code is upper case! (en -> getContentsEN).\n        */\n        $function = 'getContents' . ucfirst(strtolower($this->getInput('language')));\n\n        if (!method_exists($this, $function)) {\n            throwServerException('A function to get the contents for your language is missing (\\'' . $function . '\\')!');\n        }\n\n        /*\n        * The method takes care of creating all items.\n        */\n        $this->$function($html, $subject, $fullArticle);\n    }\n\n    /**\n    * Replaces all relative URIs with absolute ones\n    * @param $element A simplehtmldom element\n    * @return The $element->innertext with all URIs replaced\n    */\n    private function replaceUriInHtmlElement($element)\n    {\n        return str_replace('href=\"/', 'href=\"' . $this->getURI() . '/', $element->innertext);\n    }\n\n    /*\n    * Adds a new item to $items using a generic operation (should work for most\n    * (all?) wikis) $anchorText can be specified if the wiki in question doesn't\n    * use '...' (like Dutch, French and Italian) $anchorFallbackIndex can be\n    * used to specify a different fallback link than the first\n    * (e.g., -1 for the last)\n    */\n    private function addTodaysFeaturedArticleGeneric(\n        $element,\n        $fullArticle,\n        $anchorText = '...',\n        $anchorFallbackIndex = 0\n    ) {\n        // Clean the bottom of the featured article\n        if ($element->find('ul', -1)) {\n            $element->find('ul', -1)->outertext = '';\n        } elseif ($element->find('div', -1)) {\n            $element->find('div', -1)->outertext = '';\n        }\n\n        // The title and URI of the article can be found in an anchor containing\n        // the string '...' in most wikis ('full article ...')\n        $target = $element->find('p a', $anchorFallbackIndex);\n        foreach ($element->find('//a') as $anchor) {\n            if (strpos($anchor->innertext, $anchorText) !== false) {\n                $target = $anchor;\n                break;\n            }\n        }\n\n        $item = [];\n        $item['uri'] = $this->getURI() . $target->href;\n        $item['title'] = $target->title;\n\n        if (!$fullArticle) {\n            $item['content'] = strip_tags($this->replaceUriInHtmlElement($element), '<a><p><br><img>');\n        } else {\n            $item['content'] = $this->loadFullArticle($item['uri']);\n        }\n\n        $this->items[] = $item;\n    }\n\n    /*\n    * Adds a new item to $items using a generic operation (should work for most (all?) wikis)\n    */\n    private function addDidYouKnowGeneric($element, $fullArticle)\n    {\n        foreach ($element->find('ul', 0)->find('li') as $entry) {\n            $item = [];\n\n            // We can only use the first anchor, there is no way of finding the 'correct' one if there are multiple\n            $item['uri'] = $this->getURI() . $entry->find('a', 0)->href;\n            $item['title'] = strip_tags($entry->innertext);\n\n            if (!$fullArticle) {\n                $item['content'] = $this->replaceUriInHtmlElement($entry);\n            } else {\n                $item['content'] = $this->loadFullArticle($item['uri']);\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n    * Loads the full article from a given URI\n    */\n    private function loadFullArticle($uri)\n    {\n        $content_html = getSimpleHTMLDOMCached($uri);\n\n        if (!$content_html) {\n            throwServerException('Could not load site: ' . $uri . '!');\n        }\n\n        $content = $content_html->find('#mw-content-text', 0);\n\n        if (!$content) {\n            throwServerException('Could not find content in page: ' . $uri . '!');\n        }\n\n        // Let's remove a couple of things from the article\n        $table = $content->find('#toc', 0); // Table of contents\n        if (!$table === false) {\n            $table->outertext = '';\n        }\n\n        foreach ($content->find('ol.references') as $reference) { // References\n            $reference->outertext = '';\n        }\n\n        return str_replace('href=\"/', 'href=\"' . $this->getURI() . '/', $content->innertext);\n    }\n\n    /**\n    * Implementation for de.wikipedia.org\n    */\n    private function getContentsDe($html, $subject, $fullArticle)\n    {\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $element = $html->find('div[id=artikel] div.hauptseite-box-content', 0);\n                $this->addTodaysFeaturedArticleGeneric($element, $fullArticle);\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $element = $html->find('div[id=wissenswertes]', 0);\n                $this->addDidYouKnowGeneric($element, $fullArticle);\n                break;\n            default:\n                break;\n        }\n    }\n\n    /**\n    * Implementation for fr.wikipedia.org\n    */\n    private function getContentsFr($html, $subject, $fullArticle)\n    {\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $element = $html->find('div[class=accueil_2017_cadre]', 0);\n                $this->addTodaysFeaturedArticleGeneric($element, $fullArticle, 'Lire la suite');\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $element = $html->find('div[class=accueil_2017_cadre]', 2);\n                $this->addDidYouKnowGeneric($element, $fullArticle);\n                break;\n            default:\n                break;\n        }\n    }\n\n    /**\n    * Implementation for en.wikipedia.org\n    */\n    private function getContentsEn($html, $subject, $fullArticle)\n    {\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $element = $html->find('div[id=mp-tfa]', 0);\n                $this->addTodaysFeaturedArticleGeneric($element, $fullArticle, '...', -1);\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $element = $html->find('div[id=mp-dyk]', 0);\n                $this->addDidYouKnowGeneric($element, $fullArticle);\n                break;\n            default:\n                break;\n        }\n    }\n\n    /**\n    * Implementation for ru.wikipedia.org\n    */\n    private function getContentsRu($html, $subject, $fullArticle)\n    {\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $element = $html->find('div[id=main-tfa]', 0);\n                $this->addTodaysFeaturedArticleGeneric($element, $fullArticle, '...', -1);\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $element = $html->find('div[id=main-dyk]', 0);\n                $this->addDidYouKnowGeneric($element, $fullArticle);\n                break;\n            default:\n                break;\n        }\n    }\n\n    /**\n    * Implementation for eo.wikipedia.org\n    */\n    private function getContentsEo($html, $subject, $fullArticle)\n    {\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $element = $html->find('div[id=mf-artikolo-de-la-monato]', 0);\n                $element->find('div', -2)->outertext = '';\n                $this->addTodaysFeaturedArticleGeneric($element, $fullArticle);\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $element = $html->find('div.hp', 1)->find('table', 4)->find('td', -1);\n                $this->addDidYouKnowGeneric($element, $fullArticle);\n                break;\n            default:\n                break;\n        }\n    }\n\n    /**\n    * Implementation for nl.wikipedia.org\n    */\n    private function getContentsNl($html, $subject, $fullArticle)\n    {\n        switch ($subject) {\n            case WIKIPEDIA_SUBJECT_TFA:\n                $element = $html->find('td[id=segment-Uitgelicht] div', 0);\n                $element->find('p', 1)->outertext = '';\n                $this->addTodaysFeaturedArticleGeneric($element, $fullArticle, 'Lees verder');\n                break;\n            case WIKIPEDIA_SUBJECT_DYK:\n                $element = $html->find('td[id=segment-Wist_je_dat] div', 0);\n                $this->addDidYouKnowGeneric($element, $fullArticle);\n                break;\n            default:\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/WirecutterDealsBridge.php",
    "content": "<?php\n\nclass WirecutterDealsBridge extends BridgeAbstract\n{\n    const NAME = 'Wirecutter Deals';\n    const URI = 'https://www.nytimes.com/wirecutter/deals/';\n    const DESCRIPTION = 'Deals from The Wirecutter';\n    const MAINTAINER = 'Vynce';\n    const CACHE_TIMEOUT = 900; // 15 minutes\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n        $json = $html->getElementById('__NEXT_DATA__');\n        $data = json_decode($json->innertext());\n\n        foreach ($data->props->pageProps->specialEvent->eventDeals as $deal) {\n            $item = [];\n            $item['uri'] = \"https://www.nytimes.com/wirecutter/deals/#deal-{$deal->id}\";\n            $item['title'] = $deal->title;\n            $item['timestamp'] = $deal->date;\n            $item['content'] = $this->generateContent($deal);\n            $item['categories'] = $deal->categories;\n            $item['uid'] = strval($deal->id);\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getIcon()\n    {\n        return <<<'EOD'\n        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAD7klEQ\n        VR4AWIgFoReXcVmdnKRuymg9WoAkiUJon2+8BnhiLP17bNtX+Bs27aN0bdtY8y1bZu9m1evIrZH\n        VTW9HVsVuTNTndH5qirz5VuP7ddL3LZdk9y2AmZdk9z2TvaZP8lj3znFY/95ssdxxWPB4GHaRI1\n        Lgq6T2ct/RyAWmExa+ySP7acZMecJlgPPK7cdOdlj/5i9qFcdTG44Ifb5Lk7Pwq5tXnUA8zbZbd\n        s7O/zf8aaCT/U6zmc7r7ESaFZwAd1fvpmuzVkhel4xzeM8K+POZcHnhBbSQ5Vb6fmmffQAC3J5d\n        IkQxDSfk5YNF9O2kQqazQClPC+fF1x4nPTOZcf+epuPemmA9JQZoXr6oCtMN+StSvL/tT+XP9+k\n        lwnywr5HWCVIOFHwZxv3km5iVlE7rWMBd7CdD7OJtUEaoul+lyAn7G8Kkk6c7cXUQrrFCSAz0gH\n        AOqb4HMcaAFDnwuz12I2XbdXL6f2uEP09kE+e0RoaYjMTgC16uaJE7d8ZDKcimTJqITcLmJrtj1\n        Rto6fq99CSoSLqor604CGqkyYqjG26+U5aeogGelWV1oMVW2j1cAn/fnX2Mp7hP7EkY+XK12YG5\n        vMkvK1oHT3dsIfWDpeiSnB6mfnBb5+jgdszOV4WWUwvt7ipm/pwFXztqqxl9HVfNl97s91n+L7a\n        6jHPHZ7/vtHQWDI5ftITNbL62cZ99MdAXmIOWAbAEn+Dhq6mcsJdJ9b93aUbUkvTAIBjXzRUhBw\n        x2ysiANAldBCU4UfdYazhrtMAICg4AL9zqdFIQLXZGzVVBdxetC4xEGhYCuDdzkDiGq7JzAk04A\n        TyZQ5IvISX4n6lALAOWsbvPwfyTVQB7L+wBiUjcQDxCHYlBQAbuybDrsleriKj9RpklMzhxeYDS\n        YFiVC8HIKiCK2NL6bPemIqMvtSg4WQOd5asT2U4gDIA9NMgjhucIAQAUsJvBYCZGlojmoMkSaiB\n        OpMAgHa/68/G9UAjGL6JAKAJ7IMF3P/G/FXSCviAPjhYw4CAlKF8oWm/sNE4Bwvp3rJNdGlkkQH\n        2utwVnKrrqIP7+Edr1cc/NqBeZXyAF2/Uy5RdD2IFrTe1Fd/HAEp23zZr34KjtcQB9cpMKrPQWv\n        VxzHc6AgqRan9F+I8H1KuCNOi1Vi+1ULcycBN10ZP1u1Wlt423YdGAdIZ6haPqNB6r3cWFye6RS\n        l6ae0eqyDFYCH1gSDCJFfGjVw1IZ6hXZjTBVnSx23WqZmZAOkO9TlRwHLt855IBfoB6NTjCktnb\n        kHD8zq0OqFcISGi4cQRuRJ1Ldm1tYBfQcJBRUDIQEwiEloquhsaCoKBXznAmx/9MoY9pELa5wwA\n        AAABJRU5ErkJggg==\n        EOD;\n    }\n\n    private function jsonToHtml($node)\n    {\n        if ($node['type'] == 'text') {\n            return htmlspecialchars($node['data'], ENT_QUOTES, 'UTF-8');\n        }\n\n        if ($node['type'] == 'tag') {\n            $html = \"<{$node['name']}\";\n\n            foreach ($node['attribs'] as $key => $value) {\n                $html .= sprintf(\n                    ' %s=\"%s\"',\n                    htmlspecialchars($key, ENT_QUOTES, 'UTF-8'),\n                    htmlspecialchars($value, ENT_QUOTES, 'UTF-8')\n                );\n            }\n\n            $html .= '>';\n\n            foreach ($node['children'] as $child) {\n                $html .= $this->jsonToHtml($child);\n            }\n\n            $html .= \"</{$node['name']}>\";\n\n            return $html;\n        }\n\n        return '';\n    }\n\n    private function generateContent($deal)\n    {\n        $img_link = $deal->image->source;\n        $content = \"<p><img src=\\\"https://cdn.thewirecutter.com/{$img_link}?width=314&amp;quality=75&amp;crop=3:2&amp;auto=webp\\\"></p>\";\n\n        $content .= \"<p><strong>\\${$deal->price}</strong> <del>\\${$deal->streetPrice}</del></p>\";\n\n        foreach ($deal->buyButtons as $buy) {\n            $content .= \"<p>Buy from <a href=\\\"{$buy->url}\\\">$buy->merchant</a>\";\n            if ($buy->promo->effect) {\n                $content .= \" {$buy->promo->effect}\";\n            }\n            if ($buy->promo->code) {\n                $content .= \" (Use promo code {$buy->promo->code})\";\n            }\n            $content .= '</p>';\n        }\n\n        $content .= '<p>&nbsp;</p>';\n        $structuredContent = json_decode($deal->structuredContent, true);\n        foreach ($structuredContent as $node) {\n            $content .= $this->jsonToHtml($node);\n        }\n\n        if ($deal->relatedArticle) {\n            $review = $deal->relatedArticle;\n            $content .= '<p>&nbsp;</p>';\n            $content .= \"<p>Read the review: <a href=\\\"https://www.nytimes.com/wirecutter{$review->link}\\\">{$review->title}</a></p>\";\n        }\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/WiredBridge.php",
    "content": "<?php\n\nclass WiredBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'WIRED';\n    const URI = 'https://www.wired.com/';\n    const DESCRIPTION = 'Returns the newest articles from WIRED';\n\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed',\n            'type' => 'list',\n            'values' => [\n                'WIRED Top Stories' => 'rss',           // /feed/rss\n                'Business' => 'business',               // /feed/category/business/latest/rss\n                'Culture' => 'culture',                 // /feed/category/culture/latest/rss\n                'Gear' => 'gear',                       // /feed/category/gear/latest/rss\n                'Ideas' => 'ideas',                     // /feed/category/ideas/latest/rss\n                'Science' => 'science',                 // /feed/category/science/latest/rss\n                'Security' => 'security',               // /feed/category/security/latest/rss\n                'Transportation' => 'transportation',   // /feed/category/transportation/latest/rss\n                'Backchannel' => 'backchannel',         // /feed/category/backchannel/latest/rss\n                'WIRED Guides' => 'wired-guide',        // /feed/tag/wired-guide/latest/rss\n                'Photo' => 'photo'                      // /feed/category/photo/latest/rss\n            ]\n        ],\n        'limit' => self::LIMIT,\n    ]];\n\n    public function collectData()\n    {\n        $feed = $this->getInput('feed');\n        if (empty($feed) || !ctype_alpha(str_replace('-', '', $feed))) {\n            throwClientException('Invalid feed, please check the \"feed\" parameter.');\n        }\n\n        $feed_url = $this->getURI() . 'feed/';\n        if ($feed != 'rss') {\n            if ($feed != 'wired-guide') {\n                $feed_url .= 'category/';\n            } else {\n                $feed_url .= 'tag/';\n            }\n            $feed_url .= \"$feed/latest/\";\n        }\n        $feed_url .= 'rss';\n\n        $limit = $this->getInput('limit') ?? -1;\n        $this->collectExpandableDatas($feed_url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $originalContent = $item['content'];\n\n        $article = getSimpleHTMLDOMCached($item['uri']);\n        $item['content'] = $this->extractArticleContent($article);\n\n        $headline = $originalContent;\n        if (!empty($headline)) {\n            $item['content'] = '<p><b>' . $headline . '</b></p>' . $item['content'];\n        }\n\n        $item_image = $article->find('meta[property=\"og:image\"]', 0);\n        if (!empty($item_image)) {\n            $item['enclosures'] = [$item_image->content];\n            $item['content'] = '<p><img src=\"' . $item_image->content . '\" /></p>' . $item['content'];\n        }\n\n        return $item;\n    }\n\n    private function extractArticleContent($article)\n    {\n        $content = $article->find('article', 0);\n        $truncate = true;\n\n        if (empty($content)) {\n            $content = $article->find('div.listicle-main-component__container', 0);\n            $truncate = false;\n        }\n\n        if (!empty($content)) {\n            $content = $content->innertext;\n        }\n\n        foreach (\n            [\n            '<div class=\"content-header',\n            '<div class=\"mid-banner-wrap',\n            '<div class=\"related',\n            '<div class=\"social-icons',\n            '<div class=\"recirc-most-popular',\n            '<div class=\"grid--item article-related-video',\n            '<div class=\"row full-bleed-ad',\n            ] as $div_start\n        ) {\n            $content = stripRecursiveHTMLSection($content, 'div', $div_start);\n        }\n\n        if ($truncate) {\n            //Clutter after standard article is too hard to clean properly\n            $content = trim(explode('<hr', $content)[0]);\n        }\n\n        $content = str_replace('href=\"/', 'href=\"' . $this->getURI() . '/', $content);\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/WordPressBridge.php",
    "content": "<?php\n\nclass WordPressBridge extends FeedExpander\n{\n    const NAME = 'Wordpress';\n    const URI = 'https://wordpress.org/';\n    const DESCRIPTION = 'Returns the newest full posts of a WordPress powered website';\n    const MAINTAINER = 'ORelio';\n\n    const PARAMETERS = [ [\n        'url' => [\n            'name' => 'Blog URL',\n            'exampleValue' => 'https://wordpress.org/',\n            'required' => true\n        ],\n        'limit' => self::LIMIT,\n        'content-selector' => [\n            'name' => 'Content Selector (Optional - Advanced users)',\n            'exampleValue' => '.custom-article-class',\n        ],\n    ]];\n\n    public function collectData()\n    {\n        $limit = $this->getInput('limit') ?? 10;\n        if ($this->getInput('url') && substr($this->getInput('url'), 0, strlen('http')) !== 'http') {\n            // just in case someone find a way to access local files by playing with the url\n            throwClientException('The url parameter must either refer to http or https protocol.');\n        }\n        try {\n            $this->collectExpandableDatas($this->getURI() . '/feed/atom/', $limit);\n        } catch (Exception $e) {\n            $this->collectExpandableDatas($this->getURI() . '/?feed=atom', $limit);\n        }\n    }\n\n    protected function parseItem(array $item)\n    {\n        $dom = getSimpleHTMLDOMCached($item['uri']);\n\n        // Find article body\n        $article = null;\n        switch (true) {\n            case !empty($this->getInput('content-selector')):\n                // custom contect selector (manually specified by user)\n                $article = $dom->find($this->getInput('content-selector'), 0);\n                break;\n            case !is_null($dom->find('[itemprop=articleBody]', 0)):\n                // highest priority content div (used for SEO)\n                $article = $dom->find('[itemprop=articleBody]', 0);\n                break;\n            case !is_null($dom->find('.article-content', 0)):\n                // more precise than article when present\n                $article = $dom->find('.article-content', 0);\n                break;\n            case !is_null($dom->find('article', 0)):\n                // most common content div\n                $article = $dom->find('article', 0);\n                break;\n            case !is_null($dom->find('.single-content', 0)):\n                // another common content div\n                $article = $dom->find('.single-content', 0);\n                break;\n            case !is_null($dom->find('.post-content', 0)):\n                // another common content div\n                $article = $dom->find('.post-content', 0);\n                break;\n            case !is_null($dom->find('.post', 0)):\n                // for old WordPress themes without HTML5\n                $article = $dom->find('.post', 0);\n                break;\n        }\n\n        // Remove duplicate title from content\n        foreach ($article->find('h1') as $title) {\n            if (trim(html_entity_decode($title->plaintext) == $item['title'])) {\n                $title->outertext = '';\n            }\n        }\n\n        // Find article main image\n        $article = convertLazyLoading($article);\n        $article_image = $dom->find('img.wp-post-image', 0);\n        if (!empty($item['content']) && (!is_object($article_image) || empty($article_image->src))) {\n            $article_image = str_get_html($item['content'])->find('img.wp-post-image', 0);\n        }\n        if (is_object($article_image) && !empty($article_image->src)) {\n            $article_image = $article_image->src;\n            $mime_type = parse_mime_type($article_image);\n            if (strpos($mime_type, 'image') === false) {\n                $article_image .= '#.image'; // force image\n            }\n            if (empty($item['enclosures'])) {\n                $item['enclosures'] = [$article_image];\n            } else {\n                $item['enclosures'] = array_merge($item['enclosures'], (array) $article_image);\n            }\n        }\n\n        // Unwrap images figures\n        foreach ($article->find('figure.wp-block-image') as $figure) {\n            $figure->outertext = $figure->innertext;\n        }\n\n        if (!is_null($article)) {\n            $item['content'] = $this->cleanContent($article->innertext);\n            $item['content'] = defaultLinkTo($item['content'], $item['uri']);\n        }\n\n        return $item;\n    }\n\n    private function cleanContent($content)\n    {\n        $content = stripWithDelimiters($content, '<script', '</script>');\n        $content = preg_replace('/<div class=\"wpa\".*/', '', $content);\n        $content = preg_replace('/<form.*\\/form>/', '', $content);\n        return $content;\n    }\n\n    public function getURI()\n    {\n        $url = $this->getInput('url');\n        if (empty($url)) {\n            $url = parent::getURI();\n        }\n        return $url;\n    }\n}\n"
  },
  {
    "path": "bridges/WordPressMadaraBridge.php",
    "content": "<?php\n\n/**\n * This bridge currently parses only chapter lists, but it can be further\n * extended to extract a list of manga titles using the implementation in this\n * project as a reference: https://github.com/manga-download/hakuneko\n*/\nclass WordPressMadaraBridge extends BridgeAbstract\n{\n    const URI = 'https://live.mangabooth.com/';\n    const NAME = 'WordPress Madara';\n    const DESCRIPTION = 'Returns latest chapters published through the Madara Manga theme.\nThe default URI shows the Madara demo page.';\n    const PARAMETERS = [\n        'Manga Chapters' => [\n            'url' => [\n                'name' => 'Manga URL',\n                'exampleValue' => 'https://live.mangabooth.com/manga/manga-text-chapter/',\n                'required' => true\n            ]\n        ]\n    ];\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'Manga Chapters':\n                $mangaInfo = $this->getMangaInfo($this->getInput('url'));\n                return $mangaInfo['title'];\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getURI()\n    {\n        return $this->getInput('url') ?? self::URI;\n    }\n\n    public function collectData()\n    {\n        $html = $this->queryAjaxChapters();\n\n        // Check if the list subcategorizes by volume\n        $volumes = $html->find('ul.volumns', 0);\n        if ($volumes) {\n            $this->parseVolumes($volumes);\n        } else {\n            $this->parseChapterList($html, null);\n        }\n    }\n\n    protected function queryAjaxChaptersNew()\n    {\n        $uri = rtrim($this->getInput('url'), '/') . '/ajax/chapters/';\n        $headers = [];\n        $opts = [CURLOPT_POST => 1];\n        return str_get_html(getContents($uri, $headers, $opts));\n    }\n\n    protected function queryAjaxChaptersOld()\n    {\n        $mangaInfo = $this->getMangaInfo($this->getInput('url'));\n        $uri = rtrim($mangaInfo['root'], '/') . '/wp-admin/admin-ajax.php';\n        $headers = [];\n        $opts = [CURLOPT_POSTFIELDS => [\n            'action' => 'manga_get_chapters',\n            'manga' => $mangaInfo['id']\n        ]];\n        return str_get_html(getContents($uri, $headers, $opts));\n    }\n\n    protected function queryAjaxChapters()\n    {\n        $new = $this->queryAjaxChaptersNew();\n        if ($new->find('.wp-manga-chapter')) {\n            return $new;\n        } else {\n            return $this->queryAjaxChaptersOld();\n        }\n    }\n\n    protected function parseVolumes($volumes)\n    {\n        foreach ($volumes->children(-1) as $volume) {\n            $volume_name = trim($volume->find('a.has-child', 0)->plaintext);\n            $this->parseChapterList($volume->find('ul', -1), $volume_name);\n        }\n    }\n\n    protected function parseChapterList($chapters, $volume)\n    {\n        $mangaInfo = $this->getMangaInfo($this->getInput('url'));\n        foreach ($chapters->find('li.wp-manga-chapter') as $chap) {\n            $link = $chap->find('a', 0);\n\n            $item = [];\n            $item['title'] = ($volume ?? '') . ' ' . trim($link->plaintext);\n            $item['uri'] = $link->href;\n            $item['uid'] = $link->href;\n            $item['timestamp'] = $chap->find('span.chapter-release-date', 0)->plaintext;\n            $item['author'] = $mangaInfo['author'] ?? null;\n            $item['categories'] = $mangaInfo['categories'] ?? null;\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Retrieves manga info from cache or title page.\n     * The returned array contains 'title', 'author', and 'categories' keys for use in feed items.\n     * The 'id' key contains the manga title id, used for the old ajax api.\n     * The 'root' key contains the website root.\n     *\n     * @param $url\n     * @return array\n     */\n    protected function getMangaInfo($url)\n    {\n        $url_cache = 'TitleInfo_' . preg_replace('/[^\\w]/', '.', rtrim($url, '/'));\n        $cache = $this->loadCacheValue($url_cache);\n        if ($cache) {\n            return $cache;\n        }\n\n        $info = [];\n        $html = getSimpleHTMLDOMCached($url);\n\n        $info['title'] = html_entity_decode($html->find('*[property=og:title]', 0)->content);\n        $author = $html->find('.author-content', 0);\n        if (!is_null($author)) {\n            $info['author'] = trim($author->plaintext);\n        }\n        $cats = $html->find('.genres-content', 0);\n        if (!is_null($cats)) {\n            $info['categories'] = explode(', ', trim($cats->plaintext));\n        }\n\n        $info['id'] = $html->find('#manga-chapters-holder', 0)->getAttribute('data-id');\n        // It's possible to find this from the input parameters, but it is already available here.\n        $info['root'] = $html->find('a.logo', 0)->href;\n\n        $this->saveCacheValue($url_cache, $info);\n        return $info;\n    }\n}\n"
  },
  {
    "path": "bridges/WordPressPluginUpdateBridge.php",
    "content": "<?php\n\nfinal class WordPressPluginUpdateBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'dvikan';\n    const NAME = 'WordPress Plugins Update';\n    const URI = 'https://wordpress.org/plugins/';\n    const DESCRIPTION = 'Returns latest updates of wordpress.org plugins.';\n\n    const PARAMETERS = [\n        [\n            // The incorrectly named pluginUrl is kept for BC\n            'pluginUrl' => [\n                'name' => 'Plugin slug',\n                'exampleValue' => 'akismet',\n                'required' => true,\n                'title' => 'Slug or url',\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $input = trim($this->getInput('pluginUrl'));\n        if (preg_match('#https://wordpress\\.org/plugins/([\\w-]+)#', $input, $m)) {\n            $slug = $m[1];\n        } else {\n            $slug = str_replace(['/'], '', $input);\n        }\n\n        $pluginData = self::fetchPluginData($slug);\n\n        if ($pluginData->versions === []) {\n            throw new \\Exception('This plugin does not have versioning data');\n        }\n\n        // We don't need trunk. I think it's the latest commit.\n        unset($pluginData->versions->trunk);\n\n        foreach ($pluginData->versions as $version => $downloadUrl) {\n            $this->items[] = [\n                'title'     => $version,\n                'uri'       => sprintf('https://wordpress.org/plugins/%s/#developers', $slug),\n                'uid'       => $downloadUrl,\n            ];\n        }\n\n        usort($this->items, function ($a, $b) {\n            return version_compare($b['title'], $a['title']);\n        });\n    }\n\n    /**\n     * Fetch plugin data from wordpress.org json api\n     *\n     * https://codex.wordpress.org/WordPress.org_API#Plugins\n     * https://wordpress.org/support/topic/using-the-wordpress-org-api/\n     */\n    private static function fetchPluginData(string $slug): \\stdClass\n    {\n        $api = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request[slug]=%s';\n        return json_decode(getContents(sprintf($api, $slug)));\n    }\n}\n"
  },
  {
    "path": "bridges/WorldOfTanksBridge.php",
    "content": "<?php\n\nclass WorldOfTanksBridge extends FeedExpander\n{\n    const MAINTAINER = 'Riduidel';\n    const NAME = 'World of Tanks';\n    const URI = 'https://worldoftanks.eu/';\n    const DESCRIPTION = 'News about the tank slaughter game.';\n\n    const PARAMETERS = [ [\n        'lang' => [\n            'name' => 'Langue',\n            'type' => 'list',\n            'values' => [\n                'Français' => 'fr',\n                'English' => 'en',\n                'Español' => 'es',\n                'Deutsch' => 'de',\n                'Čeština' => 'cs',\n                'Polski' => 'pl',\n                'Türkçe' => 'tr'\n            ]\n        ]\n    ]];\n\n    const POSSIBLE_ARTICLES = ['article', 'rich-article'];\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas(sprintf('https://worldoftanks.eu/%s/rss/news/', $this->getInput('lang')));\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['content'] = $this->loadFullArticle($item['uri']);\n        return $item;\n    }\n\n    /**\n     * Loads the full article and returns the contents\n     * @param $uri The article URI\n     * @return The article content\n     */\n    private function loadFullArticle($uri)\n    {\n        $html = getSimpleHTMLDOMCached($uri);\n\n        foreach (self::POSSIBLE_ARTICLES as $article_class) {\n            $content = $html->find('article', 0);\n\n            if ($content !== null) {\n                // Remove the scripts, please\n                foreach ($content->find('script') as $script) {\n                    $script->outertext = '';\n                }\n                return $content->innertext;\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "bridges/WorldbankBridge.php",
    "content": "<?php\n\nclass WorldbankBridge extends BridgeAbstract\n{\n    const NAME = 'World Bank Group';\n    const URI = 'https://www.worldbank.org/en/news/all';\n    const DESCRIPTION = 'Return articles from The World Bank Group All News';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'lang' => [\n                'name' => 'Language',\n                'type' => 'list',\n                'defaultValue' => 'English',\n                'values' => [\n                    'English' => 'English',\n                    'French' => 'French',\n                ]\n            ],\n            'limit' => [\n                'name' => 'limit (max 100)',\n                'type' => 'number',\n                'defaultValue' => 5,\n                'required' => true,\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $apiUrl = 'https://search.worldbank.org/api/v2/news?format=json&rows='\n            . min(100, $this->getInput('limit'))\n            . '&lang_exact=' . $this->getInput('lang');\n\n        $jsonData = json_decode(getContents($apiUrl));\n\n        // Remove unnecessary data from the original object\n        if (isset($jsonData->documents->facets)) {\n            unset($jsonData->documents->facets);\n        }\n\n        foreach ($jsonData->documents as $element) {\n            $this->items[] = [\n                'uid' => $element->id,\n                'timestamp' => $element->lnchdt,\n                'title' => $element->title->{'cdata!'},\n                'uri' => $element->url,\n                'content' => $element->descr->{'cdata!'},\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/XPathBridge.php",
    "content": "<?php\n\nclass XPathBridge extends XPathAbstract\n{\n    const NAME = 'XPathBridge';\n    const URI = 'https://github.com/rss-bridge/rss-bridge';\n    const DESCRIPTION\n        = 'Parse any webpage using <a href=\"https://devhints.io/xpath\" target=\"_blank\">XPath expressions</a>';\n    const MAINTAINER = 'Niehztog';\n    const PARAMETERS = [\n        '' => [\n\n            'url' => [\n                'name' => 'Enter web page URL',\n                'title' => <<<'EOL'\nYou can specify any website URL which serves data suited for display in RSS feeds\n(for example a news blog).\nEOL,\n                'type' => 'text',\n                'exampleValue' => 'https://news.blizzard.com/en-en',\n                'defaultValue' => 'https://news.blizzard.com/en-en',\n                'required' => true\n            ],\n\n            'item' => [\n                'name' => 'Item selector',\n                'title' => <<<'EOL'\nEnter an XPath expression matching a list of dom nodes, each node containing one\nfeed article item in total (usually a surrounding &lt;div&gt; or &lt;span&gt; tag). This will\nbe the context nodes for all of the following expressions. This expression usually\nstarts with a single forward slash.\nEOL,\n                'type' => 'text',\n                'exampleValue' => '/html/body/div/div[4]/div[2]/div[2]/div/div/section/ol/li/article',\n                'defaultValue' => '/html/body/div/div[4]/div[2]/div[2]/div/div/section/ol/li/article',\n                'required' => true\n            ],\n\n            'title' => [\n                'name' => 'Item title selector',\n                'title' => <<<'EOL'\nThis expression should match a node contained within each article item node\ncontaining the article headline. It should start with a dot followed by two\nforward slashes, referring to any descendant nodes of the article item node.\nEOL,\n                'type' => 'text',\n                'exampleValue' => './/div/div[2]/h2',\n                'defaultValue' => './/div/div[2]/h2',\n                'required' => true\n            ],\n\n            'content' => [\n                'name' => 'Item description selector',\n                'title' => <<<'EOL'\nThis expression should match a node contained within each article item node\ncontaining the article content or description. It should start with a dot\nfollowed by two forward slashes, referring to any descendant nodes of the\narticle item node.\nEOL,\n                'type' => 'text',\n                'exampleValue' => './/div[@class=\"ArticleListItem-description\"]/div[@class=\"h6\"]',\n                'defaultValue' => './/div[@class=\"ArticleListItem-description\"]/div[@class=\"h6\"]',\n                'required' => false\n            ],\n\n            'raw_content' => [\n                'name' => 'Use raw item description',\n                'title' => <<<'EOL'\n                Whether to use the raw item description or to replace certain characters with\n                special significance in HTML by HTML entities (using the PHP function htmlspecialchars).\n                EOL,\n                'type' => 'checkbox',\n                'defaultValue' => false,\n                'required' => false\n            ],\n\n            'uri' => [\n                'name' => 'Item URL selector',\n                'title' => <<<'EOL'\nThis expression should match a node's attribute containing the article URL\n(usually the href attribute of an &lt;a&gt; tag). It should start with a dot\nfollowed by two forward slashes, referring to any descendant nodes of\nthe article item node. Attributes can be selected by prepending an @ char\nbefore the attributes name.\nEOL,\n                'type' => 'text',\n                'exampleValue' => './/a[@class=\"ArticleLink ArticleLink\"]/@href',\n                'defaultValue' => './/a[@class=\"ArticleLink ArticleLink\"]/@href',\n                'required' => false\n            ],\n\n            'author' => [\n                'name' => 'Item author selector',\n                'title' => <<<'EOL'\nThis expression should match a node contained within each article item\nnode containing the article author's name. It should start with a dot\nfollowed by two forward slashes, referring to any descendant nodes of\nthe article item node.\nEOL,\n                'type' => 'text',\n                'required' => false\n            ],\n\n            'timestamp' => [\n                'name' => 'Item date selector',\n                'title' => <<<'EOL'\nThis expression should match a node or node's attribute containing the\narticle timestamp or date (parsable by PHP's strtotime function). It\nshould start with a dot followed by two forward slashes, referring to\nany descendant nodes of the article item node. Attributes can be\nselected by prepending an @ char before the attributes name.\nEOL,\n                'type' => 'text',\n                'exampleValue' => './/time[@class=\"ArticleListItem-footerTimestamp\"]/@timestamp',\n                'defaultValue' => './/time[@class=\"ArticleListItem-footerTimestamp\"]/@timestamp',\n                'required' => false\n            ],\n\n            'enclosures' => [\n                'name' => 'Item image selector',\n                'title' => <<<'EOL'\nThis expression should match a node's attribute containing an article\nimage URL (usually the src attribute of an &lt;img&gt; tag or a style\nattribute). It should start with a dot followed by two forward slashes,\nreferring to any descendant nodes of the article item node. Attributes\ncan be selected by prepending an @ char before the attributes name.\nEOL,\n                'type' => 'text',\n                'exampleValue' => './/div[@class=\"ArticleListItem-image\"]/@style',\n                'defaultValue' => './/div[@class=\"ArticleListItem-image\"]/@style',\n                'required' => false\n            ],\n\n            'categories' => [\n                'name' => 'Item category selector',\n                'title' => <<<'EOL'\nThis expression should match a node or node's attribute contained\nwithin each article item node containing the article category. This\ncould be inside &lt;div&gt; or &lt;span&gt; tags or sometimes be hidden\nin a data attribute. It should start with a dot followed by two\nforward slashes, referring to any descendant nodes of the article\nitem node. Attributes can be selected by prepending an @ char\nbefore the attributes name.\nEOL,\n                'type' => 'text',\n                'exampleValue' => './/div[@class=\"ArticleListItem-label\"]',\n                'defaultValue' => './/div[@class=\"ArticleListItem-label\"]',\n                'required' => false\n            ],\n\n            'fix_encoding' => [\n                'name' => 'Fix encoding',\n                'title' => <<<'EOL'\nCheck this to fix feed encoding by invoking PHP's utf8_decode\nfunction on all extracted texts. Try this in case you see \"broken\" or\n\"weird\" characters in your feed where you'd normally expect umlauts\nor any other non-ascii characters.\nEOL,\n                'type' => 'checkbox',\n                'required' => false\n            ],\n\n        ]\n    ];\n\n    /**\n     * Source Web page URL (should provide either HTML or XML content)\n     * @return string\n     */\n    protected function getSourceUrl(): string\n    {\n        return $this->encodeUri($this->getInput('url') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting the feed items from the source page\n     * @return string\n     */\n    protected function getExpressionItem(): string\n    {\n        return urldecode($this->getInput('item') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting an item title from the item context\n     * @return string\n     */\n    protected function getExpressionItemTitle(): string\n    {\n        return urldecode($this->getInput('title') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting an item's content from the item context\n     * @return string\n     */\n    protected function getExpressionItemContent(): string\n    {\n        return urldecode($this->getInput('content') ?? '');\n    }\n\n    /**\n     * Use raw item content\n     * @return bool\n     */\n    protected function getSettingUseRawItemContent(): bool\n    {\n        return $this->getInput('raw_content');\n    }\n\n    /**\n     * XPath expression for extracting an item link from the item context\n     * @return string\n     */\n    protected function getExpressionItemUri(): string\n    {\n        return urldecode($this->getInput('uri') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting an item author from the item context\n     * @return string\n     */\n    protected function getExpressionItemAuthor(): string\n    {\n        return urldecode($this->getInput('author') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting an item timestamp from the item context\n     * @return string\n     */\n    protected function getExpressionItemTimestamp(): string\n    {\n        return urldecode($this->getInput('timestamp') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting item enclosures (media content like\n     * images or movies) from the item context\n     * @return string\n     */\n    protected function getExpressionItemEnclosures(): string\n    {\n        return urldecode($this->getInput('enclosures') ?? '');\n    }\n\n    /**\n     * XPath expression for extracting an item category from the item context\n     * @return string\n     */\n    protected function getExpressionItemCategories(): string\n    {\n        return urldecode($this->getInput('categories') ?? '');\n    }\n\n    /**\n     * Fix encoding\n     * @return bool\n     */\n    protected function getSettingFixEncoding(): bool\n    {\n        return $this->getInput('fix_encoding');\n    }\n\n    /**\n     * Fixes URL encoding issues in input URL's\n     * @param $uri\n     * @return string|string[]\n     */\n    private function encodeUri($uri)\n    {\n        $uri = $uri ?? '';\n        if (\n            strpos($uri, 'https%3A%2F%2F') === 0\n            || strpos($uri, 'http%3A%2F%2F') === 0\n        ) {\n            $uri = urldecode($uri);\n        }\n\n        $uri = str_replace('|', '%7C', $uri);\n\n        return $uri;\n    }\n}\n"
  },
  {
    "path": "bridges/XbooruBridge.php",
    "content": "<?php\n\nclass XbooruBridge extends GelbooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Xbooru';\n    const URI = 'https://xbooru.com/';\n    const DESCRIPTION = 'Returns images from given page';\n\n    protected function buildThumbnailURI($element)\n    {\n        return $this->getURI() . 'thumbnails/' . $element->directory\n        . '/thumbnail_' . $element->hash . '.jpg';\n    }\n}\n"
  },
  {
    "path": "bridges/XenForoBridge.php",
    "content": "<?php\n\n/**\n * This bridge generates feeds for threads from forums running XenForo version 2\n *\n * Examples:\n * - https://xenforo.com/community/\n * - http://www.ign.com/boards/\n *\n * Notice: XenForo does provide RSS feeds for forums. For example:\n * - https://xenforo.com/community/forums/-/index.rss\n *\n * For more information on XenForo, visit\n * - https://xenforo.com/\n * - https://en.wikipedia.org/wiki/XenForo\n */\nclass XenForoBridge extends BridgeAbstract\n{\n    // Bridge specific constants\n    const CONTEXT_THREAD = 'Thread';\n    const XENFORO_VERSION_1 = '1.0';\n    const XENFORO_VERSION_2 = '2.0';\n\n    // RSS-Bridge constants\n    const NAME = 'XenForo';\n    const URI = 'https://xenforo.com/';\n    const DESCRIPTION = 'Generates feeds for threads in forums powered by XenForo';\n    const MAINTAINER = 'logmanoriginal';\n    const PARAMETERS = [\n        self::CONTEXT_THREAD => [\n            'url' => [\n                'name' => 'Thread URL',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert URL to the thread for which the feed should be generated',\n                'exampleValue' => 'https://xenforo.com/community/threads/guide-to-suggestions.2285/'\n            ]\n        ],\n        'global' => [\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Specify maximum number of elements to return in the feed',\n                'defaultValue' => 10\n            ]\n        ]\n    ];\n    const CACHE_TIMEOUT = 7200; // 10 minutes\n\n    private $title = '';\n    private $threadurl = '';\n    private $version; // Holds the XenForo version\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_THREAD:\n                return $this->title . ' - ' . static::NAME;\n        }\n\n        return parent::getName();\n    }\n\n    public function getURI()\n    {\n        switch ($this->queriedContext) {\n            case self::CONTEXT_THREAD:\n                return $this->threadurl;\n        }\n\n        return parent::getURI();\n    }\n\n    public function collectData()\n    {\n        $this->threadurl = filter_var(\n            $this->getInput('url'),\n            FILTER_VALIDATE_URL,\n            FILTER_FLAG_PATH_REQUIRED\n        );\n\n        if ($this->threadurl === false) {\n            throwClientException('The URL you provided is invalid!');\n        }\n\n        $urlparts = parse_url($this->threadurl, PHP_URL_SCHEME);\n\n        // Scheme must be \"http\" or \"https\"\n        if (preg_match('/http[s]{0,1}/', parse_url($this->threadurl, PHP_URL_SCHEME)) == false) {\n            throwClientException('The URL you provided doesn\\'t specify a valid scheme (http or https)!');\n        }\n\n        // Path cannot be root (../)\n        if (parse_url($this->threadurl, PHP_URL_PATH) === '/') {\n            throwClientException('The URL you provided doesn\\'t link to a valid thread (root path)!');\n        }\n\n        // XenForo adds a thread ID to the URL, like \"...-thread.454934283\". It must be present\n        if (preg_match('/.+\\.\\d+[\\/]{0,1}/', parse_URL($this->threadurl, PHP_URL_PATH)) == false) {\n            throwClientException('The URL you provided doesn\\'t link to a valid thread (ID missing)!');\n        }\n\n        // We want to start at the first page in the thread. XenForo uses \"../page-n\" syntax\n        // to identify pages (except for the first page).\n        // Notice: XenForo uses the concept of \"sentinels\" to find and replace parts in the\n        // URL. Technically forum hosts can change the syntax!\n        if (preg_match('/.+\\/(page-\\d+.*)$/', $this->threadurl, $matches) != false) {\n            // before: https://xenforo.com/community/threads/guide-to-suggestions.2285/page-5\n            // after : https://xenforo.com/community/threads/guide-to-suggestions.2285/\n            $this->threadurl = str_replace($matches[1], '', $this->threadurl);\n        }\n\n        $html = getSimpleHTMLDOMCached($this->threadurl);\n\n        $html = defaultLinkTo($html, $this->threadurl);\n\n        // Notice: The DOM structure changes depending on the XenForo version used\n        if ($mainContent = $html->find('div.mainContent', 0)) {\n            $this->version = self::XENFORO_VERSION_1;\n        } elseif ($mainContent = $html->find('div[class~=\"p-body\"]', 0)) {\n            $this->version = self::XENFORO_VERSION_2;\n        } else {\n            throwServerException('This forum is currently not supported!');\n        }\n\n        switch ($this->version) {\n            case self::XENFORO_VERSION_1:\n                $titleBar = $mainContent->find('div.titleBar > h1', 0)\n                    or throwServerException('Error finding title bar!');\n\n                $this->title = $titleBar->plaintext;\n\n                // Store items from current page (we'll use $this->items as LIFO buffer)\n                $this->extractThreadPostsV1($html, $this->threadurl);\n                $this->extractPagesV1($html);\n\n                break;\n\n            case self::XENFORO_VERSION_2:\n                $titleBar = $mainContent->find('div[class~=\"p-title\"] h1', 0)\n                    or throwServerException('Error finding title bar!');\n\n                $this->title = $titleBar->plaintext;\n                $this->extractThreadPostsV2($html, $this->threadurl);\n                $this->extractPagesV2($html);\n\n                break;\n        }\n\n        usort($this->items, function ($a, $b) {\n            return $b['timestamp'] <=> $a['timestamp'];\n        });\n\n        $this->items = array_slice($this->items, 0, $this->getInput('limit'));\n    }\n\n    /**\n     * Extracts thread posts\n     * @param $html A simplehtmldom object\n     * @param $url The url from which $html was loaded\n     */\n    private function extractThreadPostsV1($html, $url)\n    {\n        $lang = $html->find('html', 0)->lang;\n\n        // Posts are contained in an \"ol\"\n        $messageList = $html->find('#messageList > li')\n            or throwServerException('Error finding message list!');\n\n        foreach ($messageList as $post) {\n            if (!isset($post->attr['id'])) { // Skip ads\n                continue;\n            }\n\n            $item = [];\n\n            $item['uri'] = $url . '#' . $post->getAttribute('id');\n\n            $content = $post->find('.messageContent > article', 0);\n\n            // Add some style to quotes\n            foreach ($content->find('.bbCodeQuote') as $quote) {\n                $quote->style = '\n\t\t\t\t\tcolor: #495566;\n\t\t\t\t\tbackground-color: rgb(248,251,253);\n\t\t\t\t\tborder: 1px solid rgb(111, 140, 180);\n\t\t\t\t\tborder-color: rgb(111, 140, 180);\n\t\t\t\t\tfont-style: italic;';\n            }\n\n            // Remove script tags\n            foreach ($content->find('script') as $script) {\n                $script->outertext = '';\n            }\n\n            $item['content'] = $content->innertext;\n\n            // Remove quotes (for the title)\n            foreach ($content->find('.bbCodeQuote') as $quote) {\n                $quote->innertext = '';\n            }\n\n            $title = trim($content->plaintext);\n\n            if (strlen($title) > 70) {\n                $item['title'] = substr($title, 0, strpos($title, ' ', 70)) . '...';\n            } else {\n                $item['title'] = $title;\n            }\n\n            /**\n             * Timestamps are presented in two forms:\n             *\n             * 1) short version (for older posts?)\n             * <span\n             *  class=\"DateTime\"\n             *  title=\"22 Oct. 2018 at 23:47\"\n             * >22 Oct. 2018</span>\n             *\n             * This form has to be interpreted depending on the current language.\n             *\n             * 2) long version (for newer posts?)\n             * <abbr\n             *  class=\"DateTime\"\n             *  data-time=\"1541008785\"\n             *  data-diff=\"310694\"\n             *  data-datestring=\"31 Oct. 2018\"\n             *  data-timestring=\"18:59\"\n             *  title=\"31 Oct. 2018 at 18:59\"\n             * >Wednesday at 18:59</abbr>\n             *\n             * This form has the timestamp embedded (data-time)\n             */\n            if ($timestamp = $post->find('abbr.DateTime', 0)) { // long version (preffered)\n                $item['timestamp'] = $timestamp->{'data-time'};\n            } elseif ($timestamp = $post->find('span.DateTime', 0)) { // short version\n                $item['timestamp'] = $this->fixDate($timestamp->title, $lang);\n            }\n\n            $item['author'] = $post->getAttribute('data-author');\n\n            // Bridge specific properties\n            $item['id'] = $post->getAttribute('id');\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function extractThreadPostsV2($html, $url)\n    {\n        $lang = $html->find('html', 0)->lang;\n\n        $messageList = $html->find('div[class~=\"block-body\"] article')\n            or throwServerException('Error finding message list!');\n\n        foreach ($messageList as $post) {\n            if (!isset($post->attr['id'])) { // Skip ads\n                continue;\n            }\n\n            $item = [];\n\n            $item['uri'] = $url . '#' . $post->getAttribute('id');\n\n            $title = $post->find('div[class~=\"message-content\"] article', 0)->plaintext;\n            $end = strpos($title, ' ', min(70, strlen($title)));\n            $item['title'] = substr($title, 0, $end);\n\n            if ($post->find('time[datetime]', 0)) {\n                $item['timestamp'] = $post->find('time[datetime]', 0)->datetime;\n            } else {\n                $item['timestamp'] = $this->fixDate($post->find('time', 0)->title, $lang);\n            }\n            $item['author'] = $post->getAttribute('data-author');\n            $item['content'] = $post->find('div[class~=\"message-content\"] article', 0);\n\n            // Bridge specific properties\n            $item['id'] = $post->getAttribute('id');\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function extractPagesV1($html)\n    {\n        // A navigation bar becomes available if the number of posts grows too\n        // high. When this happens we need to load further pages (from last backwards)\n        if (($pageNav = $html->find('div.PageNav', 0))) {\n            $lastpage = $pageNav->{'data-last'};\n            $baseurl  = $pageNav->{'data-baseurl'};\n            $sentinel = $pageNav->{'data-sentinel'};\n\n            $hosturl  = parse_url($this->threadurl, PHP_URL_SCHEME)\n            . '://'\n            . parse_url($this->threadurl, PHP_URL_HOST)\n            . '/';\n\n            $page = $lastpage;\n\n            // Load at least the last page\n            do {\n                $pageurl = str_replace($sentinel, $lastpage, $baseurl);\n\n                // We can optimize performance by caching all but the last page\n                if ($page != $lastpage) {\n                    $html = getSimpleHTMLDOMCached($pageurl);\n                } else {\n                    $html = getSimpleHTMLDOM($pageurl);\n                }\n\n                $html = defaultLinkTo($html, $hosturl);\n\n                $this->extractThreadPostsV1($html, $pageurl);\n\n                $page--;\n            } while (count($this->items) < $this->getInput('limit') && $page != 1);\n        }\n    }\n\n    private function extractPagesV2($html)\n    {\n        // A navigation bar becomes available if the number of posts grows too\n        // high. When this happens we need to load further pages (from last backwards)\n        if (($pageNav = $html->find('div.pageNav', 0))) {\n            foreach ($pageNav->find('li') as $nav) {\n                $lastpage = $nav->plaintext;\n            }\n\n            // Manually extract baseurl and inject sentinel\n            $baseurl = $pageNav->find('li > a', -1)->href;\n            $baseurl = str_replace('page-' . $lastpage, 'page-{{sentinel}}', $baseurl);\n\n            $sentinel = '{{sentinel}}';\n\n            $hosturl  = parse_url($this->threadurl, PHP_URL_SCHEME)\n            . '://'\n            . parse_url($this->threadurl, PHP_URL_HOST);\n\n            $page = $lastpage;\n\n            // Load at least the last page\n            do {\n                $pageurl = str_replace($sentinel, $lastpage, $baseurl);\n\n                // We can optimize performance by caching all but the last page\n                if ($page != $lastpage) {\n                    $html = getSimpleHTMLDOMCached($pageurl);\n                } else {\n                    $html = getSimpleHTMLDOM($pageurl);\n                }\n\n                $html = defaultLinkTo($html, $hosturl);\n\n                $this->extractThreadPostsV2($html, $pageurl);\n\n                $page--;\n            } while (count($this->items) < $this->getInput('limit') && $page != 1);\n        }\n    }\n\n    /**\n     * Fixes dates depending on the choosen language:\n     *\n     * de : dd.mm.yy\n     * en : dd.mm.yy\n     * it : dd/mm/yy\n     *\n     * Basically strtotime doesn't convert dates correctly due to formats\n     * being hard to interpret. So we use the DateTime object.\n     *\n     * We don't know the timezone, so just assume +00:00 (or whatever\n     * DateTime chooses)\n     */\n    private function fixDate($date, $lang = 'en-US')\n    {\n        $mnamesen = [\n            'January',\n            'Feburary',\n            'March',\n            'April',\n            'May',\n            'June',\n            'July',\n            'August',\n            'September',\n            'October',\n            'November',\n            'December'\n        ];\n\n        switch ($lang) {\n            case 'en-US': // example: Jun 9, 2018 at 11:46 PM\n                $df = date_create_from_format('M d, Y \\a\\t H:i A', $date);\n                break;\n\n            case 'de-DE': // example: 19 Juli 2018 um 19:27 Uhr\n                $mnamesde = [\n                    'Januar',\n                    'Februar',\n                    'März',\n                    'April',\n                    'Mai',\n                    'Juni',\n                    'Juli',\n                    'August',\n                    'September',\n                    'Oktober',\n                    'November',\n                    'Dezember'\n                ];\n\n                $mnamesdeshort = [\n                    'Jan.',\n                    'Feb.',\n                    'Mär.',\n                    'Apr.',\n                    'Mai',\n                    'Juni',\n                    'Juli',\n                    'Aug.',\n                    'Sep.',\n                    'Okt.',\n                    'Nov.',\n                    'Dez.'\n                ];\n\n                $date = str_ireplace($mnamesde, $mnamesen, $date);\n                $date = str_ireplace($mnamesdeshort, $mnamesen, $date);\n\n                $df = date_create_from_format('d M Y \\u\\m H:i \\U\\h\\r', $date);\n                break;\n        }\n\n        return date_format($df, 'U');\n    }\n}\n"
  },
  {
    "path": "bridges/YGGTorrentBridge.php",
    "content": "<?php\n\n/* This is a mashup of FlickrExploreBridge by sebsauvage and FlickrTagBridge\n * by erwang.providing the functionality of both in one.\n */\nclass YGGTorrentBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'teromene';\n    const NAME = 'Yggtorrent';\n    const URI = 'https://www3.yggtorrent.qa';\n    const DESCRIPTION = 'Returns torrent search from Yggtorrent';\n\n    const PARAMETERS = [\n        [\n            'cat' => [\n                'name' => 'category',\n                'type' => 'list',\n                'values' => [\n                    'Toutes les catégories' => 'all.all',\n                    'Film/Vidéo - Toutes les sous-catégories' => '2145.all',\n                    'Film/Vidéo - Animation' => '2145.2178',\n                    'Film/Vidéo - Animation Série' => '2145.2179',\n                    'Film/Vidéo - Concert' => '2145.2180',\n                    'Film/Vidéo - Documentaire' => '2145.2181',\n                    'Film/Vidéo - Émission TV' => '2145.2182',\n                    'Film/Vidéo - Film' => '2145.2183',\n                    'Film/Vidéo - Série TV' => '2145.2184',\n                    'Film/Vidéo - Spectacle' => '2145.2185',\n                    'Film/Vidéo - Sport' => '2145.2186',\n                    'Film/Vidéo - Vidéo-clips' => '2145.2186',\n                    'Audio - Toutes les sous-catégories' => '2139.all',\n                    'Audio - Karaoké' => '2139.2147',\n                    'Audio - Musique' => '2139.2148',\n                    'Audio - Podcast Radio' => '2139.2150',\n                    'Audio - Samples' => '2139.2149',\n                    'Jeu vidéo - Toutes les sous-catégories' => '2142.all',\n                    'Jeu vidéo - Autre' => '2142.2167',\n                    'Jeu vidéo - Linux' => '2142.2159',\n                    'Jeu vidéo - MacOS' => '2142.2160',\n                    'Jeu vidéo - Microsoft' => '2142.2162',\n                    'Jeu vidéo - Nintendo' => '2142.2163',\n                    'Jeu vidéo - Smartphone' => '2142.2165',\n                    'Jeu vidéo - Sony' => '2142.2164',\n                    'Jeu vidéo - Tablette' => '2142.2166',\n                    'Jeu vidéo - Windows' => '2142.2161',\n                    'eBook - Toutes les sous-catégories' => '2140.all',\n                    'eBook - Audio' => '2140.2151',\n                    'eBook - Bds' => '2140.2152',\n                    'eBook - Comics' => '2140.2153',\n                    'eBook - Livres' => '2140.2154',\n                    'eBook - Mangas' => '2140.2155',\n                    'eBook - Presse' => '2140.2156',\n                    'Emulation - Toutes les sous-catégories' => '2141.all',\n                    'Emulation - Emulateurs' => '2141.2157',\n                    'Emulation - Roms' => '2141.2158',\n                    'GPS - Toutes les sous-catégories' => '2141.all',\n                    'GPS - Applications' => '2141.2168',\n                    'GPS - Cartes' => '2141.2169',\n                    'GPS - Divers' => '2141.2170'\n                ]\n            ],\n            'nom' => [\n                'name' => 'Nom',\n                'description' => 'Nom du torrent',\n                'type' => 'text',\n                'exampleValue' => 'matrix'\n            ],\n            'description' => [\n                'name' => 'Description',\n                'description' => 'Description du torrent',\n                'type' => 'text'\n            ],\n            'fichier' => [\n                'name' => 'Fichier',\n                'description' => 'Fichier du torrent',\n                'type' => 'text'\n            ],\n            'uploader' => [\n                'name' => 'Uploader',\n                'description' => 'Uploader du torrent',\n                'type' => 'text'\n            ],\n\n        ]\n    ];\n\n    public function collectData()\n    {\n        $catInfo = explode('.', $this->getInput('cat'));\n        $category = $catInfo[0];\n        $subcategory = $catInfo[1];\n\n        $html = getSimpleHTMLDOM(self::URI . '/engine/search?name='\n                    . $this->getInput('nom')\n                    . '&description='\n                    . $this->getInput('description')\n                    . '&file='\n                    . $this->getInput('fichier')\n                    . '&uploader='\n                    . $this->getInput('uploader')\n                    . '&category='\n                    . $category\n                    . '&sub_category='\n                    . $subcategory\n                    . '&do=search&order=desc&sort=publish_date');\n\n        $count = 0;\n        $results = $html->find('.results', 0);\n        if (!$results) {\n            return;\n        }\n\n        foreach ($results->find('tr') as $row) {\n            $count++;\n            if ($count == 1) {\n                continue; // Skip table header\n            }\n            if ($count == 22) {\n                break; // Stop processing after 21 items (20 + 1 table header)\n            }\n            $item = [];\n            $item['timestamp'] = $row->find('.hidden', 1)->plaintext;\n            $item['title'] = $row->find('a#torrent_name', 0)->plaintext;\n            $item['uri'] = $this->processLink($row->find('a#torrent_name', 0)->href);\n            $item['seeders'] = $row->find('td', 7)->plaintext;\n            $item['leechers'] = $row->find('td', 8)->plaintext;\n            $item['size'] = $row->find('td', 5)->plaintext;\n            $item = array_merge($item, $this->collectTorrentData($item['uri']));\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * Convert special characters like é to %C3%A9 in the url\n     */\n    private function processLink($url)\n    {\n        $url = explode('/', $url);\n        foreach ($url as $index => $value) {\n            // Skip https://{self::URI}/\n            if ($index < 3) {\n                continue;\n            }\n            // Decode first so that characters like + are not encoded\n            $url[$index] = urlencode(urldecode($value));\n        }\n        return implode('/', $url);\n    }\n\n    private function collectTorrentData($url)\n    {\n        $page = defaultLinkTo(getSimpleHTMLDOMCached($url), self::URI);\n        $author = $page->find('.informations tr', 5)->find('td', 1)->plaintext;\n        $content = $page->find('.default', 1);\n        return ['author' => $author, 'content' => $content];\n    }\n}\n"
  },
  {
    "path": "bridges/YandereBridge.php",
    "content": "<?php\n\nclass YandereBridge extends MoebooruBridge\n{\n    const MAINTAINER = 'mitsukarenai';\n    const NAME = 'Yande.re';\n    const URI = 'https://yande.re/';\n    const DESCRIPTION = 'Returns images from given page and tags';\n}\n"
  },
  {
    "path": "bridges/YandexZenBridge.php",
    "content": "<?php\n\nclass YandexZenBridge extends BridgeAbstract\n{\n    const NAME = 'YandexZen';\n    const URI = 'https://dzen.ru';\n    const DESCRIPTION = 'Latest posts from the specified channel.';\n    const MAINTAINER = 'llamasblade';\n    const PARAMETERS = [\n        [\n            'channelURL' => [\n                'name' => 'Channel URL',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'The channel\\'s URL',\n                'exampleValue' => 'https://dzen.ru/dream_faity_diy',\n            ],\n            'limit' => [\n                'name' => 'Limit',\n                'type' => 'number',\n                'required' => false,\n                'title' => 'Number of posts to display. Max is 20.',\n                'exampleValue' => '20',\n                'defaultValue' => 20,\n            ],\n        ],\n    ];\n\n    # credit: https://github.com/teromene see #1032\n    const _BASE_API_URL_WITH_CHANNEL_NAME = 'https://dzen.ru/api/v3/launcher/more?channel_name=';\n    const _BASE_API_URL_WITH_CHANNEL_ID = 'https://dzen.ru/api/v3/launcher/more?channel_id=';\n\n    const _ACCOUNT_URL_WITH_CHANNEL_ID_REGEX = '#^https?://dzen\\.ru/id/(?<channelID>[a-z0-9]{24})#';\n    const _ACCOUNT_URL_WITH_CHANNEL_NAME_REGEX = '#^https?://dzen\\.ru/(?<channelName>[\\w\\.]+)#';\n\n    private $channelRealName = null;  # as shown in the webpage, not in the URL\n\n\n    public function collectData()\n    {\n        $channelURL = $this->getInput('channelURL');\n\n        if (preg_match(self::_ACCOUNT_URL_WITH_CHANNEL_ID_REGEX, $channelURL, $matches)) {\n            $channelID = $matches['channelID'];\n            $channelAPIURL = self::_BASE_API_URL_WITH_CHANNEL_ID . $channelID;\n        } elseif (preg_match(self::_ACCOUNT_URL_WITH_CHANNEL_NAME_REGEX, $channelURL, $matches)) {\n            $channelName = $matches['channelName'];\n            $channelAPIURL = self::_BASE_API_URL_WITH_CHANNEL_NAME . $channelName;\n        } else {\n            throwClientException(<<<EOT\nInvalid channel URL provided.\nThe channel\\'s URL must be in one of these two forms:\n- https://dzen.ru/dream_faity_diy\n- https://dzen.ru/id/5ad7777f1aa80ce576015250\nEOT);\n        }\n\n        $APIResponse = json_decode(getContents($channelAPIURL));\n\n        $this->channelRealName = $APIResponse->header->title;\n\n        $limit = $this->getInput('limit');\n\n        foreach (array_slice($APIResponse->items, 0, $limit) as $post) {\n            $item = [];\n\n            $item['uri'] = $post->share_link;\n            $item['title'] = $post->title;\n\n            $publicationDateUnixTimestamp = $post->publication_date ?? null;\n            if ($publicationDateUnixTimestamp) {\n                $item['timestamp'] = date(DateTimeInterface::ATOM, $publicationDateUnixTimestamp);\n            }\n\n            $postImage = $post->image ?? null;\n            $item['content'] = $post->text;\n            if ($postImage) {\n                $item['content'] .= \"<br /><img src='$postImage' />\";\n                $item['enclosures'] = [$postImage];\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (is_null($this->getInput('channelURL'))) {\n            return parent::getURI();\n        }\n        return $this->getInput('channelURL');\n    }\n\n    public function getName()\n    {\n        if (is_null($this->channelRealName)) {\n            return parent::getName();\n        }\n        return $this->channelRealName . '\\'s latest zen.yandex posts';\n    }\n}\n"
  },
  {
    "path": "bridges/YeggiBridge.php",
    "content": "<?php\n\nclass YeggiBridge extends BridgeAbstract\n{\n    const NAME = 'Yeggi Search';\n    const URI = 'https://www.yeggi.com';\n    const DESCRIPTION = 'Returns 3D Models from Thingiverse, MyMiniFactory, Cults3D, and more';\n    const MAINTAINER = 'AntoineTurmel';\n    const PARAMETERS = [\n        [\n            'query' => [\n                'name' => 'Search query',\n                'type' => 'text',\n                'required' => true,\n                'title' => 'Insert your search term here',\n                'exampleValue' => 'vase'\n            ],\n            'sortby' => [\n                'name' => 'Sort by',\n                'type' => 'list',\n                'required' => false,\n                'values' => [\n                    'Best match' => '0',\n                    'Popular' => '1',\n                    'Latest' => '2',\n                ],\n                'defaultValue' => 'newest'\n            ],\n            'show' => [\n                'name' => 'Show',\n                'type' => 'list',\n                'required' => false,\n                'values' => [\n                    'All' => '0',\n                    'Free' => '1',\n                    'For sale' => '2',\n                ],\n                'defaultValue' => 'all'\n            ],\n            'showimage' => [\n                'name' => 'Show image in content',\n                'type' => 'checkbox',\n                'required' => false,\n                'title' => 'Activate to show the image in the content',\n                'defaultValue' => 'checked'\n            ]\n        ]\n    ];\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        $results = $html->find('div.item_1_A');\n\n        foreach ($results as $result) {\n            $item = [];\n            $title = $result->find('.item_3_B_2', 0)->plaintext;\n            $explodeTitle = explode('&nbsp;  ', $title);\n            if (count($explodeTitle) == 2) {\n                $item['title'] = $explodeTitle[1];\n            } else {\n                $item['title'] = $explodeTitle[0];\n            }\n            $item['uri'] = self::URI . $result->find('a', 0)->href;\n            $item['author'] = 'Yeggi';\n\n            $text = $result->find('i');\n            $item['content'] = $text[0]->plaintext . ' on ' . $text[1]->plaintext;\n            $item['uid'] = hash('md5', $item['title']);\n\n            foreach ($result->find('.item_3_B_2 > a[href^=/q/]') as $tag) {\n                $item['tags'][] = $tag->plaintext;\n            }\n\n            $image = $result->find('img', 0)->src;\n\n            if ($this->getInput('showimage')) {\n                $item['content'] .= '<br><img src=\"' . $image . '\">';\n            }\n\n            $item['enclosures'] = [$image];\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('query'))) {\n            $uri = self::URI . '/q/' . urlencode($this->getInput('query')) . '/';\n            $uri .= '?o_f=' . $this->getInput('show');\n            $uri .= '&o_s=' . $this->getInput('sortby');\n\n            return $uri;\n        }\n\n        return parent::getURI();\n    }\n}\n"
  },
  {
    "path": "bridges/YorushikaBridge.php",
    "content": "<?php\n\nclass YorushikaBridge extends BridgeAbstract\n{\n    const NAME = 'Yorushika';\n    const URI = 'https://yorushika.com';\n    const DESCRIPTION = 'Return news from Yorushika\\'s offical website';\n    const MAINTAINER = 'Miicat_47';\n    const PARAMETERS = [\n        'global' => [\n            'lang' => [\n                'name' => 'Language',\n                'defaultValue' => 'jp',\n                'type' => 'list',\n                'values' => [\n                    '日本語' => 'jp',\n                    'English' => 'en',\n                    '한국어' => 'ko',\n                    '中文(繁體字)' => 'zh-tw',\n                    '中文(簡体字)' => 'zh-cn',\n                ]\n            ],\n        ],\n        'All categories' => [\n        ],\n        'Only selected categories' => [\n            'yorushika' => [\n                'name' => 'Yorushika',\n                'type' => 'checkbox',\n            ],\n            'suis' => [\n                'name' => 'suis',\n                'type' => 'checkbox',\n            ],\n            'n-buna' => [\n                'name' => 'n-buna',\n                'type' => 'checkbox',\n            ],\n        ]\n    ];\n\n    public function collectData()\n    {\n        switch ($this->getInput('lang')) {\n            case 'jp':\n                $url = 'https://yorushika.com/news/5/';\n                break;\n            case 'en':\n                $url = 'https://yorushika.com/news/5/?lang=en';\n                break;\n            case 'ko':\n                $url = 'https://yorushika.com/news/5/?lang=ko';\n                break;\n            case 'zh-tw':\n                $url = 'https://yorushika.com/news/5/?lang=zh-tw';\n                break;\n            case 'zh-cn':\n                $url = 'https://yorushika.com/news/5/?lang=zh-cn';\n                break;\n            default:\n                $url = 'https://yorushika.com/news/5/';\n                break;\n        }\n\n        $categories = [];\n        if ($this->queriedContext == 'All categories') {\n            array_push($categories, 'all');\n        } else if ($this->queriedContext == 'Only selected categories') {\n            if ($this->getInput('yorushika')) {\n                array_push($categories, 'ヨルシカ');\n            }\n            if ($this->getInput('suis')) {\n                array_push($categories, 'suis');\n            }\n            if ($this->getInput('n-buna')) {\n                array_push($categories, 'n-buna');\n            }\n        }\n\n        $html = getSimpleHTMLDOM($url)->find('.list--news', 0);\n        $html = defaultLinkTo($html, $this->getURI());\n\n        foreach ($html->find('.inview') as $art) {\n            $item = [];\n\n            // Get article category and check the filters\n            $art_category = $art->find('.category', 0)->plaintext;\n            if (!in_array('all', $categories) && !in_array($art_category, $categories)) {\n                // Filtering is enabled and the category is not selected, skipping\n                continue;\n            }\n\n            // Get article title\n            $title = $art->find('.tit', 0)->plaintext;\n\n            // Get article url\n            $url = $art->find('a.clearfix', 0)->href;\n\n            // Get article date\n            $date = $art->find('.date', 0)->plaintext;\n            preg_match('/(\\d+)[\\.年](\\d+)[\\.月](\\d+)/u', $date, $matches);\n            $formattedDate = sprintf('%d.%02d.%02d', $matches[1], $matches[2], $matches[3]);\n            $date = date_create_from_format('Y.m.d', $formattedDate);\n            $date = date_format($date, 'd.m.Y');\n\n            // Get article info\n            $art_html = getSimpleHTMLDOMCached($url)->find('.text.inview', 0);\n            $art_html = defaultLinkTo($art_html, $this->getURI());\n\n            // Rewrite the YouTube embed with a YouTube link\n            $yt_embed = $art_html->find('iframe[src*=\"youtube.com\"]', 0);\n            if ($yt_embed) {\n                $yt_embed->outertext = handleYoutube($yt_embed->outertext);\n            }\n\n\n            $item['uri'] = $url;\n            $item['title'] = $title . ' (' . $art_category . ')';\n            $item['content'] = $art_html;\n            $item['timestamp'] = $date;\n\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/YouTubeCommunityTabBridge.php",
    "content": "<?php\n\nclass YouTubeCommunityTabBridge extends BridgeAbstract\n{\n    const NAME = 'YouTube Posts Tab';\n    const URI = 'https://www.youtube.com';\n    const DESCRIPTION = 'Returns posts from a channel\\'s posts tab';\n    const MAINTAINER = 'VerifiedJoseph';\n    const PARAMETERS = [\n        'By channel ID' => [\n            'channel' => [\n                'name' => 'Channel ID',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'UCULkRHBdLC5ZcEQBaL0oYHQ'\n            ]\n        ],\n        'By username' => [\n            'username' => [\n                'name' => 'Username',\n                'type' => 'text',\n                'required' => true,\n                'exampleValue' => 'YouTubeUK'\n            ],\n        ]\n    ];\n\n    const CACHE_TIMEOUT = 3600; // 1 hour\n\n    private $feedUrl = '';\n    private $feedName = '';\n    private $itemTitle = '';\n\n    private $urlRegex = '/youtube\\.com\\/(channel|user|c)\\/([\\w]+)\\/posts/';\n    private $jsonRegex = '/var ytInitialData = ([^<]*);<\\/script>/';\n\n    public function detectParameters($url)\n    {\n        $params = [];\n\n        if (preg_match($this->urlRegex, $url, $matches)) {\n            if ($matches[1] === 'channel') {\n                $params['context'] = 'By channel ID';\n                $params['channel'] = $matches[2];\n            }\n\n            if ($matches[1] === 'user') {\n                $params['context'] = 'By username';\n                $params['username'] = $matches[2];\n            }\n\n            return $params;\n        }\n\n        return null;\n    }\n\n    public function collectData()\n    {\n        if (is_null($this->getInput('username')) === false) {\n            try {\n                $this->feedUrl = $this->buildPostsUri($this->getInput('username'), 'c');\n                $html = getSimpleHTMLDOM($this->feedUrl);\n            } catch (Exception $e) {\n                $this->feedUrl = $this->buildPostsUri($this->getInput('username'), 'user');\n                $html = getSimpleHTMLDOM($this->feedUrl);\n            }\n        } else {\n            $this->feedUrl = $this->buildPostsUri($this->getInput('channel'), 'channel');\n            $html = getSimpleHTMLDOM($this->feedUrl);\n        }\n\n        $json = $this->extractJson($html->find('html', 0)->innertext);\n\n        $this->feedName = $json->header->c4TabbedHeaderRenderer->title ?? null;\n        $this->feedName ??= $json->header->pageHeaderRenderer->pageTitle ?? null;\n        $this->feedName ??= $json->metadata->channelMetadataRenderer->title ?? null;\n        $this->feedName ??= $json->microformat->microformatDataRenderer->title ?? null;\n        $this->feedName ??= '';\n\n        if ($this->hasPostsTab($json) === false) {\n            throwServerException('Channel does not have a posts tab');\n        }\n\n        $posts = $this->getPosts($json);\n        foreach ($posts as $key => $post) {\n            $this->itemTitle = '';\n\n            if (!isset($post->backstagePostThreadRenderer)) {\n                continue;\n            }\n\n            if (isset($post->backstagePostThreadRenderer->post->backstagePostRenderer)) {\n                $details = $post->backstagePostThreadRenderer->post->backstagePostRenderer;\n            } elseif (isset($post->backstagePostThreadRenderer->post->sharedPostRenderer)) {\n                // todo: properly extract data from this shared post\n                $details = $post->backstagePostThreadRenderer->post->sharedPostRenderer;\n            } else {\n                continue;\n            }\n\n            $item = [];\n            $item['uri'] = self::URI . '/post/' . $details->postId;\n            $item['author'] = $details->authorText->runs[0]->text ?? null;\n            $item['content'] = $item['uri'];\n\n            if (isset($details->contentText->runs)) {\n                $text = $this->getText($details->contentText->runs);\n\n                $this->itemTitle = $this->ellipsisTitle($text);\n                $item['content'] = $text;\n            }\n\n            $item['content'] .= $this->getAttachments($details);\n            $item['title'] = $this->itemTitle;\n\n            $date = strtotime(str_replace(' (edited)', '', $details->publishedTimeText->runs[0]->text));\n            if (is_int($date)) {\n                // subtract an increasing multiple of 60 seconds to always preserve the original order\n                $item['timestamp'] = $date - $key * 60;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    public function getURI()\n    {\n        if (!empty($this->feedUrl)) {\n            return $this->feedUrl;\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        if (!empty($this->feedName)) {\n            return $this->feedName . ' - YouTube Posts Tab';\n        }\n\n        return parent::getName();\n    }\n\n    /**\n     * Build Posts URI\n     */\n    private function buildPostsUri($value, $type)\n    {\n        return self::URI . '/' . $type . '/' . $value . '/posts';\n    }\n\n    /**\n     * Extract JSON from page\n     */\n    private function extractJson($html)\n    {\n        if (!preg_match($this->jsonRegex, $html, $parts)) {\n            throwServerException('Failed to extract data from page');\n        }\n\n        $data = json_decode($parts[1]);\n\n        if ($data === false) {\n            throwServerException('Failed to decode extracted data');\n        }\n\n        return $data;\n    }\n\n    /**\n     * Check if channel has a posts tab\n     */\n    private function hasPostsTab($json)\n    {\n        foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {\n            if (\n                isset($tab->tabRenderer)\n                && str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'posts')\n            ) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Get posts from posts tab\n     */\n    private function getPosts($json)\n    {\n        foreach ($json->contents->twoColumnBrowseResultsRenderer->tabs as $tab) {\n            if (\n                isset($tab->tabRenderer)\n                && str_ends_with($tab->tabRenderer->endpoint->commandMetadata->webCommandMetadata->url, 'posts')\n            ) {\n                return $tab->tabRenderer->content->sectionListRenderer->contents[0]->itemSectionRenderer->contents;\n            }\n        }\n    }\n\n    /**\n     * Get text content for a post\n     */\n    private function getText($runs)\n    {\n        $text = '';\n\n        foreach ($runs as $part) {\n            if (isset($part->navigationEndpoint->browseEndpoint->canonicalBaseUrl)) {\n                $text .= $this->formatUrls($part->text, $part->navigationEndpoint->browseEndpoint->canonicalBaseUrl);\n            } elseif (isset($part->navigationEndpoint->urlEndpoint->url)) {\n                $text .= $this->formatUrls($part->text, $part->navigationEndpoint->urlEndpoint->url);\n            } elseif (isset($part->navigationEndpoint->commandMetadata->webCommandMetadata->url)) {\n                $text .= $this->formatUrls($part->text, $part->navigationEndpoint->commandMetadata->webCommandMetadata->url);\n            } else {\n                $text .= $this->formatUrls($part->text, null);\n            }\n        }\n\n        return nl2br($text);\n    }\n\n    /**\n     * Get attachments for posts\n     */\n    private function getAttachments($details)\n    {\n        $content = '';\n\n        if (isset($details->backstageAttachment)) {\n            $attachments = $details->backstageAttachment;\n\n            if (isset($attachments->videoRenderer) && isset($attachments->videoRenderer->videoId)) {\n                // Video\n                if (empty($this->itemTitle)) {\n                    $this->itemTitle = $this->feedName . ' posted a video';\n                }\n\n                $content = handleYoutube($attachments->videoRenderer->videoId);\n            } elseif (isset($attachments->backstageImageRenderer)) {\n                // Image\n                if (empty($this->itemTitle)) {\n                    $this->itemTitle = $this->feedName . ' posted an image';\n                }\n\n                $lastThumb = end($attachments->backstageImageRenderer->image->thumbnails);\n\n                $content = <<<EOD\n<p><img src=\"{$lastThumb->url}\"></p>\nEOD;\n            } elseif (isset($attachments->pollRenderer)) {\n                // Poll\n                if (empty($this->itemTitle)) {\n                    $this->itemTitle = $this->feedName . ' posted a poll';\n                }\n\n                $pollChoices = '';\n\n                foreach ($attachments->pollRenderer->choices as $choice) {\n                    $pollChoices .= <<<EOD\n<li>{$choice->text->runs[0]->text}</li>\nEOD;\n                }\n\n                $content = <<<EOD\n<hr><p>Poll ({$attachments->pollRenderer->totalVotes->simpleText})<br><ul>{$pollChoices}</ul><p>\nEOD;\n            } elseif (isset($attachments->postMultiImageRenderer->images)) {\n                // Multiple images\n                $images = $attachments->postMultiImageRenderer->images;\n\n                if (is_array($images)) {\n                    if (empty($this->itemTitle)) {\n                        $this->itemTitle = $this->feedName . ' posted ' . count($images) . ' images';\n                    }\n\n                    foreach ($images as $image) {\n                        $lastThumb = end($image->backstageImageRenderer->image->thumbnails);\n\n                        $content .= <<<EOD\n<p><img src=\"{$lastThumb->url}\"></p>\nEOD;\n                    }\n                }\n            }\n        }\n\n        return $content;\n    }\n\n    /*\n        Ellipsis text for title\n    */\n    private function ellipsisTitle($text)\n    {\n        $length = 100;\n\n        $text = strip_tags($text);\n        if (strlen($text) > $length) {\n            $text = explode('<br>', wordwrap($text, $length, '<br>'));\n            return $text[0] . '...';\n        }\n\n        return $text;\n    }\n\n    private function formatUrls($content, $url)\n    {\n        if (substr(strval($url), 0, 1) == '/') {\n            // fix relative URL\n            $url = 'https://www.youtube.com' . $url;\n        } elseif (substr(strval($url), 0, 33) == 'https://www.youtube.com/redirect?') {\n            // extract actual URL from YouTube redirect\n            parse_str(substr($url, 33), $params);\n            if (strpos(($params['q'] ?? ''), rtrim($content, '.')) === 0) {\n                $url = $params['q'];\n            }\n        }\n\n        // ensure all URLs are made clickable\n        $url = $url ?? $content;\n\n        if (filter_var($url, FILTER_VALIDATE_URL)) {\n            return '<a href=\"' . $url . '\" target=\"_blank\">' . $content . '</a>';\n        }\n\n        return $content;\n    }\n}\n"
  },
  {
    "path": "bridges/YouTubeFeedExpanderBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass YouTubeFeedExpanderBridge extends FeedExpander\n{\n    const NAME = 'YouTube Feed Expander';\n    const MAINTAINER = 'phantop';\n    const URI = 'https://www.youtube.com/';\n    const DESCRIPTION = 'Returns the latest videos from a YouTube channel';\n    const PARAMETERS = [[\n        'channel' => [\n            'name' => 'Channel ID',\n            'required' => true,\n            // Example: vinesauce\n            'exampleValue' => 'UCzORJV8l3FWY4cFO8ot-F2w',\n        ],\n        'embed' => [\n            'name' => 'Add embed to entry',\n            'type' => 'checkbox',\n            'title' => 'Add embed to entry',\n            'defaultValue' => 'checked',\n        ],\n        'embedurl' => [\n            'name' => 'Use embed page as entry url',\n            'type' => 'checkbox',\n            'title' => 'Use embed page as entry url',\n        ],\n        'nocookie' => [\n            'name' => 'Use nocookie embed page',\n            'type' => 'checkbox',\n            'title' => 'Use nocookie embed page'\n        ],\n        'hideshorts' => [\n            'name' => 'Hide shorts',\n            'type' => 'checkbox',\n            'title' => 'Hide shorts'\n        ]\n    ]];\n\n    public function getIcon()\n    {\n        if ($this->getInput('channel') != null) {\n            $html = getSimpleHTMLDOMCached($this->getURI());\n            return $html->find('[itemprop=\"thumbnailUrl\"]', 0)->href;\n        }\n        return parent::getIcon();\n    }\n\n    public function collectData()\n    {\n        $url = 'https://www.youtube.com/feeds/videos.xml?channel_id=' . $this->getInput('channel');\n        $this->collectExpandableDatas($url);\n    }\n\n    protected function parseItem(array $item)\n    {\n        if ($this->getInput('hideshorts') && str_contains($item['uri'], '/shorts/')) {\n            return;\n        }\n\n        $id = $item['yt']['videoId'];\n        $item['comments'] = $item['uri'] . '#comments';\n        $item['uid'] = $item['id'];\n\n        $thumbnail = sprintf('https://img.youtube.com/vi/%s/maxresdefault.jpg', $id);\n        $item['enclosures'] = [$thumbnail];\n\n        $item['content'] = $item['media']['group']['description'];\n        $item['content'] = str_replace(\"\\n\", '<br>', $item['content']);\n        unset($item['media']);\n\n        $embedURI = self::URI;\n        if ($this->getInput('nocookie')) {\n            $embedURI = 'https://www.youtube-nocookie.com/';\n        }\n        $embed = $embedURI . 'embed/' . $id;\n        if ($this->getInput('embed')) {\n            $iframe = handleYoutube($id) . '<br>';\n            $item['content'] = $iframe . $item['content'];\n        }\n        if ($this->getInput('embedurl')) {\n            $item['uri'] = $embed;\n        }\n\n        return $item;\n    }\n}\n\n"
  },
  {
    "path": "bridges/YoutubeBridge.php",
    "content": "<?php\n\nclass YoutubeBridge extends BridgeAbstract\n{\n    const NAME = 'YouTube';\n    const URI = 'https://www.youtube.com';\n    const CACHE_TIMEOUT = 60 * 60 * 3; // 3 hours\n    const DESCRIPTION = 'Returns the 10 newest videos by username/channel/playlist or search';\n\n    const PARAMETERS = [\n        'By username' => [\n            'u' => [\n                'name' => 'username',\n                'exampleValue' => 'LinusTechTips',\n                'required' => true\n            ]\n        ],\n        'By channel id' => [\n            'c' => [\n                'name' => 'channel id',\n                'exampleValue' => 'UCw38-8_Ibv_L6hlKChHO9dQ',\n                'required' => true\n            ]\n        ],\n        'By custom name' => [\n            'custom' => [\n                'name' => 'custom name',\n                'exampleValue' => 'LinusTechTips',\n                'required' => true\n            ]\n        ],\n        'By playlist Id' => [\n            'p' => [\n                'name' => 'playlist id',\n                'exampleValue' => 'PL8mG-RkN2uTzJc8N0EoyhdC54prvBBLpj',\n                'required' => true\n            ]\n        ],\n        'Search result' => [\n            's' => [\n                'name' => 'search keyword',\n                'exampleValue' => 'LinusTechTips',\n                'required' => true\n            ],\n            'pa' => [\n                'name' => 'page',\n                'type' => 'number',\n                'title' => 'This option is not work anymore, as YouTube will always return the same page',\n                'exampleValue' => 1\n            ]\n        ],\n        'global' => [\n            'duration_min' => [\n                'name' => 'min. duration (minutes)',\n                'type' => 'number',\n                'title' => 'Minimum duration for the video in minutes',\n                'exampleValue' => 5\n            ],\n            'duration_max' => [\n                'name' => 'max. duration (minutes)',\n                'type' => 'number',\n                'title' => 'Maximum duration for the video in minutes',\n                'exampleValue' => 10\n            ]\n        ]\n    ];\n\n    private $feedName = '';\n    private $feeduri = '';\n    private $feedIconUrl = '';\n    // This took from repo BetterVideoRss of VerifiedJoseph.\n    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\n\n    public function collectData()\n    {\n        $cacheKey = 'youtube_rate_limit';\n        if ($this->cache->get($cacheKey)) {\n            throwRateLimitException();\n        }\n        try {\n            $this->collectDataInternal();\n        } catch (HttpException $e) {\n            if ($e->getCode() === 429) {\n                $this->cache->set($cacheKey, true, 60 * 16);\n                throwRateLimitException();\n            }\n            throw $e;\n        }\n    }\n\n    private function collectDataInternal()\n    {\n        $html = '';\n        $url_feed = '';\n        $url_listing = '';\n\n        $username = $this->getInput('u');\n        $channel = $this->getInput('c');\n        $custom = $this->getInput('custom');\n        $playlist = $this->getInput('p');\n        $search = $this->getInput('s');\n\n        $durationMin = $this->getInput('duration_min');\n        $durationMax = $this->getInput('duration_max');\n\n        // Whether to discriminate videos by duration\n        $filterByDuration = $durationMin || $durationMax;\n\n        if ($username) {\n            // user and channel\n            $url_feed = self::URI . '/feeds/videos.xml?user=' . urlencode($username);\n            $url_listing = self::URI . '/user/' . urlencode($username) . '/videos';\n        } elseif ($channel) {\n            $url_feed = self::URI . '/feeds/videos.xml?channel_id=' . urlencode($channel);\n            $url_listing = self::URI . '/channel/' . urlencode($channel) . '/videos';\n        } elseif ($custom) {\n            $url_listing = self::URI . '/' . urlencode($custom) . '/videos';\n        }\n\n        if ($url_feed || $url_listing) {\n            // user, channel or custom\n            $this->feeduri = $url_listing;\n            if ($custom) {\n                // Extract the feed url for the custom name\n                $html = $this->fetch($url_listing);\n                $jsonData = $this->extractJsonFromHtml($html);\n                // Pluck out the rss feed url\n                $url_feed = $jsonData->metadata->channelMetadataRenderer->rssUrl;\n                $this->feedIconUrl = $jsonData->metadata->channelMetadataRenderer->avatar->thumbnails[0]->url;\n            }\n            if ($filterByDuration) {\n                if (!$custom) {\n                    // Fetch the html page\n                    $html = $this->fetch($url_listing);\n                    $jsonData = $this->extractJsonFromHtml($html);\n                }\n                $channel_id = '';\n                if (isset($jsonData->contents)) {\n                    $channel_id = $jsonData->metadata->channelMetadataRenderer->externalId;\n                    $jsonData = $jsonData->contents->twoColumnBrowseResultsRenderer->tabs[1];\n                    $jsonData = $jsonData->tabRenderer->content->richGridRenderer->contents;\n                    // $jsonData = $jsonData->itemSectionRenderer->contents[0]->gridRenderer->items;\n                    $this->fetchItemsFromFromJsonData($jsonData);\n                } else {\n                    throwServerException('Unable to get data from YouTube');\n                }\n            } else {\n                // Fetch the xml feed\n                $html = $this->fetch($url_feed);\n                $this->extractItemsFromXmlFeed($html);\n            }\n            $this->feedName = str_replace(' - YouTube', '', $html->find('title', 0)->plaintext);\n        } elseif ($playlist) {\n            // playlist\n            $url_feed = self::URI . '/feeds/videos.xml?playlist_id=' . urlencode($playlist);\n            $url_listing = self::URI . '/playlist?list=' . urlencode($playlist);\n            $html = $this->fetch($url_listing);\n            $jsonData = $this->extractJsonFromHtml($html);\n            // TODO: this method returns only first 100 video items\n            // if it has more videos, playlistVideoListRenderer will have continuationItemRenderer as last element\n            $jsonData = $jsonData->contents->twoColumnBrowseResultsRenderer->tabs[0] ?? null;\n            if (!$jsonData) {\n                // playlist probably doesnt exists\n                throw new \\Exception('Unable to find playlist: ' . $url_listing);\n            }\n            $jsonData = $jsonData->tabRenderer->content->sectionListRenderer->contents[0]->itemSectionRenderer;\n            $jsonData = $jsonData->contents[0]->playlistVideoListRenderer->contents;\n            $item_count = count($jsonData);\n\n            if ($item_count > 15 || $filterByDuration) {\n                $this->fetchItemsFromFromJsonData($jsonData);\n            } else {\n                $xml = $this->fetch($url_feed);\n                $this->extractItemsFromXmlFeed($xml);\n            }\n            $this->feedName = 'Playlist: ' . str_replace(' - YouTube', '', $html->find('title', 0)->plaintext);\n            usort($this->items, function ($item1, $item2) {\n                if (!is_int($item1['timestamp']) && !is_int($item2['timestamp'])) {\n                    $item1['timestamp'] = strtotime($item1['timestamp']);\n                    $item2['timestamp'] = strtotime($item2['timestamp']);\n                }\n                return $item2['timestamp'] - $item1['timestamp'];\n            });\n        } elseif ($search) {\n            // search\n            $today_filter = 'EgIIAg'; // restrict the upload date to the last 24 hours\n            $url_listing = self::URI . '/results?sp=' . $today_filter . '&search_query=' . urlencode($search);\n            if (!preg_match(\"/\\b(before|after):/i\", $search)) {\n                // unless explicitly overridden, a special \"after:yyyy-mm-dd\" keyword is appended to restrict the upload date to the last 6-30 hours\n                $html = $this->fetch($url_listing . urlencode(' after:' . date('Y-m-d', strtotime('-6 hours'))));\n            } else {\n                $html = $this->fetch($url_listing);\n            }\n            $jsonData = $this->extractJsonFromHtml($html);\n            $jsonData = $jsonData->contents->twoColumnSearchResultsRenderer->primaryContents;\n            $jsonData = $jsonData->sectionListRenderer->contents[0]->itemSectionRenderer->contents;\n            $this->fetchItemsFromFromJsonData($jsonData);\n            $this->feeduri = $url_listing;\n            $this->feedName = 'Search: ' . $search;\n        } else {\n            throwClientException(\"You must either specify either:\\n - YouTube username (?u=...)\\n - Channel id (?c=...)\\n - Playlist id (?p=...)\\n - Search (?s=...)\");\n        }\n    }\n\n    private function fetchVideoDetails($videoId, &$author, &$description, &$timestamp)\n    {\n        $url = self::URI . \"/watch?v=$videoId\";\n        $html = $this->fetch($url, true);\n\n        // Skip unavailable videos\n        if (strpos($html->innertext, 'IS_UNAVAILABLE_PAGE') !== false) {\n            return;\n        }\n\n        $elAuthor = $html->find('span[itemprop=author] > link[itemprop=name]', 0);\n        if (!is_null($elAuthor)) {\n            $author = $elAuthor->getAttribute('content');\n        }\n\n        $elDatePublished = $html->find('meta[itemprop=datePublished]', 0);\n        if (!is_null($elDatePublished)) {\n            $timestamp = strtotime($elDatePublished->getAttribute('content'));\n        }\n\n        $jsonData = $this->extractJsonFromHtml($html);\n        if (!isset($jsonData->contents)) {\n            return;\n        }\n\n        $jsonData = $jsonData->contents->twoColumnWatchNextResults->results->results->contents ?? null;\n        if (!$jsonData) {\n            throw new \\Exception('Unable to find json data');\n        }\n        $videoSecondaryInfo = null;\n        foreach ($jsonData as $item) {\n            if (isset($item->videoSecondaryInfoRenderer)) {\n                $videoSecondaryInfo = $item->videoSecondaryInfoRenderer;\n                break;\n            }\n        }\n        if (!$videoSecondaryInfo) {\n            throwServerException('Could not find videoSecondaryInfoRenderer. Error at: ' . $videoId);\n        }\n\n        $description = $videoSecondaryInfo->attributedDescription->content ?? '';\n\n        // Default whitespace chars used by trim + non-breaking spaces (https://en.wikipedia.org/wiki/Non-breaking_space)\n        $whitespaceChars = \" \\t\\n\\r\\0\\x0B\\u{A0}\\u{2060}\\u{202F}\\u{2007}\";\n        $descEnhancements = $this->ytBridgeGetVideoDescriptionEnhancements($videoSecondaryInfo, $description, self::URI, $whitespaceChars);\n        foreach ($descEnhancements as $descEnhancement) {\n            if (isset($descEnhancement['url'])) {\n                $descBefore = mb_substr($description, 0, $descEnhancement['pos']);\n                $descValue = mb_substr($description, $descEnhancement['pos'], $descEnhancement['len']);\n                $descAfter = mb_substr($description, $descEnhancement['pos'] + $descEnhancement['len'], null);\n\n                // Extended trim for the display value of internal links, e.g.:\n                // FAVICON • Video Name\n                // FAVICON / @ChannelName\n                $descValue = trim($descValue, $whitespaceChars . '•/');\n\n                $description = sprintf('%s<a href=\"%s\" target=\"_blank\">%s</a>%s', $descBefore, $descEnhancement['url'], $descValue, $descAfter);\n            }\n        }\n    }\n\n    private function ytBridgeGetVideoDescriptionEnhancements(\n        object $videoSecondaryInfo,\n        string $descriptionContent,\n        string $baseUrl,\n        string $whitespaceChars\n    ): array {\n        $commandRuns = $videoSecondaryInfo->attributedDescription->commandRuns ?? [];\n        if (count($commandRuns) <= 0) {\n            return [];\n        }\n\n        $enhancements = [];\n\n        $boundaryWhitespaceChars = mb_str_split($whitespaceChars);\n        $boundaryStartChars = array_merge($boundaryWhitespaceChars, [':', '-', '(']);\n        $boundaryEndChars = array_merge($boundaryWhitespaceChars, [',', '.', \"'\", ')']);\n        $hashtagBoundaryEndChars = array_merge($boundaryEndChars, ['#', '-']);\n\n        $descriptionContentLength = mb_strlen($descriptionContent);\n\n        $minPositionOffset = 0;\n\n        $prevStartPosition = 0;\n        $totalLength = 0;\n        $maxPositionByStartIndex = [];\n        foreach (array_reverse($commandRuns) as $commandRun) {\n            $endPosition = $commandRun->startIndex + $commandRun->length;\n            if ($endPosition < $prevStartPosition) {\n                $totalLength += 1;\n            }\n            $totalLength += $commandRun->length;\n            $maxPositionByStartIndex[$commandRun->startIndex] = $totalLength;\n            $prevStartPosition = $commandRun->startIndex;\n        }\n\n        foreach ($commandRuns as $commandRun) {\n            $commandMetadata = $commandRun->onTap->innertubeCommand->commandMetadata->webCommandMetadata ?? null;\n            if (!isset($commandMetadata)) {\n                continue;\n            }\n\n            $enhancement = null;\n\n            /*\n            $commandRun->startIndex can be offset by few positions in the positive direction\n            when some multibyte characters (e.g. emojis, but maybe also others) are used in the plain text video description.\n            (probably some difference between php and javascript in handling multibyte characters)\n            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.\n            Several safeguards ensure that the correct word is chosen. When a link can not be matched,\n            everything will be discarded to prevent corrupting the description.\n            Hashtags require a different set of boundary chars.\n            */\n            $isHashtag = $commandMetadata->webPageType === 'WEB_PAGE_TYPE_BROWSE';\n            $prevEnhancement = end($enhancements);\n            $minPosition = $prevEnhancement === false ? 0 : $prevEnhancement['pos'] + $prevEnhancement['len'];\n            $maxPosition = $descriptionContentLength - $maxPositionByStartIndex[$commandRun->startIndex];\n            $position = min($commandRun->startIndex - $minPositionOffset, $maxPosition);\n            while ($position >= $minPosition) {\n                // The link display value can only ever include a new line at the end (which will be removed further below), never in between.\n                $newLinePosition = mb_strpos($descriptionContent, \"\\n\", $position);\n                if ($newLinePosition !== false && $newLinePosition < $position + ($commandRun->length - 1)) {\n                    $position = $newLinePosition - ($commandRun->length - 1);\n                    continue;\n                }\n\n                $firstChar = mb_substr($descriptionContent, $position, 1);\n                $boundaryStart = mb_substr($descriptionContent, $position - 1, 1);\n                $boundaryEndIndex = $position + $commandRun->length;\n                $boundaryEnd = mb_substr($descriptionContent, $boundaryEndIndex, 1);\n\n                $boundaryStartIsValid = $position === 0 ||\n                    in_array($boundaryStart, $boundaryStartChars) ||\n                    ($isHashtag && $firstChar === '#');\n                $boundaryEndIsValid = $boundaryEndIndex === $descriptionContentLength ||\n                    in_array($boundaryEnd, $isHashtag ? $hashtagBoundaryEndChars : $boundaryEndChars);\n\n                if ($boundaryStartIsValid && $boundaryEndIsValid) {\n                    $minPositionOffset = $commandRun->startIndex - $position;\n                    $enhancement = [\n                        'pos' => $position,\n                        'len' => $commandRun->length,\n                    ];\n                    break;\n                }\n\n                $position--;\n            }\n\n            if (!isset($enhancement)) {\n                $this->logger->debug(sprintf('Position %d cannot be corrected in \"%s\"', $commandRun->startIndex, substr($descriptionContent, 0, 50) . '...'));\n                // Skip to prevent the description from becoming corrupted\n                continue;\n            }\n\n            // $commandRun->length sometimes incorrectly includes the newline as last char\n            $lastChar = mb_substr($descriptionContent, $enhancement['pos'] + $enhancement['len'] - 1, 1);\n            if ($lastChar === \"\\n\") {\n                $enhancement['len'] -= 1;\n            }\n\n            $commandUrl = parse_url($commandMetadata->url);\n            if ($commandUrl['path'] === '/redirect') {\n                parse_str($commandUrl['query'], $commandUrlQuery);\n                $enhancement['url'] = urldecode($commandUrlQuery['q']);\n            } elseif (isset($commandUrl['host'])) {\n                $enhancement['url'] = $commandMetadata->url;\n            } else {\n                $enhancement['url'] = $baseUrl . $commandMetadata->url;\n            }\n\n            $enhancements[] = $enhancement;\n        }\n\n        if (count($enhancements) !== count($commandRuns)) {\n            // At least one link can not be matched. Discard everything to prevent corrupting the description.\n            return [];\n        }\n\n        // Sort by position in descending order to be able to safely replace values\n        return array_reverse($enhancements);\n    }\n\n    private function extractItemsFromXmlFeed($xml)\n    {\n        $this->feedName = $this->decodeTitle($xml->find('feed > title', 0)->plaintext);\n\n        foreach ($xml->find('entry') as $element) {\n            $videoId = str_replace('yt:video:', '', $element->find('id', 0)->plaintext);\n            if (strpos($videoId, 'googleads') !== false) {\n                continue;\n            }\n            $title = $this->decodeTitle($element->find('title', 0)->plaintext);\n            $author = $element->find('name', 0)->plaintext;\n            $desc = $element->find('media:description', 0)->innertext;\n            $desc = htmlspecialchars($desc);\n            $desc = nl2br($desc);\n            $desc = preg_replace(self::URI_REGEX, '<a href=\"$1\" target=\"_blank\">$1</a> ', $desc);\n            $time = strtotime($element->find('published', 0)->plaintext);\n            $this->addItem($videoId, $title, $author, $desc, $time);\n        }\n    }\n\n    private function fetch($url, bool $cache = false)\n    {\n        $header = ['Accept-Language: en-US'];\n        $ttl = 86400 * 3; // 3d\n        $stripNewlines = false;\n        if ($cache) {\n            return getSimpleHTMLDOMCached($url, $ttl, $header, [], true, true, DEFAULT_TARGET_CHARSET, $stripNewlines);\n        }\n        return getSimpleHTMLDOM($url, $header, [], true, true, DEFAULT_TARGET_CHARSET, $stripNewlines);\n    }\n\n    private function extractJsonFromHtml($html)\n    {\n        $scriptRegex = '/var ytInitialData = (.*?);<\\/script>/';\n        $result = preg_match($scriptRegex, $html, $matches);\n        if (! $result) {\n            $this->logger->debug('Could not find ytInitialData');\n            return null;\n        }\n        $data = json_decode($matches[1]);\n        return $data;\n    }\n\n    private function fetchItemsFromFromJsonData($jsonData)\n    {\n        $minimumDurationSeconds = ($this->getInput('duration_min') ?: -1) * 60;\n        $maximumDurationSeconds = ($this->getInput('duration_max') ?: INF) * 60;\n\n        foreach ($jsonData as $item) {\n            $wrapper = null;\n            if (isset($item->gridVideoRenderer)) {\n                $wrapper = $item->gridVideoRenderer;\n            } elseif (isset($item->videoRenderer)) {\n                $wrapper = $item->videoRenderer;\n            } elseif (isset($item->playlistVideoRenderer)) {\n                $wrapper = $item->playlistVideoRenderer;\n            } elseif (isset($item->richItemRenderer)) {\n                $wrapper = $item->richItemRenderer->content->videoRenderer;\n            } else {\n                continue;\n            }\n\n            // 01:03:30 | 15:06 | 1:24\n            $lengthText = $wrapper->lengthText->simpleText ?? null;\n            // 6,875 views\n            $viewCount = $wrapper->viewCountText->simpleText ?? null;\n            // Dc645M8Het8\n            $videoId = $wrapper->videoId;\n            // Jumbo frames - transfer more data faster!\n            $title = $wrapper->title->runs[0]->text ?? $wrapper->title->accessibility->accessibilityData->label ?? null;\n            $author = null;\n            $description = $wrapper->descriptionSnippet->runs[0]->text ?? null;\n            // 5 days ago | 1 month ago\n            $publishedTimeText = $wrapper->publishedTimeText->simpleText ?? $wrapper->videoInfo->runs[2]->text ?? null;\n            $timestamp = null;\n            if ($publishedTimeText) {\n                try {\n                    $publicationDate = new \\DateTimeImmutable($publishedTimeText);\n                    // Hard-code hour, minute and second\n                    $publicationDate = $publicationDate->setTime(0, 0, 0);\n                    $timestamp = $publicationDate->getTimestamp();\n                } catch (\\Exception $e) {\n                }\n            }\n\n            $durationText = 0;\n            if ($lengthText) {\n                $durationText = $lengthText;\n            } else {\n                foreach ($wrapper->thumbnailOverlays as $overlay) {\n                    if (isset($overlay->thumbnailOverlayTimeStatusRenderer)) {\n                        $durationText = $overlay->thumbnailOverlayTimeStatusRenderer->text;\n                        break;\n                    }\n                }\n            }\n            if (is_string($durationText)) {\n                if (preg_match('/([\\d]{1,2})\\:([\\d]{1,2})\\:([\\d]{2})/', $durationText)) {\n                    $durationText = preg_replace('/([\\d]{1,2})\\:([\\d]{1,2})\\:([\\d]{2})/', '$1:$2:$3', $durationText);\n                } else {\n                    $durationText = preg_replace('/([\\d]{1,2})\\:([\\d]{2})/', '00:$1:$2', $durationText);\n                }\n                sscanf($durationText, '%d:%d:%d', $hours, $minutes, $seconds);\n                $duration = $hours * 3600 + $minutes * 60 + $seconds;\n                if ($duration < $minimumDurationSeconds || $duration > $maximumDurationSeconds) {\n                    continue;\n                }\n            }\n            if (!$description || !$timestamp) {\n                $this->fetchVideoDetails($videoId, $author, $description, $timestamp);\n            }\n            $this->addItem($videoId, $title, $author, $description, $timestamp);\n            if (count($this->items) >= 99) {\n                break;\n            }\n        }\n    }\n\n    private function addItem($videoId, $title, $author, $description, $timestamp, $thumbnail = '')\n    {\n        $description = nl2br($description);\n\n        $item = [];\n        // This should probably be uid?\n        $item['id'] = $videoId;\n        $item['title'] = $title;\n        $item['author'] = $author ?? '';\n        $item['timestamp'] = $timestamp;\n        $item['uri'] = self::URI . '/watch?v=' . $videoId;\n        if (!$thumbnail) {\n            // Fallback to default thumbnail if there aren't any provided.\n            $thumbnail = '0';\n        }\n        $thumbnailUri = str_replace('/www.', '/img.', self::URI) . '/vi/' . $videoId . '/' . $thumbnail . '.jpg';\n        $item['content'] = sprintf('<a href=\"%s\"><img src=\"%s\" /></a><br />%s', $item['uri'], $thumbnailUri, $description);\n        $this->items[] = $item;\n    }\n\n    private function decodeTitle($title)\n    {\n        // convert both &#1234; and &quot; to UTF-8\n        return html_entity_decode($title, ENT_QUOTES, 'UTF-8');\n    }\n\n    public function getURI()\n    {\n        if (!is_null($this->getInput('p'))) {\n            return static::URI . '/playlist?list=' . $this->getInput('p');\n        } elseif ($this->feeduri) {\n            return $this->feeduri;\n        }\n\n        return parent::getURI();\n    }\n\n    public function getName()\n    {\n        switch ($this->queriedContext) {\n            case 'By username':\n            case 'By channel id':\n            case 'By custom name':\n            case 'By playlist Id':\n            case 'Search result':\n                return htmlspecialchars_decode($this->feedName) . ' - YouTube';\n            default:\n                return parent::getName();\n        }\n    }\n\n    public function getIcon()\n    {\n        if (empty($this->feedIconUrl)) {\n            return parent::getIcon();\n        } else {\n            return $this->feedIconUrl;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ZDFMediathekBridge.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass ZDFMediathekBridge extends BridgeAbstract\n{\n    const NAME = 'ZDF-Mediathek';\n    const URI = 'https://www.zdf.de/';\n    const DESCRIPTION = 'Feed of any show,series,documentary etc. in the ZDF-Mediathek, specified by its path';\n    const MAINTAINER = 'nabakolu';\n    const PARAMETERS = [\n    [\n      'path' => [\n        'name' => 'ZDF Show URL',\n        'type' => 'text',\n        'required' => true,\n        'exampleValue' => 'https://www.zdf.de/magazine/zdfheute-live-102'\n      ]\n    ]\n    ];\n\n    public function collectData()\n    {\n        $url = $this->getInput('path');\n        $data = $this->getJSON($url);\n\n        foreach ($data['value']['data']['smartCollectionByCanonical']['seasons']['nodes'][0]['episodes']['nodes'] as $episode_node) {\n            $item = [];\n            $item['title'] = $episode_node['title'];\n            $item['timestamp'] = strtotime($episode_node['editorialDate']);\n            $item['uri'] = $episode_node['sharingUrl'];\n            $item['uid'] = $episode_node['id'];\n\n            $description = $episode_node['teaser']['description'];\n            $image = $episode_node['teaser']['imageWithoutLogo']['layouts']['dim1920X1080'];\n            $image_desc = $episode_node['teaser']['imageWithoutLogo']['altText'];\n\n            $item['content'] = \"<img src='{$image}' alt='$image_desc' /><p>{$description}</p>\";\n\n            $this->items[] = $item;\n        }\n    }\n\n    private function getJSON($url)\n    {\n        $html = getContents($url);\n\n      // Find all <script> tags in the HTML content\n        preg_match_all('/<script.*?>(.*?)<\\/script>/is', $html, $script_matches);\n\n      // Contains json data\n        $data = null;\n\n        foreach ($script_matches[1] as $script_content) {\n            if (strpos($script_content, 'self.__next_f.push([1,\"{') === 0) {\n                // Strip the 'self.__next_f.push([1,\"{'\n                $json_string = substr($script_content, 23);\n\n                // Strip the trailing '\"])'\n                $json_string = substr($json_string, 0, -3);\n\n                // Unescape \\\" and \\\\\n                $json_string = stripslashes($json_string);\n\n                $data = json_decode($json_string, true);\n\n                break;\n            }\n        }\n        return $data;\n    }\n}\n"
  },
  {
    "path": "bridges/ZDNetBridge.php",
    "content": "<?php\n\nclass ZDNetBridge extends FeedExpander\n{\n    const MAINTAINER = 'ORelio';\n    const NAME = 'ZDNet';\n    const URI = 'https://www.zdnet.com/';\n    const DESCRIPTION = 'Technology News, Analysis, Comments and Product Reviews for IT Professionals.';\n\n    //http://www.zdnet.com/zdnet.opml\n    const PARAMETERS = [ [\n        'feed' => [\n            'name' => 'Feed',\n            'type' => 'list',\n            'values' => [\n                'Subscribe to ZDNet RSS Feeds' => [\n                    'All Blogs' => 'blog',\n                    'Just News' => 'news',\n                    'All Reviews' => 'topic/reviews',\n                    'Latest Downloads' => 'downloads!recent',\n                    'Latest Articles' => '/',\n                    'Latest Australia Articles' => 'au',\n                    'Latest UK Articles' => 'uk',\n                    'Latest US Articles' => 'us',\n                    'Latest Asia Articles' => 'as'\n                ],\n                'Keep up with ZDNet Blogs RSS:' => [\n                    'Transforming the Datacenter' => 'blog/transforming-datacenter',\n                    'SMB India' => 'blog/smb-india',\n                    'Indonesia BizTech' => 'blog/indonesia-biztech',\n                    'Hong Kong Techie' => 'blog/hong-kong-techie',\n                    'Tech Taiwan' => 'blog/tech-taiwan',\n                    'Startup India' => 'blog/startup-india',\n                    'Starting Up Asia' => 'blog/starting-up-asia',\n                    'Next-Gen Partner' => 'blog/partner',\n                    'Post-PC Developments' => 'blog/post-pc',\n                    'Benelux' => 'blog/benelux',\n                    'Heat Sink' => 'blog/heat-sink',\n                    'Italy\\'s got tech' => 'blog/italy',\n                    'African Enterprise' => 'blog/african-enterprise',\n                    'New Tech for Old India' => 'blog/new-india',\n                    'Estonia Uncovered' => 'blog/estonia',\n                    'IT Iberia' => 'blog/iberia',\n                    'Brazil Tech' => 'blog/brazil',\n                    '500 words into the future' => 'blog/500-words-into-the-future',\n                    'ÜberTech' => 'blog/ubertech',\n                    'All About Microsoft' => 'blog/microsoft',\n                    'Back office' => 'blog/back-office',\n                    'Barker Bites Back' => 'blog/barker-bites-back',\n                    'Between the Lines' => 'blog/btl',\n                    'Big on Data' => 'blog/big-data',\n                    'bootstrappr' => 'blog/bootstrappr',\n                    'By The Way' => 'blog/by-the-way',\n                    'Central European Processing' => 'blog/central-europe',\n                    'Cloud Builders' => 'blog/cloud-builders',\n                    'Communication Breakdown' => 'blog/communication-breakdown',\n                    'Collaboration 2.0' => 'blog/collaboration',\n                    'Constellation Research' => 'blog/constellation',\n                    'Consumerization: BYOD' => 'blog/consumerization',\n                    'DIY-IT' => 'blog/diy-it',\n                    'Enterprise Web 2.0' => 'blog/hinchcliffe',\n                    'Five Nines: The Next Gen Datacenter' => 'blog/datacenter',\n                    'Forrester Research' => 'blog/forrester',\n                    'Full Duplex' => 'blog/full-duplex',\n                    'Gen Why?' => 'blog/gen-why',\n                    'Hardware 2.0' => 'blog/hardware',\n                    'Identity Matters' => 'blog/identity',\n                    'iGeneration' => 'blog/igeneration',\n                    'Internet of Everything' => 'blog/cisco',\n                    'Beyond IT Failure' => 'blog/projectfailures',\n                    'Jamie\\'s Mostly Linux Stuff' => 'blog/jamies-mostly-linux-stuff',\n                    'Jack\\'s Blog' => 'blog/jacks-blog',\n                    'Laptops & Desktops' => 'blog/computers',\n                    'Linux and Open Source' => 'blog/open-source',\n                    'London Calling' => 'blog/london',\n                    'Mapping Babel' => 'blog/mapping-babel',\n                    'Mixed Signals' => 'blog/mixed-signals',\n                    'Mobile India' => 'blog/mobile-india',\n                    'Mobile News' => 'blog/mobile-news',\n                    'Networking' => 'blog/networking',\n                    'Norse Code' => 'blog/norse-code',\n                    'Null Pointer' => 'blog/null-pointer',\n                    'The Full Tilt' => 'blog/the-full-tilt',\n                    'Pinoy Post' => 'blog/pinoy-post',\n                    'Practically Tech' => 'blog/practically-tech',\n                    'Product Central' => 'blog/product-central',\n                    'Pulp Tech' => 'blog/violetblue',\n                    'Qubits and Pieces' => 'blog/qubits-and-pieces',\n                    'Securify This!' => 'blog/securify-this',\n                    'Service Oriented' => 'blog/service-oriented',\n                    'Small Talk' => 'blog/small-talk',\n                    'Small Business Matters' => 'blog/small-business-matters',\n                    'Smartphones and Cell Phones' => 'blog/cell-phones',\n                    'Social Business' => 'blog/feeds',\n                    'Social CRM: The Conversation' => 'blog/crm',\n                    'Software & Services Safari' => 'blog/sommer',\n                    'Storage Bits' => 'blog/storage',\n                    'Stacking up Open Clouds' => 'blog/apac-redhat',\n                    'Techie Isles' => 'blog/techie-isles',\n                    'Technolatte' => 'blog/technolatte',\n                    'Tech Podium' => 'blog/tech-podium',\n                    'Tel Aviv Tech' => 'blog/tel-aviv',\n                    'Tech Broiler' => 'blog/perlow',\n                    'The SANMAN' => 'blog/the-sanman',\n                    'The open source revolution' => 'blog/the-open-source-revolution',\n                    'The German View' => 'blog/german',\n                    'The Ed Bott Report' => 'blog/bott',\n                    'The Mobile Gadgeteer' => 'blog/mobile-gadgeteer',\n                    'The Apple Core' => 'blog/apple',\n                    'Tom Foremski: IMHO' => 'blog/foremski',\n                    'Twisted Wire' => 'blog/twisted-wire',\n                    'Vive la tech' => 'blog/france',\n                    'Virtually Speaking' => 'blog/virtualization',\n                    'View from China' => 'blog/china',\n                    'Web design & Free Software' => 'blog/web-design-and-free-software',\n                    'ZDNet Government' => 'blog/government',\n                    'ZDNet UK Book Reviews' => 'blog/zdnet-uk-book-reviews',\n                    'ZDNet UK First Take' => 'blog/zdnet-uk-first-take',\n                    'Zero Day' => 'blog/security'\n                ],\n                'ZDNet Hot Topics RSS:' => [\n                    'Apple' => 'topic/apple',\n                    'Collaboration' => 'topic/collaboration',\n                    'Enterprise Software' => 'topic/enterprise-software',\n                    'Google' => 'topic/google',\n                    'Great debate' => 'topic/great-debate',\n                    'Hardware' => 'topic/hardware',\n                    'IBM' => 'topic/ibm',\n                    'iOS' => 'topic/ios',\n                    'iPhone' => 'topic/iphone',\n                    'iPad' => 'topic/ipad',\n                    'IT Priorities' => 'topic/it-priorities',\n                    'Laptops' => 'topic/laptops',\n                    'Legal' => 'topic/legal',\n                    'Linux' => 'topic/linux',\n                    'Microsoft' => 'topic/microsoft',\n                    'Mobile OS' => 'topic/mobile-os',\n                    'Mobility' => 'topic/mobility',\n                    'Networking' => 'topic/networking',\n                    'Oracle' => 'topic/oracle',\n                    'Processors' => 'topic/processors',\n                    'Samsung' => 'topic/samsung',\n                    'Security' => 'topic/security',\n                    'Small business: going big on mobility' => 'topic/small-business-going-big-on-mobility'\n                ],\n                'Product Blogs:' => [\n                    'Digital Cameras & Camcorders' => 'blog/digitalcameras',\n                    'Home Theater' => 'blog/home-theater',\n                    'Laptops and Desktops' => 'blog/computers',\n                    'The Mobile Gadgeteer' => 'blog/mobile-gadgeteer',\n                    'Smartphones and Cell Phones' => 'blog/cell-phones',\n                    'The ToyBox' => 'blog/gadgetreviews'\n                ],\n                'Vertical Blogs:' => [\n                    'ZDNet Education' => 'blog/education',\n                    'ZDNet Healthcare' => 'blog/healthcare',\n                    'ZDNet Government' => 'blog/government'\n                ]\n            ]\n        ],\n        'limit' => self::LIMIT,\n    ]];\n\n    public function collectData()\n    {\n        $baseUri = static::URI;\n        $feed = $this->getInput('feed');\n        if (strpos($feed, 'downloads!') !== false) {\n            $feed = str_replace('downloads!', '', $feed);\n            $baseUri = str_replace('www.', 'downloads.', $baseUri);\n        }\n        $url = $baseUri . trim($feed, '/') . '/rss.xml';\n        $limit = $this->getInput('limit') ?? 10;\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $article = getSimpleHTMLDOMCached($item['uri']);\n        if (!$article) {\n            $this->logger->info('Unable to parse the dom from ' . $item['uri']);\n            return $item;\n        }\n\n        $articleTag = $article->find('article', 0) ?? $article->find('.c-articleContent', 0);\n        if (!$articleTag) {\n            $this->logger->info('Unable to parse <article> tag in ' . $item['uri']);\n            return $item;\n        }\n        $contents = $articleTag->innertext;\n        foreach (\n            [\n            '<div class=\"shareBar\"',\n            '<div class=\"shortcodeGalleryWrapper\"',\n            '<div class=\"relatedContent',\n            '<div class=\"downloadNow',\n            '<div data-shortcode',\n            '<div id=\"sharethrough',\n            '<div id=\"inpage-video',\n            '<div class=\"share-bar-wrapper\"',\n            ] as $div_start\n        ) {\n            $contents = stripRecursiveHtmlSection($contents, 'div', $div_start);\n        }\n        $contents = stripWithDelimiters($contents, '<script', '</script>');\n        $contents = stripWithDelimiters($contents, '<meta itemprop=\"image\"', '>');\n        $contents = stripWithDelimiters($contents, '<svg class=\"svg-symbol', '</svg>');\n        $contents = trim(stripWithDelimiters($contents, '<section class=\"sharethrough-top', '</section>'));\n        $item['content'] = convertLazyLoading($contents);\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ZeitBridge.php",
    "content": "<?php\n\nclass ZeitBridge extends FeedExpander\n{\n    const MAINTAINER = 'Mynacol';\n    const NAME = 'Zeit Online';\n    const URI = 'https://www.zeit.de/';\n    const CACHE_TIMEOUT = 1800; // 30min\n    const DESCRIPTION = 'Returns the full articles instead of only the intro';\n    const PARAMETERS = [[\n        'category' => [\n            'name' => 'Category',\n            'type' => 'list',\n            'values' => [\n                'Startseite'\n                => 'https://newsfeed.zeit.de/index',\n                'Politik'\n                => 'https://newsfeed.zeit.de/politik/index',\n                'Wirtschaft'\n                => 'https://newsfeed.zeit.de/wirtschaft/index',\n                'Gesellschaft'\n                => 'https://newsfeed.zeit.de/gesellschaft/index',\n                'Kultur'\n                => 'https://newsfeed.zeit.de/kultur/index',\n                'Wissen'\n                => 'https://newsfeed.zeit.de/wissen/index',\n                'Digital'\n                => 'https://newsfeed.zeit.de/digital/index',\n                'ZEIT Campus ONLINE'\n                => 'https://newsfeed.zeit.de/campus/index',\n                'ZEIT ONLINE Arbeit'\n                => 'https://newsfeed.zeit.de/arbeit/index',\n                'ZEIT Magazin ONLINE'\n                => 'https://newsfeed.zeit.de/zeit-magazin/index',\n                'Entdecken'\n                => 'https://newsfeed.zeit.de/entdecken/index',\n                'Mobilität'\n                => 'https://newsfeed.zeit.de/mobilitaet/index',\n                'Sport'\n                => 'https://newsfeed.zeit.de/sport/index',\n                'Alle Inhalte'\n                => 'https://newsfeed.zeit.de/all'\n            ]\n        ],\n        'limit' => [\n            'name' => 'Limit',\n            'type' => 'number',\n            'required' => false,\n            'title' => 'Specify number of full articles to return',\n            'defaultValue' => 5\n        ]\n    ]];\n\n    public function collectData()\n    {\n        $url = $this->getInput('category');\n        $limit = $this->getInput('limit') ?: 5;\n\n        $this->collectExpandableDatas($url, $limit);\n    }\n\n    protected function parseItem(array $item)\n    {\n        $item['enclosures'] = [];\n\n        $headers = [\n            'Cookie: zonconsent=' . date('Y-m-d\\TH:i:s.v\\Z'),\n        ];\n\n        // one-page article\n        $article = getSimpleHTMLDOM($item['uri'], $headers);\n        if ($article->find('a[href=\"' . $item['uri'] . '/komplettansicht\"]', 0)) {\n            $item['uri'] .= '/komplettansicht';\n            $article = getSimpleHTMLDOM($item['uri'], $headers);\n        }\n\n        $article = defaultLinkTo($article, $item['uri']);\n        $item = $this->parseArticle($item, $article);\n\n        return $item;\n    }\n\n    private function parseArticle($item, $article)\n    {\n        $article = $article->find('main', 0);\n\n        // remove known bad elements\n        foreach (\n            $article->find(\n                'aside, .visually-hidden, .carousel-container, #tickaroo-liveblog, .zplus-badge,\n                .article-heading__container--podcast, .podcast-player__image, div[data-paywall],\n                .js-embed-consent, script, nav, .article-flexible-toc__subheading-link, .faq-link,\n                .zoner-article-magazinbox'\n            ) as $bad\n        ) {\n            $bad->remove();\n        }\n        // reload html, as remove() is buggy\n        $article = str_get_html($article->outertext);\n\n        // podcast audio, if available\n        $podcast_src = $article->find('.article-heading__podcast audio[src]', 0);\n        if ($podcast_src) {\n            $item['enclosures'][] = $podcast_src->src;\n        }\n\n        // full res images\n        foreach ($article->find('img[data-src]') as $img) {\n            $img->src = $img->getAttribute('data-src');\n            $item['enclosures'][] = $img->src;\n        }\n\n        // authors\n        $authors = $article->find('*[itemtype*=\"schema.org/Person\"]') ?? $article->find('.metadata__source');\n        if ($authors) {\n            $item['author'] = implode(', ', array_map(function ($e) {\n                return trim($e->plaintext);\n            }, $authors));\n        }\n\n        $item['content'] = '';\n        // advertorial marker\n        $advert = $article->find('.advertorial-marker', 0);\n        if ($advert) {\n            $item['content'] .= $advert;\n        }\n\n        // summary\n        $summary = $article->find('.summary');\n        if ($summary) {\n            $item['content'] .= implode('', $summary);\n        }\n\n        // header image\n        $headerimg = $article->find('*[data-ct-row=\"headerimage\"]', 0) ?? $article->find('.article-header', 0) ?? $article->find('header', 0);\n        if ($headerimg) {\n            $item['content'] .= implode('', $headerimg->find('img[src], figcaption'));\n        }\n\n        // article content\n        $pages = $article->find('.article-page');\n\n        if ($pages) {\n            foreach ($pages as $page) {\n                $elements = $page->find('p, ul, ol, h2, figure.article__media img[src], figure.article__media figcaption, figure.quote');\n                $item['content'] .= implode('', $elements);\n            }\n        }\n\n        return $item;\n    }\n}\n"
  },
  {
    "path": "bridges/ZenodoBridge.php",
    "content": "<?php\n\nclass ZenodoBridge extends BridgeAbstract\n{\n    const MAINTAINER = 'theradialactive';\n    const NAME = 'Zenodo';\n    const URI = 'https://zenodo.org';\n    const CACHE_TIMEOUT = 10;\n    const DESCRIPTION = 'Returns the newest content of Zenodo';\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM($this->getURI());\n\n        foreach ($html->find('div.record-elem.row') as $element) {\n            $item = [];\n            $item['uri'] = self::URI . $element->find('h4 > a', 0)->href;\n            $item['title'] = trim(htmlspecialchars_decode($element->find('h4 > a', 0)->innertext, ENT_QUOTES));\n\n            $authors = $element->find('p', 0);\n            if ($authors) {\n                $item['author'] = $authors->plaintext;\n            }\n\n            $summary = $element->find('p.hidden-xs > a', 0);\n            if ($summary) {\n                $content = $summary->innertext . '<br>';\n            } else {\n                $content = 'No content';\n            }\n\n            $type = '<br>Type: ' . $element->find('span.label-default', 0)->innertext;\n            $item['categories'] = [$element->find('span.label-default', 0)->innertext];\n\n            $raw_date = $element->find('small.text-muted', 0)->innertext;\n            $clean_date = str_replace('Uploaded on ', '', $raw_date);\n\n            $content = $content . $raw_date;\n\n            $item['timestamp'] = $clean_date;\n\n            $access = '';\n            if ($element->find('span.label-success', 0)) {\n                $access = 'Open Access';\n            } elseif ($element->find('span.label-warning', 0)) {\n                $access = 'Embargoed Access';\n            } else {\n                $access = $element->find('span.label-error', 0)->innertext;\n            }\n            $access = '<br>Access: ' . $access;\n            $publication = '<br>Publication Date: ' . $element->find('span.label-info', 0)->innertext;\n            $item['content'] = $content . $type . $access . $publication;\n            $this->items[] = $item;\n        }\n    }\n}\n"
  },
  {
    "path": "bridges/ZonebourseBridge.php",
    "content": "<?php\n\nclass ZonebourseBridge extends BridgeAbstract\n{\n    const NAME = 'Zonebourse';\n    const URI = 'https://www.zonebourse.com';\n    const DESCRIPTION = 'Retrieve news from zonebourse.com';\n    const MAINTAINER = 'tillcash';\n    const PARAMETERS = [\n        [\n            'topic' => [\n                'name' => 'topic',\n                'type' => 'list',\n                'values' => [\n                    'toute-l-actualite' => [\n                        'monde' => '/actualite-bourse/',\n                        'france' => '/actualite-bourse/regions/locales/',\n                        'europe' => '/actualite-bourse/regions/europe/',\n                        'amerique-du-nord' => '/actualite-bourse/regions/amerique-du-nord/',\n                        'amerique-du-sud' => '/actualite-bourse/regions/amerique-du-sud/',\n                        'asie' => '/actualite-bourse/regions/asie/',\n                        'afrique' => '/actualite-bourse/regions/afrique/',\n                        'moyen-orient' => '/actualite-bourse/regions/moyenorient/',\n                        'emergents' => '/actualite-bourse/regions/emergents/',\n                    ],\n                    'societes' => [\n                        'toute-l-actualite' => '/actualite-bourse/societes/',\n                        'reco-analystes' => '/actualite-bourse/societes/recommandations/',\n                        'rumeurs' => '/actualite-bourse/societes/rumeur/',\n                        'introductions' => '/actualite-bourse/societes/introductions/',\n                        'operations-capitalistiques' => '/actualite-bourse/societes/operations/',\n                        'nouveaux-contrats' => '/actualite-bourse/societes/nouveaux-contrats/',\n                        'profits-warnings' => '/actualite-bourse/societes/profits-warnings/',\n                        'nominations' => '/actualite-bourse/societes/nominations/',\n                        'communiques' => '/actualite-bourse/societes/communique/',\n                        'operations-sur-titre' => '/actualite-bourse/societes/operations_titre/',\n                        'publications-de-resultats' => '/actualite-bourse/societes/publications/',\n                        'nouveaux-marches' => '/actualite-bourse/societes/nouveaux-marches/',\n                        'nouveaux-produits' => '/actualite-bourse/societes/nouveaux-produits/',\n                        'strategies-societes' => '/actualite-bourse/societes/strategies-societes/',\n                        'risques-juridiques' => '/actualite-bourse/societes/risques-juridiques/',\n                        'rachats-d-actions' => '/actualite-bourse/societes/rachats-actions/',\n                        'fusions-et-acquisitions' => '/actualite-bourse/societes/fusions-acquisitions/',\n                        'call-transcripts' => '/actualite-bourse/societes/call-transcripts/',\n                        'guidance' => '/actualite-bourse/societes/guidance/',\n                    ],\n                ],\n            ],\n        ],\n    ];\n\n    public function getName()\n    {\n        $topic = $this->getKey('topic');\n        return self::NAME . ($topic ? ': ' . $topic : '');\n    }\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM(self::URI . $this->getInput('topic'));\n        $articles = $dom->find('table#newsScreener tbody tr');\n\n        if (!$articles) {\n            throwServerException('Failed to retrieve news content');\n        }\n\n        foreach ($articles as $article) {\n            $element = $article->find('.grid a', 0);\n\n            if (!$element || empty($element->plaintext) || empty($element->href)) {\n                continue;\n            }\n\n            $date = $article->find('span.js-date-relative.txt-muted.h-100', 0);\n            $timestamp = $date->{'data-utc-date'} ?? '';\n\n            $this->items[] = [\n                'timestamp'  => $timestamp,\n                'title'      => trim($element->plaintext),\n                'uid'        => $element->href,\n                'uri'        => self::URI . $element->href,\n            ];\n        }\n    }\n}\n"
  },
  {
    "path": "caches/ArrayCache.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Also known as an in-memory/runtime cache\n */\nclass ArrayCache implements CacheInterface\n{\n    private array $data = [];\n\n    public function get(string $key, $default = null)\n    {\n        $item = $this->data[$key] ?? null;\n        if (!$item) {\n            return $default;\n        }\n        $expiration = $item['expiration'];\n        if ($expiration === 0 || $expiration > time()) {\n            return $item['value'];\n        }\n        $this->delete($key);\n        return $default;\n    }\n\n    public function set(string $key, $value, ?int $ttl = null): void\n    {\n        if ($ttl === 0) {\n            return; // TTL is 0, do nothing\n        }\n\n        $this->data[$key] = [\n            'key'           => $key,\n            'value'         => $value,\n            'expiration'    => $ttl === null ? 0 : time() + $ttl,\n        ];\n    }\n\n    public function delete(string $key): void\n    {\n        unset($this->data[$key]);\n    }\n\n    public function clear(): void\n    {\n        $this->data = [];\n    }\n\n    public function prune(): void\n    {\n        foreach ($this->data as $key => $item) {\n            $expiration = $item['expiration'];\n            if ($expiration === 0 || $expiration > time()) {\n                continue;\n            }\n            $this->delete($key);\n        }\n    }\n}\n"
  },
  {
    "path": "caches/FileCache.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass FileCache implements CacheInterface\n{\n    private Logger $logger;\n    private array $config;\n\n    public function __construct(\n        Logger $logger,\n        array $config = []\n    ) {\n        $this->logger = $logger;\n        $default = [\n            'path'          => null,\n            'enable_purge'  => true,\n        ];\n        $this->config = array_merge($default, $config);\n        if (!$this->config['path']) {\n            throw new \\Exception('The FileCache needs a path value');\n        }\n        // Normalize with a single trailing slash\n        $this->config['path'] = rtrim($this->config['path'], '/') . '/';\n    }\n\n    public function get(string $key, $default = null)\n    {\n        $cacheFile = $this->createCacheFile($key);\n        if (!file_exists($cacheFile)) {\n            return $default;\n        }\n        $data = file_get_contents($cacheFile);\n        $item = unserialize($data);\n        if ($item === false) {\n            $this->logger->warning(sprintf('Failed to unserialize: %s', $cacheFile));\n            $this->delete($key);\n            return $default;\n        }\n        $expiration = $item['expiration'] ?? time();\n        if ($expiration === 0 || $expiration > time()) {\n            return $item['value'];\n        }\n        $this->delete($key);\n        return $default;\n    }\n\n    public function set($key, $value, ?int $ttl = null): void\n    {\n        if ($ttl === 0) {\n            return; // ttl is 0, do nothing\n        }\n\n        $item = [\n            'key'           => $key,\n            'expiration'    => $ttl === null ? 0 : time() + $ttl, // if ttl not provided, store forever\n            'value'         => $value,\n        ];\n        $cacheFile = $this->createCacheFile($key);\n        $bytes = file_put_contents($cacheFile, serialize($item));\n\n        // TODO: Consider tightening the permissions of the created file.\n        // It usually allow others to read, depending on umask\n\n        if ($bytes === false) {\n            // Typically means no disk space remaining\n            $this->logger->warning(sprintf('Failed to write to: %s', $cacheFile));\n        }\n    }\n\n    public function delete(string $key): void\n    {\n        unlink($this->createCacheFile($key));\n    }\n\n    public function clear(): void\n    {\n        foreach (scandir($this->config['path']) as $filename) {\n            $cacheFile = $this->config['path'] . $filename;\n            $excluded = ['.' => true, '..' => true, '.gitkeep' => true];\n            if (isset($excluded[$filename]) || !is_file($cacheFile)) {\n                continue;\n            }\n            unlink($cacheFile);\n        }\n    }\n\n    public function prune(): void\n    {\n        if (! $this->config['enable_purge']) {\n            return;\n        }\n        foreach (scandir($this->config['path']) as $filename) {\n            $cacheFile = $this->config['path'] . $filename;\n            $excluded = ['.' => true, '..' => true, '.gitkeep' => true];\n            if (isset($excluded[$filename]) || !is_file($cacheFile)) {\n                continue;\n            }\n            $data = file_get_contents($cacheFile);\n            $item = unserialize($data);\n            if ($item === false) {\n                unlink($cacheFile);\n                continue;\n            }\n            $expiration = $item['expiration'] ?? time();\n            if ($expiration === 0 || $expiration > time()) {\n                // Cached forever, or not expired yet\n                continue;\n            }\n            // Expired, so delete file\n            unlink($cacheFile);\n        }\n    }\n\n    private function createCacheFile(string $key): string\n    {\n        return $this->config['path'] . hash('md5', $key) . '.cache';\n    }\n\n    public function getConfig()\n    {\n        return $this->config;\n    }\n}\n"
  },
  {
    "path": "caches/MemcachedCache.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass MemcachedCache implements CacheInterface\n{\n    private Logger $logger;\n    private \\Memcached $conn;\n\n    public function __construct(\n        Logger $logger,\n        string $host,\n        int $port\n    ) {\n        $this->logger = $logger;\n        $this->conn = new \\Memcached();\n        // This call does not actually connect to server yet\n        if (!$this->conn->addServer($host, $port)) {\n            throw new \\Exception('Unable to add memcached server');\n        }\n    }\n\n    public function get(string $key, $default = null)\n    {\n        $value = $this->conn->get($this->createCacheKey($key));\n        if ($value === false) {\n            return $default;\n        }\n        return $value;\n    }\n\n    public function set(string $key, $value, $ttl = null): void\n    {\n        if ($ttl === 0) {\n            return; // TTL is 0, do nothing\n        }\n\n        $expiration = $ttl === null ? 0 : time() + $ttl; // if ttl not provided, store forever\n        $result = $this->conn->set($this->createCacheKey($key), $value, $expiration);\n        if ($result === false) {\n            $this->logger->warning('Failed to store an item in memcached', [\n                'key'            => $this->createCacheKey($key),\n                'resultCode'     => $this->conn->getResultCode(),\n                'resultMessage'  => $this->conn->getResultMessage(),\n                'errorCode'      => $this->conn->getLastErrorCode(),\n                'errorMessage'   => $this->conn->getLastErrorMessage(),\n                'errorNumber'    => $this->conn->getLastErrorErrno(),\n            ]);\n            // Intentionally not throwing an exception\n        }\n    }\n\n    public function delete(string $key): void\n    {\n        $this->conn->delete($this->createCacheKey($key));\n    }\n\n    public function clear(): void\n    {\n        $this->conn->flush();\n    }\n\n    public function prune(): void\n    {\n        // memcached manages pruning on its own\n    }\n\n    private function createCacheKey(string $key): string\n    {\n        return hash('sha1', $key);\n    }\n}\n"
  },
  {
    "path": "caches/NullCache.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass NullCache implements CacheInterface\n{\n    public function get(string $key, $default = null)\n    {\n        return $default;\n    }\n\n    public function set(string $key, $value, ?int $ttl = null): void\n    {\n    }\n\n    public function delete(string $key): void\n    {\n    }\n\n    public function clear(): void\n    {\n    }\n\n    public function prune(): void\n    {\n    }\n}\n"
  },
  {
    "path": "caches/SQLiteCache.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * The storage table has a column `updated` which is incorrectly named.\n * It should have been named `expiration` and the code treats it as an expiration date (in unix timestamp)\n */\nclass SQLiteCache implements CacheInterface\n{\n    private Logger $logger;\n    private array $config;\n    private \\SQLite3 $db;\n\n    public function __construct(\n        Logger $logger,\n        array $config\n    ) {\n        $this->logger = $logger;\n        $default = [\n            'file'          => null,\n            'timeout'       => 5000,\n            'enable_purge'  => true,\n        ];\n        $config = array_merge($default, $config);\n        $this->config = $config;\n\n        if (!$config['file']) {\n            throw new \\Exception('sqlite cache needs a file');\n        }\n\n        if (is_file($config['file'])) {\n            $this->db = new \\SQLite3($config['file']);\n            $this->db->enableExceptions(true);\n        } else {\n            // Create the file and create sql schema\n            $this->db = new \\SQLite3($config['file']);\n            $this->db->enableExceptions(true);\n            $this->db->exec(\"CREATE TABLE storage ('key' BLOB PRIMARY KEY, 'value' BLOB, 'updated' INTEGER)\");\n            $this->db->exec('CREATE INDEX idx_storage_updated ON storage (updated)');\n        }\n        $this->db->busyTimeout($config['timeout']);\n\n        // https://www.sqlite.org/pragma.html#pragma_journal_mode\n        $this->db->exec('PRAGMA journal_mode = wal');\n\n        // https://www.sqlite.org/pragma.html#pragma_synchronous\n        $this->db->exec('PRAGMA synchronous = NORMAL');\n    }\n\n    public function get(string $key, $default = null)\n    {\n        $cacheKey = $this->createCacheKey($key);\n        $stmt = $this->db->prepare('SELECT value, updated FROM storage WHERE key = :key');\n        $stmt->bindValue(':key', $cacheKey, \\SQLITE3_BLOB);\n        $result = $stmt->execute();\n        if (!$result) {\n            return $default;\n        }\n        $row = $result->fetchArray(\\SQLITE3_ASSOC);\n        if ($row === false) {\n            return $default;\n        }\n        $expiration = $row['updated'];\n        if ($expiration === 0 || $expiration > time()) {\n            $blob = $row['value'];\n            $value = unserialize($blob);\n            if ($value === false) {\n                $this->logger->error(sprintf(\"Failed to unserialize: '%s'\", mb_substr($blob, 0, 100)));\n                // delete?\n                return $default;\n            }\n            return $value;\n        }\n        // delete?\n        return $default;\n    }\n\n    public function set(string $key, $value, ?int $ttl = null): void\n    {\n        if ($ttl === 0) {\n            return; // ttl is 0, do nothing\n        }\n\n        $cacheKey = $this->createCacheKey($key);\n        $blob = serialize($value);\n        $expiration = $ttl === null ? 0 : time() + $ttl; // if ttl not provided, store forever\n        $stmt = $this->db->prepare('INSERT OR REPLACE INTO storage (key, value, updated) VALUES (:key, :value, :updated)');\n        $stmt->bindValue(':key', $cacheKey, \\SQLITE3_BLOB);\n        $stmt->bindValue(':value', $blob, \\SQLITE3_BLOB);\n        $stmt->bindValue(':updated', $expiration, \\SQLITE3_INTEGER);\n        try {\n            $stmt->execute();\n        } catch (\\Exception $e) {\n            $this->logger->warning(create_sane_exception_message($e));\n            // Intentionally not rethrowing exception\n        }\n    }\n\n    public function delete(string $key): void\n    {\n        $cacheKey = $this->createCacheKey($key);\n        $stmt = $this->db->prepare('DELETE FROM storage WHERE key = :key');\n        $stmt->bindValue(':key', $cacheKey, \\SQLITE3_BLOB);\n        try {\n            $stmt->execute();\n        } catch (\\Exception $e) {\n            $this->logger->warning(create_sane_exception_message($e));\n        }\n    }\n\n    public function prune(): void\n    {\n        if (!$this->config['enable_purge']) {\n            return;\n        }\n        $stmt = $this->db->prepare('DELETE FROM storage WHERE updated > 0 AND updated <= :now');\n        $stmt->bindValue(':now', time(), \\SQLITE3_INTEGER);\n        try {\n            $stmt->execute();\n        } catch (\\Exception $e) {\n            $this->logger->warning(create_sane_exception_message($e));\n        }\n    }\n\n    public function clear(): void\n    {\n        $this->db->query('DELETE FROM storage');\n    }\n\n    private function createCacheKey($key)\n    {\n        return hash('sha1', $key, true);\n    }\n}\n"
  },
  {
    "path": "composer.json",
    "content": "{\n    \"name\": \"rss-bridge/rss-bridge\",\n    \"type\": \"project\",\n    \"description\": \"RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one. It can be used on webservers or as a stand-alone application in CLI mode.\",\n    \"keywords\": [\n        \"php\",\n        \"rss\",\n        \"bridge\",\n        \"rss-bridge\",\n        \"atom\",\n        \"html\",\n        \"json\",\n        \"feed\",\n        \"cli\"\n    ],\n    \"homepage\": \"https://github.com/rss-bridge/rss-bridge/\",\n    \"license\": \"UNLICENSE\",\n    \"support\": {\n        \"issues\": \"https://github.com/rss-bridge/rss-bridge/issues/\",\n        \"docs\": \"https://rss-bridge.github.io/rss-bridge/\",\n        \"source\": \"https://github.com/rss-bridge/rss-bridge/\",\n        \"rss\": \"https://github.com/RSS-Bridge/rss-bridge/commits/master.atom\"\n    },\n    \"require\": {\n        \"php\": \">=7.4\",\n        \"ext-mbstring\": \"*\",\n        \"ext-curl\": \"*\",\n        \"ext-openssl\": \"*\",\n        \"ext-libxml\": \"*\",\n        \"ext-simplexml\": \"*\",\n        \"ext-dom\": \"*\",\n        \"ext-json\": \"*\",\n        \"ext-filter\": \"*\"\n    },\n    \"require-dev\": {\n        \"phpunit/phpunit\": \"^9\",\n        \"squizlabs/php_codesniffer\": \"^4\"\n    },\n    \"suggest\": {\n        \"php-webdriver/webdriver\": \"Required for Selenium usage\",\n        \"ext-memcached\": \"Allows to use memcached as cache type\",\n        \"ext-sqlite3\": \"Allows to use an SQLite database for caching\",\n        \"ext-zip\": \"Required for FDroidRepoBridge\",\n        \"ext-intl\": \"Required for OLXBridge\"\n    },\n    \"autoload-dev\": {\n        \"psr-4\": {\n            \"RssBridge\\\\Tests\\\\\": \"tests\"\n        }\n    },\n    \"scripts\": {\n        \"test\": \"./vendor/bin/phpunit\",\n        \"lint\": \"./vendor/bin/phpcs --parallel=2 --standard=phpcs.xml --warning-severity=0 --extensions=php -p ./\",\n        \"compat\": \"./vendor/bin/phpcs --standard=phpcompatibility.xml --warning-severity=0 --extensions=php -p ./\"\n    },\n    \"config\": {\n        \"platform\": {\n            \"php\": \"7.4\"\n        }\n    }\n}\n"
  },
  {
    "path": "config/nginx.conf",
    "content": "server {\n    listen 80 default_server;\n    listen [::]:80 default_server;\n    root /app;\n    access_log /var/log/nginx/access.log;\n    error_log /var/log/nginx/error.log;\n    index index.php;\n\n    server_tokens off; # Hide nginx version\n\n    location ~ /(\\.|vendor|tests) {\n        deny all;\n        return 403; # Forbidden\n    }\n\n    location ~ \\.php$ {\n        include snippets/fastcgi-php.conf;\n        fastcgi_read_timeout 45s;\n        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;\n    }\n}\n"
  },
  {
    "path": "config/php-fpm.conf",
    "content": "; Inspired by https://github.com/docker-library/php/blob/master/8.2/bookworm/fpm/Dockerfile\n\n[global]\nerror_log = /proc/self/fd/2\n\n; https://github.com/docker-library/php/pull/725#issuecomment-443540114\nlog_limit = 8192\n\n[www]\n; php-fpm closes STDOUT on startup, so sending logs to /proc/self/fd/1 does not work.\n; https://bugs.php.net/bug.php?id=73886\naccess.log = /proc/self/fd/2\n\nclear_env = no\n\n; Ensure worker stdout and stderr are sent to the main error log.\ncatch_workers_output = yes\ndecorate_workers_output = no\n"
  },
  {
    "path": "config/php.ini",
    "content": "; Inspired by https://github.com/docker-library/php/blob/master/8.2/bookworm/fpm/Dockerfile\n\n; https://github.com/docker-library/php/issues/878#issuecomment-938595965\nfastcgi.logging = Off\n"
  },
  {
    "path": "config.default.ini.php",
    "content": "; <?php exit; ?> DO NOT REMOVE THIS LINE\n\n; This file contains the default settings for RSS-Bridge. Do not change this\n; file, it will be replaced on the next update of RSS-Bridge! You can specify\n; your own configuration in 'config.ini.php' (copy this file).\n\n[system]\n\n; System environment: \"dev\" or \"prod\"\nenv = \"prod\"\n\n; Only these bridges are available for feed production\n; How to enable all bridges: enabled_bridges[] = *\n;enabled_bridges[] = CssSelectorBridge\n;enabled_bridges[] = FeedMerge\n;enabled_bridges[] = FeedReducerBridge\n;enabled_bridges[] = Filter\n;enabled_bridges[] = GettrBridge\n;enabled_bridges[] = MastodonBridge\n;enabled_bridges[] = Reddit\n;enabled_bridges[] = RumbleBridge\n;enabled_bridges[] = SoundcloudBridge\n;enabled_bridges[] = Telegram\n;enabled_bridges[] = ThePirateBay\n;enabled_bridges[] = TikTokBridge\n;enabled_bridges[] = Twitch\n;enabled_bridges[] = XPathBridge\n;enabled_bridges[] = Youtube\n;enabled_bridges[] = YouTubeCommunityTabBridge\nenabled_bridges[] = *\n\ntimezone = \"UTC\"\n\n; Display a system message to users.\n;message = \"Hello world\"\n\n; Whether to enable maintenance mode. If enabled, feed requests receive 503 Service Unavailable\nenable_maintenance_mode = false\n\n; Max file size for simple_html_dom in bytes (10000000 => 10 MB)\nmax_file_size = 10000000\n\n[http]\n\n; Operation timeout in seconds\ntimeout = 5\n\n; Operation retry count in case of curl error\nretries = 1\n\n; Curl user agent\n; This is already set by curl-impersonate, which comes included as default \n; in RSS-Bridge docker container. Use only if you know what you're doing.\n; For reference, see https://github.com/lexiforest/curl-impersonate/tree/main/docs\n;useragent = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0\"\n\n; Max http response size in MB\nmax_filesize = 20\n\n[cache]\n\n; Cache type: file, sqlite, memcached, array, null\ntype = \"file\"\n\n; Allow users to specify custom timeout for specific requests.\n; true  = enabled\n; false = disabled (default)\ncustom_timeout = false\n\n[logging]\n\n;file_path = \"/var/log/rss-bridge.log\"\n; DEBUG, INFO, WARNING or ERROR\n;file_level = \"INFO\"\n\n[admin]\n\n; Advertise an email address where people can reach the administrator.\n; This address is displayed on the main page, visible to everyone!\n; \"\"    = Disabled (default)\nemail = \"\"\n\n; Advertise a contact URL (can be any URL!) e.g. \"https://t.me/elegantobjects\"\ntelegram = \"\"\n\n; Show Donation information for bridges if available.\n; This will display a 'Donate' link on the bridge view\n; and a \"Donate\" button in the HTML view of the bridges feed.\n; true  = enabled (default)\n; false = disabled\ndonations = true\n\n[proxy]\n\n; The HTTP proxy to tunnel requests through\n; https://curl.se/libcurl/c/CURLOPT_PROXY.html\n; \"\"    = Proxy disabled (default)\nurl = \"\"\n\n; Sets the proxy name that is shown on the bridge instead of the proxy url.\n; \"\"    = Show proxy url\nname = \"Hidden proxy name\"\n\n; Allow users to disable proxy usage for specific requests.\n; true  = enabled\n; false = disabled (default)\nby_bridge = false\n\n[webdriver]\n\n; Sets the url of the webdriver or selenium server\nselenium_server_url = \"http://localhost:4444\"\n\n; Sets whether the browser should run in headless mode (no visible ui)\n; true = enabled\n; false = disabled (default)\nheadless = false\n\n[authentication]\n\n; HTTP basic authentication\nenable = false\nusername = \"admin\"\npassword = \"\"\n\n; Token authentication (URL)\ntoken = \"\"\n\n[error]\n\n; Defines how error messages are returned by RSS-Bridge\n;\n; \"feed\" = As part of the feed (default)\n; \"http\" = As HTTP error message\n; \"none\" = No errors are reported\noutput = \"feed\"\n\n; Defines how often an error must occur before it is reported to the user\nreport_limit = 1\n\n[youtube]\n\n; Whether to use an iframe to directly embed YouTube videos in feeds.\n; If false, a clickable video thumbnail is used instead. This avoids sending a referrer to YouTube or only getting the error 153 if suppressing the referrer browser-wide.\niframe = true\n\n; Use the youtube-nocookie.com domain instead of youtube.com for iframe embeds.\n; Only relevant if youtube.iframe is true.\n; See the following for a description:\n; https://support.google.com/youtube/answer/171780?hl=en#zippy=%2Cturn-on-privacy-enhanced-mode\nnocookie = false\n\n; --- Cache specific configuration ---------------------------------------------\n\n[FileCache]\n\n; The root folder to store files in.\n; \"\" = Use the cache folder in the repository (default)\npath = \"\"\n; Whether to actually delete files when purging. Can be useful to turn off to increase performance.\nenable_purge = true\n\n[SQLiteCache]\n\n; Filepath of the sqlite db file\nfile = \"cache.sqlite\"\n; Whether to actually delete data when purging\nenable_purge = true\n; Busy wait in ms before timing out\ntimeout = 5000\n\n[MemcachedCache]\n\nhost = \"localhost\"\nport = 11211\n\n; --- Bridge specific configuration ------\n\n[TelegramBridge]\n\n; Max pages to fetch (1 page => 20 messages), min=1 max=100\nmax_pages = 1\n\n[DiscogsBridge]\n\n; Sets the personal access token for interactions with Discogs. When\n; provided, images can be included in generated feeds.\n;\n; \"\" = no token used (default)\npersonal_access_token = \"\"\n"
  },
  {
    "path": "contrib/.gitkeep",
    "content": ""
  },
  {
    "path": "docker-bake.hcl",
    "content": "target \"docker-metadata-action\" {}\n\ngroup \"default\" {\n  targets = [\"image-local\"]\n}\n\ntarget \"image\" {\n  inherits = [\"docker-metadata-action\"]\n}\n\ntarget \"image-local\" {\n  inherits = [\"image\"]\n  output = [\"type=docker\"]\n}\n\ntarget \"image-all\" {\n  inherits = [\"image\"]\n  platforms = [\n    \"linux/amd64\",\n    \"linux/arm64\",\n    \"linux/arm/v7\"\n  ]\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '2'\nservices:\n  rss-bridge:\n    image: rssbridge/rss-bridge:latest\n    volumes:\n      - ./config:/config\n    ports:\n      - 3000:80\n    restart: unless-stopped\n"
  },
  {
    "path": "docker-entrypoint.sh",
    "content": "#!/usr/bin/env bash\n\n# - Find custom files (bridges, formats, whitelist, config.ini) in the /config folder\n# - Copy them to the respective folders in /app\n# This will overwrite previous configs, bridges and formats of same name\n# If there are no matching files, rss-bridge works like default.\n\nfind /config/ -type f -name '*' -print0 2> /dev/null |\nwhile IFS= read -r -d '' file; do\n    file_name=\"$(basename \"$file\")\" # Strip leading path\n    if [[ $file_name = *\" \"* ]]; then\n        printf 'Custom file %s has a space in the name and will be skipped.\\n' \"$file_name\"\n        continue\n    fi\n    case \"$file_name\" in\n    *Bridge.php)    yes | cp \"$file\" /app/bridges/ ;\n                    chown www-data:www-data \"/app/bridges/$file_name\";\n                    printf \"Custom Bridge %s added.\\n\" $file_name;;\n    *Format.php)    yes | cp \"$file\" /app/formats/ ;\n                    chown www-data:www-data \"/app/formats/$file_name\";\n                    printf \"Custom Format %s added.\\n\" $file_name;;\n    config.ini.php) yes | cp \"$file\" /app/ ;\n                    chown www-data:www-data \"/app/$file_name\";\n                    printf \"Custom config.ini.php added.\\n\";;\n    whitelist.txt)  yes | cp \"$file\" /app/ ;\n                    chown www-data:www-data \"/app/$file_name\";\n                    printf \"Custom whitelist.txt added.\\n\";;\n    DEBUG)          yes | cp \"$file\" /app/ ;\n                    chown www-data:www-data \"/app/$file_name\";\n                    printf \"DEBUG file added.\\n\";;\n    esac\ndone\n\n# This feature can set the internal port that apache uses to something else.\n# If docker is run on network:service mode, no two containers can use port 80\n# To use this, start the container with the additional environment variable \"HTTP_PORT\"\nif [ ! -z ${HTTP_PORT} ]; then\n\tsed -i \"s/80/$HTTP_PORT/g\" /etc/nginx/sites-enabled/default\nfi\n\n# nginx will daemonize\nnginx\n\n# php-fpm should not daemonize\nexec php-fpm8.2 --nodaemonize\n"
  },
  {
    "path": "docs/01_General/01_Project-goals.md",
    "content": "**RSS-Bridge** aims at sites that:\n\n  - don't provide public accessible Atom or RSS feeds\n  - force their users to subscribe to e-mail notifications\n  - force their users to use their own proprietary APIs\n  - require their users to come back on a regular basis in order to check for new content\n\n**RSS-Bridge** will generate feeds based on \"bridges\" that are developed for any site. Those bridges will collect data and extract all necessary information which is then converted into various feed formats like Atom or RSS."
  },
  {
    "path": "docs/01_General/02_Contribute.md",
    "content": "There are many things you can do to contribute to **RSS-Bridge** as developer or as user without any knowledge in PHP or (web) development. Here are a few things:\n\n- Share **RSS-Bridge** with your friends (Twitter, Facebook, ..._you name it_...)\n- Report broken bridges or bugs [here](https://github.com/RSS-Bridge/rss-bridge/issues)\n- Request new features or propose ideas (via [Issues](https://github.com/RSS-Bridge/rss-bridge/issues))\n- Discuss bugs, features, ideas or [issues](https://github.com/RSS-Bridge/rss-bridge/issues)\n- Add new bridges or improve the API\n- Improve this documentation\n- Host **RSS-Bridge**"
  },
  {
    "path": "docs/01_General/03_Requirements.md",
    "content": "\n\n  - PHP 7.4 (or higher)\n"
  },
  {
    "path": "docs/01_General/04_Screenshots.md",
    "content": "## Welcome screen:\n![welcome screen](../images/screenshot_rss-bridge_welcome.png)\n\n## rss-bridge hashtag (#rss-bridge) search on Twitter:\n_in Atom format (as displayed by Firefox)_\n\n![twitter bridge](../images/screenshot_twitterbridge_atom.png)"
  },
  {
    "path": "docs/01_General/05_FAQ.md",
    "content": "## Why doesn't my bridge show new contents?\n\nRSS-Bridge creates a cached version of your feed in order to reduce traffic and respond faster.\nThe cached version is created on the first request and served for all subsequent requests.\nOn every request RSS-Bridge checks if the cache timeout has elapsed.\nIf the timeout has elapsed, it loads new contents and updates the cached version.\n\n_Notice_: RSS-Bridge only updates feeds if you actively request it,\nfor example by pressing F5 in your browser or using a feed reader.\n\nThe cache duration is bridge specific (usually `1h`)\nYou can specify a custom cache timeout for each bridge if \n[this option](#how-can-i-make-a-bridge-update-more-frequently) has been enabled on the server.\n\n## How can I make a bridge update more frequently?\n\nYou can adjust the cache TTL value only if you are hosting the RSS-Bridge instance:\n- programmatically, by adjusting the bridge's [`CACHE_TIMEOUT`](../05_Bridge_API/02_BridgeAbstract.md#Step_3_-_Add_general_constants_to_the_class) constant value;\n- by enabling the [`custom_timeout`](../03_For_Hosts/08_Custom_Configuration.md#customtimeout)\n  configuration option and using the bridge with additional [`Cache timeout in seconds`](../03_For_Hosts/07_Customizations.md#Customizable_cache_timeout) parameter.\n\n## Firefox doesn't show feeds anymore, what can I do?\n\nAs of version 64, Firefox removed support for viewing Atom and RSS feeds in the browser.\nThis results in the browser downloading the pages instead of showing contents.\n\nFurther reading:\n- https://support.mozilla.org/en-US/kb/feed-reader-replacements-firefox\n- https://bugzilla.mozilla.org/show_bug.cgi?id=1477667\n\nTo restore the original behavior in Firefox 64 or higher you can use following Add-on\nwhich attempts to recreate the original behavior (with some sugar on top):\n\n- https://addons.mozilla.org/en-US/firefox/addon/rsspreview/\n"
  },
  {
    "path": "docs/01_General/06_Public_Hosts.md",
    "content": "# Public instances\n\n| Country | Address | Status |  Contact | Comment |\n|:-------:|---------|--------|----------|---------|\n| ![](https://iplookup.flagfox.net/images/h16/GB.png) | https://rss-bridge.org/bridge01 | ![](https://img.shields.io/website/https/rss-bridge.org/bridge01.svg) | [@dvikan](https://github.com/dvikan) | London, Digital Ocean|\n| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://rssbridge.flossboxin.org.in | ![](https://img.shields.io/badge/website-up-brightgreen) | [@vdbhb59](https://github.com/vdbhb59) | Hosted with Netcup Germany (Maintained in India) |\n| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://rss-bridge.cheredeprince.net | ![](https://img.shields.io/website/https/rss-bridge.cheredeprince.net) | [@La_Bécasse](https://cheredeprince.net/contact) | Self-Hosted at home in France |\n| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://rss-bridge.sans-nuage.fr | ![](https://img.shields.io/website/https/rss-bridge.sans-nuage.fr) | [@Alsace Réseau Neutre](https://arn-fai.net/contact) | Hosted in Alsace, France |\n| ![](https://iplookup.flagfox.net/images/h16/GB.png) | https://rss-bridge.lewd.tech | ![](https://img.shields.io/website/https/rss-bridge.lewd.tech.svg) | [@Erisa](https://github.com/Erisa) | Hosted in London, protected by Cloudflare Rate Limiting |\n| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://wtf.roflcopter.fr/rss-bridge | ![](https://img.shields.io/website/https/wtf.roflcopter.fr/rss-bridge.svg) | [roflcopter.fr](https://wtf.roflcopter.fr/) | Hosted in France |\n| ![](https://iplookup.flagfox.net/images/h16/DE.png) | https://rss.nixnet.services | ![](https://img.shields.io/website/https/rss.nixnet.services.svg) | [@amolith](https://nixnet.services/contact) | Hosted in Wunstorf, Germany |\n| ![](https://iplookup.flagfox.net/images/h16/AT.png) | https://rss-bridge.ggc-project.de | ![](https://img.shields.io/website/https/rss-bridge.ggc-project.de) | [@ggc-project.de](https://social.dev-wiki.de/@ggc_project) | Hosted in Steyr, Austria |\n| ![](https://iplookup.flagfox.net/images/h16/NL.png) | https://feeds.proxeuse.com | ![](https://img.shields.io/website/https/feeds.proxeuse.com) | [Proxeuse](https://www.proxeuse.com/en/contact-us) | Hosted in Germany |\n| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://rssbridge.boldair.dev | ![](https://img.shields.io/website?down_color=red&down_message=down&up_color=lime&up_message=up&url=https%3A%2F%2Frssbridge.boldair.dev) | [@Boldairdev](https://github.com/Boldairdev) | Latest Github release, Hosted on PHP 8.0 in Roubaix, France |\n| ![](https://iplookup.flagfox.net/images/h16/IN.png) | https://rss-bridge.bb8.fun | ![](https://img.shields.io/website/https/rss-bridge.bb8.fun.svg) | [@captn3m0](https://github.com/captn3m0) | Hosted in Bengaluru, India |\n| ![](https://iplookup.flagfox.net/images/h16/RU.png) | https://ololbu.ru/rss-bridge | ![](https://img.shields.io/website/https/ololbu.ru) | [@Ololbu](https://github.com/Ololbu) | Hosted in Moscow, Russia |\n| ![](https://iplookup.flagfox.net/images/h16/DE.png) | https://tools.bheil.net/rss-bridge | ![](https://img.shields.io/website/https/tools.bheil.net.svg) | [@bheil](https://www.bheil.net) | Hosted in Germany |\n| ![](https://iplookup.flagfox.net/images/h16/FR.png) | https://bridge.suumitsu.eu | ![](https://img.shields.io/website/https/bridge.suumitsu.eu.svg) | [@mitsukarenai](https://github.com/mitsukarenai) | Hosted in Paris, France |\n| ![](https://iplookup.flagfox.net/images/h16/NL.png) | https://feed.eugenemolotov.ru | ![](https://img.shields.io/website/https/feed.eugenemolotov.ru.svg) | [@em92](https://github.com/em92) | Hosted in Amsterdam, Netherlands |\n| ![](https://iplookup.flagfox.net/images/h16/DE.png) | https://rss-bridge.mediani.de | ![](https://img.shields.io/website/https/rss-bridge.mediani.de.svg) | [@sokai](https://github.com/sokai) | Hosted with Netcup, Germany |\n| ![](https://iplookup.flagfox.net/images/h16/DE.png) | https://rb.ash.fail | ![](https://img.shields.io/website/https/rb.ash.fail.svg) | [@ash](https://ash.fail/contact.html) | Hosted with Hostaris, Germany\n| ![](https://iplookup.flagfox.net/images/h16/UA.png) | https://rss.noleron.com | ![](https://img.shields.io/website/https/rss.noleron.com) | [@ihor](https://noleron.com/about) | Hosted with Hosting Ukraine, Ukraine\n| ![](https://iplookup.flagfox.net/images/h16/IN.png) | https://rssbridge.projectsegfau.lt | ![](https://img.shields.io/website/https/rssbridge.projectsegfau.lt) | [@gi-yt](https://aryak.me) | Self-Hosted at Mumbai, India with Airtel (ISP) |\n| ![](https://iplookup.flagfox.net/images/h16/US.png) | https://rb.vern.cc | ![](https://img.shields.io/website/https/rb.vern.cc.svg) | [@vern.cc](https://vern.cc/en/admin) | Hosted with Hetzner, US |\n| ![](https://iplookup.flagfox.net/images/h16/DE.png) | https://rss.bloat.cat | ![](https://img.shields.io/website/https/rss.bloat.cat) | [@vlnst](https://bloat.cat/contact) | Hosted with Datalix, Germany |\n| ![](https://iplookup.flagfox.net/images/h16/CZ.png) | https://rssbridge.prenghy.org | ![](https://img.shields.io/website/https/rssbridge.prenghy.org.svg) | [@pprenghy](https://github.com/pprenghy) | Hosted with vpsFree, The Czech Republic |\n\n\n## Inactive instances\n\n| Country | Address | Status |  Contact | Comment |\n|:-------:|---------|--------|----------|---------|\n| ![](https://iplookup.flagfox.net/images/h16/FI.png) | https://rss-bridge.snopyta.org | ![](https://img.shields.io/website/https/rss-bridge.snopyta.org.svg) | [@Perflyst](https://github.com/Perflyst) | Hosted in Helsinki, Finland |\n"
  },
  {
    "path": "docs/02_CLI/index.md",
    "content": "RSS-Bridge supports calls via CLI.\nYou can use the same parameters as you would normally use via the URI. Example:\n\n`php index.php action=display bridge=DansTonChat format=Json`\n\n## Required parameters\n\nRSS-Bridge requires a few parameters that must be specified on every call.\nOmitting these parameters will result in error messages:\n\n### action\n\nDefines how RSS-Bridge responds to the request.\n\nValue | Description\n----- | -----------\n`action=list` | Returns a JSON formatted list of bridges. Other parameters are ignored.\n`action=display` | Returns (displays) a feed.\n\n### bridge\n\nThis parameter specifies the name of the bridge RSS-Bridge should return feeds from.\nThe name of the bridge equals the class name of the bridges in the ./bridges/ folder without the 'Bridge' prefix.\nFor example: DansTonChatBridge => DansTonChat.\n\n### format\n\nThis parameter specifies the format in which RSS-Bridge returns the contents.\n\n## Optional parameters\n\nRSS-Bridge supports optional parameters.\nThese parameters are only valid if the options have been enabled in the index.php script.\n\n### \\_noproxy\n\nThis parameter is only available if a proxy server has been specified via `proxy.url` and `proxy.by_bridge`\nhas been enabled. This is a Boolean parameter that can be set to `true` or `false`.\n\n## Bridge parameters\n\nEach bridge can specify its own set of parameters.\nAs in the example above, some bridges don't specify any parameters or only optional parameters that can be neglected.\nFor more details read the `PARAMETERS` definition for your bridge.\n"
  },
  {
    "path": "docs/03_For_Hosts/01_Installation.md",
    "content": "https://github.com/RSS-Bridge/rss-bridge/blob/master/README.md\n"
  },
  {
    "path": "docs/03_For_Hosts/02_Updating.md",
    "content": ""
  },
  {
    "path": "docs/03_For_Hosts/04_Heroku_Installation.md",
    "content": "This guide is for people who want to run RSS Bridge on [Heroku](https://heroku.com).\n\nYou can run it on Heroku for free, however make sure your RSS reader interval is not set to a fast rate, otherwise your Heroku hours will use up quicker. Heroku puts the app to sleep after 30 mins of no activity. When the app is asleep no Heroku hours are used. So choose an interval that won't make the app exceed the limit!\n\nYou can increase your Heroku hours to 1000 a month from 550 a month by simply verifying your account with a credit card.\n\n## Deploy button\nYou can simply press the button below to easily deploy RSS Bridge on Heroku and use the default bridges. Or you can follow the manual instructions given below.\n\n[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/RSS-Bridge/rss-bridge)\n\n## Manual deploy\n1. Fork this repo by clicking the Fork button at the top right of this page (only on desktop site)\n\n![image](../images/fork_button.png)\n\n2. To customise what bridges can be used if need, see [here](../03_For_Hosts/05_Whitelisting.md). You don’t need to do this if you’re fine with the default bridges.\n\n3. [Log in to Heroku](https://dashboard.heroku.com) and create a new app. The app name will be the URL of the RSS Bridge (appname.herokuapp.com)\n\n4. Go to Deploy, select the GitHub option and connect your GitHub account. Search for the repo named `yourusername/rss-bridge`\n\n![image](../images/heroku_deploy.png)\n\n5. Deploy the master branch."
  },
  {
    "path": "docs/03_For_Hosts/05_Whitelisting.md",
    "content": "Modify `config.ini.php` to limit available bridges. Those changes should be applied in the `[system]` section.\n\n## Enable all bridges\n\n```\n[system]\n\nenabled_bridges[] = *\n```\n\n## Enable some bridges\n\n```\n[system]\n\nenabled_bridges[] = TwitchBridge\nenabled_bridges[] = GettrBridge\n```\n\n## Enable all bridges (legacy shortcut)\n\n```\necho '*' > whitelist.txt\n```\n\n## Enable some bridges (legacy shortcut)\n\n```\necho -e \"TwitchBridge\\nTwitterBridge\" > whitelist.txt\n```\n"
  },
  {
    "path": "docs/03_For_Hosts/06_Authentication.md",
    "content": "\n* http basic auth\n* token\n* Access control via webserver (see nginx/caddy/apache docs)\n \nhttps://github.com/RSS-Bridge/rss-bridge/blob/master/README.md\n"
  },
  {
    "path": "docs/03_For_Hosts/07_Customizations.md",
    "content": "RSS-Bridge ships a few options the host may or may not activate.\nAll options are listed in the [config.default.ini.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/config.default.ini.php) file,\nsee [Custom Configuration](08_Custom_Configuration.md) section for more information.\n\n## Customizable cache timeout\n\nSometimes it is necessary to specify custom timeouts to update contents more frequently\nthan the bridge maintainer intended.\nIn these cases the client may specify a custom cache timeout to prevent loading contents\nfrom cache earlier (or later).\n\nThis option can be activated by setting the [`cache.custom_timeout`](08_Custom_Configuration.md#custom_timeout)\noption to `true`. When enabled each bridge receives an additional parameter\n`Cache timeout in seconds` that can be set to any value. Setting the value to\n0 will altogether disable caching of that feed.\n"
  },
  {
    "path": "docs/03_For_Hosts/08_Custom_Configuration.md",
    "content": "RSS-Bridge supports custom configurations for common parameters on the server side!\n\nA default configuration file (`config.default.ini.php`) is shipped with RSS-Bridge.\nPlease do not edit this file, as it gets replaced when upgrading RSS-Bridge!\n\nYou should, however, use this file as template to create your own configuration\n(or leave it as is, to keep the default settings).\nIn order to create your own configuration perform following actions:\n\n* Create the file `config.ini.php` in the RSS-Bridge root folder (next to `config.default.ini.php`)\n* Copy the contents from `config.default.ini.php` to your configuration file\n* Change the parameters to satisfy your requirements\n\nRSS-Bridge will automatically detect the `config.ini.php` and use it.\nIf the file doesn't exist it will default to `config.default.ini.php` automatically.\n\n__Notice__: If a parameter is not specified in your `config.ini.php` RSS-Bridge will\nautomatically use the default settings from `config.default.ini.php`.\n\n# Available parameters\n\nThe configuration file is split into sections:\n\n* [system](#system)\n* [http client](#http-client)\n* [cache](#cache)\n* [proxy](#proxy)\n* [authentication](#authentication)\n* [admin](#admin)\n* [error](#error)\n\n_System_: This section specifies system specific parameters\n\n_Http client_: This section has http client options\n\n_Cache_: This section is all about the caching behavior of RSS-Bridge\n\n_Proxy_: This section can be used to specify a proxy server for RSS-Bridge to utilize for fetching contents\n\n_Authentication_: This section defines parameters to require authentication to use RSS-Bridge\n\n_Admin_: This section specifies parameters related to the administrator of your instance of RSS-Bridge\n\n## System\n\nThis section provides following parameters:\n\n- [timezone](#timezone)\n\n### Timezone\n\nDefines the timezone used by RSS-Bridge. This parameter can be set to any value of the values defined at https://www.php.net/manual/en/timezones.php\n\nThe default value is `UTC`.\n\n## Cache\n\nThis section provides following parameters:\n\n- [type](#type)\n- [custom_timeout](#custom_timeout)\n\n### type\n\nDefines the cache type used by RSS-Bridge.\n\n| Type       | Description  \n| -------    | -----------\n|`file`      | File based (default)\n|`sqlite`    | SQLite database\n|`memcached` | Memcached service\n\n### custom_timeout\n\nAllow users to specify custom timeout for specific requests.\n\n`true` = enabled\n\n`false` = disabled (default)\n\n## Proxy\n\nThis section provides following parameters:\n\n- [url](#url)\n- [name](#name)\n- [by_bridge](#by_bridge)\n\n### url\n\nSets the proxy url (i.e. \"tcp://192.168.0.0:32\")\n\n`\"\"` = Proxy disabled (default)\n\n### name\n\nSets the proxy name that is shown on the bridge instead of the proxy url.\n\n`\"\"` = Show proxy url (default: \"Hidden proxy name\")\n\n### by_bridge\n\nAllow users to disable proxy usage for specific requests.\n\n`true`  = enabled\n\n`false` = disabled (default)\n\n## Http client\n\nThis section provides the following parameters:\n\n- timeout\n- useragent\n\n### timeout\n\nDefault network timeout.\n\n### useragent\n\nOverrides the user agent value. Note that the default value, together with a set of other detection-preventing options is set\nautomatically by the [libcurl-impersonate](https://github.com/lexiforest/curl-impersonate), which is used by the default Docker container distributed together with RSS-Bridge. Use only if you know what you're doing, otherwise you may stop libcurl-impersonate\nfrom doing its job impersonating real browser.\n\n## Authentication\n\nThis section provides following parameters:\n\n- [enable](#enable)\n- [username](#username)\n- [password](#password)\n\n### enable\n\nEnables authentication for RSS-Bridge.\n\n_Notice_: Login is required for all requests when enabled! Make sure to update feed subscriptions accordingly.\n\n`true`  = enabled\n\n`false` = disabled (default)\n\n### username\n\nDefines the user name used for login.\n\n### password\n\nDefines the password used for login. Use a strong password to prevent others from guessing your login!\n\n## Admin\n\nThis section provides following parameters:\n\n- [email](#email)\n\n### email\n\nAdvertises an email address where people can reach the administrator.\n\n*Notice*: This address is displayed on the main page, visible to everyone!\n\n`\"\"`    = Disabled (default)\n\nExample: `email = \"admin@instance.rss-bridge.com\"`\n\n## error\n\nThis section provides following parameters:\n\n- [output](#output)\n- [report_limit](#report_limit)\n\n### output\n\nDefines how error messages are returned by RSS-Bridge\n\n`feed`: As part of the feed (default)\n\n`http`: As HTTP error message\n\n`none`: No errors are reported\n\n### report_limit\n\nDefines how often an error must occur before it is reported to the user\n\n`report_limit`: 1 (default)\n"
  },
  {
    "path": "docs/03_For_Hosts/index.md",
    "content": "This section is directed at **hosts** and **server administrators**.\n\nRSS-Bridge comes with a large amount of bridges.\n\nSome bridges could be implemented more efficiently by actually using proprietary APIs,\nbut there are reasons against it:\n\n- RSS-Bridge exists in the first place to NOT use APIs.\n  See [the rant](https://github.com/RSS-Bridge/rss-bridge/blob/master/README.md#Rant).\n\n- APIs require private keys that could be stored on servers running RSS-Bridge,\n  which is a security concern, involves complex authorizations for inexperienced users and could cause harm (when using paid services for example). In a closed environment (a server only you use for yourself) however you might be interested in using them anyway. So, check [this](https://github.com/RSS-Bridge/rss-bridge/pull/478/files) possible implementation of an anti-captcha solution.\n"
  },
  {
    "path": "docs/04_For_Developers/01_Coding_style_policy.md",
    "content": "This section explains the coding style policy for RSS-Bridge with examples and references to external resources.\nPlease make sure your code is compliant before opening a pull request.\n\nYou will automatically be notified if issues were found in your pull request.\nYou must fix those issues before the pull request will be merged.\nRefer to [phpcs.xml](https://github.com/RSS-Bridge/rss-bridge/blob/master/phpcs.xml) for a complete list of policies enforced by Travis-CI.\n\nIf you want to run the checks locally, make sure you have\n[`phpcs`](https://github.com/PHPCSStandards/PHP_CodeSniffer) and\n[`phpunit`](https://phpunit.de/) installed on your machine and run following commands in the root directory of RSS-Bridge (tested on Debian):\n\n```console\n./vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0 --extensions=php -p ./\n./vendor/bin/phpunit\n```\n\nThe following list provides an overview of all policies applied to RSS-Bridge.\n\n# Whitespace\n\n## Add a new line at the end of a file\n\nEach PHP/CSS/HTML file must end with a new line at the end of a file.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n{\n    // code here\n} // This is the end of the file\n```\n\n**Good**\n\n```PHP\n{\n    // code here\n}\n// This is the end of the file\n```\n\n</div></details><br>\n\n_Reference_: [`PSR2.Files.EndFileNewline`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PSR2/Sniffs/Files/EndFileNewlineSniff.php)\n\n## Do not add a whitespace before a semicolon\n\nA semicolon indicates the end of a line of code. Spaces before the semicolon is unnecessary and must be removed.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\necho 'Hello World!' ;\n```\n\n**Good**\n\n```PHP\necho 'Hello World!';\n```\n\n</div></details><br>\n\n_Reference_: [`Squiz.WhiteSpace.SemicolonSpacing`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/WhiteSpace/SemicolonSpacingSniff.php)\n\n## Do not add whitespace at start or end of a file or end of a line\n\nWhitespace at the end of lines or at the start or end of a file is invisible to the reader and absolutely unnecessary. Thus it must be removed.\n\n_Reference_: [`Squiz.WhiteSpace.SuperfluousWhitespace`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/WhiteSpace/SuperfluousWhitespaceSniff.php)\n\n# Indentation\n## Use spaces indentation\n\n# Maximum Line Length\n\n180\n\n# Strings\n\n## Whenever possible use single quote strings\n\nPHP supports both single quote strings and double quote strings. For pure text you must use single quote strings for consistency. Double quote strings are only allowed for special characters (i.e. `\"\\n\"`) or inlined variables (i.e. `\"My name is {$name}\"`);\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\necho \"Hello World!\";\n```\n\n**Good**\n\n```PHP\necho 'Hello World!';\n```\n\n</div></details><br>\n\n_Reference_: [`Squiz.Strings.DoubleQuoteUsage`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/Strings/DoubleQuoteUsageSniff.php)\n\n## Add spaces around the concatenation operator\n\nThe concatenation operator should have one space on both sides in order to improve readability.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$text = $greeting.' '.$name.'!';\n```\n\n**Good** (add spaces)\n\n```PHP\n$text = $greeting . ' ' . $name . '!';\n```\n\n</div></details><br>\n\nYou may break long lines into multiple lines using the concatenation operator. That way readability can improve considerable when combining lots of variables.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$text = $greeting.' '.$name.'!';\n```\n\n**Good** (split into multiple lines)\n\n```PHP\n$text = $greeting\n. ' '\n. $name\n. '!';\n```\n\n</div></details><br>\n\n_Reference_: [`Squiz.Strings.ConcatenationSpacing`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/Strings/ConcatenationSpacingSniff.php)\n\n## Use a single string instead of concatenating\n\nWhile concatenation is useful for combining variables with other variables or static text. It should not be used to combine two sets of static text. See also: [Maximum line length](#maximum-line-length)\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$text = 'This is' . 'a bad idea!';\n```\n\n**Good**\n\n```PHP\n$text = 'This is a good idea!';\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.Strings.UnnecessaryStringConcat`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/Strings/UnnecessaryStringConcatSniff.php)\n\n# Constants\n\n## Use UPPERCASE for constants\n\nAs in most languages, constants should be written in UPPERCASE.\n\n_Notice_: This does not apply to keywords!\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nconst pi = 3.14;\n```\n\n**Good**\n\n```PHP\nconst PI = 3.14;\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.NamingConventions.UpperCaseConstantName`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/NamingConventions/UpperCaseConstantNameSniff.php)\n\n# Keywords\n## Use lowercase for `true`, `false` and `null`\n\n`true`, `false` and `null` must be written in lower case letters.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif($condition === TRUE && $error === FALSE) {\n    return NULL;\n}\n```\n\n**Good**\n\n```PHP\nif($condition === true && $error === false) {\n    return null;\n}\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.PHP.LowerCaseConstant`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/PHP/LowerCaseConstantSniff.php)\n\n# Operators\n## Operators must have a space around them\n\nOperators must be readable and therefore should have spaces around them.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$text='Hello '.$name.'!';\n```\n\n**Good**\n\n```PHP\n$text = 'Hello ' . $name . '!';\n```\n\n</div></details><br>\n\n_Reference_: [`Squiz.WhiteSpace.OperatorSpacing`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/WhiteSpace/OperatorSpacingSniff.php)\n\n# Functions\n## Parameters with default values must appear last in functions\n\nIt is considered good practice to make parameters with default values last in function declarations.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nfunction showTitle($duration = 60000, $title) { ... }\n```\n\n**Good**\n\n```PHP\nfunction showTitle($title, $duration = 60000) { ... }\n```\n\n</div></details><br>\n\n_Reference_: [`PEAR.Functions.ValidDefaultValue`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PEAR/Sniffs/Functions/ValidDefaultValueSniff.php)\n\n## Calling functions\n\nFunction calls must follow a few rules in order to maintain readability throughout the project:\n\n**Do not add whitespace before the opening parenthesis**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$result = my_function ($param);\n```\n\n**Good**\n\n```PHP\n$result = my_function($param);\n```\n\n</div></details><br>\n\n**Do not add whitespace after the opening parenthesis**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$result = my_function( $param);\n```\n\n**Good**\n\n```PHP\n$result = my_function($param);\n```\n\n</div></details><br>\n\n**Do not add a space before the closing parenthesis**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$result = my_function($param );\n```\n\n**Good**\n\n```PHP\n$result = my_function($param);\n```\n\n</div></details><br>\n\n**Do not add a space before a comma**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$result = my_function($param1 ,$param2);\n```\n\n**Good**\n\n```PHP\n$result = my_function($param1, $param2);\n```\n\n</div></details><br>\n\n**Add a space after a comma**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$result = my_function($param1,$param2);\n```\n\n**Good**\n\n```PHP\n$result = my_function($param1, $param2);\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.Functions.FunctionCallArgumentSpacing`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/Functions/FunctionCallArgumentSpacingSniff.php)\n\n## Do not add spaces after opening or before closing bracket\n\nParenthesis must tightly enclose parameters.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif( $condition ) { ... }\n```\n\n**Good**\n\n```PHP\nif($condition) { ... }\n```\n\n</div></details><br>\n\n_Reference_: [`PSR2.ControlStructures.ControlStructureSpacing`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PSR2/Sniffs/ControlStructures/ControlStructureSpacingSniff.php)\n\n# Structures\n## Structures must always be formatted as multi-line blocks\n\nA structure should always be treated as if it contains a multi-line block.\n\n**Add a space after closing parenthesis**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif($condition){\n    ...\n}\n```\n\n**Good**\n\n```PHP\nif($condition) {\n    ...\n}\n```\n\n</div></details><br>\n\n**Add body into new line**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif($condition){ ... }\n```\n\n**Good**\n\n```PHP\nif($condition) {\n    ...\n}\n```\n\n</div></details><br>\n\n**Close body in new line**\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif($condition){\n    ... }\n```\n\n**Good**\n\n```PHP\nif($condition) {\n    ...\n}\n```\n\n</div></details><br>\n\n_Reference_: [`Squiz.ControlStructures.ControlSignature`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/ControlStructures/ControlSignatureSniff.php)\n\n# If-Statements\n## Use `elseif` instead of `else if`\n\nFor sake of consistency `else if` is considered bad practice.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif($conditionA) {\n\n} else if($conditionB) {\n\n}\n```\n\n**Good**\n\n```PHP\nif($conditionA) {\n\n} elseif($conditionB) {\n\n}\n```\n\n</div></details><br>\n\n_Reference_: [`PSR2.ControlStructures.ElseIfDeclaration`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PSR2/Sniffs/ControlStructures/ElseIfDeclarationSniff.php)\n\n## Do not write empty statements\n\nEmpty statements are considered bad practice and must be avoided.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nif($condition) {\n    // empty statement\n} else {\n    // do something here\n}\n```\n\n**Good** (invert condition)\n\n```PHP\nif(!$condition) {\n    // do something\n}\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.CodeAnalysis.EmptyStatement`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/CodeAnalysis/EmptyStatementSniff.php)\n\n## Do not write unconditional if-statements\n\nIf-statements without conditions are considered bad practice and must be avoided.\n\n<details><summary>Example</summary><div><br>\n\n```PHP\nif(true) {\n\n}\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.CodeAnalysis.UnconditionalIfStatement`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/CodeAnalysis/UnconditionalIfStatementSniff.php)\n\n# Classes\n## Use PascalCase for class names\n\nClass names must be written in [PascalCase](http://wiki.c2.com/?PascalCase).\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nclass mySUPERclass { ... }\n```\n\n**Good**\n\n```PHP\nclass MySuperClass { ... }\n```\n\n</div></details><br>\n\n_Reference_: [`PEAR.NamingConventions.ValidClassName`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PEAR/Sniffs/NamingConventions/ValidClassNameSniff.php)\n\n## Do not use final statements inside final classes\n\nFinal classes cannot be extended, so it doesn't make sense to add the final keyword to class members.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nfinal class MyClass {\n    final public function MyFunction() {\n\n    }\n}\n```\n\n**Good** (remove the final keyword from class members)\n\n```PHP\nfinal class MyClass {\n    public function MyFunction() {\n\n    }\n}\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.CodeAnalysis.UnnecessaryFinalModifier`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/CodeAnalysis/UnnecessaryFinalModifierSniff.php)\n\n## Do not override methods to call their parent\n\nIt doesn't make sense to override a method only to call their parent. When overriding methods, make sure to add some functionality to it.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nclass MyClass extends BaseClass {\n    public function BaseFunction() {\n        parent::BaseFunction();\n    }\n}\n```\n\n**Good** (don't override the function)\n\n```PHP\nclass MyClass extends BaseClass {\n\n}\n```\n\n</div></details><br>\n\n_Reference_: [`Generic.CodeAnalysis.UselessOverridingMethod`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Generic/Sniffs/CodeAnalysis/UselessOverridingMethodSniff.php)\n\n## abstract and final declarations MUST precede the visibility declaration\n\nWhen declaring `abstract` and `final` functions, the visibility (scope) must follow after `abstract` or `final`.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nclass MyClass extends BaseClass {\n    public abstract function AbstractFunction() { }\n    public final function FinalFunction() { }\n}\n```\n\n**Good** (`abstract` and `final` before `public`)\n\n```PHP\nclass MyClass extends BaseClass {\n    abstract public function AbstractFunction() { }\n    final public function FinalFunction() { }\n}\n```\n\n</div></details><br>\n\n_Reference_: [`PSR2.Methods.MethodDeclaration`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PSR2/Sniffs/Methods/MethodDeclarationSniff.php)\n\n## static declaration MUST come after the visibility declaration\n\nThe `static` keyword must come after the visibility (scope) parameter.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\nclass MyClass extends BaseClass {\n    static public function StaticFunction() { }\n}\n```\n\n**Good** (`static` after `public`)\n\n```PHP\nclass MyClass extends BaseClass {\n    public static function StaticFunction() { }\n}\n```\n\n</div></details><br>\n\n_Reference_: [`PSR2.Methods.MethodDeclaration`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/PSR2/Sniffs/Methods/MethodDeclarationSniff.php)\n\n# Casting\n## Do not add spaces when casting\n\nThe casting type should be put into parenthesis without spaces.\n\n<details><summary>Example</summary><div><br>\n\n**Bad**\n\n```PHP\n$text = ( string )$number;\n```\n\n**Good**\n\n```PHP\n$text = (string)$number;\n```\n\n</div></details><br>\n\n_Reference_: [`Squiz.WhiteSpace.CastSpacing`](https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/src/Standards/Squiz/Sniffs/WhiteSpace/CastSpacingSniff.php)\n\n# Arrays\n## Always use the short array syntax\n"
  },
  {
    "path": "docs/04_For_Developers/02_Pull_Request_policy.md",
    "content": "Pull requests allow you to improve RSS-Bridge. Maintainers will have to understand your changes before merging. In order to make this process as efficient as possible, please follow the policies explained below. Maintainers will merge your pull request much faster that way.\n\n# Fix one issue per pull request\n\nIt is considered good practice to fix one specific (or a specific set of) error(s). You can open multiple pull requests if you need to address multiple subjects. The same applies to adding features to RSS-Bridge. Maintainers must be able to comprehend your pull request for it to be merged quickly.\n\n# Respect the coding style policy\n\nThe [coding style policy](./01_Coding_style_policy.md) requires you to write your code in certain ways. If you plan to get it merged into RSS-Bridge, please make sure your code follows the policy. Maintainers will only merge pull requests that pass all tests.\n\n# Properly name your commits\n\nCommits are not only for show, they do help maintainers understand what you did in your pull request, just like a table of contents in a well formed book (or Wiki). Here are a few rules you should follow:\n\n* When fixing a bridge (located in the `bridges` directory), write `[BridgeName] Feature` <br>(i.e. `[YoutubeBridge] Fix typo in video titles`).\n* When fixing other files, use `[FileName] Feature` <br>(i.e. `[index.php] Add multilingual support`).\n* When fixing a general problem that applies to multiple files, write `category: feature` <br>(i.e. `bridges: Fix various typos`)."
  },
  {
    "path": "docs/04_For_Developers/03_Folder_structure.md",
    "content": "The repository contains a few folders that make up **RSS-Bridge**. Here is a brief description of what you can expect to find where:\n\nFolder | Description\n-------|------------\n[`actions/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/actions) | Contains all “[controllers](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller)” for the web front-end.\n[`bridges/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/bridges) | Contains all bridges that are currently supported. Each file represents one Bridge that is displayed on the [Welcome screen](../01_General/04_Screenshots.md#welcome-screen) of **RSS-Bridge**\n[`caches/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/caches) | Contains implementation for different cache back-ends supported.\n[`contrib/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/contrib) | Contains various helpers for development and release process.\n[`docs/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/docs) | Contains this documentation.\n[`formats/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/formats) | Contains all export formats.\n[`lib/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/lib) | Contains the core API and helper functions.\n[`static/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/static) | Contains all static assets for the web front-end, including images, style sheets and JavaScript code.\n[`templates/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/templates) | Contains templates for producing the HTML of the web front-end.\n[`tests/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/tests) | Contains the test suite and fixtures.\n[`vendor/`](https://github.com/RSS-Bridge/rss-bridge/tree/master/vendor) | Contains third-party libraries used by **RSS-Bridge**. Development of all files in this folder must be done in the vendor specific repository (not part of **RSS-Bridge**)\n"
  },
  {
    "path": "docs/04_For_Developers/04_Actions.md",
    "content": "RSS-Bridge currently supports four 'actions' which it can operate:\n\n1) [Display](#display) (`?action=display`)\n2) [Detect](#detect) (`?action=detect`)\n3) [List](#list) (`?action=list`)\n3) [FindFeed](#findfeed) (`?action=findfeed`)\n\n## Display\n\nThe `display` action returns feeds generated by [bridges](../05_Bridge_API/index.md). It requires additional parameter, some of which are specific to each bridge (see implementation details for your specific bridge). The following list contains mandatory parameter applicable to all bridges, excluding parameters that require [additional options](../03_For_Hosts/08_Custom_Configuration.md). Errors are returned for missing mandatory parameters:\n\nParameter | Required | Description\n----------|----------|------------\n`bridge`  | yes      | Specifies the name of the bridge to display. Possible values are determined from the bridges available to the current instance of RSS-Bridge and the [whitelist](../03_For_Hosts/05_Whitelisting.md).\n`format`  | yes      | Specifies the name of the format to use for displaying the feed. Possible values are determined from the formats available to the current instance of RSS-Bridge.\n\n## Detect\n\nThe `detect` action attempts to redirect the user to an appropriate `display` action for a feed based on a supplied URL. As bridges have to individually implement this it may not work for every bridge.\n\nIf an appropriate bridge is found, a `301 Moved Permanently` HTTP status code is returned with a relative location for a `display` action. If no appropriate bridge is found or a required parameter is missing, a `400 Bad Request` status code is returned.\n\nThe parameters for this action are listed below:\n\nParameter | Required | Description\n----------|----------|------------\n`url`     | yes      | Specifies the URL to attempt to find a feed from. The value of this should be URL encoded.\n`format`  | yes      | Specifies the name of the format to use for displaying the feed. This is passed to the detected `display` action. Possible values are determined from the formats available to the current instance of RSS-Bridge.\n\n## List\n\nThe `list` action returns a JSON formatted text containing information on all bridges available to the current instance of RSS-Bridge. Inactive bridges (not [whitelisted](../03_For_Hosts/05_Whitelisting.md)) are included as well. Broken bridges are also included, but with limited parameters (only `\"status\": \"inactive\"`).\n\nThis example shows JSON data for a single bridge:\n\n```JSON\n{\n    \"bridges\": {\n        \"ABCTabs\": {\n            \"status\": \"active\",\n            \"uri\": \"https:\\/\\/www.abc-tabs.com\\/\",\n            \"name\": \"ABC Tabs Bridge\",\n            \"parameters\": [],\n            \"maintainer\": \"kranack\",\n            \"description\": \"Returns 22 newest tabs\"\n        }\n    },\n    \"total\": 1\n}\n```\n\nThe top-level JSON object contains two parameters:\n\n* [`bridges`](#bridges): A collection of bridges\n* [`total`](#total): The total number of bridges\n\n```JSON\n{\n  \"bridges\": { },\n  \"total\": 0\n}\n```\n\n### `bridges`\n\nThe `bridges` parameter is a collection of bridge objects. The name of each object represents the name of the bridge as needed for the [`display` action](#display). Each object contains parameters, most of which are optional. The following table summarizes the parameters:\n\nParameter     | Optional | Description\n--------------|----------|------------\n`status`      | No       | Indicates the status of the bridge. Possible values are '`active`' and '`inactive`'. Only `active` bridges can be used for the [`display` action](#display).\n`uri`         | Yes      | Returns the URI of the bridge, as returned by the [`getURI`](../05_Bridge_API/02_BridgeAbstract.md#geturi) function of the bridge.\n`name`        | Yes      | Returns the name of the bridge, as returned by the [`getName`](../05_Bridge_API/02_BridgeAbstract.md#getname) function of the bridge.\n`parameters`  | Yes      | Returns the `PARAMETERS` object of the bridge\n`maintainer`  | Yes      | Returns the name(s) of maintainer(s) for the bridge\n`description` | Yes      | Returns the description of the bridge\n\n### `total`\n\nThis parameter represents the total number of bridges available to the current instance of RSS-Bridge.\n\n## FindFeed\n\nThe `findfeed` action attempts to list all available feeds based on a supplied URL for the active bridges of this instance. As bridges have to individually implement `detectParameters`, this it may not work for every bridge.\n\nIf one or more bridges return a feed, a JSON data array structure is returned. If no feeds were found, a `404 Not Found` status code is returned. If a required parameter is missing, a `400 Bad Request` status code is returned.\n\nFor each feed, the whole feed URL is sent in the `url` member, the feed specific bridge parameters metadata in the `bridgeData` member and the Bridge metadata in the `bridgeMeta` member.\n\nThis example shows JSON data for the NASA Instagram account URL (`https://www.instagram.com/nasa/`) using the `Html` format :\n\n```JSON\n[\n    {\n        \"url\": \"https://rssbridge.host/?action=display&context=Username&u=nasa&bridge=InstagramBridge&format=Html\",\n        \"bridgeParams\": {\n            \"context\": \"Username\",\n            \"u\": \"nasa\",\n            \"bridge\": \"InstagramBridge\",\n            \"format\": \"Html\"\n        },\n        \"bridgeData\": {\n            \"context\": {\n                \"name\": \"Context\",\n                \"value\": \"Username\"\n            },\n            \"u\": {\n                \"name\": \"username\",\n                \"value\": \"nasa\"\n            }\n        },\n        \"bridgeMeta\": {\n            \"name\": \"Instagram Bridge\",\n            \"description\": \"Returns the newest images\",\n            \"parameters\": {\n                \"Username\": {\n                    \"u\": {\n                        \"name\": \"username\",\n                        \"exampleValue\": \"aesoprockwins\",\n                        \"required\": true\n                    }\n                },\n                \"Hashtag\": {\n                    \"h\": {\n                        \"name\": \"hashtag\",\n                        \"exampleValue\": \"beautifulday\",\n                        \"required\": true\n                    }\n                },\n                \"Location\": {\n                    \"l\": {\n                        \"name\": \"location\",\n                        \"exampleValue\": \"london\",\n                        \"required\": true\n                    }\n                },\n                \"global\": {\n                    \"media_type\": {\n                        \"name\": \"Media type\",\n                        \"type\": \"list\",\n                        \"required\": false,\n                        \"values\": {\n                            \"All\": \"all\",\n                            \"Video\": \"video\",\n                            \"Picture\": \"picture\",\n                            \"Multiple\": \"multiple\"\n                        },\n                        \"defaultValue\": \"all\"\n                    },\n                    \"direct_links\": {\n                        \"name\": \"Use direct media links\",\n                        \"type\": \"checkbox\"\n                    }\n                }\n            },\n            \"icon\": \"https://www.instagram.com//favicon.ico\"\n        }\n    }\n]\n```\n\nThe parameters for this action are listed below:\n\nParameter | Required | Description\n----------|----------|------------\n`url`     | yes      | Specifies the URL to attempt to find a feed from. The value of this should be URL encoded.\n`format`  | yes      | Specifies the name of the format to use for the URL of the feeds. This is passed to the detected `display` action. Possible values are determined from the formats available to the current instance of RSS-Bridge.\n"
  },
  {
    "path": "docs/04_For_Developers/05_Debug_mode.md",
    "content": "Debug mode has been removed.\n\nIf you want to disable caching you can set cache type to array (in-memory cache):\n\n```ini\n[cache]\n\n; Cache type: file, sqlite, memcached, array, null\ntype = \"array\"\n```\n\nAlternatively, you can comment out the cache middleware in `lib/RssBridge.php`:\n\n```diff\ndiff --git a/lib/RssBridge.php b/lib/RssBridge.php\nindex d16f1d89..da3df8be 100644\n--- a/lib/RssBridge.php\n+++ b/lib/RssBridge.php\n@@ -24,7 +24,7 @@ final class RssBridge\n\n         $middlewares = [\n             new BasicAuthMiddleware(),\n-            new CacheMiddleware($this->container['cache']),\n+            //new CacheMiddleware($this->container['cache']),\n             new ExceptionMiddleware($this->container['logger']),\n             new SecurityMiddleware(),\n             new MaintenanceMiddleware(),\n```"
  },
  {
    "path": "docs/04_For_Developers/06_Github_Codespaces_Tutorial.md",
    "content": "Github Codespaces lets you develop RSS-Bridge right from within your browser in an online hosted environment without the need to install anything. Github Codespaces is free, check out [this](https://github.com/features/codespaces) for more info.\n\n# How to get started\n\nYou must enable Codespaces for your account [here](https://github.com/features/codespaces) . After you are enabled to use Codespaces, you will get the additional functionality that you can see in the screenshots below.\n\n# How to develop for RSS-Bridge\n\nThis will give you an example workflow of how to create a bridge for RSS-Bridge using codespaces\n\n1. Fork the main RSS-Bridge repo\n2. On your own repo, click the \"code\" icon on the top on your repo and select \"Create codespace on master\"\n\n   ![create codespace](../images/codespaces_01.png)\n3. A new window will open and show this screen. This means that your dev environment is being prepared\n\n   ![creating](../images/codespaces_02.png)\n4. When the window has loaded, give it some time to run all the preparation scripts. You will see that it is done when you see a \"Listen for Xdebug (rss-bridge)\" line in the bottom row\n\n   ![done](../images/codespaces_03.png)\n5. At this point, there is a running instance of RSS-Bridge active that you can open by clicking on the \"PORTS\" tab and then on the icon to open the website for port 3100\n\n   ![ports](../images/codespaces_04.png)\n6. Xdebug is already started so you can set breakpoints and check out the variables in the debug pane\n\n   ![debug](../images/codespaces_05.png)\n7. You can now create a new branch for your new bridge by clicking on the \"master\" entry in the bottom left and select \"create new branch\" from the menu.\n8. You can commit straight from the IDE as your github credentials are already included in the Codespace.\n9. To open a PR, either go back to the Github website and open it there or do it right from the Codespaces instance using the github integration (when you push a new branch, it will ask you if you want to open a new PR).\n\n# How-Tos\n\nThis guide assumes that you already know the basics of php development, some basics in VScode and some basics in working with git. If you want to know more about any of these steps, check out these How-Tos\n* Check [How to create a new Bridge](../05_Bridge_API/01_How_to_create_a_new_bridge.md) on how to do that.\n* Check [This Youtube Tutorial](https://youtu.be/i_23KUAEtUM?t=54) for a quick introduction to using VSCode with Git (ignore the initial git setup, Codespaces does that for you)"
  },
  {
    "path": "docs/04_For_Developers/07_Development_Environment_Setup.md",
    "content": "\n## Docs with Docker\n\nIf you want to edit the docs add this to your docker-compose.yml:\n\n```yml\nservices:\n  [...]\n\n  daux:\n    image: daux/daux.io\n    ports:\n      - 8085:8085\n    working_dir: /build\n    volumes:\n      - ./rss-bridge/docs:/build/docs\n    network_mode: host\n```\n\nand run for example the `daux serve` command with `docker-compose run --rm daux daux serve`.\nAfter that you can access the docs at `localhost:8085` and edit the files in `rss-bridge/docs`.\n"
  },
  {
    "path": "docs/04_For_Developers/index.md",
    "content": "This area is intended for developers who decide to contribute to **RSS-Bridge**.\n\nIt is written in PHP.\n\nIf you are new to **RSS-Bridge** you should make yourself familiar with some general aspects:\n\n  - [Coding style policy](./01_Coding_style_policy.md)\n  - [Folder structure](./03_Folder_structure.md)\n  - [Bridge API](../05_Bridge_API/index.md)\n  - [Cache API](../07_Cache_API/index.md)\n  - [Technical recommendations](../09_Technical_recommendations/index.md)\n"
  },
  {
    "path": "docs/05_Bridge_API/01_How_to_create_a_new_bridge.md",
    "content": "# How to create a completely new bridge\n\nNew code files MUST have `declare(strict_types=1);` at the top of file:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n```\n\nCreate the new bridge in e.g. `bridges/BearBlogBridge.php`:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\nclass BearBlogBridge extends BridgeAbstract\n{\n    const NAME = 'BearBlog (bearblog.dev)';\n\n    public function collectData()\n    {\n        $dom = getSimpleHTMLDOM('https://herman.bearblog.dev/blog/');\n        foreach ($dom->find('.blog-posts li') as $li) {\n            $a = $li->find('a', 0);\n            $this->items[] = [\n                'title' => $a->plaintext,\n                'uri' => 'https://herman.bearblog.dev' . $a->href,\n            ];\n        }\n    }\n}\n```\n\nLearn more in [bridge api](https://rss-bridge.github.io/rss-bridge/Bridge_API/index.html).\n"
  },
  {
    "path": "docs/05_Bridge_API/02_BridgeAbstract.md",
    "content": "`BridgeAbstract` is a base class for standard bridges.\nIt implements the most common functions to simplify the process of adding new bridges.\n\n***\n\n# Creating a new bridge\n\nYou need four basic steps in order to create a new bridge:\n\n[**Step 1**](#step-1---create-a-new-file) - Create a new file\n[**Step 2**](#step-2---add-a-class-extending-bridgeabstract) - Add a class, extending `BridgeAbstract`\n[**Step 3**](#step-3---add-general-constants-to-the-class) - Add general constants to the class\n[**Step 4**](#step-4---implement-a-function-to-collect-feed-data) - Implement a function to collect feed data\n\nThese steps are described in more detail below.\nAt the end of this document you'll find a complete [template](#template) based on these instructions.\nThe pictures below show an example based on these instructions:\n\n<details><summary>Show pictures</summary><div>\n\n![example card](../images/screenshot_bridgeabstract_example_card.png)\n\n***\n\n![example atom](../images/screenshot_bridgeabstract_example_atom.png)\n\n</div></details><br>\n\nMake sure to read these instructions carefully.\nPlease don't hesitate to open an \n[Issue](https://github.com/RSS-Bridge/rss-bridge/issues)\nif you have further questions (or suggestions).\nOnce your bridge is finished, please open a [Pull Request](https://github.com/RSS-Bridge/rss-bridge/pulls),\nin order to get your bridge merge into RSS-Bridge.\n\n***\n\n## Step 1 - Create a new file\n\nPlease read [these instructions](./01_How_to_create_a_new_bridge.md) on how to create a new file for RSS-Bridge.\n\n## Step 2 - Add a class, extending `BridgeAbstract`\n\nYour bridge needs to be a class, which extends `BridgeAbstract`.\nThe class name must **exactly** match the name of the file, without the file extension.\n\nFor example: `MyBridge.php` => `MyBridge`\n\n<details><summary>Show example</summary><div>\n\n```PHP\n<?PHP\nclass MyBridge extends BridgeAbstract\n{\n\n}\n```\n\n</div></details>\n\n## Step 3 - Add general constants to the class\n\nIn order to present your bridge on the front page, RSS-Bridge requires a few constants:\n\n```PHP\nconst NAME          // Name of the Bridge (default: \"Unnamed Bridge\")\nconst URI           // URI to the target website of the bridge (default: empty)\nconst DESCRIPTION   // A brief description of the Bridge (default: \"No description provided\")\nconst MAINTAINER    // Name of the maintainer, i.e. your name on GitHub (default: \"No maintainer\")\nconst PARAMETERS    // (optional) Definition of additional parameters (default: empty)\nconst CACHE_TIMEOUT // (optional) Defines the maximum duration for storing the cached feed result in seconds (default: 3600). 0 disables caching altogether. Note that this pertains to the feed itself, not the individual items you may want to save to/load from the cache using saveCacheValue()/loadCacheValue() helpers.\n```\n\n<details><summary>Show example</summary><div>\n\n```PHP\n<?php\n\nclass MyBridge extends BridgeAbstract\n{\n\tconst NAME        = 'My Bridge';\n\tconst URI         = 'https://rss-bridge.github.io/rss-bridge/Bridge_API/BridgeAbstract.html';\n\tconst DESCRIPTION = 'Returns \"Hello World!\"';\n\tconst MAINTAINER  = 'ghost';\n}\n```\n\n</div></details><br>\n\n**Notice**: `const PARAMETERS` can be used to request information from the user.\nRefer to [these instructions](#parameters) for more information.\n\n## Step 4 - Implement a function to collect feed data\n\nIn order for RSS-Bridge to collect data, you must implement the **public** function `collectData`.\nThis function takes no arguments and returns nothing.\nIt generates a list of feed elements, which must be placed into the variable `$this->items`.\n\n<details><summary>Show example</summary><div>\n\n```PHP\n<?php\n\nclass MyBridge extends BridgeAbstract\n{\n    const NAME        = 'My Bridge';\n    const URI         = 'https://rss-bridge.github.io/rss-bridge/Bridge_API/BridgeAbstract.html';\n    const DESCRIPTION = 'Returns \"Hello World!\"';\n    const MAINTAINER  = 'ghost';\n\n    public function collectData()\n    {\n        $item = [];\n        $item['title'] = 'Hello World!';\n        $this->items[] = $item;\n    }\n}\n```\n\n</div></details><br>\n\nFor more details on the `collectData` function refer to [these instructions](#collectdata).\n\n***\n\n# Template\n\nUse this template to create your own bridge.\nPlease remove any unnecessary comments and parameters.\n\n```php\n<?php\n\nclass MyBridge extends BridgeAbstract\n{\n    const NAME = 'Unnamed';\n    const URI = '';\n    const DESCRIPTION = 'No description provided';\n    const MAINTAINER = 'No maintainer';\n    const PARAMETERS = []; // Can be omitted!\n    const CACHE_TIMEOUT = 3600; // Can be omitted!\n    \n    public function collectData()\n    {\n        $item = []; // Create an empty item\n    \n        $item['title'] = 'Hello World!';\n    \n        $this->items[] = $item; // Add item to the list\n    }\n}\n```\n\n# PARAMETERS\n\nYou can specify additional parameters in order to customize the bridge (i.e. to specify how many items to return).\nThis document explains how to specify those parameters and which options are available to you.\n\nFor information on how to read parameter values during execution, please refer to the [getInput](../06_Helper_functions/index.md#getinput) function.\n\n***\n\n## Adding parameters to a bridge\n\nParameters are specified as part of the bridge class.\nAn empty list of parameters is defined as `const PARAMETERS = [];`\n\n<details><summary>Show example</summary><div>\n\n```PHP\n<?php\n\nclass MyBridge extends BridgeAbstract {\n\t/* ... */\n\tconst PARAMETERS = []; // Empty list of parameters (can be omitted)\n\t/* ... */\n}\n```\n\n</div></details><br>\n\nParameters are organized in two levels:\n\n[**Level 1**](##level-1---context) - Context\n[**Level 2**](##level-2---parameter) - Parameter\n\n## Level 1 - Context\n\nA context is defined as a associative array of parameters.\nThe name of a context is displayed by RSS-Bridge.\n\n<details><summary>Show example</summary><div>\n\n```PHP\nconst PARAMETERS = [\n\t'My Context 1' => [],\n\t'My Context 2' => [],\n];\n```\n\n**Output**\n\n![bridge context named](../images/bridge_context_named.png)\n\n</div></details><br>\n\n_Notice_: The name of a context can be left empty if only one context is needed!\n\n<details><summary>Show example</summary><div>\n\n```PHP\nconst PARAMETERS = [\n\t[]\n];\n```\n\n</div></details><br>\n\nYou can also define a set of parameters that will be applied to every possible context of your bridge.\nTo do this, specify a context named `global`.\n\n<details><summary>Show example</summary><div>\n\n```PHP\nconst PARAMETERS = [\n\t'global' => [] // Applies to all contexts!\n];\n```\n\n</div></details>\n\n## Level 2 - Parameter\n\nParameters are placed inside a context.\nThey are defined as associative array of parameter specifications.\nEach parameter is defined by it's internal input name, a definition in the form `'n' => [];`, \nwhere `n` is the name with which the bridge can access the parameter during execution.\n\n<details><summary>Show example</summary><div>\n\n```PHP\nconst PARAMETERS = [\n    'My Context' => [\n        'n' => [],\n    ]\n];\n```\n\n</div></details><br>\n\nThe parameter specification consists of various fields, listed in the table below.\n\n<details><summary>Show example</summary><div>\n\n```PHP\nconst PARAMETERS = [\n\t'My Context' => [\n\t\t'n' => [\n\t\t\t'name' => 'Limit',\n\t\t\t'type' => 'number',\n\t\t\t'required' => false,\n\t\t\t'title' => 'Maximum number of items to return',\n\t\t\t'defaultValue' => 10,\n\t\t]\n\t]\n];\n```\n\n**Output**\n\n![context parameter](../images/context_parameter_example.png)\n\n</div></details>\n\n***\n\nParameter Name | Required | Type | Supported values | Description\n---------------|----------|------|------------------| -----------\n`name` | **yes** | Text | | Input name as displayed to the user\n`type` | no | Text | `text`, `number`, `list`, `checkbox` | Type of the input (default: `text`)\n`required` | no | Boolean | `true`, `false` | Specifies if the parameter is required or not (default: `false`). Not supported for lists and checkboxes.\n[`values`](#list-values) | no | associative array | | name/value pairs used by the HTML option tag, required for type '`list`'\n`title` | no | Text | | Used as tool-tip when mouse-hovering over the input box\n`pattern` | no | Text | | Defines a pattern for an element of type `text`. The pattern should be mentioned in the `title` attribute!\n`exampleValue` | no | Text | | Defines an example value displayed for elements of type `text` and `number` when no data has been entered yet\n[`defaultValue`](#defaultvalue) | no | | | Defines the default value if left blank by the user\n\n#### List values\n\nList values are defined in an associative array where keys are the string displayed in the combo list of the **RSS-Bridge** web interface, and values are the content of the \\<option\\> HTML tag value attribute.\n\n```PHP\n...\n    'type' => 'list',\n    'values' => [\n        'Item A' => 'itemA'\n        'Item B' => 'itemB'\n     ]\n...\n```\n\nIf a more complex organization is required to display the values, the above key/value can be used to set a title as a key and another array as a value:\n\n```PHP\n...\n    'type' => 'list',\n    'values' => [\n        'Item A' => 'itemA',\n        'List 1' => [\n            'Item C' => 'itemC',\n            'Item D' => 'itemD'\n        ],\n        'List 2' => [\n            'Item E' => 'itemE',\n            'Item F' => 'itemF'\n        ],\n        'Item B' => 'itemB'\n    ]\n...\n```\n\n#### defaultValue\n\nThis attribute defines the default value for your parameter. Its behavior depends on the `type`:\n\n- `text`: Allows any text\n- `number`: Allows any number\n- `list`: Must match either name or value of one element\n- `checkbox`: Must be \"checked\" to activate the checkbox\n\n***\n\n# queriedContext\n\nThe queried context is defined via `PARAMETERS` and can be accessed via `$this->queriedContext`.\nIt provides a way to identify which context the bridge is called with.\n\nExample:\n\n```PHP\nconst PARAMETERS = [\n    'By user name' => [\n        'u' => ['name' => 'Username']\n    ],\n    'By user ID' => [\n        'id' => ['name' => 'User ID']\n    ]\n];\n```\n\nIn this example `$this->queriedContext` will either return **By user name** or **By user ID**.\nThe queried context might return no value, so the best way to handle it is by using a case-structure:\n\n```PHP\nswitch($this->queriedContext){\n\tcase 'By user name':\n\t\tbreak;\n\tcase 'By user ID':\n\t\tbreak;\n\tdefault: // Return default value\n}\n```\n\n# collectData\n\nThe `collectData` function is responsible for collecting data and adding items to generate feeds from.\nIf you are unsure how to solve a specific problem, please don't hesitate to open an [Issue](https://github.com/RSS-Bridge/rss-bridge/issues) on GitHub.\nExisting bridges are also a good source to learn implementing your own bridge.\n\n## Implementing the `collectData` function\n\nImplementation for the `collectData` function is specific to each bridge.\nHowever, there are certain reoccurring elements, described below. RSS-Bridge also provides functions to simplify the process of collecting and parsing HTML data (see \"Helper Functions\" on the sidebar)\n\nElements collected by this function must be stored in `$this->items`.\nThe `items` variable is an array of item elements, each of which is an associative array that may contain arbitrary keys.\nRSS-Bridge specifies common keys which are used to generate most common feed formats.\n\n<details><summary>Show example</summary><div>\n\n```PHP\n$item = [];\n$item['title'] = 'Hello World!';\n$this->items[] = $item;\n```\n\n</div></details><br>\n\nAdditional keys may be added for custom APIs (ignored by RSS-Bridge).\n\n## Item parameters\n\nThe item array should provide as much information as possible for RSS-Bridge to generate feature rich feeds.\nFind below list of keys supported by RSS-Bridge.\n\n```PHP\n$item['uri']        // URI to reach the subject (\"https://...\")\n$item['title']      // Title of the item\n$item['timestamp']  // Timestamp of the item in numeric or text format (compatible for strtotime())\n$item['author']     // Name of the author for this item\n$item['content']    // Content in HTML format\n$item['enclosures'] // Array of URIs to an attachments (pictures, files, etc...)\n$item['categories'] // Array of categories / tags / topics\n$item['uid']        // A unique ID to identify the current item\n```\n\nAll formats support these parameters. The formats `Plaintext` and `JSON` also support custom parameters.\n\n# getDescription\n\nThe `getDescription` function returns the description for a bridge.\n\n**Notice:** By default **RSS-Bridge** returns the contents of `const DESCRIPTION`,\nso you only have to implement this function if you require different behavior!\n\n```PHP\npublic function getDescription()\n{\n    return self::DESCRIPTION;\n}\n```\n\n# getMaintainer\n\nThe `getMaintainer` function returns the name of the maintainer for a bridge.\n\n**Notice:** By default **RSS-Bridge** returns `const MAINTAINER`,\nso you only have to implement this function if you require different behavior!\n\n```PHP\npublic function getMaintainer()\n{\n    return self::MAINTAINER;\n}\n```\n\n# getName\n\nThe `getName` function returns the name of a bridge.\n\n**Notice:** By default **RSS-Bridge** returns `const NAME`,\nso you only have to implement this function if you require different behavior!\n\n```PHP\npublic function getName()\n{\n    return self::NAME;\n}\n```\n\n# getURI\n\nThe `getURI` function returns the base URI for a bridge.\n\n**Notice:** By default **RSS-Bridge** returns `const URI`,\nso you only have to implement this function if you require different behavior!\n\n```PHP\npublic function getURI()\n{\n    return self::URI;\n}\n```\n\n# getIcon\n\nThe `getIcon` function returns the URI for an icon, used as favicon in feeds.\n\nIf no icon is specified by the bridge,\nRSS-Bridge will use a default location: `static::URI . '/favicon.ico'` (i.e. \"https://github.com/favicon.ico\") which may or may not exist.\n\n```PHP\npublic function getIcon()\n{\n    return static::URI . '/favicon.ico';\n}\n```\n\n# detectParameters\n\nThe `detectParameters` function takes a URL and attempts to extract a valid set of parameters for the current bridge.\n\nIf the passed URL is valid for this bridge, the function should return an array of parameter -> value pairs that can be used by this bridge, including context if available, or an empty array if the bridge requires no parameters. If the URL is not relevant for this bridge, the function should return `null`.\n\n**Notice:** Implementing this function is optional. By default, **RSS-Bridge** tries to match the supplied URL to the `URI` constant defined in the bridge, which may be enough for bridges without any parameters defined.\n\n```PHP\npublic function detectParameters($url)\n{\n    $regex = '/^(https?:\\/\\/)?(www\\.)?(.+?)(\\/)?$/';\n    if (empty(static::PARAMETERS)\n        && preg_match($regex, $url, $urlMatches) > 0\n        && preg_match($regex, static::URI, $bridgeUriMatches) > 0\n        && $urlMatches[3] === $bridgeUriMatches[3]\n    ) {\n        return [];\n    } else {\n        return null;\n    }\n}\n```\n\n**Notice:** This function is also used by the [findFeed](../04_For_Developers/04_Actions.md#findfeed) action.\nThis action allows an user to get a list of all feeds corresponding to an URL.\n\nYou can implement automated tests for the `detectParameters` function by adding the `TEST_DETECT_PARAMETERS` constant to your bridge class constant.\n\n`TEST_DETECT_PARAMETERS` is an array, with as key the URL passed to the `detectParameters`function and as value, the array of parameters returned by `detectParameters` \n\n```PHP\nconst TEST_DETECT_PARAMETERS = [\n    'https://www.instagram.com/metaverse' => ['context' => 'Username', 'u' => 'metaverse'],\n    'https://instagram.com/metaverse' => ['context' => 'Username', 'u' => 'metaverse'],\n    'http://www.instagram.com/metaverse' => ['context' => 'Username', 'u' => 'metaverse'],\n];\n```\n\n**Notice:** Adding this constant is optional. If the constant is not present, no automated test will be executed.\n\n\n***\n\n# Helper Methods\n\n`BridgeAbstract` implements helper methods to make it easier for bridge maintainers to create bridges.\nUse these methods whenever possible instead of writing your own.\n\n## saveCacheValue\n\nWithin the context of the current bridge, stores a value by key in the cache.\nOptionally specifies the cache duration in seconds for the key.\nThe value can later be retrieved with [loadCacheValue](#loadcachevalue).\n\n```php\nprotected function saveCacheValue($key, $value, $ttl = null)\n```\n\nExample:\n\n```php\npublic function collectData()\n{\n    $this->saveCacheValue('my_key', 'my_value', 3600); // 1h\n}\n```\n\n## loadCacheValue\n\nWithin the context of the current bridge, loads a value by key from cache.\nReturns `null` if the key doesn't exist or the value is expired.\n\n```php\nprotected function loadCacheValue($key, $default = null)\n```\n\nExample:\n\n```php\npublic function collectData()\n{\n    $value = $this->loadCacheValue('my_key');\n\n    if (! $value) {\n        $this->saveCacheValue('my_key', 'foobar');\n    }\n}\n```\n"
  },
  {
    "path": "docs/05_Bridge_API/03_FeedExpander.md",
    "content": "**Usage example**: _You have discovered a site that provides feeds which are hidden and inaccessible by normal means. You want your bridge to directly read the feeds and provide them via **RSS-Bridge**_\n\nFind a [template](#template) at the end of this file.\n\n**Notice:** For a standard feed only `collectData` need to be implemented. `collectData` should call `$this->collectExpandableDatas('your URI here');` to automatically load feed items and header data (will subsequently call `parseItem` for each item in the feed). You can limit the number of items to fetch by specifying an additional parameter for: `$this->collectExpandableDatas('your URI here', 10)` (limited to 10 items).\n\n## The `parseItem` method\n\nThis method receives one item from the current feed and should return one **RSS-Bridge** item.\nThe default function does all the work to get the item data from the feed, whether it is RSS 1.0,\nRSS 2.0 or Atom 1.0.\n\n**Notice:** The following code sample is just an example. Implementation depends on your requirements!\n\n```PHP\nprotected function parseItem(array $item)\n{\n    $item['content'] = str_replace('rssbridge','RSS-Bridge',$item['content']);\n    return $item;\n}\n```\n\n### Feed parsing\n\nHow rss-bridge processes xml feeds:\n\nFunction | uri | title | timestamp | author | content\n---------|-----|-------|-----------|--------|--------\n`atom` | id | title | updated | author | content\n`rss 0.91` | link | title | | | description\n`rss 1.0` | link | title | dc:date | dc:creator | description\n`rss 2.0` | link, guid | title | pubDate, dc:date | author, dc:creator | description\n\n# Template\n\nThis is the template for a new bridge:\n\n```PHP\n<?php\nclass MySiteBridge extends FeedExpander\n{\n\n    const MAINTAINER = 'No maintainer';\n    const NAME = 'Unnamed';\n    const URI = '';\n    const DESCRIPTION = 'No description provided';\n    const PARAMETERS = [];\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $this->collectExpandableDatas('your feed URI');\n    }\n}\n```"
  },
  {
    "path": "docs/05_Bridge_API/04_WebDriverAbstract.md",
    "content": "`WebDriverAbstract` extends [`BridgeAbstract`](./02_BridgeAbstract.md) and adds functionality for generating feeds\nfrom active websites that use XMLHttpRequest (XHR) to load content and / or JavaScript to\nmodify content.\nIt highly depends on the php-webdriver library which offers Selenium WebDriver bindings for PHP.\n\n- https://github.com/php-webdriver/php-webdriver (Project Repository)\n- https://php-webdriver.github.io/php-webdriver/latest/ (API)\n\nPlease note that this class is intended as a solution for websites _that cannot be covered\nby the other classes_. The WebDriver starts a browser and is therefore very resource-intensive.\n\n# Configuration\n\nYou need a running WebDriver to use bridges that depend on `WebDriverAbstract`.\nThe easiest way is to start the Selenium server from the project of the same name:\n```\ndocker run -d -p 4444:4444 --shm-size=\"2g\" docker.io/selenium/standalone-chrome:latest\n```\n\n- https://github.com/SeleniumHQ/docker-selenium\n\nWith these parameters only one browser window can be started at a time.\nOn a multi-user site, Selenium Grid should be used\nand the number of sessions should be adjusted to the number of processor cores.\n\nFinally, the `config.ini.php` file must be adjusted so that the WebDriver\ncan find the Selenium server:\n```\n[webdriver]\n\nselenium_server_url = \"http://localhost:4444\"\n```\n\n# Development\n\nWhile you are programming a new bridge, it is easier to start a local WebDriver because then you can see what is happening and where the errors are. I've also had good experience recording the process with a screen video to find any timing problems.\n\n```\nchromedriver --port=4444\n```\n\n- https://chromedriver.chromium.org/\n\nIf you start rss-bridge from a container, then Chrome driver is only accessible\nif you call it with the `--allowed-ips` option so that it binds to all network interfaces.\n\n```\nchromedriver --port=4444 --allowed-ips=192.168.1.42\n```\n\nThe **most important rule** is that after an event such as loading the web page\nor pressing a button, you often have to explicitly wait for the desired elements to appear.\n\nA simple example is the bridge `ScalableCapitalBlogBridge.php`.\nA more complex and relatively complete example is the bridge `GULPProjekteBridge.php`.\n\n# Template\n\nUse this template to create your own bridge.\n\n```PHP\n<?php\n\nclass MyBridge extends WebDriverAbstract\n{\n    const NAME = 'My Bridge';\n    const URI = 'https://www.example.org';\n    const DESCRIPTION = 'Further description';\n    const MAINTAINER = 'your name';\n\n    public function collectData()\n    {\n        parent::collectData();\n\n        try {\n            // TODO\n        } finally {\n            $this->cleanUp();\n        }\n    }\n}\n\n```"
  },
  {
    "path": "docs/05_Bridge_API/05_XPathAbstract.md",
    "content": "`XPathAbstract` extends [`BridgeAbstract`](./02_BridgeAbstract.md) and adds functionality for generating feeds based on _XPath expressions_. It makes creation of new bridges easy and if you're familiar with XPath expressions this class is probably the right point for you to start with.\n\nAt the end of this document you'll find a complete [template](#template) based on these instructions.\n\n***\n# Required constants\nTo create a new Bridge based on `XPathAbstract` your inheriting class should specify a set of constants describing the feed and the XPath expressions.\n\nIt is advised to override constants inherited from [`BridgeAbstract`](./02_BridgeAbstract.md#step-3---add-general-constants-to-the-class) aswell.\n\n## Class constant `FEED_SOURCE_URL`\nSource Web page URL (should provide either HTML or XML content). You can specify any website URL which serves data suited for display in RSS feeds\n\n## Class constant `XPATH_EXPRESSION_FEED_TITLE`\nXPath expression for extracting the feed title from the source page. If this is left blank or does not provide any data `BridgeAbstract::getName()` is used instead as the feed's title.\n\n## Class constant `XPATH_EXPRESSION_FEED_ICON`\nXPath expression for extracting the feed favicon URL from the source page. If this is left blank or does not provide any data `BridgeAbstract::getIcon()` is used instead as the feed's favicon URL.\n\n## Class constant `XPATH_EXPRESSION_ITEM`\nXPath expression for extracting the feed items from the source page. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_TITLE`\nXPath expression for extracting an item title from the item context. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_CONTENT`\nXPath expression for extracting an item's content from the item context. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_URI`\nXPath expression for extracting an item link from the item context. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_AUTHOR`\nXPath expression for extracting an item author from the item context. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_TIMESTAMP`\nXPath expression for extracting an item timestamp from the item context. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_ENCLOSURES`\nXPath expression for extracting item enclosures (media content like images or movies) from the item context. 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.\n\n## Class constant `XPATH_EXPRESSION_ITEM_CATEGORIES`\nXPath expression for extracting an item category from the item context. 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.\n\n## Class constant `SETTING_FIX_ENCODING`\nTurns on automatic fixing of encoding errors. Set this to true for fixing 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.\n\n# Optional methods\n`XPathAbstract` offers a set of methods which can be overridden by derived classes for fine tuning and customization. This is optional. The methods provided for overriding can be grouped into three categories.\n\n## Methods for providing XPath expressions\nUsually XPath expressions are defined in the class constants described above. By default the following base methods just return the value of its corresponding class constant. However deriving classed can override them in case if XPath expressions need to be formed dynamically or based on conditions. In case any of these methods is defined, the method's return value is used instead of the corresponding constant for providing the value.\n\n### Method `getSourceUrl()`\nShould return the source Web page URL used as a base for applying the XPath expressions.\n\n### Method `getExpressionTitle()`\nShould return the XPath expression for extracting the feed title from the source page.\n\n### Method `getExpressionIcon()`\nShould return the XPath expression for extracting the feed favicon from the source page.\n\n### Method `getExpressionItem()`\nShould return the XPath expression for extracting the feed items from the source page.\n\n### Method `getExpressionItemTitle()`\nShould return the XPath expression for extracting an item title from the item context.\n\n### Method `getExpressionItemContent()`\nShould return the XPath expression for extracting an item's content from the item context.\n\n### Method `getSettingUseRawItemContent()`\nShould return the 'Use raw item content' setting value (bool true or false).\n\n### Method `getExpressionItemUri()`\nShould return the XPath expression for extracting an item link from the item context.\n\n### Method `getExpressionItemAuthor()`\nShould return the XPath expression for extracting an item author from the item context.\n\n### Method `getExpressionItemTimestamp()`\nShould return the XPath expression for extracting an item timestamp from the item context.\n\n### Method `getExpressionItemEnclosures()`\nShould return the XPath expression for extracting item enclosures (media content like images or movies) from the item context.\n\n### Method `getExpressionItemCategories()`\nShould return the XPath expression for extracting an item category from the item context.\n\n### Method `getSettingFixEncoding()`\nShould return the Fix encoding setting value (bool true or false).\n\n## Methods for providing feed data\nThose methods are invoked for providing the HTML source as a base for applying the XPath expressions as well as feed meta data as the title and icon.\n\n### Method `provideWebsiteContent()`\nThis method should return the HTML source as a base for the XPath expressions. Usually it merely returns the HTML content of the URL specified in the constant `FEED_SOURCE_URL` retrieved by curl. Some sites however require user authentication mechanisms, the use of special cookies and/or headers, where the direct retrival using standard curl would not suffice. In that case this method should be overridden and take care of the page retrival.\n\n### Method `provideFeedTitle()`\nThis method should provide the feed title. Usually the XPath expression defined in `XPATH_EXPRESSION_FEED_TITLE` is used for extracting the title directly from the page source.\n\n### Method `provideFeedIcon()`\nThis method should provide the URL of the feed's favicon. Usually the XPath expression defined in `XPATH_EXPRESSION_FEED_ICON` is used for extracting the title directly from the page source.\n\n### Method `provideFeedItems()`\nThis method should provide the feed items. Usually the XPath expression defined in `XPATH_EXPRESSION_ITEM` is used for extracting the items from the page source. All other XPath expressions are applied on a per-item basis, item by item, and only on the item's contents.\n\n## Methods for formatting and filtering feed item attributes\nThe following methods are invoked after extraction of the feed items from the source. Each of them expect one parameter, the value of the corresponding field, which then can be processed and transformed by the method. You can override these methods in order to format or filter parts of the feed output.\n\n### Method `formatItemTitle()`\nAccepts the items title values as parameter, processes and returns it. Should return a string.\n\n### Method `formatItemContent()`\nAccepts the items content as parameter, processes and returns it. Should return a string.\n\n### Method `formatItemUri()`\nAccepts the items link URL as parameter, processes and returns it. Should return a string.\n\n### Method `formatItemAuthor()`\nAccepts the items author as parameter, processes and returns it. Should return a string.\n\n### Method `formatItemTimestamp()`\nAccepts the items creation timestamp as parameter, processes and returns it. Should return a unix timestamp as integer.\n\n### Method `cleanMediaUrl()`\nMethod invoked for cleaning feed icon, item image and media attachment (like .mp3, .webp) URL's. Extracts the media URL from the passed parameter, stripping any additional content. Furthermore, makes sure that relative media URL's get transformed to absolute ones.\n\n### Method `fixEncoding()`\nOnly invoked when class constant `SETTING_FIX_ENCODING` is set to true. It then passes all extracted string values through PHP's `utf8_decode` function.\n\n### Method `generateItemId()`\nThis method plays in important role for generating feed item ids for all extracted items. Every feed item needs an unique identifier (Uid), so that your feed reader updates the original item instead of adding a duplicate in case an items content is updated on the source site. Usually the items link URL is a good candidate the the Uid.\n\n***\n\n# Template\n\nUse this template to create your own bridge. Please remove any unnecessary comments and parameters.\n\n```PHP\n<?php\n\nclass TestBridge extends XPathAbstract {\n    const NAME = 'Test';\n    const URI = 'https://www.unbemerkt.eu/de/blog/';\n    const DESCRIPTION = 'Test';\n    const MAINTAINER = 'your name';\n    const CACHE_TIMEOUT = 3600;\n\n    const FEED_SOURCE_URL = 'https://www.unbemerkt.eu/de/blog/';\n    const XPATH_EXPRESSION_ITEM = '/html[1]/body[1]/section[1]/section[1]/div[1]/div[1]/div[1]/div[1]/div[1]/div[*]/article[1]';\n    const XPATH_EXPRESSION_ITEM_TITLE = './/a[@target=\"_self\"]';\n    const XPATH_EXPRESSION_ITEM_CONTENT = './/div[@class=\"post-content\"]';\n    const XPATH_EXPRESSION_ITEM_URI = './/a[@class=\"more-btn\"]/@href';\n    const XPATH_EXPRESSION_ITEM_AUTHOR = '/html[1]/body[1]/section[1]/div[2]/div[1]/div[1]/h1[1]';\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = './/time/@datetime';\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@data-src';\n    const SETTING_FIX_ENCODING = false;\n}\n```"
  },
  {
    "path": "docs/05_Bridge_API/index.md",
    "content": "A _Bridge_ is a class that allows **RSS-Bridge** to create an RSS-feed from a website.\nA _Bridge_ represents one element on the [Welcome screen](../01_General/04_Screenshots.md)\nand covers one or more sites to return feeds for.\nIt is developed in a PHP file located in the `bridges/` folder (see [Folder structure](../04_For_Developers/03_Folder_structure.md))\nand extends one of the base classes of **RSS-Bridge**:\n\nBase class | Description\n-----------|------------\n[`BridgeAbstract`](./02_BridgeAbstract.md) | This class is intended for standard _Bridges_ that need to filter HTML pages for content.\n[`FeedExpander`](./03_FeedExpander.md) | Expand/modify existing feed urls\n[`WebDriverAbstract`](./04_WebDriverAbstract) |\n[`XPathAbstract`](./05_XPathAbstract) | This class is meant as an alternative base class for bridge implementations. It offers preliminary functionality for generating feeds based on _XPath expressions_.\n\nFor more information about how to create a new _Bridge_, read [How to create a new Bridge?](./01_How_to_create_a_new_bridge.md)"
  },
  {
    "path": "docs/06_Helper_functions/index.md",
    "content": "# getInput\nThe `getInput` function is used to receive a value for a parameter, specified in `const PARAMETERS`\n\n```PHP\n$this->getInput('your input name here');\n```\n\n`getInput` will either return the value for your parameter\nor `null` if the parameter is unknown or not specified.\n\n[Defined in lib/BridgeAbstract.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/BridgeAbstract.php)\n\n# getKey\nThe `getKey` function is used to receive the key name to a selected list\nvalue given the name of the list, specified in `const PARAMETERS`\nIs able to work with multidimensional list arrays.\n\n```PHP\n// Given a multidimensional array like this\nconst PARAMETERS = [[\n        'country' => [\n            'name' => 'Country',\n            'type' => 'list',\n            'values' => [\n                'North America' => [\n                    'Mexico' => 'mx',\n                    'United States' => 'us'\n                ],\n                'South America' => [\n                    'Uruguay' => 'uy',\n                    'Venezuela' => 've'\n                ],\n            ]\n        ]\n]],\n// Provide the list name to the function\n$this->getKey('country');\n// if the selected value was \"ve\", this function will return \"Venezuela\"\n```\n\n`getKey` will either return the key name for your parameter or `null` if the parameter\nis unknown or not specified.\n\n[Defined in lib/BridgeAbstract.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/BridgeAbstract.php)\n\n# getContents\nThe `getContents` function uses [cURL](https://secure.php.net/manual/en/book.curl.php) to acquire data from the specified URI while respecting the various settings defined at a global level by RSS-Bridge (i.e., proxy host, user agent, etc.). This function accepts a few parameters:\n\n| Parameter | Type   | Optional   | Description\n| --------- | ------ | ---------- | ----------\n| `url`     | string | *required* | The URL of the contents to acquire\n| `header`  | array  | *optional* | An array of HTTP header fields to set, in the format `array('Content-type: text/plain', 'Content-length: 100')`, see [CURLOPT_HTTPHEADER](https://secure.php.net/manual/en/function.curl-setopt.php)\n| `opts`    | array  | *optional* | An array of cURL options in the format `array(CURLOPT_POST => 1);`, see [curl_setopt](https://secure.php.net/manual/en/function.curl-setopt.php) for a complete list of options.\n| `returnFull`    | boolean  | *optional* | Specifies whether to return the response body from cURL (default) or the response body, code, headers, etc.\n\n```PHP\n$header = array('Content-type:text/plain', 'Content-length: 100');\n$opts = array(CURLOPT_POST => 1);\n$html = getContents($url, $header, $opts);\n```\n\n[Defined in lib/contents.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/contents.php)\n\n# getSimpleHTMLDOM\nThe `getSimpleHTMLDOM` function is a wrapper for the \n[simple_html_dom](https://simplehtmldom.sourceforge.io/) [file_get_html](https://simplehtmldom.sourceforge.io/docs/1.9/api/file_get_html/) function in order to provide context by design.\n\n```PHP\n$html = getSimpleHTMLDOM('your URI');\n```\n\n[Defined in lib/contents.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/contents.php)\n\n# getSimpleHTMLDOMCached\nThe `getSimpleHTMLDOMCached` function does the same as the \n[`getSimpleHTMLDOM`](#getsimplehtmldom) function,\nexcept that the content received for the given URI is stored in a cache\nand loaded from cache on the next request if the specified cache duration\nwas not reached.\n\nUse this function for data that is very unlikely to change between consecutive requests to **RSS-Bridge**.\nThis function allows to specify the cache duration with the second parameter.\n\n```PHP\n$html = getSimpleHTMLDOMCached('your URI', 86400); // Duration 24h\n```\n\n[Defined in lib/contents.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/contents.php)\n\n# throwClientException($message = '')\nThe `throwClientException` function aborts execution of the current bridge.\n\n```PHP\nthrowClientException('Bad user input')\n```\n\nUse this function when the user provided invalid parameter or a required parameter is missing.\n\n[Defined in lib/utils.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/utils.php)\n\n# throwServerException($message = '')\nThe `throwServerException` function aborts execution of the current bridge.\n\n```PHP\nthrowServerException('Received empty reply from thirdparty api')\n```\n\nUse this function when a problem occurs that has nothing to do with the parameters provided by the user.\n(like: Host service gone missing, empty data received, etc...)\n\n[Defined in lib/utils.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/utils.php)\n\n# throwRateLimitException($message = '')\n\nThrows a `RateLimitException` which produces an HTTP 429 response.\n\n# defaultLinkTo\nAutomatically replaces any relative URL in a given string or DOM object\n(i.e. the one returned by [getSimpleHTMLDOM](#getsimplehtmldom)) with an absolute URL.\n\n```php\ndefaultLinkTo ( mixed $content, string $server ) : object\n```\n\nReturns a DOM object (even if provided a string).\n\n**Remarks**\n\n* Only handles `<a>` and `<img>` tags.\n\n**Example**\n\n```php\n$html = '<img src=\"/blob/master/README.md\">';\n\n$html = defaultLinkTo($html, 'https://www.github.com/rss-bridge/rss-bridge'); // Using custom server\n$html = defaultLinkTo($html, $this->getURI()); // Using bridge URL\n\n// Output\n// <img src=\"https://www.github.com/rss-bridge/rss-bridge/blob/master/README.md\">\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# backgroundToImg\nReplaces tags with styles of `backgroud-image` by `<img />` tags.\n\n```php\nbackgroundToImg(mixed $htmlContent) : object\n```\n\nReturns a DOM object (even if provided a string).\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# extractFromDelimiters\nExtract the first part of a string matching the specified start and end delimiters.\n```php\nfunction extractFromDelimiters(string $string, string $start, string $end) : mixed\n```\n\nReturns the extracted string if delimiters were found and false otherwise.\n\n**Example**\n\n```php\n$string = '<div>Post author: John Doe</div>';\n$start = 'author: ';\n$end = '<';\n$extracted = extractFromDelimiters($string, $start, $end);\n\n// Output\n// 'John Doe'\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# stripWithDelimiters\nRemove one or more part(s) of a string using a start and end delimiter.\nIt is the inverse of `extractFromDelimiters`.\n\n```php\nfunction stripWithDelimiters(string $string, string $start, string $end) : string\n```\n\nReturns the cleaned string, even if no delimiters were found.\n\n**Example**\n\n```php\n$string = 'foo<script>superscript()</script>bar';\n$start = '<script>';\n$end = '</script>';\n$cleaned = stripWithDelimiters($string, $start, $end);\n\n// Output\n// 'foobar'\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# stripRecursiveHTMLSection\nRemove HTML sections containing one or more sections using the same HTML tag.\n\n```php\nfunction stripRecursiveHTMLSection(string $string, string $tag_name, string $tag_start) : string\n```\n\n**Example**\n\n```php\n$string = 'foo<div class=\"ads\"><div>ads</div>ads</div>bar';\n$tag_name = 'div';\n$tag_start = '<div class=\"ads\">';\n$cleaned = stripRecursiveHTMLSection($string, $tag_name, $tag_start);\n\n// Output\n// 'foobar'\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# markdownToHtml\nConverts markdown input to HTML using [Parsedown](https://parsedown.org/).\n\n| Parameter | Type   | Optional   | Description\n| --------- | ------ | ---------- | ----------\n| `string`  | string | *required* | The URL of the contents to acquire\n| `config`  | array  | *optional* | An array of Parsedown options in the format `['breaksEnabled' => true]`\n\nValid options:\n| Option          | Default | Description\n| --------------- | ------- | -----------\n| `breaksEnabled` | `false` | Enable automatic line breaks\n| `markupEscaped` | `false` | Escape inline markup (HTML)\n| `urlsLinked`    | `true`  | Automatically convert URLs to links\n\n```php\nfunction markdownToHtml(string $string, array $config = []) : string\n```\n\n**Example**\n```php\n$input = <<<EOD\nRELEASE-2.8\n * Share QR code of a token\n * Dark mode improvemnet\n * Fix some layout issues\n * Add shortcut to launch the app with screenshot mode on\n * Translation improvements\nEOD;\n$html = markdownToHtml($input);\n\n// Output:\n// <p>RELEASE-2.8</p>\n// <ul>\n// <li>Share QR code of a token</li>\n// <li>Dark mode improvemnet</li>\n// <li>Fix some layout issues</li>\n// <li>Add shortcut to launch the app with screenshot mode on</li>\n// <li>Translation improvements</li>\n// </ul>\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# e\nThe `e` function is used to convert special characters to HTML entities\n\n```PHP\ne('0 < 1 and 2 > 1');\n```\n\n`e` will return the content of the string escape that can be rendered as is in HTML\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# truncate\nThe `truncate` function is used to shorten a string if exceeds a certain length, and add a string indicating that the string has been shortened.\n\n```PHP\ntruncate('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed a neque nunc. Nam nibh sem.', 20 , '...');\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# sanitize\nThe `sanitize` function is used to remove some tags from a given HTML text.\n\n```PHP\n$html = '<head><title>Sample Page</title></head>\n<body><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit...</p>\n<iframe src=\"https://www.example.com\" width=\"600\" height=\"400\" frameborder=\"0\" allowfullscreen></iframe>\n</body>\n</html>';\n$tags_to_remove = ['script', 'iframe', 'input', 'form'];\n$attributes_to_keep = ['title', 'href', 'src'];\n$text_to_keep = [];\nsanitize($html, $tags_to_remove, $attributes_to_keep, $text_to_keep);\n```\n\nThis function returns a simplehtmldom object of the remaining contents.\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# convertLazyLoading\nThe `convertLazyLoading` function is used to convert onvert lazy-loading images and frames (video embeds) into static elements. It accepts the HTML content as HTML objects or string objects. It returns the HTML content with fixed image/frame URLs (same type as input).\n\n```PHP\n$html = '<html>\n<body style=\"background-image: url('bgimage.jpg');\">\n<h1>Hello world!</h1>\n</body>\n</html>\nbackgroundToImg($html);\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n\n# Json::encode\nThe `Json::encode` function is used to encode a value as à JSON string.\n\n```PHP\n$array = [\n    \"foo\" => \"bar\",\n    \"bar\" => \"foo\",\n];\nJson::encode($array, true, true);\n```\n\n[Defined in lib/utils.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/utils.php)\n\n# Json::decode\nThe `Json::decode` function is used to decode a JSON string into à PHP variable.\n\n```PHP\n$json = '{\n    \"foo\": \"bar\",\n    \"bar\": \"foo\"\n}';\nJson::decode($json);\n```\n\n[Defined in lib/utils.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/utils.php)\n\n# get_sitemap(string $url): array\n\nConvenience function to fetch urls from xml sitemap.\n\n```php\n$urls = get_sitemap('https://arte.sky.it/sitemap-mostre-eventi.xml');\n\nforeach ($urls as $url) {\n    $loc = $url['loc'];\n    $lastmod = $url['lastmod'];\n}\n```\n\n# handleYoutube(string $html): string\n\nUse this function to throw a YouTube link, iframe tag or video ID and get a HTML snippet that returns a normalized iframe tag or clickable image thumbnail, depending on system configuration.\n\n```php\n$result = handleYoutube('naYc5X6EL_Y');\n\n$result = handleYoutube('https://www.youtube.com/watch?v=naYc5X6EL_Y');\n\n$result = handleYoutube('https://www.youtube.com/embed/naYc5X6EL_Y');\n\n$iframe = '<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/naYc5X6EL_Y?si=abcdefgh\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen></iframe>';\n$result = handleYoutube($iframe);\n```\n\n[Defined in lib/html.php](https://github.com/RSS-Bridge/rss-bridge/blob/master/lib/html.php)\n"
  },
  {
    "path": "docs/07_Cache_API/01_How_to_create_a_new_cache.md",
    "content": "Create a new file in the `caches/` folder (see [Folder structure](../04_For_Developers/03_Folder_structure.md)).\n\nSee `NullCache` and `SQLiteCache` for examples."
  },
  {
    "path": "docs/07_Cache_API/02_CacheInterface.md",
    "content": "See `CacheInterface`.\n\n```php\ninterface CacheInterface\n{\n    public function get(string $key, $default = null);\n\n    public function set(string $key, $value, int $ttl = null): void;\n\n    public function delete(string $key): void;\n\n    public function clear(): void;\n\n    public function prune(): void;\n}\n```\n"
  },
  {
    "path": "docs/07_Cache_API/index.md",
    "content": "A _Cache_ is a class that allows **RSS-Bridge** to store fetched data in a local storage area on the server.\nCache imlementations are placed in the `caches/` folder (see [Folder structure](../04_For_Developers/03_Folder_structure.md)).\nA cache must implement the [`CacheInterface`](../07_Cache_API/02_CacheInterface.md) interface.\n\nFor more information about how to create a new `Cache`, read\n[How to create a new cache?](../07_Cache_API/01_How_to_create_a_new_cache.md)\n"
  },
  {
    "path": "docs/09_Technical_recommendations/index.md",
    "content": "## General recommendations\n\n## Test a site before building a bridge\n\nSome sites make use of anti-bot mechanisms (e.g.: by using JavaScript) in which case they work fine in regular browsers,\nbut not in the PHP environment. RSS-Bridge Docker container by default resorts to using libcurl-impersonate, which helps mitigating anti-bot mechanisms.\n\nTo check if a site works with RSS-Bridge, create a new bridge using the \n[template](../05_Bridge_API/02_BridgeAbstract.md#template)\nand load a valid URL (not the base URL!).\n\n**Example (using github.com)**\n\n```PHP\n<?php\nclass TestBridge extends BridgeAbstract\n{\n    const NAME = 'Unnamed';\n    const URI = '';\n    const DESCRIPTION = 'No description provided';\n    const MAINTAINER = 'No maintainer';\n    const PARAMETERS = [];\n    const CACHE_TIMEOUT = 3600;\n\n    public function collectData()\n    {\n        $html = getSimpleHTMLDOM('https://github.com/rss-bridge/rss-bridge');\n    }\n}\n```\n\nThis bridge should return an empty page (HTML format)\n"
  },
  {
    "path": "docs/10_Bridge_Specific/ActivityPub_(Mastodon).md",
    "content": "# MastodonBridge (aka. ActivityPub Bridge)\n\nCertain ActivityPub implementations, such as [Mastodon](https://docs.joinmastodon.org/spec/security/#http) and [Pleroma](https://docs-develop.pleroma.social/backend/configuration/cheatsheet/#activitypub), allow instances to require requests to ActivityPub endpoints to be signed. RSS-Bridge can handle the HTTP signature header if a private key is provided, while the ActivityPub instance must be able to know the corresponding public key.\n\nYou do **not** need to configure this if the usage on your RSS-Bridge instance is limited to accessing ActivityPub instances that do not have such requirements. While the majority of ActivityPub instances don't have them at the time of writing, the situation may change in the future.\n\n## Configuration\n\n[This article](https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/) is referenced.\n\n1. Select a domain. It may, but does not need to, be the one RSS-Bridge is on. For all subsequent steps, replace `DOMAIN` with this domain.\n2. Run the following commands on your machine:\n```bash\n$ openssl genrsa -out private.pem 2048\n$ openssl rsa -in private.pem -outform PEM -pubout -out public.pem\n```\n3. Place `private.pem` in an appropriate location and note down its absolute path.\n4. Serve the following page at `https://DOMAIN/.well-known/webfinger`:\n```json\n{\n\t\"subject\": \"acct:DOMAIN@DOMAIN\",\n\t\"aliases\": [\"https://DOMAIN/actor\"],\n\t\"links\": [{\n\t\t\"rel\": \"self\",\n\t\t\"type\": \"application/activity+json\",\n\t\t\"href\": \"https://DOMAIN/actor\"\n\t}]\n}\n```\n5. Serve the following page at `https://DOMAIN/actor`, replacing the value of `publicKeyPem` with the contents of the `public.pem` file in step 2, with all line breaks substituted with `\\n`:\n```json\n{\n    \"@context\": [\n      \"https://www.w3.org/ns/activitystreams\",\n      \"https://w3id.org/security/v1\"\n    ],\n    \"id\": \"https://DOMAIN/actor\",\n    \"type\": \"Application\",\n    \"inbox\": \"https://DOMAIN/actor/inbox\",\n    \"preferredUsername\": \"DOMAIN\",\n    \"publicKey\": {\n        \"id\": \"https://DOMAIN/actor#main-key\",\n        \"owner\": \"https://DOMAIN/actor\",\n        \"publicKeyPem\": \"-----BEGIN PUBLIC KEY-----\\n...\\n-----END PUBLIC KEY-----\\n\"\n    }\n}\n```\n6. Add the following configuration in `config.ini.php` in your RSS-Bridge folder, replacing the path with the one from step 3:\n```ini\n[MastodonBridge]\nprivate_key = \"/absolute/path/to/your/private.pem\"\nkey_id = \"https://DOMAIN/actor#main-key\"\n```\n\n## Considerations\n\nAny ActivityPub instance your users requested content from will be able to identify requests from your RSS-Bridge instance by the domain you specified in the configuration. This also means that an ActivityPub instance may choose to block this domain should they judge your instance's usage excessive. Therefore, public instance operators should monitor for abuse and prepare to communicate with ActivityPub instance admins when necessary. You may also leave contact information as the `summary` value in the actor JSON (step 5).\n"
  },
  {
    "path": "docs/10_Bridge_Specific/Economist.md",
    "content": "# EconomistWorldInBriefBridge and EconomistBridge\n\nIn May 2024, The Economist finally fixed its paywall, and it started requiring authorization. Which means you can't use this bridge unless you have an active subscription.\n\nIf you do, the way to use the bridge is to snitch a cookie:\n1. Log in to The Economist\n2. Open DevTools (Chrome DevTools or Firefox Developer Tools)\n2. Go to https://www.economist.com/the-world-in-brief\n3. In DevTools, go to the \"Network\" tab, there select the first request (`the-world-in-brief`) and copy the value of the `Cookie:` header from \"Request Headers\".\n\nThe cookie lives three months.\n\nOnce you've done this, add the cookie to your `config.ini.php`:\n\n```\n[EconomistWorldInBriefBridge]\ncookie = \"<value>\"\n\n[EconomistBridge]\ncookie = \"<value>\"\n```\n"
  },
  {
    "path": "docs/10_Bridge_Specific/FacebookBridge.md",
    "content": "FacebookBridge\n===============\nState of this bridge:\n- Facebook Groups (and probably other sections too) do not work at all\n- No maintainer\n- Needs cookie consent support for public pages\n- Needs login support see [this example](https://github.com/RSS-Bridge/rss-bridge/issues/1891) for Instagram) for private groups\n\nDue to the 2020 [Facebook redesign](https://engineering.fb.com/2020/05/08/web/facebook-redesign/)\nand the requirement to [accept cookies](https://www.facebook.com/business/help/348535683460989)\nusers are getting [problems with Facebook on public RSS-Bridge instances](https://github.com/RSS-Bridge/rss-bridge/issues/2047).\n\nRelevant Info\n--------------\n\n- [Facebook Cookies](https://www.facebook.com/policy/cookies/)\n- \"Datr\" is a unique identifier for your browser and it has a lifespan of two years.\n- \"c_user\" and \"xs\" cookies to verify the account and have a lifespan of 365 days\n"
  },
  {
    "path": "docs/10_Bridge_Specific/FurAffinityBridge.md",
    "content": "FurAffinityBridge\n===============\nBy default this bridge will only return submissions that are rated \"General\" and are public.\n\nTo unlock the ability to load submissions that require an account to view or are rated \"Mature\" and higher, you must set the following in `config.ini.php` with cookies from an existing FurAffinity account with the desired maturity ratings enabled in [Account Settings](https://www.furaffinity.net/controls/settings/).\n\n```\n[FurAffinityBridge]\naCookie = \"your-a-cookie-value-here\" ; from cookie \"a\"\nbCookie = \"your-b-cookie-value-here\" ; from cookie \"b\"\n```\n\nTo confirm the bridge is authenticated, the name of the authenticating account will be shown in the bridge's name once the bridge has been used at least once. (Example: `user's FurAffinity Bridge`)\n"
  },
  {
    "path": "docs/10_Bridge_Specific/Furaffinityuser.md",
    "content": "# FuraffinityuserBridge\n\n## How to retrieve and use cookie values\n---\n> The following steps describe how to get the session cookies using a Chromium-based browser. Other browser may require slightly different steps. Keyword search \"how to find cookie values\" in your favorite search engine.\n\n### Retreiving session cookies.\n\n- Login to Furaffinity\n\n- Open DevTools by pressing F12\n\n- Open \"Application\"\n\n- On the left side, select \"Cookies\" -> \"Furaffinity.net\"\n\n- There will be (at least) two cookie informations in the main window. You need the values of the \"a\" key and \"b\" key\n\n### Configuring RSS-Bridge\n\n- Copy/Paste the values from cookies \"a\" and \"b\" into their respective fields in the bridge config and generate the feed"
  },
  {
    "path": "docs/10_Bridge_Specific/Instagram.md",
    "content": "InstagramBridge\n===============\n\nTo somehow bypass the [rate limiting issue](https://github.com/RSS-Bridge/rss-bridge/issues/1891)\nit is suggested to deploy a private RSS-Bridge instance that uses a working Instagram account.\n\n**NOTE**: There exists alternative bridges (e.g. PicukiBridge and PicnobBridge) for viewing posts without a working account.\n\nConfiguration\n-------------\n\n1. Retreiving `session id` and `ds_user_id`.\nThe following steps describe how to get the `session id` and `ds user id` using a Chromium-based browser.\n\n- Create an Instagram account, that you will use for your RSS-Bridge instance.\nIt is NOT recommended to use your existing account that is used for common interaction with Instagram services.\n\n- Login to Instagram\n\n- Open DevTools by pressing F12\n\n- Open \"Networks tab\"\n\n- In the \"Filter\" field input \"i.instagram.com\"\n\n- Click on \"Fetch/XHR\"\n\n- Refresh web page\n\n- Click on any item from the table of http requests\n\n- In the new frame open the \"Headers\" tab and scroll to \"Request Headers\"\n\n- There will be a cookie param will lots of `<key>=<value>;` text. You need the value of the \"sessionid\" and \"ds_user_id\" keys. Copy them.\n\n2. Configuring RSS-Bridge\n\n- In config.ini.php add following configuration:\n\n```\n[InstagramBridge]\nsession_id = %sessionid from step 1%\nds_user_id = %ds_user_id from step 1%\ncache_timeout = %cache timeout in seconds%\n```\n\nThe bigger the cache_timeout value, the smaller the chance for RSS-Bridge to throw 429 errors.\nDefault cache_timeout is 3600 seconds (1 hour).\n"
  },
  {
    "path": "docs/10_Bridge_Specific/PixivBridge.md",
    "content": "PixivBridge\n===============\n\n# Image proxy\n\nAs Pixiv requires images to be loaded with the `Referer \"https://www.pixiv.net/\"` header set,\ncaching or image proxy is required to use this bridge.\n\nTo turn off image caching, set the `proxy_url` value in this bridge's configuration section of `config.ini.php`\nto the url of the proxy.\n\nThe bridge will then use the proxy in this format (essentially replacing `https://i.pximg.net` with the proxy):\n\nBefore: `https://i.pximg.net/img-original/img/0000/00/00/00/00/00/12345678_p0.png`\n\nAfter: `https://proxy.example.com/img-original/img/0000/00/00/00/00/00/12345678_p0.png`\n\n```\nproxy_url = \"https://proxy.example.com\"\n```\n\n# Authentication\n\nAuthentication is required to view and search R-18+ and non-public images.\nTo enable this, set the following in this bridge's configuration in `config.ini.php`.\n\n```ini\n; from cookie \"PHPSESSID\". Recommend to get in incognito browser. \ncookie = \"00000000_hashedsessionidhere\"\n```"
  },
  {
    "path": "docs/10_Bridge_Specific/Substack.md",
    "content": "# SubstackBridge\n\n[Substack](https://substack.com) provides RSS feeds at `/feed` path, e.g., https://newsletter.pragmaticengineer.com/feed/. However, these feeds have two problems, addressed by this bridge:\n- They use RSS 2.0 with the draft [content extension](https://web.resource.org/rss/1.0/modules/content/), which isn't supported by some readers;\n- They don't have the full content for paywalled posts.\n\nRetrieving the full content is only possible _with an active subscription to the blog_. If you have one, Substack will return the full feed if it's fetched with the right set of cookies. Figuring out whether it's the intended behaviour is left as an exercise for the reader.\n\nTo obtain the session cookie, authorize at https://substack.com/, open DevTools, go to Application -> Cookies -> https://substack.com, copy the value of `substack.sid` and paste it to the RSS bridge config:\n\n```\n[SubstackBridge]\nsid = \"<your-sid>\"\n```\n\nAuthorization sometimes requires CAPTCHA, hence this operation is manual. The cookie lives for three months.\n\nAfter you've done this, the bridge should return full feeds for your subscriptions.\n"
  },
  {
    "path": "docs/10_Bridge_Specific/Telegram.md",
    "content": "# TelegramBridge\n\nBy default, it fetches a single page with up to 20 messages.\n\nTo increase this limit, tweak the `max_pages` config:\n\n```ini\n[TelegramBridge]\n\n; Fetch a maximum of 3 pages (requires 3 http requests)\nmax_pages = 3\n```\n"
  },
  {
    "path": "docs/10_Bridge_Specific/TwitterV2.md",
    "content": "TwitterV2Bridge\n===============\n\nTo automatically retrieve Tweets containing potentially sensitive/age-restricted content, you'll need to acquire your own unique API Bearer token, which will be used by this Bridge to query Twitter's API v2.\n\nConfiguration\n-------------\n\n1. Make a Twitter Developer account\n\n\t- Developer Portal: https://dev.twitter.com\n\n\t- I will not detail exactly how to do this, as the specific process will likely change over time. You should easily be able to find guides using your search engine of choice.\n\n\t- Note: as of April 2023, the \"Free\" access level no longer allows read access. The cheapest access level with read access is called \"Basic\".\n\n2. Create a Twitter Project and App, get Bearer Token\n\n\t- Once you have an active Twitter Developer account, sign in to the dev portal\n\n\t- Create a new Project (name doesn't matter)\n\n\t- Create an App within the Project (again, name doesn't matter)\n\n\t- Go to the **Keys and tokens** tab\n\n\t- Generate a **Bearer Token** (you don't want the API Key and Secret, or the Access Token and Secret)\n\n3. Configure RSS-Bridge\n\n\t- In **config.ini.php** (in rss-bridge root directory) add following lines at the end:\n\n\t```\n\t[TwitterV2Bridge]\n\ttwitterv2apitoken = %Bearer Token from step 2%\n\t```\n\t- If you don't have a **config.ini.php**, create one by making a copy of **config.default.ini.php**\n"
  },
  {
    "path": "docs/10_Bridge_Specific/Vk2.md",
    "content": "Vk2Bridge\n=========\n\nРабота этого скрипта основана [VK API](https://dev.vk.com/reference).\nПо сравнению с VkBridge у этого скрипта есть свои приемущества и недостатки.\n\nПриемущества\n------------\n\n- Стабильность.\n  Скрипт не зависит от HTML-структуры страницы VK групп или пользователей, которые могут поменяться в любой момент.\n\nНедостатки\n----------\n\n- Требуется наличие зарегистированного в ВК пользователя.\n  Данный пользователь должен получить `access_token`, который используется для этого скрипта.\n  Подробнее в разделе \"Настройка\"\n\n- Количество запросов при выключенном кэше ограничено - [5000 запросов в сутки](https://dev.vk.com/ru/reference/roadmap#%D0%9E%D0%B3%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%B5%D0%BD%D0%B8%D1%8F%20API%20%D0%B4%D0%BB%D1%8F%20%D0%BF%D0%BE%D0%B8%D1%81%D0%BA%D0%B0)\n\nНастройка\n---------\n\n1. Перейдите по [ссылке](https://oauth.vk.com/oauth/authorize?client_id=5149410&scope=offline&redirect_uri=https://oauth.vk.com/blank.html&display=page&response_type=token)\n\n2. Авторизуйтесь в приложение `my_personal_app`\n\n3. Получите ссылку вида `https://oauth.vk.com/blank.html#access_token=MNOGO_BUKAV&expires_in=0&user_id=123456`.\n   Из этой ссылки скопируйте `MNOGO_BUKAV`.\n\n4. В `config.ini.php` в раздел Vk2Bridge вставьте `access_token`\n\n```\n[Vk2Bridge]\naccess_token = \"MNOGO_BUKAV\"\n```\n\nПримечание: в данной инструкции используется приложение, администратор которого является [@em92](https://github.com/em92).\nДопускается вместо упомянутого приложения использование своего standalone-приложения.\nДля этого надо в ссылке из п.1. заменить значение `client_id` на свой.\n"
  },
  {
    "path": "docs/99_Theme/rssbridge/config.json",
    "content": "{\n    \"favicon\": \"<theme_url>img/favicon.png\",\n    \"js\": [\n        \"<theme_url>js/daux.min.js\"\n    ],\n    \"css\": [\n        \"<theme_url>css/theme.min.css\"\n    ]\n}"
  },
  {
    "path": "docs/config.json",
    "content": "{\n    \"title\": \"RSS-Bridge\",\n    \"tagline\": \"The RSS feed for websites missing it\",\n    \"author\": \"RSS-Bridge Contributors\",\n    \"image\": \"./images/rssbridgelogo.png\",\n    \"ignore\": {\n            \"files\": [\"Work_In_Progress.md\", \"readme.md\"],\n            \"folders\": [\"99_Theme\"]\n    },\n    \"live\": {\n        \"clean_urls\": true\n    },\n    \"templates\": \"daux/templates\",\n    \"html\": {\n        \"theme\": \"daux-blue\",\n        \"breadcrumbs\": true,\n        \"breadcrumb_separator\": \"Chevrons\",\n        \"toggle_code\": true,\n        \"date_modified\": true,\n        \"inherit_index\": true,\n\n        \"repo\": \"RSS-Bridge/rss-bridge\",\n        \"edit_on_github\": \"RSS-Bridge/rss-bridge/tree/master/docs\",\n        \"google_analytics\": false,\n        \"plausible_domain\": false,\n        \"links\": {\n            \"GitHub Repository\": \"https://github.com/RSS-Bridge/rss-bridge\",\n            \"Help/Support/Bugs\": \"https://github.com/RSS-Bridge/rss-bridge/issues\",\n            \"Docker Images\": \"https://github.com/RSS-Bridge/rss-bridge/pkgs/container/rss-bridge\"\n        },\n        \"powered_by\": \"Powered by Daux.io\"\n    }\n}"
  },
  {
    "path": "docs/index.md",
    "content": "RSS-Bridge is a web application.\n\nIt generates web feeds for websites that don't have one.\n\nOfficially hosted instance: https://rss-bridge.org/bridge01/\n\n  - You want to know more about **RSS-Bridge**?  \nCheck out our **[project goals](01_General/01_Project-goals.md)**.\n\n  - You want to contribute and don't know how?  \nCheck out our **[How can I contribute?](01_General/02_Contribute.md)** section.\n\n  - You are a developer and searching for more details?  \nCheck out our **[For developers](04_For_Developers/index.md)** section.\n\n  - You want to know what is required to host **RSS-Bridge**?  \nCheck out the **[Requirements](01_General/03_Requirements.md)** section.\n\n  - You want to host **RSS-Bridge**?  \nCheck out the **[For hosts](03_For_Hosts/index.md)** section.\n\n  - You have questions?  \nCheck out the **[FAQ](01_General/05_FAQ.md)**."
  },
  {
    "path": "docs/readme.md",
    "content": "# RSS-Bridge Documentation\n\nThis folder contains the RSS-Bridge documentation. It uses daux.io to compile the static website for gh pages.\n\nSee the language folders for the .md files\n\n## Important links\n\nDaux.io documentation : https://daux.io/Getting_Started.html\n"
  },
  {
    "path": "formats/AtomFormat.php",
    "content": "<?php\n\n/**\n * AtomFormat - RFC 4287: The Atom Syndication Format\n * https://tools.ietf.org/html/rfc4287\n *\n * Validator:\n * https://validator.w3.org/feed/\n */\nclass AtomFormat extends FormatAbstract\n{\n    const MIME_TYPE = 'application/atom+xml';\n\n    protected const ATOM_NS = 'http://www.w3.org/2005/Atom';\n    protected const MRSS_NS = 'http://search.yahoo.com/mrss/';\n\n    public function render(): string\n    {\n        $document = new \\DomDocument('1.0', 'UTF-8');\n        $document->formatOutput = true;\n\n        $feedUrl = get_current_url();\n\n        $feed = $document->createElementNS(self::ATOM_NS, 'feed');\n        $document->appendChild($feed);\n        $feed->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:media', self::MRSS_NS);\n\n        $feedArray = $this->getFeed();\n        foreach ($feedArray as $feedKey => $feedValue) {\n            if (in_array($feedKey, ['donationUri'])) {\n                continue;\n            }\n            if ($feedKey === 'name') {\n                $title = $document->createElement('title');\n                $feed->appendChild($title);\n                $title->setAttribute('type', 'text');\n                $title->appendChild($document->createTextNode($feedValue));\n            } elseif ($feedKey === 'icon') {\n                if ($feedValue) {\n                    $icon = $document->createElement('icon');\n                    $feed->appendChild($icon);\n                    $icon->appendChild($document->createTextNode($feedValue));\n\n                    $logo = $document->createElement('logo');\n                    $feed->appendChild($logo);\n                    $logo->appendChild($document->createTextNode($feedValue));\n                }\n            } elseif ($feedKey === 'uri') {\n                if ($feedValue) {\n                    $linkAlternate = $document->createElement('link');\n                    $feed->appendChild($linkAlternate);\n                    $linkAlternate->setAttribute('rel', 'alternate');\n                    $linkAlternate->setAttribute('type', 'text/html');\n                    $linkAlternate->setAttribute('href', $feedValue);\n\n                    $linkSelf = $document->createElement('link');\n                    $feed->appendChild($linkSelf);\n                    $linkSelf->setAttribute('rel', 'self');\n                    $linkSelf->setAttribute('type', 'application/atom+xml');\n                    $linkSelf->setAttribute('href', $feedUrl);\n                }\n            } elseif ($feedKey === 'itunes') {\n                // todo: skip?\n            } else {\n                $element = $document->createElement($feedKey);\n                $feed->appendChild($element);\n                $element->appendChild($document->createTextNode($feedValue));\n            }\n        }\n\n        $id = $document->createElement('id');\n        $feed->appendChild($id);\n        $id->appendChild($document->createTextNode($feedUrl));\n\n        $updated = $document->createElement('updated');\n        $feed->appendChild($updated);\n        $updated->appendChild($document->createTextNode(gmdate(DATE_ATOM, $this->lastModified)));\n\n        // since we can't guarantee that all items have an author,\n        // a global feed author is mandatory\n        $feedAuthor = 'RSS-Bridge';\n        $author = $document->createElement('author');\n        $feed->appendChild($author);\n        $authorName = $document->createElement('name');\n        $author->appendChild($authorName);\n        $authorName->appendChild($document->createTextNode($feedAuthor));\n\n        foreach ($this->getItems() as $item) {\n            $itemArray = $item->toArray();\n            $entryTimestamp = $item->getTimestamp();\n            $entryTitle = $item->getTitle();\n            $entryContent = $item->getContent();\n            $entryUri = $item->getURI();\n            $entryID = '';\n\n            if (!empty($item->getUid())) {\n                $entryID = 'urn:sha1:' . $item->getUid();\n            }\n\n            if (empty($entryID)) {\n                // Fallback to provided URI\n                $entryID = $entryUri;\n            }\n\n            if (empty($entryID)) {\n                // Fallback to title and content\n                $entryID = 'urn:sha1:' . hash('sha1', $entryTitle . $entryContent);\n            }\n\n            if (empty($entryTitle)) {\n                $entryTitle = str_replace(\"\\n\", ' ', strip_tags($entryContent));\n                if (strlen($entryTitle) > 140) {\n                    $wrapPos = strpos(wordwrap($entryTitle, 140), \"\\n\");\n                    $entryTitle = substr($entryTitle, 0, $wrapPos) . '...';\n                }\n            }\n\n            if (empty($entryContent)) {\n                $entryContent = ' ';\n            }\n\n            $entry = $document->createElement('entry');\n            $feed->appendChild($entry);\n\n            $title = $document->createElement('title');\n            $entry->appendChild($title);\n            $title->setAttribute('type', 'html');\n            $title->appendChild($document->createTextNode($entryTitle));\n\n            if ($entryTimestamp) {\n                $timestamp = gmdate(\\DATE_ATOM, $entryTimestamp);\n\n                $published = $document->createElement('published');\n                $entry->appendChild($published);\n                $published->appendChild($document->createTextNode($timestamp));\n\n                $updated = $document->createElement('updated');\n                $entry->appendChild($updated);\n                $updated->appendChild($document->createTextNode($timestamp));\n            }\n\n            $id = $document->createElement('id');\n            $entry->appendChild($id);\n            $id->appendChild($document->createTextNode($entryID));\n\n            if (isset($itemArray['itunes'])) {\n                $feed->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:itunes', self::ITUNES_NS);\n                foreach ($itemArray['itunes'] as $itunesKey => $itunesValue) {\n                    $itunesProperty = $document->createElementNS(self::ITUNES_NS, $itunesKey);\n                    $entry->appendChild($itunesProperty);\n                    $itunesProperty->appendChild($document->createTextNode($itunesValue));\n                }\n                if (isset($itemArray['enclosure'])) {\n                    $itunesEnclosure = $document->createElement('enclosure');\n                    $entry->appendChild($itunesEnclosure);\n                    $itunesEnclosure->setAttribute('url', $itemArray['enclosure']['url']);\n                    $itunesEnclosure->setAttribute('length', $itemArray['enclosure']['length']);\n                    $itunesEnclosure->setAttribute('type', $itemArray['enclosure']['type']);\n                }\n            } elseif (!empty($entryUri)) {\n                $entryLinkAlternate = $document->createElement('link');\n                $entry->appendChild($entryLinkAlternate);\n                $entryLinkAlternate->setAttribute('rel', 'alternate');\n                $entryLinkAlternate->setAttribute('type', 'text/html');\n                $entryLinkAlternate->setAttribute('href', $entryUri);\n            }\n\n            if (!empty($item->getAuthor())) {\n                $author = $document->createElement('author');\n                $entry->appendChild($author);\n                $authorName = $document->createElement('name');\n                $author->appendChild($authorName);\n                $authorName->appendChild($document->createTextNode($item->getAuthor()));\n            }\n\n            $content = $document->createElement('content');\n            $content->setAttribute('type', 'html');\n            $content->appendChild($document->createTextNode($entryContent));\n            $entry->appendChild($content);\n\n            foreach ($item->getEnclosures() as $enclosure) {\n                $entryEnclosure = $document->createElement('link');\n                $entry->appendChild($entryEnclosure);\n                $entryEnclosure->setAttribute('rel', 'enclosure');\n                $entryEnclosure->setAttribute('type', parse_mime_type($enclosure));\n                $entryEnclosure->setAttribute('href', $enclosure);\n            }\n\n            foreach ($item->getCategories() as $category) {\n                $entryCategory = $document->createElement('category');\n                $entry->appendChild($entryCategory);\n                $entryCategory->setAttribute('term', $category);\n            }\n\n            if (!empty($item->thumbnail)) {\n                $thumbnail = $document->createElementNS(self::MRSS_NS, 'thumbnail');\n                $entry->appendChild($thumbnail);\n                $thumbnail->setAttribute('url', $item->thumbnail);\n            }\n        }\n\n        $xml = $document->saveXML();\n        return $xml;\n    }\n}\n"
  },
  {
    "path": "formats/HtmlFormat.php",
    "content": "<?php\n\nclass HtmlFormat extends FormatAbstract\n{\n    const MIME_TYPE = 'text/html';\n\n    public function render(): string\n    {\n        // This query string is url encoded\n        $queryString = $_SERVER['QUERY_STRING'];\n\n        // TODO: this should be the proper bridge short name and not user provided string\n        $bridgeName = $_GET['bridge'];\n\n        $feedArray = $this->getFeed();\n        $formatFactory = new FormatFactory();\n        $formats = [];\n\n        // Create all formats (except HTML)\n        $formatNames = $formatFactory->getFormatNames();\n        foreach ($formatNames as $formatName) {\n            if ($formatName === 'Html') {\n                continue;\n            }\n            // The format url is relative, but should be absolute in order to help feed readers.\n            $formatUrl = '?' . str_ireplace('format=Html', 'format=' . $formatName, $queryString);\n            $formatObject = $formatFactory->create($formatName);\n            $formats[] = [\n                'url'       => $formatUrl,\n                'name'      => $formatName,\n                'type'      => $formatObject->getMimeType(),\n            ];\n        }\n\n        $items = [];\n        foreach ($this->getItems() as $item) {\n            $items[] = [\n                'url'           => $item->getURI() ?: $feedArray['uri'],\n                'title'         => $item->getTitle() ?? '(no title)',\n                'timestamp'     => $item->getTimestamp(),\n                'author'        => $item->getAuthor(),\n                'content'       => $item->getContent() ?? '',\n                'enclosures'    => $item->getEnclosures(),\n                'categories'    => $item->getCategories(),\n            ];\n        }\n\n        $donationUri = null;\n        if (Configuration::getConfig('admin', 'donations') && $feedArray['donationUri']) {\n            $donationUri = $feedArray['donationUri'];\n        }\n\n        $html = render_template(__DIR__ . '/../templates/html-format.html.php', [\n            'bridge_name'   => $bridgeName,\n            'title'         => $feedArray['name'],\n            'formats'       => $formats,\n            'uri'           => $feedArray['uri'],\n            'items'         => $items,\n            'donation_uri'  => $donationUri,\n        ]);\n        return $html;\n    }\n}\n"
  },
  {
    "path": "formats/JsonFormat.php",
    "content": "<?php\n\n/**\n * JsonFormat - JSON Feed Version 1\n * https://jsonfeed.org/version/1\n *\n * Validators:\n * https://validator.jsonfeed.org\n * https://github.com/vigetlabs/json-feed-validator\n */\nclass JsonFormat extends FormatAbstract\n{\n    const MIME_TYPE = 'application/json';\n\n    const VENDOR_EXCLUDES = [\n        'author',\n        'title',\n        'uri',\n        'timestamp',\n        'content',\n        'enclosures',\n        'categories',\n        'uid',\n    ];\n\n    public function render(): string\n    {\n        $feedArray = $this->getFeed();\n\n        $data = [\n            'version'       => 'https://jsonfeed.org/version/1',\n            'title'         => $feedArray['name'],\n            'home_page_url' => $feedArray['uri'],\n            'feed_url'      => get_current_url(),\n        ];\n\n        if ($feedArray['icon']) {\n            $data['icon'] = $feedArray['icon'];\n            $data['favicon'] = $feedArray['icon'];\n        }\n\n        $items = [];\n        foreach ($this->getItems() as $item) {\n            $entry = [];\n\n            $entryAuthor = $item->getAuthor();\n            $entryTitle = $item->getTitle();\n            $entryUri = $item->getURI();\n            $entryTimestamp = $item->getTimestamp();\n            $entryContent = $item->getContent() ?? '';\n            $entryEnclosures = $item->getEnclosures();\n            $entryCategories = $item->getCategories();\n\n            $vendorFields = $item->toArray();\n            foreach (self::VENDOR_EXCLUDES as $key) {\n                unset($vendorFields[$key]);\n            }\n\n            $entry['id'] = $item->getUid();\n\n            if (empty($entry['id'])) {\n                $entry['id'] = $entryUri;\n            }\n\n            if (!empty($entryTitle)) {\n                $entry['title'] = $entryTitle;\n            }\n            if (!empty($entryAuthor)) {\n                $entry['author'] = [\n                    'name' => $entryAuthor\n                ];\n            }\n            if (!empty($entryTimestamp)) {\n                $entry['date_modified'] = gmdate(\\DATE_ATOM, $entryTimestamp);\n            }\n            if (!empty($entryUri)) {\n                $entry['url'] = $entryUri;\n            }\n            if (!empty($entryContent)) {\n                if (is_html($entryContent)) {\n                    $entry['content_html'] = $entryContent;\n                } else {\n                    $entry['content_text'] = $entryContent;\n                }\n            }\n            if (!empty($entryEnclosures)) {\n                $entry['attachments'] = [];\n                foreach ($entryEnclosures as $enclosure) {\n                    $entry['attachments'][] = [\n                        'url' => $enclosure,\n                        'mime_type' => parse_mime_type($enclosure)\n                    ];\n                }\n            }\n            if (!empty($entryCategories)) {\n                $entry['tags'] = [];\n                foreach ($entryCategories as $category) {\n                    $entry['tags'][] = $category;\n                }\n            }\n            if (!empty($vendorFields)) {\n                $entry['_rssbridge'] = $vendorFields;\n            }\n\n            if (empty($entry['id'])) {\n                $entry['id'] = hash('sha1', $entryTitle . $entryContent);\n            }\n\n            $items[] = $entry;\n        }\n        $data['items'] = $items;\n\n        // Ignoring invalid json\n        $json = json_encode($data, \\JSON_PRETTY_PRINT | \\JSON_INVALID_UTF8_IGNORE);\n\n        return $json;\n    }\n}\n"
  },
  {
    "path": "formats/MrssFormat.php",
    "content": "<?php\n\n/**\n * MrssFormat - RSS 2.0 + Media RSS\n * http://www.rssboard.org/rss-specification\n * http://www.rssboard.org/media-rss\n *\n * Validators:\n * https://validator.w3.org/feed/\n * http://www.rssboard.org/rss-validator/\n *\n * Notes about the implementation:\n *\n * - The item author is not supported as it needs to be an e-mail address to be\n *   valid.\n * - The RSS specification does not explicitly allow to have more than one\n *   enclosure as every item is meant to provide one \"story\", thus having\n *   multiple enclosures per item may lead to unexpected behavior.\n *   On top of that, it requires to have a length specified, which RSS-Bridge\n *   can't provide.\n * - The Media RSS extension comes in handy, since it allows to have multiple\n *   enclosures, even though they recommend to have only one enclosure because\n *   of the one-story-per-item reason. It only requires to specify the URL,\n *   everything else is optional.\n * - Since the Media RSS extension has its own namespace, the output is a valid\n *   RSS 2.0 feed that works with feed readers that don't support the extension.\n */\nclass MrssFormat extends FormatAbstract\n{\n    const MIME_TYPE = 'application/rss+xml';\n\n    protected const ATOM_NS = 'http://www.w3.org/2005/Atom';\n    protected const MRSS_NS = 'http://search.yahoo.com/mrss/';\n\n    public function render(): string\n    {\n        $document = new \\DomDocument('1.0', 'UTF-8');\n        $document->formatOutput = true;\n\n        $feed = $document->createElement('rss');\n        $document->appendChild($feed);\n        $feed->setAttribute('version', '2.0');\n        $feed->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:atom', self::ATOM_NS);\n        $feed->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:media', self::MRSS_NS);\n\n        $channel = $document->createElement('channel');\n        $feed->appendChild($channel);\n\n        $feedArray = $this->getFeed();\n        $uri = $feedArray['uri'];\n        $title = $feedArray['name'];\n\n        foreach ($feedArray as $feedKey => $feedValue) {\n            if (in_array($feedKey, ['atom', 'donationUri'])) {\n                continue;\n            }\n            if ($feedKey === 'name') {\n                $channelTitle = $document->createElement('title');\n                $channel->appendChild($channelTitle);\n                $channelTitle->appendChild($document->createTextNode($title));\n\n                $description = $document->createElement('description');\n                $channel->appendChild($description);\n                $description->appendChild($document->createTextNode($title));\n            } elseif ($feedKey === 'uri') {\n                $link = $document->createElement('link');\n                $channel->appendChild($link);\n                $link->appendChild($document->createTextNode($uri));\n\n                $linkAlternate = $document->createElementNS(self::ATOM_NS, 'link');\n                $channel->appendChild($linkAlternate);\n                $linkAlternate->setAttribute('rel', 'alternate');\n                $linkAlternate->setAttribute('type', 'text/html');\n                $linkAlternate->setAttribute('href', $uri);\n\n                $linkSelf = $document->createElementNS(self::ATOM_NS, 'link');\n                $channel->appendChild($linkSelf);\n                $linkSelf->setAttribute('rel', 'self');\n                $linkSelf->setAttribute('type', 'application/atom+xml');\n                $feedUrl = get_current_url();\n                $linkSelf->setAttribute('href', $feedUrl);\n            } elseif ($feedKey === 'icon') {\n                $icon = $feedValue;\n                if ($icon) {\n                    $feedImage = $document->createElement('image');\n                    $channel->appendChild($feedImage);\n                    $iconUrl = $document->createElement('url');\n                    $iconUrl->appendChild($document->createTextNode($icon));\n                    $feedImage->appendChild($iconUrl);\n                    $iconTitle = $document->createElement('title');\n                    $iconTitle->appendChild($document->createTextNode($title));\n                    $feedImage->appendChild($iconTitle);\n                    $iconLink = $document->createElement('link');\n                    $iconLink->appendChild($document->createTextNode($uri));\n                    $feedImage->appendChild($iconLink);\n                }\n            } elseif ($feedKey === 'itunes') {\n                $feed->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:itunes', self::ITUNES_NS);\n                foreach ($feedValue as $itunesKey => $itunesValue) {\n                    $itunesProperty = $document->createElementNS(self::ITUNES_NS, $itunesKey);\n                    $channel->appendChild($itunesProperty);\n                    $itunesProperty->appendChild($document->createTextNode($itunesValue));\n                }\n            } else {\n                $element = $document->createElement($feedKey);\n                $channel->appendChild($element);\n                $element->appendChild($document->createTextNode($feedValue));\n            }\n        }\n\n        foreach ($this->getItems() as $item) {\n            $itemArray = $item->toArray();\n            $itemTimestamp = $item->getTimestamp();\n            $itemTitle = $item->getTitle();\n            $itemUri = $item->getURI();\n            $itemContent = $item->getContent() ?? '';\n            $itemUid = $item->getUid();\n            $isPermaLink = 'false';\n\n            if (empty($itemUid) && !empty($itemUri)) {\n                // Fallback to provided URI\n                $itemUid = $itemUri;\n                $isPermaLink = 'true';\n            }\n\n            if (empty($itemUid)) {\n                // Fallback to title and content\n                $itemUid = hash('sha1', $itemTitle . $itemContent);\n            }\n\n            $entry = $document->createElement('item');\n            $channel->appendChild($entry);\n\n            if (!empty($itemTitle)) {\n                $entryTitle = $document->createElement('title');\n                $entry->appendChild($entryTitle);\n                $entryTitle->appendChild($document->createTextNode($itemTitle));\n            }\n\n            if (isset($itemArray['itunes'])) {\n                $feed->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:itunes', self::ITUNES_NS);\n                foreach ($itemArray['itunes'] as $itunesKey => $itunesValue) {\n                    $itunesProperty = $document->createElementNS(self::ITUNES_NS, $itunesKey);\n                    $entry->appendChild($itunesProperty);\n                    $itunesProperty->appendChild($document->createTextNode($itunesValue));\n                }\n\n                if (isset($itemArray['enclosure'])) {\n                    $itunesEnclosure = $document->createElement('enclosure');\n                    $entry->appendChild($itunesEnclosure);\n                    $itunesEnclosure->setAttribute('url', $itemArray['enclosure']['url']);\n                    $itunesEnclosure->setAttribute('length', $itemArray['enclosure']['length']);\n                    $itunesEnclosure->setAttribute('type', $itemArray['enclosure']['type']);\n                }\n            }\n\n            if (!empty($itemUri)) {\n                $entryLink = $document->createElement('link');\n                $entry->appendChild($entryLink);\n                $entryLink->appendChild($document->createTextNode($itemUri));\n            }\n\n            $entryGuid = $document->createElement('guid');\n            $entryGuid->setAttribute('isPermaLink', $isPermaLink);\n            $entry->appendChild($entryGuid);\n            $entryGuid->appendChild($document->createTextNode($itemUid));\n\n            if (!empty($itemTimestamp)) {\n                $entryPublished = $document->createElement('pubDate');\n                $entry->appendChild($entryPublished);\n                $entryPublished->appendChild($document->createTextNode(gmdate(\\DATE_RFC2822, $itemTimestamp)));\n            }\n\n            if (!empty($itemContent)) {\n                $entryDescription = $document->createElement('description');\n                $entry->appendChild($entryDescription);\n                $entryDescription->appendChild($document->createTextNode($itemContent));\n            }\n\n            foreach ($item->getEnclosures() as $enclosure) {\n                $entryEnclosure = $document->createElementNS(self::MRSS_NS, 'content');\n                $entry->appendChild($entryEnclosure);\n                $entryEnclosure->setAttribute('url', $enclosure);\n                $entryEnclosure->setAttribute('type', parse_mime_type($enclosure));\n            }\n\n            foreach ($item->getCategories() as $category) {\n                $entryCategory = $document->createElement('category');\n                $entry->appendChild($entryCategory);\n                $entryCategory->appendChild($document->createTextNode($category));\n            }\n        }\n\n        $xml = $document->saveXML();\n        return $xml;\n    }\n}\n"
  },
  {
    "path": "formats/PlaintextFormat.php",
    "content": "<?php\n\nclass PlaintextFormat extends FormatAbstract\n{\n    const MIME_TYPE = 'text/plain';\n\n    public function render(): string\n    {\n        $feed = $this->getFeed();\n        foreach ($this->getItems() as $item) {\n            $feed['items'][] = $item->toArray();\n        }\n        $text = print_r($feed, true);\n        return $text;\n    }\n}\n"
  },
  {
    "path": "formats/SfeedFormat.php",
    "content": "<?PHP\n\nclass SfeedFormat extends FormatAbstract\n{\n    const MIME_TYPE = 'text/plain';\n\n    public function render(): string\n    {\n        $text = '';\n        foreach ($this->getItems() as $item) {\n            $text .= sprintf(\n                \"%s\\t%s\\t%s\\t%s\\thtml\\t\\t%s\\t%s\\t%s\\n\",\n                $item->toArray()['timestamp'],\n                preg_replace('/\\s/', ' ', $item->toArray()['title']),\n                $item->toArray()['uri'],\n                $this->escape($item->toArray()['content']),\n                $item->toArray()['author'],\n                $this->getFirstEnclosure(\n                    $item->toArray()['enclosures']\n                ),\n                $this->escape(\n                    $this->getCategories(\n                        $item->toArray()['categories']\n                    )\n                )\n            );\n        }\n\n        return $text;\n    }\n\n    private function escape(string $str)\n    {\n        $str = str_replace('\\\\', '\\\\\\\\', $str);\n        $str = str_replace(\"\\n\", '\\\\n', $str);\n        return str_replace(\"\\t\", '\\\\t', $str);\n    }\n\n    private function getFirstEnclosure(array $enclosures)\n    {\n        if (count($enclosures) >= 1) {\n            return $enclosures[0];\n        }\n        return '';\n    }\n\n    private function getCategories(array $cats)\n    {\n        $toReturn = '';\n        $i = 1;\n        foreach ($cats as $cat) {\n            $toReturn .= trim($cat);\n            if (count($cats) > $i++) {\n                $toReturn .= '|';\n            }\n        }\n        return $toReturn;\n    }\n}\n"
  },
  {
    "path": "index.php",
    "content": "<?php\n\nif (version_compare(\\PHP_VERSION, '7.4.0') === -1) {\n    http_response_code(500);\n    exit(\"RSS-Bridge requires minimum PHP version 7.4\\n\");\n}\n\nif (!extension_loaded('curl')) {\n    http_response_code(500);\n    exit(\"RSS-Bridge requires curl (apt install php-curl)\\n\");\n}\n\nrequire __DIR__ . '/lib/bootstrap.php';\nrequire __DIR__ . '/lib/config.php';\n\n$container = require __DIR__ . '/lib/dependencies.php';\n\n$logger = $container['logger'];\n\nset_exception_handler(function (\\Throwable $e) use ($logger) {\n    $response = new Response(render(__DIR__ . '/templates/exception.html.php', ['e' => $e]), 500);\n    $response->send();\n    $logger->error('Uncaught Exception', ['e' => $e]);\n});\n\nset_error_handler(function ($code, $message, $file, $line) use ($logger) {\n    // Consider: ini_set('error_reporting', E_ALL & ~E_DEPRECATED);\n    if ((error_reporting() & $code) === 0) {\n        // Deprecation messages and other masked errors are typically ignored here\n        return false;\n    }\n    if (Configuration::getConfig('system', 'env') === 'dev') {\n        // This might be annoying, but it's for the greater good\n        throw new \\ErrorException($message, 0, $code, $file, $line);\n    }\n    $text = sprintf(\n        '%s at %s line %s',\n        sanitize_root($message),\n        sanitize_root($file),\n        $line\n    );\n    $logger->warning($text);\n    // todo: return false to prevent default error handler from running?\n});\n\n// There might be some fatal errors which are not caught by set_error_handler() or \\Throwable.\nregister_shutdown_function(function () use ($logger) {\n    $error = error_get_last();\n    if ($error) {\n        $message = sprintf(\n            '(shutdown) %s: %s in %s line %s',\n            $error['type'],\n            sanitize_root($error['message']),\n            sanitize_root($error['file']),\n            $error['line']\n        );\n        $logger->error($message);\n    }\n});\n\ndate_default_timezone_set(Configuration::getConfig('system', 'timezone'));\n\n$argv = $argv ?? null;\nif ($argv) {\n    parse_str(implode('&', array_slice($argv, 1)), $cliArgs);\n    $request = Request::fromCli($cliArgs);\n} else {\n    $request = Request::fromGlobals();\n}\n\n$rssBridge = new RssBridge($container);\n\n$response = $rssBridge->main($request);\n\n$response->send();"
  },
  {
    "path": "lib/ActionInterface.php",
    "content": "<?php\n\ninterface ActionInterface\n{\n    public function __invoke(Request $request): Response;\n}\n"
  },
  {
    "path": "lib/BridgeAbstract.php",
    "content": "<?php\n\nabstract class BridgeAbstract\n{\n    const NAME = null;\n    const URI = null;\n    const DONATION_URI = '';\n    const DESCRIPTION = 'No description provided';\n\n    /**\n     * Preferably a github username\n     */\n    const MAINTAINER = 'No maintainer';\n\n    /**\n     * Cache TTL in seconds\n     */\n    const CACHE_TIMEOUT = 3600;\n\n    const CONFIGURATION = [];\n    const PARAMETERS = [];\n    const TEST_DETECT_PARAMETERS = [];\n\n    /**\n     * This is a convenient const for the limit option in bridge contexts.\n     * Can be inlined and modified if necessary.\n     */\n    protected const LIMIT = [\n        'name'          => 'Limit',\n        'type'          => 'number',\n        'title'         => 'Maximum number of items to return',\n    ];\n\n    protected array $items = [];\n    protected array $inputs = [];\n    protected ?string $queriedContext = '';\n    private array $configuration = [];\n\n    protected CacheInterface $cache;\n    protected Logger $logger;\n\n    public function __construct(\n        CacheInterface $cache,\n        Logger $logger\n    ) {\n        $this->cache = $cache;\n        $this->logger = $logger;\n    }\n\n    abstract public function collectData();\n\n    public function getFeed(): array\n    {\n        return [\n            'name'          => $this->getName(),\n            'uri'           => $this->getURI(),\n            'donationUri'   => $this->getDonationURI(),\n            'icon'          => $this->getIcon(),\n        ];\n    }\n\n    public function getName()\n    {\n        return static::NAME ?? $this->getShortName();\n    }\n\n    public function getURI()\n    {\n        return static::URI ?? 'https://github.com/RSS-Bridge/rss-bridge/';\n    }\n\n    public function getDonationURI(): string\n    {\n        return static::DONATION_URI;\n    }\n\n    public function getIcon()\n    {\n        if (static::URI) {\n            // This favicon may or may not exist\n            return rtrim(static::URI, '/') . '/favicon.ico';\n        }\n        return '';\n    }\n\n    public function getOption(string $name)\n    {\n        return $this->configuration[$name] ?? null;\n    }\n\n    /**\n     * The description is only used in bridge card rendering on frontpage\n     */\n    public function getDescription()\n    {\n        return static::DESCRIPTION;\n    }\n\n    public function getMaintainer(): string\n    {\n        return static::MAINTAINER;\n    }\n\n    public function getParameters(): array\n    {\n        return static::PARAMETERS;\n    }\n\n    public function getItems()\n    {\n        return $this->items;\n    }\n\n    public function getCacheTimeout()\n    {\n        return static::CACHE_TIMEOUT;\n    }\n\n    public function loadConfiguration()\n    {\n        foreach (static::CONFIGURATION as $optionName => $optionValue) {\n            $section = $this->getShortName();\n            $configurationOption = Configuration::getConfig($section, $optionName);\n\n            if ($configurationOption !== null) {\n                $this->configuration[$optionName] = $configurationOption;\n                continue;\n            }\n\n            if (isset($optionValue['required']) && $optionValue['required'] === true) {\n                throw new \\Exception(sprintf('Missing configuration option: %s', $optionName));\n            } elseif (isset($optionValue['defaultValue'])) {\n                $this->configuration[$optionName] = $optionValue['defaultValue'];\n            }\n        }\n    }\n\n    public function setInput(array $input)\n    {\n        // This is the submitted context\n        $contextName = $input['context'] ?? null;\n        if ($contextName) {\n            // Context hinting (optional)\n            $this->queriedContext = $contextName;\n            unset($input['context']);\n        }\n\n        $parameters = $this->getParameters();\n\n        if (!$parameters) {\n            if ($input) {\n                throw new \\Exception('Invalid parameters value(s)');\n            }\n            return;\n        }\n\n        $validator = new ParameterValidator();\n\n        // $input IS PASSED BY REFERENCE!\n        $errors = $validator->validateInput($input, $parameters);\n        if ($errors !== []) {\n            $invalidParameterKeys = array_column($errors, 'name');\n            throw new \\Exception(sprintf('Invalid parameters value(s): %s', implode(', ', $invalidParameterKeys)));\n        }\n\n        // Guess the context from input data\n        if (empty($this->queriedContext)) {\n            $queriedContext = $validator->getQueriedContext($input, $parameters);\n            $this->queriedContext = $queriedContext;\n        }\n\n        if (is_null($this->queriedContext)) {\n            throw new \\Exception('Required parameter(s) missing');\n        } elseif ($this->queriedContext === false) {\n            throw new \\Exception('Mixed context parameters');\n        }\n\n        $this->setInputWithContext($input, $this->queriedContext);\n    }\n\n    private function setInputWithContext(array $input, $queriedContext)\n    {\n        $parameters = $this->getParameters();\n\n        // Import and assign all inputs to their context\n        foreach ($input as $name => $value) {\n            foreach ($parameters as $context => $set) {\n                if (array_key_exists($name, $parameters[$context])) {\n                    $this->inputs[$context][$name]['value'] = $value;\n                }\n            }\n        }\n\n        // Apply default values to missing data\n        $contextNames = [$queriedContext];\n        if (array_key_exists('global', $parameters)) {\n            $contextNames[] = 'global';\n        }\n\n        foreach ($contextNames as $context) {\n            if (!isset($parameters[$context])) {\n                // unknown context provided by client, throw exception here? or continue?\n            }\n\n            foreach ($parameters[$context] as $name => $parameter) {\n                if (isset($this->inputs[$context][$name]['value'])) {\n                    continue;\n                }\n\n                $type = $parameter['type'] ?? 'text';\n\n                switch ($type) {\n                    case 'checkbox':\n                        $this->inputs[$context][$name]['value'] = $input[$context][$name]['value'] ?? false;\n                        break;\n                    case 'list':\n                        if (!isset($parameter['defaultValue'])) {\n                            $firstItem = reset($parameter['values']);\n                            if (is_array($firstItem)) {\n                                $firstItem = reset($firstItem);\n                            }\n                            $this->inputs[$context][$name]['value'] = $firstItem;\n                        } else {\n                            $this->inputs[$context][$name]['value'] = $parameter['defaultValue'];\n                        }\n                        break;\n                    default:\n                        if (isset($parameter['defaultValue'])) {\n                            $this->inputs[$context][$name]['value'] = $parameter['defaultValue'];\n                        }\n                        break;\n                }\n            }\n            unset($parameter);\n        }\n\n        // Copy global parameter values to the guessed context\n        if (array_key_exists('global', $parameters)) {\n            foreach ($parameters['global'] as $name => $parameter) {\n                if (isset($input[$name])) {\n                    $value = $input[$name];\n                } else {\n                    if (($parameter['type'] ?? null) === 'checkbox') {\n                        $value = false;\n                    } elseif (isset($parameter['defaultValue'])) {\n                        $value = $parameter['defaultValue'];\n                    } else {\n                        continue;\n                    }\n                }\n                $this->inputs[$queriedContext][$name]['value'] = $value;\n            }\n        }\n\n        // Only keep guessed context parameters values\n        if (isset($this->inputs[$queriedContext])) {\n            $this->inputs = [\n                $queriedContext => $this->inputs[$queriedContext],\n            ];\n        } else {\n            $this->inputs = [];\n        }\n    }\n\n    protected function getInput($input)\n    {\n        return $this->inputs[$this->queriedContext][$input]['value'] ?? null;\n    }\n\n    /**\n     * Get the key name of a given input\n     * Can process multilevel arrays with two levels, the max level a list can have\n     *\n     * @param string $input The input name\n     * @return string|null The accompaning key to a given input or null if the input is not defined\n     */\n    public function getKey($input)\n    {\n        if (!isset($this->inputs[$this->queriedContext][$input]['value'])) {\n            return null;\n        }\n\n        $contexts = $this->getParameters();\n\n        if (array_key_exists('global', $contexts)) {\n            if (array_key_exists($input, $contexts['global'])) {\n                $contextName = 'global';\n            }\n        }\n        if (!isset($contextName)) {\n            $contextName = $this->queriedContext;\n        }\n\n        $needle = $this->inputs[$this->queriedContext][$input]['value'];\n        foreach ($contexts[$contextName][$input]['values'] as $first_level_key => $first_level_value) {\n            if (!is_array($first_level_value) && $needle === (string)$first_level_value) {\n                return $first_level_key;\n            } elseif (is_array($first_level_value)) {\n                foreach ($first_level_value as $second_level_key => $second_level_value) {\n                    if ($needle === (string)$second_level_value) {\n                        return $second_level_key;\n                    }\n                }\n            }\n        }\n    }\n\n    public function detectParameters($url)\n    {\n        $regex = '/^(https?:\\/\\/)?(www\\.)?(.+?)(\\/)?$/';\n\n        $contexts = $this->getParameters();\n\n        if (\n            empty($contexts)\n            && preg_match($regex, $url, $urlMatches) > 0\n            && preg_match($regex, static::URI, $bridgeUriMatches) > 0\n            && $urlMatches[3] === $bridgeUriMatches[3]\n        ) {\n            return [];\n        }\n        return null;\n    }\n\n    protected function loadCacheValue(string $key, $default = null)\n    {\n        return $this->cache->get($this->getShortName() . '_' . $key, $default);\n    }\n\n    protected function saveCacheValue(string $key, $value, int $ttl = 86400)\n    {\n        $this->cache->set($this->getShortName() . '_' . $key, $value, $ttl);\n    }\n\n    public function getShortName(): string\n    {\n        return (new \\ReflectionClass($this))->getShortName();\n    }\n}\n"
  },
  {
    "path": "lib/BridgeFactory.php",
    "content": "<?php\n\nfinal class BridgeFactory\n{\n    private CacheInterface $cache;\n    private Logger $logger;\n    private array $bridgeClassNames = [];\n    private array $enabledBridges = [];\n    private array $missingEnabledBridges = [];\n\n    public function __construct(\n        CacheInterface $cache,\n        Logger $logger\n    ) {\n        $this->cache = $cache;\n        $this->logger = $logger;\n\n        // Create all possible bridge class names from fs\n        foreach (scandir(__DIR__ . '/../bridges/') as $file) {\n            if (preg_match('/^([^.]+Bridge)\\.php$/U', $file, $m)) {\n                $this->bridgeClassNames[] = $m[1];\n            }\n        }\n\n        $enabledBridges = Configuration::getConfig('system', 'enabled_bridges');\n        if ($enabledBridges === null) {\n            throw new \\Exception('No bridges are enabled...');\n        }\n        foreach ($enabledBridges as $enabledBridge) {\n            if ($enabledBridge === '*') {\n                $this->enabledBridges = $this->bridgeClassNames;\n                break;\n            }\n            $bridgeClassName = $this->createBridgeClassName($enabledBridge);\n            if ($bridgeClassName) {\n                $this->enabledBridges[] = $bridgeClassName;\n            } else {\n                $this->missingEnabledBridges[] = $enabledBridge;\n                $this->logger->info(sprintf('Bridge not found: %s', $enabledBridge));\n            }\n        }\n    }\n\n    public function create(string $name): BridgeAbstract\n    {\n        return new $name($this->cache, $this->logger);\n    }\n\n    public function isEnabled(string $bridgeName): bool\n    {\n        return in_array($bridgeName, $this->enabledBridges);\n    }\n\n    public function createBridgeClassName(string $bridgeName): ?string\n    {\n        $name = self::normalizeBridgeName($bridgeName);\n        $namesLoweredCase = array_map('strtolower', $this->bridgeClassNames);\n        $nameLoweredCase = strtolower($name);\n        if (! in_array($nameLoweredCase, $namesLoweredCase)) {\n            return null;\n        }\n        $index = array_search($nameLoweredCase, $namesLoweredCase);\n        return $this->bridgeClassNames[$index];\n    }\n\n    public static function normalizeBridgeName(string $name)\n    {\n        if (preg_match('/(.+)(?:\\.php)/', $name, $matches)) {\n            $name = $matches[1];\n        }\n        if (!preg_match('/(Bridge)$/i', $name)) {\n            $name = sprintf('%sBridge', $name);\n        }\n        return $name;\n    }\n\n    public function getBridgeClassNames(): array\n    {\n        return $this->bridgeClassNames;\n    }\n\n    public function getMissingEnabledBridges(): array\n    {\n        return $this->missingEnabledBridges;\n    }\n}\n"
  },
  {
    "path": "lib/CacheFactory.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass CacheFactory\n{\n    private Logger $logger;\n\n    public function __construct(\n        Logger $logger\n    ) {\n        $this->logger = $logger;\n    }\n\n    public function create(?string $name = null): CacheInterface\n    {\n        $cacheNames = [];\n        foreach (scandir(PATH_LIB_CACHES) as $file) {\n            if (preg_match('/^([^.]+)Cache\\.php$/U', $file, $m)) {\n                $cacheNames[] = $m[1];\n            }\n        }\n        // Trim trailing '.php' if exists\n        if (preg_match('/(.+)(?:\\.php)/', $name, $matches)) {\n            $name = $matches[1];\n        }\n        // Trim trailing 'Cache' if exists\n        if (preg_match('/(.+)(?:Cache)$/i', $name, $matches)) {\n            $name = $matches[1];\n        }\n\n        $index = array_search(strtolower($name), array_map('strtolower', $cacheNames));\n        if ($index === false) {\n            throw new \\InvalidArgumentException(sprintf('Invalid cache name: \"%s\"', $name));\n        }\n\n        $className = $cacheNames[$index] . 'Cache';\n        if (!preg_match('/^[A-Z][a-zA-Z0-9-]*$/', $className)) {\n            throw new \\InvalidArgumentException(sprintf('Invalid cache classname: \"%s\"', $className));\n        }\n\n        switch ($className) {\n            case NullCache::class:\n                return new NullCache();\n            case FileCache::class:\n                $fileCacheConfig = [\n                    // Intentionally checking for truthy value because the historic default value is the empty string\n                    'path' => Configuration::getConfig('FileCache', 'path') ?: PATH_CACHE,\n                    'enable_purge' => Configuration::getConfig('FileCache', 'enable_purge'),\n                ];\n                if (!is_dir($fileCacheConfig['path'])) {\n                    throw new \\Exception(sprintf('The FileCache path does not exists: %s', $fileCacheConfig['path']));\n                }\n                if (!is_writable($fileCacheConfig['path'])) {\n                    throw new \\Exception(sprintf('The FileCache path is not writable: %s', $fileCacheConfig['path']));\n                }\n                return new FileCache($this->logger, $fileCacheConfig);\n            case SQLiteCache::class:\n                if (!extension_loaded('sqlite3')) {\n                    throw new \\Exception('\"sqlite3\" extension not loaded. Please check \"php.ini\"');\n                }\n                if (!is_writable(PATH_CACHE)) {\n                    throw new \\Exception('The cache folder is not writable');\n                }\n                $file = Configuration::getConfig('SQLiteCache', 'file');\n                if (!$file) {\n                    throw new \\Exception(sprintf('Configuration for %s missing.', 'SQLiteCache'));\n                }\n                if (dirname($file) == '.') {\n                    $file = PATH_CACHE . $file;\n                } elseif (!is_dir(dirname($file))) {\n                    throw new \\Exception(sprintf('Invalid configuration for %s', 'SQLiteCache'));\n                }\n                return new SQLiteCache($this->logger, [\n                    'file'          => $file,\n                    'timeout'       => Configuration::getConfig('SQLiteCache', 'timeout'),\n                    'enable_purge'  => Configuration::getConfig('SQLiteCache', 'enable_purge'),\n                ]);\n            case MemcachedCache::class:\n                if (!extension_loaded('memcached')) {\n                    throw new \\Exception('\"memcached\" extension not loaded. Please check \"php.ini\"');\n                }\n                $section = 'MemcachedCache';\n                $host = Configuration::getConfig($section, 'host');\n                $port = Configuration::getConfig($section, 'port');\n                if (empty($host) && empty($port)) {\n                    throw new \\Exception('Configuration for ' . $section . ' missing.');\n                }\n                if (empty($host)) {\n                    throw new \\Exception('\"host\" param is not set for ' . $section);\n                }\n                if (empty($port)) {\n                    throw new \\Exception('\"port\" param is not set for ' . $section);\n                }\n                $port = (string) $port;\n                if (!ctype_digit($port)) {\n                    throw new \\Exception('\"port\" param is invalid for ' . $section);\n                }\n                $port = intval($port);\n                if ($port < 1 || $port > 65535) {\n                    throw new \\Exception('\"port\" param is invalid for ' . $section);\n                }\n                return new MemcachedCache($this->logger, $host, $port);\n            default:\n                if (!file_exists(PATH_LIB_CACHES . $className . '.php')) {\n                    throw new \\Exception('Unable to find the cache file');\n                }\n                return new $className();\n        }\n    }\n}\n"
  },
  {
    "path": "lib/CacheInterface.php",
    "content": "<?php\n\ninterface CacheInterface\n{\n    public function get(string $key, $default = null);\n\n    public function set(string $key, $value, ?int $ttl = null): void;\n\n    public function delete(string $key): void;\n\n    public function clear(): void;\n\n    public function prune(): void;\n}\n"
  },
  {
    "path": "lib/Configuration.php",
    "content": "<?php\n\n/**\n * Configuration module for RSS-Bridge.\n *\n * This class implements a configuration module for RSS-Bridge.\n */\nfinal class Configuration\n{\n    private const VERSION = '2025-08-05';\n\n    private static $config = [];\n\n    private function __construct()\n    {\n    }\n\n    public static function loadConfiguration(array $customConfig = [], array $env = [])\n    {\n        if (!file_exists(__DIR__ . '/../config.default.ini.php')) {\n            throw new \\Exception('The default configuration file is missing');\n        }\n        $config = parse_ini_file(__DIR__ . '/../config.default.ini.php', true, INI_SCANNER_TYPED);\n        if (!$config) {\n            throw new \\Exception('Error parsing ini config');\n        }\n        foreach ($config as $header => $section) {\n            foreach ($section as $key => $value) {\n                self::setConfig($header, $key, $value);\n            }\n        }\n        foreach ($customConfig as $header => $section) {\n            foreach ($section as $key => $value) {\n                self::setConfig($header, $key, $value);\n            }\n        }\n\n        if (file_exists(__DIR__ . '/../DEBUG')) {\n            $debug = trim(file_get_contents(__DIR__ . '/../DEBUG'));\n            if ($debug === '') {\n                self::setConfig('system', 'env', 'dev');\n                self::setConfig('cache', 'type', 'array');\n            }\n        }\n\n        if (file_exists(__DIR__ . '/../whitelist.txt')) {\n            $enabledBridges = trim(file_get_contents(__DIR__ . '/../whitelist.txt'));\n            if ($enabledBridges === '*') {\n                self::setConfig('system', 'enabled_bridges', ['*']);\n            } else {\n                self::setConfig('system', 'enabled_bridges', array_filter(array_map('trim', explode(\"\\n\", $enabledBridges))));\n            }\n        }\n\n        foreach ($env as $envName => $envValue) {\n            $nameParts = explode('_', $envName);\n            if ($nameParts[0] === 'RSSBRIDGE') {\n                if (count($nameParts) < 3) {\n                    // Invalid env name\n                    continue;\n                }\n\n                // The variable is named $header but it's actually the section in config.ini.php\n                $header = $nameParts[1];\n\n                // Recombine the key if it had multiple underscores\n                $key = implode('_', array_slice($nameParts, 2));\n                $key = strtolower($key);\n\n                // Handle this specifically because it's an array\n                if ($key === 'enabled_bridges') {\n                    $envValue = explode(',', $envValue);\n                    $envValue = array_map('trim', $envValue);\n                }\n\n                if ($envValue === 'true' || $envValue === 'false') {\n                    $envValue = filter_var($envValue, FILTER_VALIDATE_BOOLEAN);\n                }\n\n                self::setConfig($header, $key, $envValue);\n            }\n        }\n\n        if (!in_array(self::getConfig('system', 'env'), ['dev', 'prod'])) {\n            self::throwConfigError('system', 'env', 'Must be dev or prod');\n        }\n\n        if (!is_array(self::getConfig('system', 'enabled_bridges'))) {\n            self::throwConfigError('system', 'enabled_bridges', 'Is not an array');\n        }\n\n        if (\n            !is_string(self::getConfig('system', 'timezone'))\n            || !in_array(self::getConfig('system', 'timezone'), timezone_identifiers_list(DateTimeZone::ALL_WITH_BC))\n        ) {\n            self::throwConfigError('system', 'timezone');\n        }\n\n        if (!is_string(self::getConfig('proxy', 'url'))) {\n            self::throwConfigError('proxy', 'url', 'Is not a valid string');\n        }\n\n        if (!is_bool(self::getConfig('proxy', 'by_bridge'))) {\n            self::throwConfigError('proxy', 'by_bridge', 'Is not a valid Boolean');\n        }\n\n        if (!is_string(self::getConfig('proxy', 'name'))) {\n            /** Name of the proxy server */\n            self::throwConfigError('proxy', 'name', 'Is not a valid string');\n        }\n\n        if (!is_string(self::getConfig('cache', 'type'))) {\n            self::throwConfigError('cache', 'type', 'Is not a valid string');\n        }\n\n        if (!is_bool(self::getConfig('cache', 'custom_timeout'))) {\n            self::throwConfigError('cache', 'custom_timeout', 'Is not a valid Boolean');\n        }\n\n        if (!is_bool(self::getConfig('authentication', 'enable'))) {\n            self::throwConfigError('authentication', 'enable', 'Is not a valid Boolean');\n        }\n\n        if (!is_string(self::getConfig('authentication', 'username'))) {\n            self::throwConfigError('authentication', 'username', 'Is not a valid string');\n        }\n\n        if (!is_string(self::getConfig('authentication', 'password'))) {\n            self::throwConfigError('authentication', 'password', 'Is not a valid string');\n        }\n\n        if (\n            !empty(self::getConfig('admin', 'email'))\n            && !filter_var(self::getConfig('admin', 'email'), FILTER_VALIDATE_EMAIL)\n        ) {\n            self::throwConfigError('admin', 'email', 'Is not a valid email address');\n        }\n\n        if (!is_bool(self::getConfig('admin', 'donations'))) {\n            self::throwConfigError('admin', 'donations', 'Is not a valid Boolean');\n        }\n\n        if (!is_string(self::getConfig('error', 'output'))) {\n            self::throwConfigError('error', 'output', 'Is not a valid String');\n        }\n        if (!in_array(self::getConfig('error', 'output'), ['feed', 'http', 'none'])) {\n            self::throwConfigError('error', 'output', 'Invalid output');\n        }\n\n        if (\n            !is_numeric(self::getConfig('error', 'report_limit'))\n            || self::getConfig('error', 'report_limit') < 1\n        ) {\n            self::throwConfigError('admin', 'report_limit', 'Value is invalid');\n        }\n    }\n\n    public static function getConfig(string $section, string $key, $default = null)\n    {\n        if (self::$config === []) {\n            throw new \\Exception('Config has not been loaded');\n        }\n        return self::$config[strtolower($section)][strtolower($key)] ?? $default;\n    }\n\n    /**\n     * @internal Please avoid usage\n     */\n    public static function setConfig(string $section, string $key, $value): void\n    {\n        self::$config[strtolower($section)][strtolower($key)] = $value;\n    }\n\n    public static function getVersion()\n    {\n        $headFile = __DIR__ . '/../.git/HEAD';\n\n        if (@is_readable($headFile)) {\n            $revisionHashFile = '.git/' . substr(file_get_contents($headFile), 5, -1);\n            $parts = explode('/', $revisionHashFile);\n\n            if (isset($parts[3])) {\n                $branchName = $parts[3];\n                if (file_exists($revisionHashFile)) {\n                    return sprintf('%s (git.%s.%s)', self::VERSION, $branchName, substr(file_get_contents($revisionHashFile), 0, 7));\n                }\n            }\n        }\n        return self::VERSION;\n    }\n\n    private static function throwConfigError($section, $key, $message = '')\n    {\n        http_response_code(500);\n        print (\"Config [$section] => [$key] is invalid. $message\");\n        exit(1);\n    }\n}\n"
  },
  {
    "path": "lib/Container.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass Container implements \\ArrayAccess\n{\n    private array $values = [];\n    private array $resolved = [];\n\n    public function offsetSet($offset, $value): void\n    {\n        $this->values[$offset] = $value;\n    }\n\n    #[\\ReturnTypeWillChange]\n    public function offsetGet($offset)\n    {\n        if (!isset($this->values[$offset])) {\n            throw new \\Exception(sprintf('Unknown container key: \"%s\"', $offset));\n        }\n        if (!isset($this->resolved[$offset])) {\n            $this->resolved[$offset] = $this->values[$offset]($this);\n        }\n        return $this->resolved[$offset];\n    }\n\n    #[\\ReturnTypeWillChange]\n    public function offsetExists($offset): bool\n    {\n        return isset($this->values[$offset]);\n    }\n\n    public function offsetUnset($offset): void\n    {\n    }\n}\n"
  },
  {
    "path": "lib/FeedExpander.php",
    "content": "<?php\n\n/**\n * Expands an existing feed\n */\nabstract class FeedExpander extends BridgeAbstract\n{\n    private array $feed;\n\n    public function collectExpandableDatas(string $url, $maxItems = -1, $headers = [])\n    {\n        if (!$url) {\n            throw new \\Exception('There is no $url for this RSS expander');\n        }\n        $maxItems = (int) $maxItems;\n        if ($maxItems === -1) {\n            $maxItems = 999;\n        }\n        $accept = [MrssFormat::MIME_TYPE, AtomFormat::MIME_TYPE, '*/*'];\n        $httpHeaders = array_merge(['Accept: ' . implode(', ', $accept)], $headers);\n        $xmlString = getContents($url, $httpHeaders);\n        if ($xmlString === '') {\n            throw new \\Exception(sprintf('Unable to parse xml from `%s` because we got the empty string', $url), 10);\n        }\n        $xmlString = $this->prepareXml($xmlString);\n        $feedParser = new FeedParser();\n        try {\n            $this->feed = $feedParser->parseFeed($xmlString);\n        } catch (\\Exception $e) {\n            // FeedMergeBridge relies on this string\n            throw new \\Exception(sprintf('Failed to parse xml from %s: %s', $url, create_sane_exception_message($e)));\n        }\n\n        $items = array_slice($this->feed['items'], 0, $maxItems);\n        // todo: extract parse logic out from FeedParser\n        foreach ($items as $item) {\n            // Give bridges a chance to modify the item\n            $item = $this->parseItem($item);\n            if ($item) {\n                $this->items[] = $item;\n            }\n        }\n    }\n\n    /**\n     * This method is overridden by bridges\n     *\n     * @return array\n     */\n    protected function parseItem(array $item)\n    {\n        return $item;\n    }\n\n    /**\n    * Prepare XML document to make it more acceptable by the parser\n    * This method can be overriden by bridges to change this behavior\n    *\n    * @return string\n    */\n    protected function prepareXml(string $xmlString): string\n    {\n        // Remove problematic escape sequences\n        $problematicStrings = [\n            '&nbsp;',\n            '&raquo;',\n            '&rsquo;',\n        ];\n        return str_replace($problematicStrings, '', $xmlString);\n    }\n\n    public function getURI()\n    {\n        return $this->feed['uri'] ?? parent::getURI();\n    }\n\n    public function getName()\n    {\n        return $this->feed['title'] ?? parent::getName();\n    }\n\n    public function getIcon()\n    {\n        return $this->feed['icon'] ?? parent::getIcon();\n    }\n}\n"
  },
  {
    "path": "lib/FeedItem.php",
    "content": "<?php\n\nclass FeedItem\n{\n    protected ?string $uri = null;\n    protected ?string $title = null;\n    protected ?int $timestamp = null;\n    protected ?string $author = null;\n    protected ?string $content = null;\n    protected array $enclosures = [];\n    protected array $categories = [];\n    protected ?string $uid = null;\n    protected array $misc = [];\n\n    public static function fromArray(array $itemArray): self\n    {\n        $item = new self();\n        foreach ($itemArray as $key => $value) {\n            $item->__set($key, $value);\n        }\n        return $item;\n    }\n\n    private function __construct()\n    {\n    }\n\n    public function __set($name, $value)\n    {\n        switch ($name) {\n            case 'uri':\n                $this->setURI($value);\n                break;\n            case 'title':\n                $this->setTitle($value);\n                break;\n            case 'timestamp':\n                $this->setTimestamp($value);\n                break;\n            case 'author':\n                $this->setAuthor($value);\n                break;\n            case 'content':\n                $this->setContent($value);\n                break;\n            case 'enclosures':\n                $this->setEnclosures($value);\n                break;\n            case 'categories':\n                $this->setCategories($value);\n                break;\n            case 'uid':\n                $this->setUid($value);\n                break;\n            default:\n                $this->addMisc($name, $value);\n        }\n    }\n\n    public function __get($name)\n    {\n        switch ($name) {\n            case 'uri':\n                return $this->getURI();\n            case 'title':\n                return $this->getTitle();\n            case 'timestamp':\n                return $this->getTimestamp();\n            case 'author':\n                return $this->getAuthor();\n            case 'content':\n                return $this->getContent();\n            case 'enclosures':\n                return $this->getEnclosures();\n            case 'categories':\n                return $this->getCategories();\n            case 'uid':\n                return $this->getUid();\n            default:\n                if (array_key_exists($name, $this->misc)) {\n                    return $this->misc[$name];\n                }\n                return null;\n        }\n    }\n\n    public function getURI(): ?string\n    {\n        return $this->uri;\n    }\n\n    public function setURI($uri)\n    {\n        $this->uri = null;\n\n        if ($uri instanceof simple_html_dom_node) {\n            if ($uri->hasAttribute('href')) { // Anchor\n                $uri = $uri->href;\n            } elseif ($uri->hasAttribute('src')) { // Image\n                $uri = $uri->src;\n            }\n        }\n        if (!is_string($uri)) {\n            return;\n        }\n        $uri = trim($uri);\n        // Intentionally doing a weak url validation here because FILTER_VALIDATE_URL is too strict\n        if (!preg_match('#^https?://#i', $uri)) {\n            return;\n        }\n        $this->uri = $uri;\n    }\n\n    public function getTitle(): ?string\n    {\n        return $this->title;\n    }\n\n    public function setTitle($title)\n    {\n        $this->title = null;\n        if (is_string($title)) {\n            $this->title = truncate(trim($title));\n        }\n    }\n\n    public function getTimestamp(): ?int\n    {\n        return $this->timestamp;\n    }\n\n    public function setTimestamp($datetime)\n    {\n        $this->timestamp = null;\n        if (is_numeric($datetime)) {\n            $timestamp = $datetime;\n        } else {\n            $timestamp = strtotime($datetime);\n        }\n        if ($timestamp > 0) {\n            $this->timestamp = $timestamp;\n        }\n    }\n\n    public function getAuthor(): ?string\n    {\n        return $this->author;\n    }\n\n    public function setAuthor($author)\n    {\n        $this->author = null;\n        if (is_string($author)) {\n            $this->author = $author;\n        }\n    }\n\n    public function getContent(): ?string\n    {\n        return $this->content;\n    }\n\n    /**\n     * @param string|array|\\simple_html_dom|\\simple_html_dom_node $content The item content\n     */\n    public function setContent($content)\n    {\n        $this->content = null;\n\n        if (\n            $content instanceof simple_html_dom\n            || $content instanceof simple_html_dom_node\n        ) {\n            $content = (string) $content;\n        }\n\n        if (is_string($content)) {\n            $this->content = $content;\n        }\n    }\n\n    public function getEnclosures(): array\n    {\n        return $this->enclosures;\n    }\n\n    public function setEnclosures($enclosures)\n    {\n        $this->enclosures = [];\n\n        if (!is_array($enclosures)) {\n            return;\n        }\n        foreach ($enclosures as $enclosure) {\n            if (\n                !filter_var(\n                    $enclosure,\n                    FILTER_VALIDATE_URL,\n                    FILTER_FLAG_PATH_REQUIRED\n                )\n            ) {\n            } elseif (!in_array($enclosure, $this->enclosures)) {\n                $this->enclosures[] = $enclosure;\n            }\n        }\n    }\n\n    public function getCategories(): array\n    {\n        return $this->categories;\n    }\n\n    public function setCategories($categories)\n    {\n        $this->categories = [];\n\n        if (!is_array($categories)) {\n            return;\n        }\n        foreach ($categories as $category) {\n            if (is_string($category)) {\n                $this->categories[] = $category;\n            }\n        }\n    }\n\n    public function getUid(): ?string\n    {\n        return $this->uid;\n    }\n\n    public function setUid($uid): void\n    {\n        $this->uid = null;\n        if (!is_string($uid)) {\n            return;\n        }\n        if (preg_match('/^[a-f0-9]{40}$/', $uid)) {\n            // Preserve sha1 hash\n            $this->uid = $uid;\n        } else {\n            $this->uid = sha1($uid);\n        }\n    }\n\n    public function addMisc($name, $value)\n    {\n        $this->misc[$name] = $value;\n    }\n\n    public function toArray(): array\n    {\n        return array_merge(\n            [\n                'uri' => $this->uri,\n                'title' => $this->title,\n                'timestamp' => $this->timestamp,\n                'author' => $this->author,\n                'content' => $this->content,\n                'enclosures' => $this->enclosures,\n                'categories' => $this->categories,\n                'uid' => $this->uid,\n            ],\n            $this->misc\n        );\n    }\n}\n"
  },
  {
    "path": "lib/FeedParser.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Very basic and naive feed parser.\n *\n * Scrapes out rss 0.91, 1.0, 2.0 and atom 1.0.\n *\n * Produces array meant to be used inside rss-bridge.\n *\n * The item structure is tweaked so that it works with FeedItem\n */\nfinal class FeedParser\n{\n    public function parseFeed(string $xmlString): array\n    {\n        libxml_use_internal_errors(true);\n        $xml = simplexml_load_string(trim($xmlString));\n        $xmlErrors = libxml_get_errors();\n        libxml_use_internal_errors(false);\n        if ($xml === false) {\n            if ($xmlErrors) {\n                $firstXmlErrorMessage = $xmlErrors[0]->message;\n            }\n            throw new \\Exception(sprintf('Unable to parse xml: %s', $firstXmlErrorMessage ?? ''));\n        }\n        $feed = [\n            'title'     => null,\n            'uri'       => null,\n            'icon'      => null,\n            'items'     => [],\n        ];\n        if (isset($xml->item[0])) {\n            // rss 1.0\n            $channel = $xml->channel[0];\n            $feed['title'] = trim((string)$channel->title);\n            $feed['uri'] = trim((string)$channel->link);\n            if (isset($channel->image->url)) {\n                $feed['icon'] = trim((string)$channel->image->url);\n            }\n            foreach ($xml->item as $item) {\n                $feed['items'][] = $this->parseRss1Item($item);\n            }\n        } elseif (isset($xml->channel[0])) {\n            // rss 2.0\n            $channel = $xml->channel[0];\n            $feed['title'] = trim((string)$channel->title);\n            $feed['uri'] = trim((string)$channel->link);\n            if (isset($channel->image->url)) {\n                $feed['icon'] = trim((string)$channel->image->url);\n            }\n            foreach ($channel->item as $item) {\n                $feed['items'][] = $this->parseRss2Item($item);\n            }\n        } elseif (isset($xml->entry[0])) {\n            // atom 1.0\n            $feed['title'] = (string)$xml->title;\n            // Find best link (only one, or first of 'alternate')\n            if (!isset($xml->link)) {\n                $feed['uri'] = '';\n            } elseif (count($xml->link) === 1) {\n                $feed['uri'] = (string)$xml->link[0]['href'];\n            } else {\n                $feed['uri'] = '';\n                foreach ($xml->link as $link) {\n                    if (strtolower((string) $link['rel']) === 'alternate') {\n                        $feed['uri'] = (string)$link['href'];\n                        break;\n                    }\n                }\n            }\n            if (isset($xml->icon)) {\n                $feed['icon'] = (string) $xml->icon;\n            } elseif (isset($xml->logo)) {\n                $feed['icon'] = (string) $xml->logo;\n            }\n            foreach ($xml->entry as $item) {\n                $feed['items'][] = $this->parseAtomItem($item);\n            }\n        } else {\n            throw new \\Exception('Unable to detect feed format');\n        }\n\n        return $feed;\n    }\n\n    public function parseAtomItem(\\SimpleXMLElement $feedItem): array\n    {\n        $item = $this->parseRss2Item($feedItem);\n        if (isset($feedItem->id)) {\n            $item['uri'] = (string)$feedItem->id;\n        }\n        if (isset($feedItem->title)) {\n            $item['title'] = trim(html_entity_decode((string)$feedItem->title));\n        }\n        if (isset($feedItem->updated)) {\n            $item['timestamp'] = strtotime((string)$feedItem->updated);\n        }\n        if (isset($feedItem->author)) {\n            $item['author'] = (string)$feedItem->author->name;\n        }\n        if (isset($feedItem->content)) {\n            $contentChildren = $feedItem->content->children();\n            if (count($contentChildren) > 0) {\n                $content = '';\n                foreach ($contentChildren as $contentChild) {\n                    $content .= $contentChild->asXML();\n                }\n                $item['content'] = $content;\n            } else {\n                $item['content'] = (string)$feedItem->content;\n            }\n        }\n\n        // When \"link\" field is present, URL is more reliable than \"id\" field\n        if (count($feedItem->link) === 1) {\n            $item['uri'] = (string)$feedItem->link[0]['href'];\n        } else {\n            foreach ($feedItem->link as $link) {\n                if (strtolower((string) $link['rel']) === 'alternate') {\n                    $item['uri'] = (string)$link['href'];\n                }\n                if (strtolower((string) $link['rel']) === 'enclosure') {\n                    $item['enclosures'][] = (string)$link['href'];\n                }\n            }\n        }\n        return $item;\n    }\n\n    public function parseRss2Item(\\SimpleXMLElement $feedItem): array\n    {\n        $item = [\n            'uri'           => '',\n            'title'         => '',\n            'content'       => '',\n            'timestamp'     => '',\n            'author'        => '',\n            //'uid'           => null,\n            //'categories'    => [],\n            //'enclosures'    => [],\n        ];\n\n        foreach ($feedItem as $k => $v) {\n            $hasChildren = count($v) !== 0;\n            if (!$hasChildren) {\n                $item[$k] = (string) $v;\n            }\n        }\n\n        if (isset($feedItem->link)) {\n            // todo: trim uri\n            $item['uri'] = (string)$feedItem->link;\n        }\n        if (isset($feedItem->title)) {\n            $item['title'] = trim(html_entity_decode((string)$feedItem->title));\n        }\n        if (isset($feedItem->description)) {\n            $item['content'] = (string)$feedItem->description;\n        }\n\n        $namespaces = $feedItem->getNamespaces(true);\n        if (isset($namespaces['dc'])) {\n            $dc = $feedItem->children($namespaces['dc']);\n        }\n        if (isset($namespaces['media'])) {\n            $media = $feedItem->children($namespaces['media']);\n        }\n\n        if (isset($namespaces['content'])) {\n            $content = $feedItem->children($namespaces['content']);\n            $item['content'] = (string) $content;\n        }\n\n        foreach ($namespaces as $namespaceName => $namespaceUrl) {\n            if (in_array($namespaceName, ['', 'content'])) {\n                continue;\n            }\n            $item[$namespaceName] = $this->parseModule($feedItem, $namespaceName, $namespaceUrl);\n        }\n        if (isset($namespaces['itunes'])) {\n            $enclosure = $feedItem->enclosure;\n            $item['enclosure'] = [\n                'url'       => (string) $enclosure['url'],\n                'length'    => (string) $enclosure['length'],\n                'type'      => (string) $enclosure['type'],\n            ];\n        }\n        if (!$item['uri']) {\n            // Let's use guid as uri if it's a permalink\n            if (isset($feedItem->guid)) {\n                foreach ($feedItem->guid->attributes() as $attribute => $value) {\n                    if ($attribute === 'isPermaLink' && ($value === 'true' || (filter_var($feedItem->guid, FILTER_VALIDATE_URL)))) {\n                        $item['uri'] = (string) $feedItem->guid;\n                        break;\n                    }\n                }\n            }\n        }\n\n        $item['timestamp'] = $feedItem->pubDate ?? $dc->date ?? '';\n        $item['timestamp'] = strtotime((string) $item['timestamp']);\n\n        $item['author'] = $feedItem->author ?? $feedItem->creator ?? $dc->creator ?? $media->credit ?? '';\n        $item['author'] = (string) $item['author'];\n\n        if (isset($feedItem->enclosure) && !empty($feedItem->enclosure['url'])) {\n            $item['enclosures'] = [\n                (string) $feedItem->enclosure['url'],\n            ];\n        }\n        return $item;\n    }\n\n    public function parseRss1Item(\\SimpleXMLElement $feedItem): array\n    {\n        $item = [\n            'uri'           => '',\n            'title'         => '',\n            'content'       => '',\n            'timestamp'     => '',\n            'author'        => '',\n            //'uid'           => null,\n            //'categories'    => [],\n            //'enclosures'    => [],\n        ];\n        if (isset($feedItem->link)) {\n            // todo: trim uri\n            $item['uri'] = (string)$feedItem->link;\n        }\n        if (isset($feedItem->title)) {\n            $item['title'] = html_entity_decode((string)$feedItem->title);\n        }\n        if (isset($feedItem->description)) {\n            $item['content'] = (string)$feedItem->description;\n        }\n        $namespaces = $feedItem->getNamespaces(true);\n        if (isset($namespaces['dc'])) {\n            $dc = $feedItem->children($namespaces['dc']);\n            if (isset($dc->date)) {\n                $item['timestamp'] = strtotime((string)$dc->date);\n            }\n            if (isset($dc->creator)) {\n                $item['author'] = (string)$dc->creator;\n            }\n        }\n        return $item;\n    }\n\n    private function parseModule(\\SimpleXMLElement $element, string $namespaceName, string $namespaceUrl): array\n    {\n        // Unfortunately this parses out only node values as string\n        // TODO: parse attributes too\n\n        $result = [];\n        $module = $element->children($namespaceUrl);\n        foreach ($module as $name => $value) {\n            if (get_class($value) === 'SimpleXMLElement' && $value->count() !== 0) {\n                $result[$name] = $this->parseModule($value, $namespaceName, $namespaceUrl);\n            } else {\n                $result[$name] = (string) $value;\n            }\n        }\n        return $result;\n    }\n}\n"
  },
  {
    "path": "lib/FormatAbstract.php",
    "content": "<?php\n\nabstract class FormatAbstract\n{\n    public const ITUNES_NS = 'http://www.itunes.com/dtds/podcast-1.0.dtd';\n\n    const MIME_TYPE = 'text/plain';\n\n    protected array $feed = [];\n    protected array $items = [];\n\n    protected int $lastModified;\n\n    abstract public function render(): string;\n\n    public function setFeed(array $feed)\n    {\n        $default = [\n            'name'          => '',\n            'uri'           => '',\n            'icon'          => '',\n            'donationUri'   => '',\n        ];\n        $this->feed = array_merge($default, $feed);\n    }\n\n    public function getFeed(): array\n    {\n        return $this->feed;\n    }\n\n    public function setItems(array $items): void\n    {\n        foreach ($items as $item) {\n            $this->items[] = FeedItem::fromArray($item);\n        }\n    }\n\n    /**\n     * @return FeedItem[] The items\n     */\n    public function getItems(): array\n    {\n        return $this->items;\n    }\n\n    public function getMimeType(): string\n    {\n        return static::MIME_TYPE;\n    }\n\n    public function setLastModified(int $lastModified)\n    {\n        $this->lastModified = $lastModified;\n    }\n}\n"
  },
  {
    "path": "lib/FormatFactory.php",
    "content": "<?php\n\nclass FormatFactory\n{\n    private array $formatNames = [];\n\n    public function __construct()\n    {\n        $iterator = new \\FilesystemIterator(__DIR__ . '/../formats');\n        foreach ($iterator as $file) {\n            if (preg_match('/^([^.]+)Format\\.php$/U', $file->getFilename(), $m)) {\n                $this->formatNames[] = $m[1];\n            }\n        }\n        sort($this->formatNames);\n    }\n\n    public function create(string $name): FormatAbstract\n    {\n        if (! preg_match('/^[a-zA-Z0-9-]*$/', $name)) {\n            throw new \\InvalidArgumentException('Format name invalid!');\n        }\n        $sanitizedName = $this->sanitizeName($name);\n        if (!$sanitizedName) {\n            throw new \\InvalidArgumentException(sprintf('Unknown format given `%s`', $name));\n        }\n        $className = '\\\\' . $sanitizedName . 'Format';\n        return new $className();\n    }\n\n    public function getFormatNames(): array\n    {\n        return $this->formatNames;\n    }\n\n    protected function sanitizeName(string $name): ?string\n    {\n        $name = ucfirst(strtolower($name));\n        // Trim trailing '.php' if exists\n        if (preg_match('/(.+)(?:\\.php)/', $name, $matches)) {\n            $name = $matches[1];\n        }\n        // Trim trailing 'Format' if exists\n        if (preg_match('/(.+)(?:Format)/i', $name, $matches)) {\n            $name = $matches[1];\n        }\n        if (in_array($name, $this->formatNames)) {\n            return $name;\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "lib/ParameterValidator.php",
    "content": "<?php\n\nclass ParameterValidator\n{\n    /**\n     * Validate and sanitize user inputs against configured bridge parameters (contexts)\n     */\n    public function validateInput(array &$input, array $parameters): array\n    {\n        $errors = [];\n\n        foreach ($input as $name => $value) {\n            $registered = false;\n            foreach ($parameters as $contextName => $contextParameters) {\n                if (!array_key_exists($name, $contextParameters)) {\n                    continue;\n                }\n                $registered = true;\n                if (!isset($contextParameters[$name]['type'])) {\n                    // Default type is text\n                    $contextParameters[$name]['type'] = 'text';\n                }\n\n                switch ($contextParameters[$name]['type']) {\n                    case 'number':\n                        $input[$name] = $this->validateNumberValue($value);\n                        break;\n                    case 'checkbox':\n                        $input[$name] = $this->validateCheckboxValue($value);\n                        break;\n                    case 'list':\n                        $input[$name] = $this->validateListValue($value, $contextParameters[$name]['values']);\n                        break;\n                    default:\n                    case 'text':\n                        if (isset($contextParameters[$name]['pattern'])) {\n                            $input[$name] = $this->validateTextValue($value, $contextParameters[$name]['pattern']);\n                        } else {\n                            $input[$name] = $this->validateTextValue($value);\n                        }\n                        break;\n                }\n\n                if (\n                    is_null($input[$name])\n                    && isset($contextParameters[$name]['required'])\n                    && $contextParameters[$name]['required']\n                ) {\n                    $errors[] = ['name' => $name, 'reason' => 'Parameter is invalid!'];\n                }\n            }\n\n            if (!$registered) {\n                $errors[] = ['name' => $name, 'reason' => 'Parameter is not registered!'];\n            }\n        }\n\n        return $errors;\n    }\n\n    public function getQueriedContext(array $input, array $parameters)\n    {\n        $queriedContexts = [];\n\n        // Detect matching context\n        foreach ($parameters as $contextName => $contextParameters) {\n            $queriedContexts[$contextName] = null;\n\n            // Ensure all user data exist in the current context\n            $notInContext = array_diff_key($input, $contextParameters);\n            if (array_key_exists('global', $parameters)) {\n                $notInContext = array_diff_key($notInContext, $parameters['global']);\n            }\n            if (count($notInContext) > 0) {\n                continue;\n            }\n\n            // Check if all parameters of the context are satisfied\n            foreach ($contextParameters as $id => $properties) {\n                if (!empty($input[$id])) {\n                    $queriedContexts[$contextName] = true;\n                } elseif (\n                    isset($properties['type'])\n                    && ($properties['type'] === 'checkbox' || $properties['type'] === 'list')\n                ) {\n                    continue;\n                } elseif (isset($properties['required']) && $properties['required'] === true) {\n                    $queriedContexts[$contextName] = false;\n                    break;\n                }\n            }\n        }\n\n        // Abort if one of the globally required parameters is not satisfied\n        if (\n            array_key_exists('global', $parameters)\n            && $queriedContexts['global'] === false\n        ) {\n            return null;\n        }\n        unset($queriedContexts['global']);\n\n        switch (array_sum($queriedContexts)) {\n            case 0:\n                // Found no match, is there a context without parameters?\n                if (isset($input['context'])) {\n                    return $input['context'];\n                }\n                foreach ($queriedContexts as $context2 => $queried) {\n                    if (is_null($queried)) {\n                        return $context2;\n                    }\n                }\n                return null;\n            case 1:\n                // Found unique match\n                return array_search(true, $queriedContexts);\n            default:\n                return false;\n        }\n    }\n\n    private function validateTextValue($value, $pattern = null)\n    {\n        if (is_null($pattern)) {\n            // No filtering taking place\n            $filteredValue = filter_var($value);\n        } else {\n            $filteredValue = filter_var($value, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^' . $pattern . '$/']]);\n        }\n        if ($filteredValue === false) {\n            return null;\n        }\n        return $filteredValue;\n    }\n\n    private function validateNumberValue($value)\n    {\n        $filteredValue = filter_var($value, FILTER_VALIDATE_INT);\n        if ($filteredValue === false) {\n            return null;\n        }\n        return $filteredValue;\n    }\n\n    private function validateCheckboxValue($value)\n    {\n        return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);\n    }\n\n    private function validateListValue($value, $expectedValues)\n    {\n        $filteredValue = filter_var($value);\n        if ($filteredValue === false) {\n            return null;\n        }\n        if (!in_array($filteredValue, $expectedValues)) {\n            // Check sub-values?\n            foreach ($expectedValues as $subName => $subValue) {\n                if (is_array($subValue) && in_array($filteredValue, $subValue)) {\n                    return $filteredValue;\n                }\n            }\n            return null;\n        }\n        return $filteredValue;\n    }\n}\n"
  },
  {
    "path": "lib/RssBridge.php",
    "content": "<?php\n\nfinal class RssBridge\n{\n    private Container $container;\n\n    public function __construct(\n        Container $container\n    ) {\n        $this->container = $container;\n    }\n\n    public function main(Request $request): Response\n    {\n        $action = $request->get('action', 'Frontpage');\n        $actionName = strtolower($action) . 'Action';\n        $actionName = implode(array_map('ucfirst', explode('-', $actionName)));\n        $filePath = __DIR__ . '/../actions/' . $actionName . '.php';\n        if (!file_exists($filePath)) {\n            return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Invalid action']), 400);\n        }\n\n        $handler = $this->container[$actionName];\n\n        $middlewares = [\n            new BasicAuthMiddleware(),\n            new CacheMiddleware($this->container['cache']),\n            new ExceptionMiddleware($this->container['logger']),\n            new SecurityMiddleware(),\n            new MaintenanceMiddleware(),\n            new TokenAuthenticationMiddleware(),\n        ];\n        $action = function ($req) use ($handler) {\n            return $handler($req);\n        };\n        foreach (array_reverse($middlewares) as $middleware) {\n            $action = fn ($req) => $middleware($req, $action);\n        }\n        return $action($request->withAttribute('action', $actionName));\n    }\n}\n"
  },
  {
    "path": "lib/TwitterClient.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass TwitterClient\n{\n    private CacheInterface $cache;\n    private string $authorization;\n    private $data;\n\n    public function __construct(CacheInterface $cache)\n    {\n        $this->cache = $cache;\n\n        $data = $this->cache->get('twitter') ?? [];\n        $this->data = $data;\n\n        $this->authorization = 'AAAAAAAAAAAAAAAAAAAAAGHtAgAAAAAA%2Bx7ILXNILCqkSGIzy6faIHZ9s3Q%3DQy97w6SIrzE7lQwPJEYQBsArEE2fC25caFwRBvAGi456G09vGR';\n        $this->tw_consumer_key = '3nVuSoBZnx6U4vzUxf5w';\n        $this->tw_consumer_secret = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys';\n        $this->oauth_token = ''; //Fill here\n        $this->oauth_token_secret = ''; //Fill here\n    }\n\n    private function getOauthAuthorization(\n        $oauth_token,\n        $oauth_token_secret,\n        $method = 'GET',\n        $url = '',\n        $body = '',\n        $timestamp = null,\n        $oauth_nonce = null\n    ) {\n        if (!$url) {\n            return '';\n        }\n        $method = strtoupper($method);\n        $parseUrl = parse_url($url);\n        $link = $parseUrl['scheme'] . '://' . $parseUrl['host'] . $parseUrl['path'];\n        parse_str($parseUrl['query'], $query_params);\n        if ($body) {\n            parse_str($body, $body_params);\n            $query_params = array_merge($query_params, $body_params);\n        }\n        $payload = [\n            'oauth_version' => '1.0',\n            'oauth_signature_method' => 'HMAC-SHA1',\n            'oauth_consumer_key' => $this->tw_consumer_key,\n            'oauth_token' => $oauth_token,\n            'oauth_nonce' => $oauth_nonce ? $oauth_nonce : implode('', array_fill(0, 3, strval(time()))),\n            'oauth_timestamp' => $timestamp ? $timestamp : time(),\n        ];\n        $payload = array_merge($payload, $query_params);\n        ksort($payload);\n\n        $url_parts = parse_url($url);\n        $url_parts['query'] = http_build_query($payload, '', '&', PHP_QUERY_RFC3986);\n        $base_url = $url_parts['scheme'] . '://' . $url_parts['host'] . $url_parts['path'];\n        $signature_base_string = strtoupper($method) . '&' . rawurlencode($base_url) . '&' . rawurlencode($url_parts['query']);\n        $hmac_key = $this->tw_consumer_secret . '&' . $oauth_token_secret;\n        $hex_signature = hash_hmac('sha1', $signature_base_string, $hmac_key, true);\n        $signature = base64_encode($hex_signature);\n\n        $header_params = [\n            'oauth_version' => '1.0',\n            'oauth_token' => $oauth_token,\n            'oauth_nonce' => $payload['oauth_nonce'],\n            'oauth_timestamp' => $payload['oauth_timestamp'],\n            'oauth_signature' => $signature,\n            'oauth_consumer_key' => $this->tw_consumer_key,\n            'oauth_signature_method' => 'HMAC-SHA1',\n        ];\n        // ksort($header_params);\n        $header_values = [];\n        foreach ($header_params as $key => $value) {\n            $header_values[] = rawurlencode($key) . '=\"' . (is_int($value) ? $value : rawurlencode($value)) . '\"';\n        }\n        return 'OAuth realm=\"http://api.twitter.com/\", ' . implode(', ', $header_values);\n    }\n\n    private function extractTweetAndUsersFromGraphQL($timeline)\n    {\n        if (isset($timeline->data->user)) {\n            $result = $timeline->data->user->result;\n            $instructions = $result->timeline_v2->timeline->instructions;\n        } elseif (isset($timeline->data->user_result)) {\n            $result = $timeline->data->user_result->result->timeline_response;\n            $instructions = $result->timeline->instructions;\n        }\n\n        if (isset($result->__typename) && $result->__typename === 'UserUnavailable') {\n            throw new \\Exception('UserUnavailable');\n        }\n\n        if (isset($timeline->data->list)) {\n            $result = $timeline->data->list->timeline_response;\n            $instructions = $result->timeline->instructions;\n        }\n\n        if (!isset($result) && !isset($instructions)) {\n            throw new \\Exception('Unable to fetch user/list timeline');\n        }\n\n        $instructionTypes = [\n            'TimelineAddEntries',\n            'TimelineClearCache',\n            'TimelinePinEntry', // unclear purpose, maybe pinned tweet?\n        ];\n        if (!isset($instructions[1]) && isset($timeline->data->user)) {\n            throw new \\Exception('The account exists but has not tweeted yet?');\n        }\n\n        $entries = null;\n        foreach ($instructions as $instruction) {\n            $instructionType = '';\n            if (isset($instruction->type)) {\n                $instructionType = $instruction->type;\n            } else {\n                $instructionType = $instruction->__typename;\n            }\n\n            if ($instructionType === 'TimelineAddEntries') {\n                $entries = $instruction->entries;\n                break;\n            }\n        }\n        if (!$entries) {\n            throw new \\Exception(sprintf('Unable to find time line tweets in: %s', implode(',', array_column($instructions, 'type'))));\n        }\n\n        $tweets = [];\n        $userIds = [];\n        foreach ($entries as $entry) {\n            $entryType = '';\n\n            if (isset($entry->content->entryType)) {\n                $entryType = $entry->content->entryType;\n            } else {\n                $entryType = $entry->content->__typename;\n            }\n\n            if ($entryType !== 'TimelineTimelineItem') {\n                continue;\n            }\n\n            if (isset($timeline->data->user)) {\n                if (!isset($entry->content->itemContent->tweet_results->result)) {\n                    continue;\n                }\n\n                if (isset($entry->content->itemContent->promotedMetadata)) {\n                    continue;\n                }\n\n                $tweets[] = $entry->content->itemContent->tweet_results->result;\n\n                $userIds[] = $entry->content->itemContent->tweet_results->result->core->user_results->result;\n            } else {\n                if (!isset($entry->content->content->tweetResult->result->legacy)) {\n                    continue;\n                }\n\n                // Filter out any advertise tweet\n                if (isset($entry->content->content->tweetPromotedMetadata)) {\n                    continue;\n                }\n\n                $tweets[] = $entry->content->content->tweetResult->result;\n\n                $userIds[] = $entry->content->content->tweetResult->result->core->user_result->result;\n            }\n        }\n\n        return (object) [\n            'userIds' => $userIds,\n            'tweets' => $tweets,\n        ];\n    }\n\n    private function extractTweetFromSearch($searchResult)\n    {\n        return $searchResult->statuses;\n    }\n\n    public function fetchUserTweets(string $screenName): \\stdClass\n    {\n        $this->fetchGuestToken();\n        try {\n            $userInfo = $this->fetchUserInfoByScreenName($screenName);\n        } catch (HttpException $e) {\n            if ($e->getCode() === 403) {\n                $this->data['guest_token'] = null;\n                $this->fetchGuestToken();\n                $userInfo = $this->fetchUserInfoByScreenName($screenName);\n            } else {\n                throw $e;\n            }\n        }\n\n        $timeline = $this->fetchTimeline($userInfo->rest_id);\n        // try {\n        //     // $timeline = $this->fetchTimelineUsingSearch($screenName);\n        // } catch (HttpException $e) {\n        //     if ($e->getCode() === 403) {\n        //         $this->data['guest_token'] = null;\n        //         $this->fetchGuestToken();\n        //         // $timeline = $this->fetchTimelineUsingSearch($screenName);\n        //         $timeline = $this->fetchTimeline($userInfo->rest_id);\n        //     } else {\n        //         throw $e;\n        //     }\n        // }\n\n        // $tweets = $this->extractTweetFromSearch($timeline);\n        $tweets = $this->extractTweetAndUsersFromGraphQL($timeline)->tweets;\n\n        return (object) [\n            'user_info' => $userInfo,\n            'tweets' => $tweets,\n        ];\n    }\n\n    public function fetchListTweets($query, $operation = '')\n    {\n        $id = '';\n        $this->fetchGuestToken();\n        if ($operation == 'By list') {\n            try {\n                $listInfo = $this->fetchListInfoBySlug($query['screenName'], $query['listSlug']);\n                $id = $listInfo->id_str;\n            } catch (HttpException $e) {\n                if ($e->getCode() === 403) {\n                    $this->data['guest_token'] = null;\n                    $this->fetchGuestToken();\n                    $listInfo = $this->fetchListInfoBySlug($query['screenName'], $query['listSlug']);\n                    $id = $listInfo->id_str;\n                } else {\n                    throw $e;\n                }\n            }\n        } else if ($operation == 'By list ID') {\n            $id = $query['listId'];\n        } else {\n            throw new \\Exception('Unknown operation to make list tweets');\n        }\n\n        try {\n            $timeline = $this->fetchListTimeline($id);\n        } catch (HttpException $e) {\n            if ($e->getCode() === 403) {\n                $this->data['guest_token'] = null;\n                $this->fetchGuestToken();\n                $timeline = $this->fetchListTimeline($id);\n            } else {\n                throw $e;\n            }\n        }\n\n        $data = $this->extractTweetAndUsersFromGraphQL($timeline);\n\n        return $data;\n    }\n\n    private function fetchGuestToken(): void\n    {\n        if (isset($this->data['guest_token'])) {\n            return;\n        }\n        $url = 'https://api.twitter.com/1.1/guest/activate.json';\n        $response = getContents($url, $this->createHttpHeaders(), [CURLOPT_POST => true]);\n        $guest_token = json_decode($response)->guest_token;\n        $this->data['guest_token'] = $guest_token;\n\n        $this->cache->set('twitter', $this->data);\n    }\n\n    private function fetchUserInfoByScreenName(string $screenName)\n    {\n        if (isset($this->data[$screenName])) {\n            return $this->data[$screenName];\n        }\n        $variables = [\n            'screen_name' => $screenName,\n            'withHighlightedLabel' => true\n        ];\n        $url = sprintf(\n            'https://twitter.com/i/api/graphql/hc-pka9A7gyS3xODIafnrQ/UserByScreenName?variables=%s',\n            urlencode(json_encode($variables))\n        );\n        $response = Json::decode(getContents($url, $this->createHttpHeaders()), false);\n        if (isset($response->errors)) {\n            // Grab the first error message\n            throwClientException(sprintf('From twitter api: \"%s\"', $response->errors[0]->message));\n        }\n        $userInfo = $response->data->user;\n        $this->data[$screenName] = $userInfo;\n\n        $this->cache->set('twitter', $this->data);\n        return $userInfo;\n    }\n\n    private function fetchTimeline($userId)\n    {\n        $variables = [\n            'autoplay_enabled' => true,\n            'count' => 40,\n            'includeEditControl' => true,\n            'includeEditPerspective' => false,\n            'includeHasBirdwatchNotes' => false,\n            'includeTweetImpression' => true,\n            'includeTweetVisibilityNudge' => true,\n            'rest_id' => $userId\n        ];\n        $features = [\n            'android_graphql_skip_api_media_color_palette' => true,\n            'blue_business_profile_image_shape_enabled' => true,\n            'creator_subscriptions_subscription_count_enabled' => true,\n            'creator_subscriptions_tweet_preview_api_enabled' => true,\n            'freedom_of_speech_not_reach_fetch_enabled' => true,\n            'longform_notetweets_consumption_enabled' => true,\n            'longform_notetweets_inline_media_enabled' => true,\n            'longform_notetweets_rich_text_read_enabled' => true,\n            'subscriptions_verification_info_enabled' => true,\n            'super_follow_badge_privacy_enabled' => true,\n            'super_follow_exclusive_tweet_notifications_enabled' => true,\n            'super_follow_tweet_api_enabled' => true,\n            'super_follow_user_api_enabled' => true,\n            'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled' => true,\n            'tweetypie_unmention_optimization_enabled' => true,\n            'unified_cards_ad_metadata_container_dynamic_card_content_query_enabled' => true,\n        ];\n        $url = sprintf(\n            'https://api.twitter.com/graphql/3JNH4e9dq1BifLxAa3UMWg/UserWithProfileTweetsQueryV2?variables=%s&features=%s',\n            urlencode(json_encode($variables)),\n            urlencode(json_encode($features))\n        );\n        $oauth = $this->getOauthAuthorization($this->oauth_token, $this->oauth_token_secret, 'GET', $url);\n        $response = Json::decode(getContents($url, $this->createHttpHeaders($oauth)), false);\n        return $response;\n    }\n\n    private function fetchTimelineUsingSearch($screenName)\n    {\n        $params = [\n            'q' => 'from:' . $screenName,\n            'modules' => 'status',\n            'result_type' => 'recent'\n        ];\n        $response = $this->search($params);\n        return $response;\n    }\n\n    public function search($queryParam)\n    {\n         $url = sprintf(\n             'https://api.twitter.com/1.1/search/tweets.json?%s',\n             http_build_query($queryParam)\n         );\n        $oauth = $this->getOauthAuthorization($this->oauth_token, $this->oauth_token_secret, 'GET', $url);\n        $response = Json::decode(getContents($url, $this->createHttpHeaders($oauth)), false);\n        return $response;\n    }\n\n    private function fetchListInfoBySlug($screenName, $listSlug)\n    {\n        if (isset($this->data[$screenName . '-' . $listSlug])) {\n            return $this->data[$screenName . '-' . $listSlug];\n        }\n\n        $features = [\n            'android_graphql_skip_api_media_color_palette' => false,\n            'blue_business_profile_image_shape_enabled' => false,\n            'creator_subscriptions_subscription_count_enabled' => false,\n            'creator_subscriptions_tweet_preview_api_enabled' => true,\n            'freedom_of_speech_not_reach_fetch_enabled' => false,\n            'graphql_is_translatable_rweb_tweet_is_translatable_enabled' => false,\n            'hidden_profile_likes_enabled' => false,\n            'highlights_tweets_tab_ui_enabled' => false,\n            'interactive_text_enabled' => false,\n            'longform_notetweets_consumption_enabled' => true,\n            'longform_notetweets_inline_media_enabled' => false,\n            'longform_notetweets_richtext_consumption_enabled' => true,\n            'longform_notetweets_rich_text_read_enabled' => false,\n            'responsive_web_edit_tweet_api_enabled' => false,\n            'responsive_web_enhance_cards_enabled' => false,\n            'responsive_web_graphql_exclude_directive_enabled' => true,\n            'responsive_web_graphql_skip_user_profile_image_extensions_enabled' => false,\n            'responsive_web_graphql_timeline_navigation_enabled' => false,\n            'responsive_web_media_download_video_enabled' => false,\n            'responsive_web_text_conversations_enabled' => false,\n            'responsive_web_twitter_article_tweet_consumption_enabled' => false,\n            'responsive_web_twitter_blue_verified_badge_is_enabled' => true,\n            'rweb_lists_timeline_redesign_enabled' => true,\n            'spaces_2022_h2_clipping' => true,\n            'spaces_2022_h2_spaces_communities' => true,\n            'standardized_nudges_misinfo' => false,\n            'subscriptions_verification_info_enabled' => true,\n            'subscriptions_verification_info_reason_enabled' => true,\n            'subscriptions_verification_info_verified_since_enabled' => true,\n            'super_follow_badge_privacy_enabled' => false,\n            'super_follow_exclusive_tweet_notifications_enabled' => false,\n            'super_follow_tweet_api_enabled' => false,\n            'super_follow_user_api_enabled' => false,\n            'tweet_awards_web_tipping_enabled' => false,\n            'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled' => false,\n            'tweetypie_unmention_optimization_enabled' => false,\n            'unified_cards_ad_metadata_container_dynamic_card_content_query_enabled' => false,\n            'verified_phone_label_enabled' => false,\n            'vibe_api_enabled' => false,\n            'view_counts_everywhere_api_enabled' => false\n        ];\n        $variables = [\n            'screenName' => $screenName,\n            'listSlug' => $listSlug\n        ];\n\n        $url = sprintf(\n            'https://twitter.com/i/api/graphql/-kmqNvm5Y-cVrfvBy6docg/ListBySlug?variables=%s&features=%s',\n            urlencode(json_encode($variables)),\n            urlencode(json_encode($features))\n        );\n\n        $response = Json::decode(getContents($url, $this->createHttpHeaders()), false);\n        if (isset($response->errors)) {\n            // Grab the first error message\n            throwClientException(sprintf('From twitter api: \"%s\"', $response->errors[0]->message));\n        }\n        $listInfo = $response->data->user_by_screen_name->list;\n        $this->data[$screenName . '-' . $listSlug] = $listInfo;\n\n        $this->cache->set('twitter', $this->data);\n        return $listInfo;\n    }\n\n    private function fetchListTimeline($listId)\n    {\n        $features = [\n            'android_graphql_skip_api_media_color_palette' => false,\n            'blue_business_profile_image_shape_enabled' => false,\n            'creator_subscriptions_subscription_count_enabled' => false,\n            'creator_subscriptions_tweet_preview_api_enabled' => true,\n            'freedom_of_speech_not_reach_fetch_enabled' => false,\n            'graphql_is_translatable_rweb_tweet_is_translatable_enabled' => false,\n            'hidden_profile_likes_enabled' => false,\n            'highlights_tweets_tab_ui_enabled' => false,\n            'interactive_text_enabled' => false,\n            'longform_notetweets_consumption_enabled' => true,\n            'longform_notetweets_inline_media_enabled' => false,\n            'longform_notetweets_richtext_consumption_enabled' => true,\n            'longform_notetweets_rich_text_read_enabled' => false,\n            'responsive_web_edit_tweet_api_enabled' => false,\n            'responsive_web_enhance_cards_enabled' => false,\n            'responsive_web_graphql_exclude_directive_enabled' => true,\n            'responsive_web_graphql_skip_user_profile_image_extensions_enabled' => false,\n            'responsive_web_graphql_timeline_navigation_enabled' => false,\n            'responsive_web_media_download_video_enabled' => false,\n            'responsive_web_text_conversations_enabled' => false,\n            'responsive_web_twitter_article_tweet_consumption_enabled' => false,\n            'responsive_web_twitter_blue_verified_badge_is_enabled' => true,\n            'rweb_lists_timeline_redesign_enabled' => true,\n            'spaces_2022_h2_clipping' => true,\n            'spaces_2022_h2_spaces_communities' => true,\n            'standardized_nudges_misinfo' => false,\n            'subscriptions_verification_info_enabled' => true,\n            'subscriptions_verification_info_reason_enabled' => true,\n            'subscriptions_verification_info_verified_since_enabled' => true,\n            'super_follow_badge_privacy_enabled' => false,\n            'super_follow_exclusive_tweet_notifications_enabled' => false,\n            'super_follow_tweet_api_enabled' => false,\n            'super_follow_user_api_enabled' => false,\n            'tweet_awards_web_tipping_enabled' => false,\n            'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled' => false,\n            'tweetypie_unmention_optimization_enabled' => false,\n            'unified_cards_ad_metadata_container_dynamic_card_content_query_enabled' => false,\n            'verified_phone_label_enabled' => false,\n            'vibe_api_enabled' => false,\n            'view_counts_everywhere_api_enabled' => false\n        ];\n        $variables = [\n            'rest_id' => $listId,\n            'count' => 20\n        ];\n\n        $url = sprintf(\n            'https://api.twitter.com/graphql/BbGLL1ZfMibdFNWlk7a0Pw/ListTimeline?variables=%s&features=%s',\n            urlencode(json_encode($variables)),\n            urlencode(json_encode($features))\n        );\n        $oauth = $this->getOauthAuthorization($this->oauth_token, $this->oauth_token_secret, 'GET', $url);\n        $response = Json::decode(getContents($url, $this->createHttpHeaders($oauth)), false);\n        return $response;\n    }\n\n    private function createHttpHeaders($oauth = null): array\n    {\n        $headers = [\n            'authorization' => sprintf('Bearer %s', $this->authorization),\n            'x-guest-token' => $this->data['guest_token'] ?? null,\n        ];\n        if (isset($oauth)) {\n            $headers['authorization'] = $oauth;\n            unset($headers['x-guest-token']);\n        }\n        foreach ($headers as $key => $value) {\n            $headers2[] = sprintf('%s: %s', $key, $value);\n        }\n        return $headers2;\n    }\n}\n"
  },
  {
    "path": "lib/WebDriverAbstract.php",
    "content": "<?php\n\nuse Facebook\\WebDriver\\Chrome\\ChromeOptions;\nuse Facebook\\WebDriver\\Remote\\DesiredCapabilities;\nuse Facebook\\WebDriver\\Remote\\RemoteWebDriver;\nuse Facebook\\WebDriver\\WebDriverCapabilities;\n\n/**\n * An alternative abstract class for bridges depending on webdriver\n *\n * This class is meant a solution for active websites that use\n * XMLHttpRequest (XHR) to load content and/or use JavaScript to\n * change content. This class depends on a working webdriver setup.\n */\nabstract class WebDriverAbstract extends BridgeAbstract\n{\n    /**\n     * Holds the remote webdriver object, including configuration and\n     * connection.\n     *\n     * @var RemoteWebDriver\n     */\n    protected RemoteWebDriver $driver;\n\n    /**\n     * Holds the uri of the feed's icon.\n     *\n     * @var string | null\n     */\n    private $feedIcon;\n\n    /**\n     * Returns the webdriver object.\n     *\n     * @return RemoteWebDriver\n     */\n    protected function getDriver(): RemoteWebDriver\n    {\n        return $this->driver;\n    }\n\n    /**\n     * Returns the uri of the feed's icon.\n     *\n     * @return string\n     */\n    public function getIcon()\n    {\n        return $this->feedIcon ?: parent::getIcon();\n    }\n\n    /**\n     * Sets the uri of the feed's icon.\n     *\n     * @param $iconurl string\n     */\n    protected function setIcon($iconurl)\n    {\n        $this->feedIcon = $iconurl;\n    }\n\n    /**\n     * Returns the ChromeOptions object.\n     *\n     * If the configuration parameter 'headless' is set to true, the\n     * argument '--headless' is added. Override this to change or add\n     * more options.\n     *\n     * @return ChromeOptions\n     */\n    protected function getBrowserOptions()\n    {\n        $chromeOptions = new ChromeOptions();\n        if (Configuration::getConfig('webdriver', 'headless')) {\n            $chromeOptions->addArguments(['--headless']);   // --window-size=1024,1024\n        }\n        return $chromeOptions;\n    }\n\n    /**\n     * Returns the DesiredCapabilities object for the Chrome browser.\n     *\n     * The Chrome options are added. Override this to change or add\n     * more capabilities.\n     *\n     * @return WebDriverCapabilities\n     */\n    protected function getDesiredCapabilities(): WebDriverCapabilities\n    {\n        $desiredCapabilities = DesiredCapabilities::chrome();\n        $desiredCapabilities->setCapability(ChromeOptions::CAPABILITY, $this->getBrowserOptions());\n        return $desiredCapabilities;\n    }\n\n    /**\n     * Constructs the remote webdriver with the url of the remote (Selenium)\n     * webdriver server and the desired capabilities.\n     *\n     * This should be called in collectData() first.\n     */\n    protected function prepareWebDriver()\n    {\n        $server = Configuration::getConfig('webdriver', 'selenium_server_url');\n        $this->driver = RemoteWebDriver::create($server, $this->getDesiredCapabilities());\n    }\n\n    /**\n     * Maximizes the remote browser window (often important for reactive sites\n     * which change their appearance depending on the window size) and opens\n     * the uri set in the constant URI.\n     */\n    protected function prepareWindow()\n    {\n        $this->getDriver()->manage()->window()->maximize();\n        $this->getDriver()->get($this->getURI());\n    }\n\n    /**\n     * Closes the remote browser window and shuts down the remote webdriver\n     * connection.\n     *\n     * This must be called at the end of scraping, for example within a\n     * 'finally' block.\n     */\n    protected function cleanUp()\n    {\n        $this->getDriver()->quit();\n    }\n\n    /**\n     * Do your web scraping here and fill the $items array.\n     *\n     * Override this but call parent() first.\n     * Don't forget to call cleanUp() at the end.\n     */\n    public function collectData()\n    {\n        $this->prepareWebDriver();\n        $this->prepareWindow();\n    }\n}"
  },
  {
    "path": "lib/XPathAbstract.php",
    "content": "<?php\n\n/**\n * An alternative abstract class for bridges utilizing XPath expressions\n *\n * This class is meant as an alternative base class for bridge implementations.\n * It offers preliminary functionality for generating feeds based on XPath\n * expressions.\n * As a minimum, extending classes should define XPath expressions pointing\n * to the feed items contents in the class constants below. In case there is\n * more manual fine tuning required, it offers a bunch of methods which can\n * be overridden, for example in order to specify formatting of field values\n * or more flexible definition of dynamic XPath expressions.\n *\n * This class extends {@see BridgeAbstract}, which means it incorporates and\n * extends all of its functionality.\n **/\nabstract class XPathAbstract extends BridgeAbstract\n{\n    /**\n     * Source Web page URL (should provide either HTML or XML content)\n     * You can specify any website URL which serves data suited for display in RSS feeds\n     * (for example a news blog).\n     *\n     * Use {@see XPathAbstract::getSourceUrl()} to read this parameter\n     */\n    const FEED_SOURCE_URL = '';\n\n    /**\n     * XPath expression for extracting the feed title from the source page.\n     * If this is left blank or does not provide any data {@see BridgeAbstract::getName()}\n     * is used instead as the feed's title.\n     *\n     * Use {@see XPathAbstract::getExpressionTitle()} to read this parameter\n     */\n    const XPATH_EXPRESSION_FEED_TITLE = './/title';\n\n    /**\n     * XPath expression for extracting the feed favicon URL from the source page.\n     * If this is left blank or does not provide any data {@see BridgeAbstract::getIcon()}\n     * is used instead as the feed's favicon URL.\n     *\n     * Use {@see XPathAbstract::getExpressionIcon()} to read this parameter\n     */\n    const XPATH_EXPRESSION_FEED_ICON = './/link[@rel=\"icon\"]/@href';\n\n    /**\n     * XPath expression for extracting the feed items from the source page\n     * Enter an XPath expression matching a list of dom nodes, each node containing one\n     * feed article item in total (usually a surrounding <div> or <span> tag). This will\n     * be the context nodes for all of the following expressions. This expression usually\n     * starts with a single forward slash.\n     *\n     * Use {@see XPathAbstract::getExpressionItem()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM = '';\n\n    /**\n     * XPath expression for extracting an item title from the item context\n     * This expression should match a node contained within each article item node\n     * containing the article headline. It should start with a dot followed by two\n     * forward slashes, referring to any descendant nodes of the article item node.\n     *\n     * Use {@see XPathAbstract::getExpressionItemTitle()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_TITLE = '';\n\n    /**\n     * XPath expression for extracting an item's content from the item context\n     * This expression should match a node contained within each article item node\n     * containing the article content or description. It should start with a dot\n     * followed by two forward slashes, referring to any descendant nodes of the\n     * article item node.\n     *\n     * Use {@see XPathAbstract::getExpressionItemContent()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_CONTENT = '';\n\n    /**\n     * XPath expression for extracting an item link from the item context\n     * This expression should match a node's attribute containing the article URL\n     * (usually the href attribute of an <a> tag). It should start with a dot\n     * followed by two forward slashes, referring to any descendant nodes of\n     * the article item node. Attributes can be selected by prepending an @ char\n     * before the attributes name.\n     *\n     * Use {@see XPathAbstract::getExpressionItemUri()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_URI = '';\n\n    /**\n     * XPath expression for extracting an item author from the item context\n     * This expression should match a node contained within each article item\n     * node containing the article author's name. It should start with a dot\n     * followed by two forward slashes, referring to any descendant nodes of\n     * the article item node.\n     *\n     * Use {@see XPathAbstract::getExpressionItemAuthor()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_AUTHOR = '';\n\n    /**\n     * XPath expression for extracting an item timestamp from the item context\n     * This expression should match a node or node's attribute containing the\n     * article timestamp or date (parsable by PHP's strtotime function). It\n     * should start with a dot followed by two forward slashes, referring to\n     * any descendant nodes of the article item node. Attributes can be\n     * selected by prepending an @ char before the attributes name.\n     *\n     * Use {@see XPathAbstract::getExpressionItemTimestamp()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_TIMESTAMP = '';\n\n    /**\n     * XPath expression for extracting item enclosures (media content like\n     * images or movies) from the item context\n     * This expression should match a node's attribute containing an article\n     * image URL (usually the src attribute of an <img> tag or a style\n     * attribute). It should start with a dot followed by two forward slashes,\n     * referring to any descendant nodes of the article item node. Attributes\n     * can be selected by prepending an @ char before the attributes name.\n     *\n     * Use {@see XPathAbstract::getExpressionItemEnclosures()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_ENCLOSURES = '';\n\n    /**\n     * XPath expression for extracting an item category from the item context\n     * This expression should match a node or node's attribute contained\n     * within each article item node containing the article category. This\n     * could be inside <div> or <span> tags or sometimes be hidden\n     * in a data attribute. It should start with a dot followed by two\n     * forward slashes, referring to any descendant nodes of the article\n     * item node. Attributes can be selected by prepending an @ char\n     * before the attributes name.\n     *\n     * Use {@see XPathAbstract::getExpressionItemCategories()} to read this parameter\n     */\n    const XPATH_EXPRESSION_ITEM_CATEGORIES = '';\n\n    /**\n     * Fix encoding\n     * Set this to true for fixing feed encoding by invoking PHP's utf8_decode\n     * function on all extracted texts. Try this in case you see \"broken\" or\n     * \"weird\" characters in your feed where you'd normally expect umlauts\n     * or any other non-ascii characters.\n     *\n     * Use {@see XPathAbstract::getSettingFixEncoding()} to read this parameter\n     */\n    const SETTING_FIX_ENCODING = false;\n\n    /**\n     * Use raw item content\n     * Whether to use the raw item content or to replace certain characters with\n     * special significance in HTML by HTML entities (using the PHP function htmlspecialchars).\n     *\n     * Use {@see XPathAbstract::getSettingUseRawItemContent()} to read this parameter\n     */\n    const SETTING_USE_RAW_ITEM_CONTENT = true;\n\n    /**\n     * Internal storage for resulting feed name, automatically detected\n     * @var string\n     */\n    private $feedName;\n\n    /**\n     * Internal storage for resulting feed name, automatically detected\n     * @var string\n     */\n    private $feedUri;\n\n    /**\n     * Internal storage for resulting feed favicon, automatically detected\n     * @var string\n     */\n    private $feedIcon;\n\n    public function getName()\n    {\n        return $this->feedName ?: parent::getName();\n    }\n\n    public function getURI()\n    {\n        return $this->feedUri ?: parent::getURI();\n    }\n\n    public function getIcon()\n    {\n        return $this->feedIcon ?: parent::getIcon();\n    }\n\n    /**\n     * Source Web page URL (should provide either HTML or XML content)\n     * @return string\n     */\n    protected function getSourceUrl()\n    {\n        return static::FEED_SOURCE_URL;\n    }\n\n    /**\n     * XPath expression for extracting the feed title from the source page\n     * @return string\n     */\n    protected function getExpressionTitle()\n    {\n        return static::XPATH_EXPRESSION_FEED_TITLE;\n    }\n\n    /**\n     * XPath expression for extracting the feed favicon from the source page\n     * @return string\n     */\n    protected function getExpressionIcon()\n    {\n        return static::XPATH_EXPRESSION_FEED_ICON;\n    }\n\n    /**\n     * XPath expression for extracting the feed items from the source page\n     * @return string\n     */\n    protected function getExpressionItem()\n    {\n        return static::XPATH_EXPRESSION_ITEM;\n    }\n\n    /**\n     * XPath expression for extracting an item title from the item context\n     * @return string\n     */\n    protected function getExpressionItemTitle()\n    {\n        return static::XPATH_EXPRESSION_ITEM_TITLE;\n    }\n\n    /**\n     * XPath expression for extracting an item's content from the item context\n     * @return string\n     */\n    protected function getExpressionItemContent()\n    {\n        return static::XPATH_EXPRESSION_ITEM_CONTENT;\n    }\n\n    /**\n     * XPath expression for extracting an item link from the item context\n     * @return string\n     */\n    protected function getExpressionItemUri()\n    {\n        return static::XPATH_EXPRESSION_ITEM_URI;\n    }\n\n    /**\n     * XPath expression for extracting an item author from the item context\n     * @return string\n     */\n    protected function getExpressionItemAuthor()\n    {\n        return static::XPATH_EXPRESSION_ITEM_AUTHOR;\n    }\n\n    /**\n     * XPath expression for extracting an item timestamp from the item context\n     * @return string\n     */\n    protected function getExpressionItemTimestamp()\n    {\n        return static::XPATH_EXPRESSION_ITEM_TIMESTAMP;\n    }\n\n    /**\n     * XPath expression for extracting item enclosures (media content like\n     * images or movies) from the item context\n     * @return string\n     */\n    protected function getExpressionItemEnclosures()\n    {\n        return static::XPATH_EXPRESSION_ITEM_ENCLOSURES;\n    }\n\n    /**\n     * XPath expression for extracting an item category from the item context\n     * @return string\n     */\n    protected function getExpressionItemCategories()\n    {\n        return static::XPATH_EXPRESSION_ITEM_CATEGORIES;\n    }\n\n    /**\n     * Fix encoding\n     * @return bool\n     */\n    protected function getSettingFixEncoding(): bool\n    {\n        return static::SETTING_FIX_ENCODING;\n    }\n\n    /**\n     * Use raw item content\n     * @return bool\n     */\n    protected function getSettingUseRawItemContent(): bool\n    {\n        return static::SETTING_USE_RAW_ITEM_CONTENT;\n    }\n\n    /**\n     * Internal helper method for quickly accessing all the user defined constants\n     * in derived classes\n     *\n     * @param $name\n     * @return bool|string\n     */\n    private function getParam($name)\n    {\n        switch ($name) {\n            case 'url':\n                return $this->getSourceUrl();\n            case 'feed_title':\n                return $this->getExpressionTitle();\n            case 'feed_icon':\n                return $this->getExpressionIcon();\n            case 'item':\n                return $this->getExpressionItem();\n            case 'title':\n                return $this->getExpressionItemTitle();\n            case 'content':\n                return $this->getExpressionItemContent();\n            case 'uri':\n                return $this->getExpressionItemUri();\n            case 'author':\n                return $this->getExpressionItemAuthor();\n            case 'timestamp':\n                return $this->getExpressionItemTimestamp();\n            case 'enclosures':\n                return $this->getExpressionItemEnclosures();\n            case 'categories':\n                return $this->getExpressionItemCategories();\n            case 'fix_encoding':\n                return $this->getSettingFixEncoding();\n            case 'raw_content':\n                return $this->getSettingUseRawItemContent();\n        }\n    }\n\n    /**\n     * Should provide the source website HTML content\n     * can be easily overwritten for example if special headers or auth infos are required\n     * @return string\n     */\n    protected function provideWebsiteContent()\n    {\n        return getContents($this->feedUri);\n    }\n\n    /**\n     * Should provide the feeds title\n     *\n     * @param \\DOMXPath $xpath\n     * @return string\n     */\n    protected function provideFeedTitle(\\DOMXPath $xpath)\n    {\n        $title = $xpath->query($this->getParam('feed_title'));\n        if (count($title) === 1) {\n            return $this->fixEncoding($this->getItemValueOrNodeValue($title));\n        }\n    }\n\n    /**\n     * Should provide the URL of the feed's favicon\n     *\n     * @param \\DOMXPath $xpath\n     * @return string\n     */\n    protected function provideFeedIcon(\\DOMXPath $xpath)\n    {\n        $icon = $xpath->query($this->getParam('feed_icon'));\n        if (count($icon) === 1) {\n            return $this->cleanMediaUrl($this->getItemValueOrNodeValue($icon));\n        }\n    }\n\n    /**\n     * Should provide the feed's items.\n     *\n     * @param \\DOMXPath $xpath\n     * @return \\DOMNodeList|false\n     */\n    protected function provideFeedItems(\\DOMXPath $xpath)\n    {\n        return @$xpath->query($this->getParam('item'));\n    }\n\n    public function collectData()\n    {\n        $this->feedUri = $this->getParam('url');\n\n        $webPageHtml = new \\DOMDocument();\n        libxml_use_internal_errors(true);\n        $webPageHtml->loadHTML($this->provideWebsiteContent());\n        libxml_clear_errors();\n        libxml_use_internal_errors(false);\n\n        // fix relative links\n        defaultLinkTo($webPageHtml, $webPageHtml->baseURI ?? $this->feedUri);\n\n        $xpath = new \\DOMXPath($webPageHtml);\n\n        $this->feedName = $this->provideFeedTitle($xpath);\n        $this->feedIcon = $this->provideFeedIcon($xpath);\n\n        $entries = $this->provideFeedItems($xpath);\n        if ($entries === false) {\n            // malformed\n            return;\n        }\n\n        foreach ($entries as $entry) {\n            $item = [];\n            $parameters = [\n                'title',\n                'content',\n                'uri',\n                'author',\n                'timestamp',\n                'enclosures',\n                'categories',\n            ];\n            foreach ($parameters as $parameter) {\n                $expression = $this->getParam($parameter);\n                if ('' === $expression) {\n                    continue;\n                }\n\n                //can be a string or DOMNodeList, depending on the expression result\n                $typedResult = @$xpath->evaluate($expression, $entry);\n                if (\n                    $typedResult === false || ($typedResult instanceof \\DOMNodeList && count($typedResult) === 0)\n                    || (is_string($typedResult) && strlen(trim($typedResult)) === 0)\n                ) {\n                    continue;\n                }\n\n                if ('categories' === $parameter && $typedResult instanceof \\DOMNodeList) {\n                    $value = [];\n                    foreach ($typedResult as $domNode) {\n                        $value[] = $this->getItemValueOrNodeValue($domNode, false);\n                    }\n                } else {\n                    $value = $this->getItemValueOrNodeValue($typedResult, 'content' === $parameter);\n                }\n\n                $item[$parameter] = $this->formatParamValue($parameter, $value);\n            }\n\n            $itemId = $this->generateItemId($item);\n            if (null !== $itemId) {\n                $item['uid'] = $itemId;\n            }\n\n            $this->items[] = $item;\n        }\n    }\n\n    /**\n     * @param $param\n     * @param $value\n     * @return string|array\n     */\n    protected function formatParamValue($param, $value)\n    {\n        $value = is_array($value) ? array_map('trim', $value) : trim($value);\n        $value = is_array($value) ? array_map([$this, 'fixEncoding'], $value) : $this->fixEncoding($value);\n        switch ($param) {\n            case 'title':\n                return $this->formatItemTitle($value);\n            case 'content':\n                return $this->formatItemContent($value);\n            case 'uri':\n                return $this->formatItemUri($value);\n            case 'author':\n                return $this->formatItemAuthor($value);\n            case 'timestamp':\n                return $this->formatItemTimestamp($value);\n            case 'enclosures':\n                return $this->formatItemEnclosures($value);\n            case 'categories':\n                return $this->formatItemCategories($value);\n        }\n        return $value;\n    }\n\n    /**\n     * Formats the title of a feed item. Takes extracted raw title and returns it formatted\n     * as string.\n     * Can be easily overwritten for in case the value needs to be transformed into something\n     * else.\n     * @param string $value\n     * @return string\n     */\n    protected function formatItemTitle($value)\n    {\n        return $value;\n    }\n\n    /**\n     * Formats the timestamp of a feed item. Takes extracted raw timestamp and returns unix\n     * timestamp as integer.\n     * Can be easily overwritten for example if a special format has to be expected on the\n     * source website.\n     * @param string $value\n     * @return string\n     */\n    protected function formatItemContent($value)\n    {\n        return $this->getParam('raw_content') ? $value : htmlspecialchars($value);\n    }\n\n    /**\n     * Formats the URI of a feed item. Takes extracted raw URI and returns it formatted\n     * as string.\n     * Can be easily overwritten for in case the value needs to be transformed into something\n     * else.\n     * @param string $value\n     * @return string\n     */\n    protected function formatItemUri($value)\n    {\n        if (strlen($value) === 0) {\n            return '';\n        }\n        if (\n            strpos($value, 'http://') === 0\n            || strpos($value, 'https://') === 0\n        ) {\n            return $value;\n        }\n\n        return urljoin($this->feedUri, $value);\n    }\n\n    /**\n     * Formats the author of a feed item. Takes extracted raw author and returns it formatted\n     * as string.\n     * Can be easily overwritten for in case the value needs to be transformed into something\n     * else.\n     * @param string $value\n     * @return string\n     */\n    protected function formatItemAuthor($value)\n    {\n        return $value;\n    }\n\n    /**\n     * Formats the timestamp of a feed item. Takes extracted raw timestamp and returns unix\n     * timestamp as integer.\n     * Can be easily overwritten for example if a special format has to be expected on the\n     * source website.\n     * @param string $value\n     * @return false|int\n     */\n    protected function formatItemTimestamp($value)\n    {\n        return strtotime($value);\n    }\n\n    /**\n     * Formats the enclosures of a feed item. Takes extracted raw enclosures and returns them\n     * formatted as array.\n     * Can be easily overwritten for in case the values need to be transformed into something\n     * else.\n     * @param string $value\n     * @return array\n     */\n    protected function formatItemEnclosures($value)\n    {\n        return [$this->cleanMediaUrl($value)];\n    }\n\n    /**\n     * Formats the categories of a feed item. Takes extracted raw categories and returns them\n     * formatted as array.\n     * Can be easily overwritten for in case the values need to be transformed into something\n     * else.\n     * @param string|array $value\n     * @return array\n     */\n    protected function formatItemCategories($value)\n    {\n        return is_array($value) ? $value : [$value];\n    }\n\n    /**\n     * @param $mediaUrl\n     * @return string|void\n     */\n    protected function cleanMediaUrl($mediaUrl)\n    {\n        $pattern = '~(?:http(?:s)?:)?[\\/a-zA-Z0-9\\-=_,\\.\\%]+\\.(?:jpg|gif|png|jpeg|ico|mp3|webp){1}~i';\n        $result = preg_match($pattern, $mediaUrl, $matches);\n        if (1 !== $result) {\n            return;\n        }\n        return urljoin($this->feedUri, $matches[0]);\n    }\n\n    /**\n     * @param $typedResult\n     * @param bool $returnXML\n     * @param bool $escapeHtml\n     * @return string\n     * @throws Exception\n     */\n    protected function getItemValueOrNodeValue($typedResult, $returnXML = false)\n    {\n        if ($typedResult instanceof \\DOMNodeList) {\n            $typedResult = $typedResult->item(0);\n        }\n\n        if ($typedResult instanceof \\DOMElement) {\n            return $returnXML ? ($typedResult->ownerDocument ?? $typedResult)->saveXML($typedResult) : $typedResult->nodeValue;\n        } elseif ($typedResult instanceof \\DOMAttr) {\n            return $typedResult->value;\n        } elseif ($typedResult instanceof \\DOMText) {\n            return $typedResult->wholeText;\n        } elseif (is_string($typedResult)) {\n            return $typedResult;\n        } elseif (null === $typedResult) {\n            return '';\n        }\n\n        throw new \\Exception('Unknown type of XPath expression result: ' . gettype($typedResult));\n    }\n\n    /**\n     * Fixes feed encoding by invoking PHP's utf8_decode function on extracted texts.\n     * Useful in case of \"broken\" or \"weird\" characters in the feed where you'd normally\n     * expect umlauts.\n     *\n     * @param $input\n     * @return string\n     */\n    protected function fixEncoding($input)\n    {\n        return $this->getParam('fix_encoding') ? utf8_decode($input) : $input;\n    }\n\n    /**\n     * Allows overriding default mechanism determining items Uid's\n     *\n     * @return string|null\n     */\n    protected function generateItemId(array $item)\n    {\n        return null;\n    }\n}\n"
  },
  {
    "path": "lib/bootstrap.php",
    "content": "<?php\n\nif (is_file(__DIR__ . '/../vendor/autoload.php')) {\n    require __DIR__ . '/../vendor/autoload.php';\n}\n\nconst PATH_LIB_CACHES = __DIR__ . '/../caches/';\nconst PATH_CACHE = __DIR__ . '/../cache/';\n\n// Files\n$files = [\n    __DIR__ . '/../lib/html.php',\n    __DIR__ . '/../lib/contents.php',\n    __DIR__ . '/../lib/php8backports.php',\n    __DIR__ . '/../lib/utils.php',\n    __DIR__ . '/../lib/http.php',\n    __DIR__ . '/../lib/logger.php',\n    __DIR__ . '/../lib/url.php',\n    __DIR__ . '/../lib/seotags.php',\n    // Vendor\n    __DIR__ . '/../lib/parsedown/Parsedown.php',\n    __DIR__ . '/../lib/php-urljoin/src/urljoin.php',\n    __DIR__ . '/../lib/simplehtmldom/simple_html_dom.php',\n];\nforeach ($files as $file) {\n    require_once $file;\n}\n\nspl_autoload_register(function ($className) {\n    $folders = [\n        __DIR__ . '/../actions/',\n        __DIR__ . '/../bridges/',\n        __DIR__ . '/../caches/',\n        __DIR__ . '/../formats/',\n        __DIR__ . '/../lib/',\n        __DIR__ . '/../middlewares/',\n    ];\n    foreach ($folders as $folder) {\n        $file = $folder . $className . '.php';\n        if (is_file($file)) {\n            require $file;\n        }\n    }\n});\n"
  },
  {
    "path": "lib/config.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n$config = [];\nif (file_exists(__DIR__ . '/../config.ini.php')) {\n    $config = parse_ini_file(__DIR__ . '/../config.ini.php', true, INI_SCANNER_TYPED);\n    if (!$config) {\n        http_response_code(500);\n        exit(\"Error parsing config.ini.php\\n\");\n    }\n}\nConfiguration::loadConfiguration($config, getenv());\n"
  },
  {
    "path": "lib/contents.php",
    "content": "<?php\n\nfunction get_sitemap(string $url): array\n{\n    $doc = new \\DOMDocument();\n    $doc->loadXML(getContents($url));\n    $urls = [];\n\n    foreach ($doc->getElementsByTagName('url') as $url) {\n        $item = [\n            'loc'           => $url->getElementsByTagName('loc')->item(0)->nodeValue ?? null,\n            'lastmod'       => $url->getElementsByTagName('lastmod')->item(0)->nodeValue ?? null,\n            'changefreq'    => $url->getElementsByTagName('changefreq')->item(0)->nodeValue ?? null,\n            'priority'      => $url->getElementsByTagName('priority')->item(0)->nodeValue ?? null,\n        ];\n\n        $news = $url->getElementsByTagNameNS('http://www.google.com/schemas/sitemap-news/0.9', '*');\n        foreach ($news as $element) {\n            $localName = $element->localName;\n            $prefix = $element->prefix;\n            $item[$prefix][$localName] = $element->nodeValue;\n        }\n        $urls[] = $item;\n    }\n    return $urls;\n}\n\n/**\n * Fetch data from an http url\n *\n * @param array $httpHeaders E.g. ['Content-type: text/plain']\n * @param array $curlOptions Associative array e.g. [CURLOPT_MAXREDIRS => 3]\n * @param bool $returnFull Whether to return Response object\n * @return string|Response\n */\nfunction getContents(\n    string $url,\n    array $httpHeaders = [],\n    array $curlOptions = [],\n    bool $returnFull = false\n) {\n    global $container;\n\n    /** @var HttpClient $httpClient */\n    $httpClient = $container['http_client'];\n\n    /** @var CacheInterface $cache */\n    $cache = $container['cache'];\n\n    // TODO: consider url validation at this point\n\n    $config = [\n        'useragent'     => Configuration::getConfig('http', 'useragent'),\n        'timeout'       => Configuration::getConfig('http', 'timeout'),\n        'retries'       => Configuration::getConfig('http', 'retries'),\n        'curl_options'  => $curlOptions,\n    ];\n\n    $httpHeadersNormalized = [];\n    foreach ($httpHeaders as $httpHeader) {\n        $parts = explode(':', $httpHeader);\n        $headerName = trim($parts[0]);\n        $headerValue = trim(implode(':', array_slice($parts, 1)));\n        $httpHeadersNormalized[$headerName] = $headerValue;\n    }\n\n    $requestBodyHash = null;\n    if (isset($curlOptions[CURLOPT_POSTFIELDS])) {\n        $requestBodyHash = md5(Json::encode($curlOptions[CURLOPT_POSTFIELDS], false));\n    }\n    $cacheKey = implode('_', ['server',  $url, $requestBodyHash]);\n\n    /** @var Response $cachedResponse */\n    $cachedResponse = $cache->get($cacheKey);\n    if ($cachedResponse) {\n        $lastModified = $cachedResponse->getHeader('last-modified');\n        if ($lastModified) {\n            try {\n                // Some servers send Unix timestamp instead of RFC7231 date. Prepend it with @ to allow parsing as DateTime\n                $lastModified = new \\DateTimeImmutable((is_numeric($lastModified) ? '@' : '') . $lastModified);\n                $config['if_not_modified_since'] = $lastModified->getTimestamp();\n            } catch (Exception $e) {\n                // Failed to parse last-modified\n            }\n        }\n        $etag = $cachedResponse->getHeader('etag');\n        if ($etag) {\n            $httpHeadersNormalized['if-none-match'] = $etag;\n        }\n    }\n\n    $config['headers'] = $httpHeadersNormalized;\n\n    $maxFileSize = Configuration::getConfig('http', 'max_filesize');\n    if ($maxFileSize) {\n        // Convert from MB to B by multiplying with 2^20 (1M)\n        $config['max_filesize'] = $maxFileSize * 2 ** 20;\n    }\n\n    if (Configuration::getConfig('proxy', 'url') && !defined('NOPROXY')) {\n        $config['proxy'] = Configuration::getConfig('proxy', 'url');\n    }\n\n    $response = $httpClient->request($url, $config);\n\n    switch ($response->getCode()) {\n        case 200:\n        case 201:\n        case 202:\n            $cacheControl = $response->getHeader('cache-control');\n            if ($cacheControl) {\n                $directives = explode(',', $cacheControl);\n                $directives = array_map('trim', $directives);\n                if (in_array('no-cache', $directives) || in_array('no-store', $directives)) {\n                    // Don't cache as instructed by the server\n                    break;\n                }\n            }\n            $cache->set($cacheKey, $response, 86400 * 10);\n            break;\n        case 301:\n        case 302:\n        case 303:\n            // todo: cache\n            break;\n        case 304:\n            // Not Modified\n            $response = $response->withBody($cachedResponse->getBody());\n            break;\n        default:\n            $e = HttpException::fromResponse($response, $url);\n            throw $e;\n    }\n    if ($returnFull === true) {\n        return $response;\n    }\n    return $response->getBody();\n}\n\n/**\n * Gets contents from the Internet as simplhtmldom object.\n *\n * @param string $url The URL.\n * @param array $header (optional) A list of cURL header.\n * For more information follow the links below.\n * * https://php.net/manual/en/function.curl-setopt.php\n * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html\n * @param array $opts (optional) A list of cURL options as associative array in\n * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`\n * option and `$value` the corresponding value.\n *\n * For more information see http://php.net/manual/en/function.curl-setopt.php\n * @param bool $lowercase Force all selectors to lowercase.\n * @param bool $forceTagsClosed Forcefully close tags in malformed HTML.\n *\n * _Remarks_: Forcefully closing tags is great for malformed HTML, but it can\n * lead to parsing errors.\n * @param string $target_charset Defines the target charset.\n * @param bool $stripRN Replace all occurrences of `\"\\r\"` and `\"\\n\"` by `\" \"`.\n * @param string $defaultBRText Specifies the replacement text for `<br>` tags\n * when returning plaintext.\n * @param string $defaultSpanText Specifies the replacement text for `<span />`\n * tags when returning plaintext.\n */\nfunction getSimpleHTMLDOM(\n    $url,\n    $header = [],\n    $opts = [],\n    $lowercase = true,\n    $forceTagsClosed = true,\n    $target_charset = DEFAULT_TARGET_CHARSET,\n    $stripRN = true,\n    $defaultBRText = DEFAULT_BR_TEXT,\n    $defaultSpanText = DEFAULT_SPAN_TEXT\n): \\simple_html_dom {\n    $html = getContents($url, $header ?? [], $opts ?? []);\n    if ($html === '') {\n        throw new \\Exception('Unable to parse dom because the http response was the empty string');\n    }\n\n    return str_get_html(\n        $html,\n        $lowercase,\n        $forceTagsClosed,\n        $target_charset,\n        $stripRN,\n        $defaultBRText,\n        $defaultSpanText\n    );\n}\n\n/**\n * Fetch contents from the Internet as simplhtmldom object. Contents are cached\n * and re-used for subsequent calls until the cache duration elapsed.\n *\n * @param string $url The URL.\n * @param int $ttl Cache duration in seconds.\n * @param array $header (optional) A list of cURL header.\n * For more information follow the links below.\n * * https://php.net/manual/en/function.curl-setopt.php\n * * https://curl.haxx.se/libcurl/c/CURLOPT_HTTPHEADER.html\n * @param array $opts (optional) A list of cURL options as associative array in\n * the format `$opts[$option] = $value;`, where `$option` is any `CURLOPT_XXX`\n * option and `$value` the corresponding value.\n *\n * For more information see http://php.net/manual/en/function.curl-setopt.php\n * @param bool $lowercase Force all selectors to lowercase.\n * @param bool $forceTagsClosed Forcefully close tags in malformed HTML.\n *\n * _Remarks_: Forcefully closing tags is great for malformed HTML, but it can\n * lead to parsing errors.\n * @param string $target_charset Defines the target charset.\n * @param bool $stripRN Replace all occurrences of `\"\\r\"` and `\"\\n\"` by `\" \"`.\n * @param string $defaultBRText Specifies the replacement text for `<br>` tags\n * when returning plaintext.\n * @param string $defaultSpanText Specifies the replacement text for `<span />`\n * tags when returning plaintext.\n * @return false|simple_html_dom Contents as simplehtmldom object.\n */\nfunction getSimpleHTMLDOMCached(\n    $url,\n    $ttl = 86400,\n    $header = [],\n    $opts = [],\n    $lowercase = true,\n    $forceTagsClosed = true,\n    $target_charset = DEFAULT_TARGET_CHARSET,\n    $stripRN = true,\n    $defaultBRText = DEFAULT_BR_TEXT,\n    $defaultSpanText = DEFAULT_SPAN_TEXT\n) {\n    global $container;\n\n    /** @var CacheInterface $cache */\n    $cache = $container['cache'];\n\n    $cacheKey = 'pages_' . $url;\n    $content = $cache->get($cacheKey);\n    if (!$content) {\n        $content = getContents($url, $header ?? [], $opts ?? []);\n        $cache->set($cacheKey, $content, $ttl);\n    }\n    return str_get_html(\n        $content,\n        $lowercase,\n        $forceTagsClosed,\n        $target_charset,\n        $stripRN,\n        $defaultBRText,\n        $defaultSpanText\n    );\n}\n"
  },
  {
    "path": "lib/dependencies.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n$container = new Container();\n\n$container[ConnectivityAction::class] = function ($c) {\n    return new ConnectivityAction($c['bridge_factory']);\n};\n\n$container[DetectAction::class] = function ($c) {\n    return new DetectAction($c['bridge_factory']);\n};\n\n$container[DisplayAction::class] = function ($c) {\n    return new DisplayAction($c['cache'], $c['logger'], $c['bridge_factory']);\n};\n\n$container[FindfeedAction::class] = function ($c) {\n    return new FindfeedAction($c['bridge_factory']);\n};\n\n$container[FrontpageAction::class] = function ($c) {\n    return new FrontpageAction($c['bridge_factory']);\n};\n\n$container[HealthAction::class] = function () {\n    return new HealthAction();\n};\n\n$container[ListAction::class] = function ($c) {\n    return new ListAction($c['bridge_factory']);\n};\n\n$container['bridge_factory'] = function ($c) {\n    return new BridgeFactory($c['cache'], $c['logger']);\n};\n\n\n$container['http_client'] = function () {\n    return new CurlHttpClient();\n};\n\n$container['cache_factory'] = function ($c) {\n    return new CacheFactory($c['logger']);\n};\n\n$container['logger'] = function () {\n    $logger = new SimpleLogger('rssbridge');\n    if (Configuration::getConfig('system', 'env') === 'dev') {\n        $logger->addHandler(new ErrorLogHandler(Logger::DEBUG));\n    } else {\n        $logger->addHandler(new ErrorLogHandler(Logger::INFO));\n    }\n\n    $file_path  = Configuration::getConfig('logging', 'file_path');\n    $file_level = Configuration::getConfig('logging', 'file_level');\n    if ($file_path && $file_level) {\n        $level = array_flip(Logger::LEVEL_NAMES)[strtoupper($file_level)];\n        $logger->addHandler(new StreamHandler($file_path, $level));\n    }\n\n    return $logger;\n};\n\n$container['cache'] = function ($c) {\n    /** @var CacheFactory $cacheFactory */\n    $cacheFactory = $c['cache_factory'];\n    $cache = $cacheFactory->create(Configuration::getConfig('cache', 'type'));\n    return $cache;\n};\n\nreturn $container;\n"
  },
  {
    "path": "lib/html.php",
    "content": "<?php\n\n/**\n * Render template using base.html.php as base\n */\nfunction render(string $template, array $context = []): string\n{\n    if ($template === 'base.html.php') {\n        throw new \\Exception('Do not render base.html.php into itself');\n    }\n    $context['messages'] = $context['messages'] ?? [];\n    if (Configuration::getConfig('system', 'message')) {\n        $context['messages'][] = [\n            'body' => Configuration::getConfig('system', 'message'),\n            'level' => 'info',\n        ];\n    }\n    if (Configuration::getConfig('system', 'env') === 'dev') {\n        $context['messages'][] = [\n            'body' => 'System environment: dev',\n            'level' => 'error'\n        ];\n        $context['messages'][] = [\n            'body' => sprintf('Cache type: %s', Configuration::getConfig('cache', 'type')),\n            'level' => 'info'\n        ];\n    }\n    $context['page'] = render_template($template, $context);\n    return render_template('base.html.php', $context);\n}\n\n/**\n * Render php template with context\n *\n * DO NOT PASS USER INPUT IN $template OR $context (keys!)\n */\nfunction render_template(string $template, array $context = []): string\n{\n    if (isset($context['template'])) {\n        throw new \\Exception(\"Don't use `template` as a context key\");\n    }\n    $templateFilepath = __DIR__ . '/../templates/' . $template;\n    extract($context);\n    ob_start();\n    try {\n        if (is_file($template)) {\n            require $template;\n        } elseif (is_file($templateFilepath)) {\n            require $templateFilepath;\n        } else {\n            throw new \\Exception(sprintf('Unable to find template `%s`', $template));\n        }\n    } catch (\\Throwable $e) {\n        ob_end_clean();\n        throw $e;\n    }\n    return ob_get_clean();\n}\n\n/**\n * Escape for html context\n */\nfunction e(string $s): string\n{\n    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');\n}\n\n/**\n * Explicitly don't escape\n */\nfunction raw(string $s): string\n{\n    return $s;\n}\n\nfunction truncate(string $s, int $length = 150, $marker = '...'): string\n{\n    $s = trim($s);\n    if (mb_strlen($s) <= $length) {\n        return $s;\n    }\n    return mb_substr($s, 0, $length) . $marker;\n}\n\nfunction html_input(array $attributes): string\n{\n    return html_tag('input', null, $attributes);\n}\n\nfunction html_option(string $name, $value, bool $selected = false): string\n{\n    return html_tag('option', $name, [\n        'value'     => $value,\n        'selected'  => $selected,\n    ]);\n}\n\nfunction html_tag(\n    string $name,\n    ?string $content = null,\n    array $attributes = []\n): string {\n    $strings = [\n        'type',\n        'id',\n        'name',\n        'value',\n        'placeholder',\n        'title',\n        'pattern',\n        'class',\n        'for',\n        'oncontextmenu',\n        'data-for',\n        'formtarget',\n    ];\n    $booleans = [\n        'checked',\n        'required',\n        'selected',\n    ];\n\n    $s = \"<$name\";\n\n    foreach ($attributes as $k => $v) {\n        if (in_array($k, $strings)) {\n            if ($v) {\n                $s .= sprintf(' %s=\"%s\"', $k, e($v));\n            }\n        } elseif (in_array($k, $booleans)) {\n            if ($v === true) {\n                $s .= sprintf(' %s', $k);\n            }\n        } else {\n            throw new Exception(sprintf('Illegal html tag attribute: %s', $k));\n        }\n    }\n\n    if ($content) {\n        $s .= sprintf('>%s</%s>', e($content), $name);\n    } else {\n        $s .= ' />';\n    }\n\n    return $s;\n}\n\n/**\n * Removes unwanted tags from a given HTML text.\n *\n * @param string $html The HTML text to sanitize.\n * @param array $tags_to_remove A list of tags to remove from the DOM.\n * @param array $attributes_to_keep A list of attributes to keep on tags (other\n * attributes are removed).\n * @param array $text_to_keep A list of tags where the innertext replaces the tag\n * (i.e. `<p>Hello World!</p>` becomes `Hello World!`).\n * @return object A simplehtmldom object of the remaining contents.\n *\n * @todo Check if this implementation is still necessary, because simplehtmldom\n * already removes some of the tags (search for `remove_noise` in simple_html_dom.php).\n */\nfunction sanitize(\n    $html,\n    $tags_to_remove = ['script', 'iframe', 'input', 'form'],\n    $attributes_to_keep = ['title', 'href', 'src'],\n    $text_to_keep = []\n) {\n    $htmlContent = str_get_html($html);\n\n    foreach ($htmlContent->find('*') as $element) {\n        if (in_array($element->tag, $text_to_keep)) {\n            $element->outertext = $element->plaintext;\n        } elseif (in_array($element->tag, $tags_to_remove)) {\n            $element->outertext = '';\n        } else {\n            foreach ($element->getAllAttributes() as $attributeName => $attribute) {\n                if (!in_array($attributeName, $attributes_to_keep)) {\n                    $element->removeAttribute($attributeName);\n                }\n            }\n        }\n    }\n\n    return $htmlContent;\n}\n\nfunction break_annoying_html_tags(string $html): string\n{\n    $html = str_replace('<script', '<&zwnj;script', $html); // Disable scripts, but leave them visible.\n    $html = str_replace('<iframe', '<&zwnj;iframe', $html);\n    $html = str_replace('<link', '<&zwnj;link', $html);\n    // We leave alone object and embed so that videos can play in RSS readers.\n    return $html;\n}\n\n/**\n * Replace background by image\n *\n * Replaces tags with styles of `backgroud-image` by `<img />` tags.\n *\n * For example:\n *\n * ```HTML\n * <html>\n *   <body style=\"background-image: url('bgimage.jpg');\">\n *     <h1>Hello world!</h1>\n *   </body>\n * </html>\n * ```\n *\n * results in this output:\n *\n * ```HTML\n * <html>\n *   <img style=\"display:block;\" src=\"bgimage.jpg\" />\n * </html>\n * ```\n *\n * @param string $htmlContent The HTML content\n * @return string The HTML content with all ocurrences replaced\n */\nfunction backgroundToImg($htmlContent)\n{\n    $regex = '/background-image[ ]{0,}:[ ]{0,}url\\([\\'\"]{0,}(.*?)[\\'\"]{0,}\\)/';\n    $htmlContent = str_get_html($htmlContent);\n\n    foreach ($htmlContent->find('*') as $element) {\n        if (preg_match($regex, $element->style, $matches) > 0) {\n            $element->outertext = '<img style=\"display:block;\" src=\"' . $matches[1] . '\" />';\n        }\n    }\n\n    return $htmlContent;\n}\n\n/**\n * Convert relative links in HTML into absolute links\n *\n * This function is based on `php-urljoin`.\n *\n * @link https://github.com/plaidfluff/php-urljoin php-urljoin\n *\n * @param string|object $dom The HTML content. Supports HTML objects or string objects\n * @param string $url Fully qualified URL to the page containing relative links\n * @return string|object Content with fixed URLs.\n */\nfunction defaultLinkTo($dom, $url)\n{\n    if ($dom === '') {\n        return $url;\n    }\n\n    $string_convert = false;\n    if (is_string($dom)) {\n        $string_convert = true;\n        $dom = str_get_html($dom);\n    }\n\n    // Use long method names for compatibility with simple_html_dom and DOMDocument\n\n    // Work around bug in simple_html_dom->getElementsByTagName\n    if ($dom instanceof simple_html_dom) {\n        $findByTag = function ($name) use ($dom) {\n            return $dom->getElementsByTagName($name, null);\n        };\n    } else {\n        $findByTag = function ($name) use ($dom) {\n            return $dom->getElementsByTagName($name);\n        };\n    }\n\n    foreach ($findByTag('img') as $image) {\n        $image->setAttribute('src', urljoin($url, $image->getAttribute('src')));\n    }\n\n    foreach ($findByTag('a') as $anchor) {\n        $anchor->setAttribute('href', urljoin($url, $anchor->getAttribute('href')));\n    }\n\n    // Will never be true for DOMDocument\n    if ($string_convert) {\n        $dom = $dom->outertext;\n    }\n\n    return $dom;\n}\n\n/**\n * Parse a srcset HTML attribute value and return size => URL mappings\n * Srcset contains a list of image URLs with associated size specified as size (e.g. 1024w) or scale (e.g. 2x)\n * The web browser should pick the most appropriate image depending on screen size and/or pixel density\n *\n * This function takes a srcset string such as the following:\n * header640.png 640w, header960.png 960w, header1024.png 1024w\n *\n * Returns an array such as the following:\n * [\n *    '640w' => 'header640.png',\n *    '960w' => 'header960.png',\n *    '1024w' => 'header1024.png'\n * ]\n *\n * @param string $srcset Content of srcset html attribute\n * @return array Content of srcset attribute as { size => url } array\n */\nfunction parseSrcset(string $srcset)\n{\n    // The srcset format is more tricky to parse that it seems:\n    //   URLs may contain commas, and space after comma is not mandatory, so the following is valid:\n    //   image.png?resize=640,640 640w,image.png?resize=960,960 960w,image.png?resize=1024,1024 1024w\n    // Since splitting by space or comma will not work, there is a precise algorithm to parse srcset attribute:\n    //   https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute\n    //   To summarize, each srcset entry has the following format:\n    //     1. Leading spaces and comma. Zero or more spaces, zero or at most one comma\n    //     2. Any amount of characters up to the next whitespace (space, tab, newline...): This is the URL\n    //     3. A nonnegative number followed by lowercase w, x or h: This is the image size\n    //   We parse the srcset entries using a regex to mimick the above parser/tokenizer behavior.\n    $preg_status = preg_match_all('/[\\s]*,?[\\s]*([^\\s]+)\\s+([0-9]+[wxh])/', $srcset, $matches);\n    $entries = [];\n    if ($preg_status !== false && $preg_status > 0) {\n        foreach ($matches[1] as $index => $url) {\n            if (array_key_exists($index, $matches[2])) {\n                $size = $matches[2][$index];\n                $entries[$size] = html_entity_decode($url);\n            }\n        }\n    }\n    return $entries;\n}\n\n/**\n * Parse a srcset HTML attribute value and return the URL of the largest image\n *\n * @param string $srcset Content of srcset html attribute\n * @return string Largest image URL\n */\nfunction parseSrcsetLargestImageUrl(string $srcset)\n{\n    $largest_image_url = null;\n    $largest_image_size = -1;\n    $entries = parseSrcset($srcset);\n    foreach ($entries as $size => $url) {\n        $size_int = intval(substr($size, 0, strlen($size) - 1));\n        if ($size_int > $largest_image_size) {\n            $largest_image_size = $size_int;\n            $largest_image_url = $url;\n        }\n    }\n    return $largest_image_url;\n}\n\n/**\n * Convert lazy-loading images and frames (video embeds) into static elements\n *\n * This function looks for lazy-loading attributes such as 'data-src' and converts\n * them back to regular ones such as 'src', making them loadable in RSS readers.\n * It also converts <picture> elements to plain <img> elements.\n *\n * @param string|object $content The HTML content. Supports HTML objects or string objects\n * @return string|object Content with fixed image/frame URLs (same type as input).\n */\nfunction convertLazyLoading($dom)\n{\n    $string_convert = false;\n    if (is_string($dom)) {\n        $string_convert = true;\n        $dom = str_get_html($dom);\n    }\n\n    // Process standalone images, embeds and picture sources\n    foreach ($dom->find('img, iframe, source') as $img) {\n        if (!empty($img->getAttribute('data-src'))) {\n            $img->src = $img->getAttribute('data-src');\n        } elseif (!empty($img->getAttribute('data-srcset'))) {\n            $img->src = parseSrcsetLargestImageUrl($img->getAttribute('data-srcset'));\n        } elseif (!empty($img->getAttribute('data-lazy-src'))) {\n            $img->src = $img->getAttribute('data-lazy-src');\n        } elseif (!empty($img->getAttribute('data-orig-file'))) {\n            $img->src = $img->getAttribute('data-orig-file');\n        } elseif (!empty($img->getAttribute('srcset'))) {\n            $img->src = parseSrcsetLargestImageUrl($img->getAttribute('srcset'));\n        } else {\n            continue; // Proceed to next element without removing attributes\n        }\n\n        // Remove data attributes, no longer necessary\n        foreach ($img->getAllAttributes() as $attr => $val) {\n            if (str_starts_with($attr, 'data-')) {\n                $img->removeAttribute($attr);\n            }\n        }\n\n        // Remove other attributes that may be processed by the client\n        foreach (['loading', 'decoding', 'srcset'] as $attr) {\n            if ($img->hasAttribute($attr)) {\n                $img->removeAttribute($attr);\n            }\n        }\n    }\n\n    // Convert complex HTML5 pictures to plain, standalone images\n    // <img> and <source> tags already have their \"src\" attribute set at this point,\n    // so we replace the whole <picture> with a standalone <img> from within the <picture>\n    foreach ($dom->find('picture') as $picture) {\n        $img = $picture->find('img, source', 0);\n        if (!empty($img)) {\n            if ($img->tag == 'source') {\n                $img->tag = 'img';\n            }\n            // Adding/removing node would change its position inside the parent element,\n            // So instead we rewrite the node in-place through the outertext attribute\n            $picture->outertext = $img->outertext;\n        }\n    }\n\n    // If the expected return type is object, reload the DOM to make sure\n    // all $picture->outertext rewritten above are converted back to objects\n    $dom = $dom->outertext;\n    if (!$string_convert) {\n        $dom = str_get_html($dom);\n    }\n\n    return $dom;\n}\n\n/**\n * Extract the first part of a string matching the specified start and end delimiters\n *\n * @param string $string Input string, e.g. `<div>Post author: John Doe</div>`\n * @param string $start Start delimiter, e.g. `author: `\n * @param string $end End delimiter, e.g. `<`\n * @return string|bool Extracted string, e.g. `John Doe`, or false if the\n * delimiters were not found.\n */\nfunction extractFromDelimiters($string, $start, $end)\n{\n    if (strpos($string, $start) !== false) {\n        $section_retrieved = substr($string, strpos($string, $start) + strlen($start));\n        $section_retrieved = substr($section_retrieved, 0, strpos($section_retrieved, $end));\n        return $section_retrieved;\n    }\n    return false;\n}\n\n/**\n * Remove one or more part(s) of a string using a start and end delmiters\n *\n * @param string $string Input string, e.g. `foo<script>superscript()</script>bar`\n * @param string $start Start delimiter, e.g. `<script>`\n * @param string $end End delimiter, e.g. `</script>`\n * @return string Cleaned string, e.g. `foobar`\n */\nfunction stripWithDelimiters($string, $start, $end)\n{\n    while (strpos($string, $start) !== false) {\n        $section_to_remove = substr($string, strpos($string, $start));\n        $section_to_remove = substr($section_to_remove, 0, strpos($section_to_remove, $end) + strlen($end));\n        $string = str_replace($section_to_remove, '', $string);\n    }\n    return $string;\n}\n\n/**\n * Remove HTML sections containing one or more sections using the same HTML tag\n *\n * @param string $string Input string, e.g. `foo<div class=\"ads\"><div>ads</div>ads</div>bar`\n * @param string $tag_name Name of the HTML tag, e.g. `div`\n * @param string $tag_start Start of the HTML tag to remove, e.g. `<div class=\"ads\">`\n * @return string Cleaned String, e.g. `foobar`\n *\n * This function works by locating the desired tag start, then finding the appropriate\n * end by counting opening and ending tags until the amount of open tags reaches zero:\n *\n * ```\n * Amount of open tags:\n *         1          2       1        0\n * |---------------||---|   |----|   |----|\n * <div class=\"ads\"><div>ads</div>ads</div>bar\n * | <-------- Section to remove -------> |\n * ```\n */\nfunction stripRecursiveHTMLSection($string, $tag_name, $tag_start)\n{\n    $open_tag = '<' . $tag_name;\n    $close_tag = '</' . $tag_name . '>';\n    $close_tag_length = strlen($close_tag);\n\n    // Make sure the provided $tag_start argument matches the provided $tag_name argument\n    if (strpos($tag_start, $open_tag) === 0) {\n        // While tag_start is present, there is at least one remaining section to remove\n        while (strpos($string, $tag_start) !== false) {\n            // In order to locate the end of the section, we attempt each closing tag until we find the right one\n            // We know we found the right one when the amount of \"<tag\" is the same as amount of \"</tag\"\n            // When the attempted \"</tag\" is not the correct one, we increase $search_offset to skip it\n            // and retry unless $max_recursion is reached (prevents infinite loop on malformed HTML)\n            $max_recursion = 100;\n            $section_to_remove = null;\n            $section_start = strpos($string, $tag_start);\n            $search_offset = $section_start;\n            do {\n                $max_recursion--;\n                // Move on to the next occurrence of \"</tag\"\n                $section_end = strpos($string, $close_tag, $search_offset);\n                $search_offset = $section_end + $close_tag_length;\n                // If the next \"</tag\" is the correct one, then this is the section we must remove:\n                $section_to_remove = substr($string, $section_start, $section_end - $section_start + $close_tag_length);\n                // Count amount of \"<tag\" and \"</tag\" in the section to remove\n                $open_tag_count = substr_count($section_to_remove, $open_tag);\n                $close_tag_count = substr_count($section_to_remove, $close_tag);\n            } while ($open_tag_count > $close_tag_count && $max_recursion > 0);\n            // We exited the loop, let's remove the section\n            $string = str_replace($section_to_remove, '', $string);\n        }\n    }\n    return $string;\n}\n\n/**\n * Convert Markdown into HTML with Parsedown.\n *\n * @link https://parsedown.org/ Parsedown\n *\n * @param string $string Input string in Markdown format\n * @param array $config Parsedown options to control output\n * @return string output string in HTML format\n */\nfunction markdownToHtml($string, $config = [])\n{\n    $Parsedown = new Parsedown();\n    foreach ($config as $option => $value) {\n        if ($option === 'breaksEnabled') {\n            $Parsedown->setBreaksEnabled($value);\n        } elseif ($option === 'markupEscaped') {\n            $Parsedown->setMarkupEscaped($value);\n        } elseif ($option === 'urlsLinked') {\n            $Parsedown->setUrlsLinked($value);\n        } else {\n            throw new \\InvalidArgumentException(\"Invalid Parsedown option \\\"$option\\\"\");\n        }\n    }\n    return $Parsedown->text($string);\n}\n\n/**\n * Handle a YouTube video by either returning an iframe that embeds the video\n * or by returning a clickable image (an <img> in a <a> tag).\n * The system config can specify which to use, and whether to use youtube-nocookie.com over youtube.com.\n *\n * @param string $string A string containing a YouTube video URL or directly a video ID.\n * @return string A HTML snippet either with an iframe or a clickable thumbnail. An empty string if no YouTube video ID is found.\n */\nfunction handleYoutube(string $string)\n{\n    $useIframe = Configuration::getConfig('youtube', 'iframe');\n    $useNocookie = Configuration::getConfig('youtube', 'nocookie');\n\n    // sourced from https://gist.github.com/afeld/1254889?permalink_comment_id=3580082#gistcomment-3580082\n    $regex = '#(?:https?://|//)?(?:www\\.|m\\.|.+\\.)?(?:youtu\\.be/|youtube(?:-nocookie)\\.com/(?:embed/|v/|shorts/|feeds/api/videos/|watch\\?v=|watch\\?.+&v=))([\\w-]{11})#i';\n    if (preg_match($regex, $string, $matches) === 1) {\n        $videoID = $matches[1];\n    } elseif (preg_match('#[\\w-]{11}#i', $string, $matches2) === 1) {\n        $videoID = $matches2[0];\n    } else {\n        return '';\n    }\n\n    if ($useIframe) {\n        if ($useNocookie) {\n            $embedUri = 'https://www.youtube-nocookie.com/embed/' . $videoID;\n        } else {\n            $embedUri = 'https://www.youtube.com/embed/' . $videoID;\n        }\n\n        return sprintf(<<<EOD\n<iframe width=\"560\" height=\"315\" src=\"%s\" title=\"YouTube video player\" frameborder=\"0\"\nallow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\"\nreferrerpolicy=\"strict-origin\" allowfullscreen></iframe>'\nEOD\n         , $embedUri);\n    } else {\n        $videoUri = 'https://www.youtube.com/watch?v=' . $videoID;\n\n        $thumbnailJpegBaseUri = 'https://i.ytimg.com/vi/' . $videoID;\n        $jpegSrcset = sprintf(\n            '%1$s/mqdefault.jpg 320w, %1$s/0.jpg 480w, %1$s/hqdefault.jpg 481w, %1$s/sddefault.jpg 640w, %1$s/hq720.jpg 720w, %1$s/maxresdefault.jpg 721w',\n            $thumbnailJpegBaseUri\n        );\n\n        $thumbnailWebpBaseUri = 'https://i.ytimg.com/vi_webp/' . $videoID;\n        $webpSrcset = sprintf(\n            '%1$s/mqdefault.webp 320w, %1$s/0.webp 480w, %1$s/hqdefault.webp 481w, %1$s/sddefault.webp 640w, %1$s/hq720.webp 720w, %1$s/maxresdefault.webp 721w',\n            $thumbnailWebpBaseUri\n        );\n\n        $fallbackUri = $thumbnailJpegBaseUri . '/maxresdefault.jpg';\n\n        return sprintf(<<<EOD\n<a href=\"%s\">\n    <picture>\n        <source srcset=\"%s\" type=\"image/webp\" referrerpolicy=\"no-referrer\" />\n        <img srcset=\"%s\" src=\"%s\" alt=\"Video thumbnail\" title=\"YouTube video thumbnail\" referrerpolicy=\"no-referrer\" />\n    </picture>\n</a>\n<p>\n<a href=\"%s\">%s</a>\n</p>\nEOD, $videoUri, $webpSrcset, $jpegSrcset, $fallbackUri, $videoUri, $videoUri);\n    }\n}\n"
  },
  {
    "path": "lib/http.php",
    "content": "<?php\n\n/**\n * Thrown by bridges\n */\nfinal class RateLimitException extends \\Exception\n{\n}\n\n/**\n * @internal Do not use this class in bridges\n */\nclass HttpException extends \\Exception\n{\n    public ?Response $response;\n\n    public function __construct(string $message = '', int $statusCode = 0, ?Response $response = null)\n    {\n        parent::__construct($message, $statusCode);\n        $this->response = $response ?? new Response('', 0);\n    }\n\n    public static function fromResponse(Response $response, string $url): HttpException\n    {\n        $message = sprintf(\n            '%s resulted in %s %s',\n            $url,\n            $response->getCode(),\n            $response->getStatusLine()\n        );\n        if (CloudFlareException::isCloudFlareResponse($response)) {\n            return new CloudFlareException($message, $response->getCode(), $response);\n        }\n        return new HttpException(trim($message), $response->getCode(), $response);\n    }\n}\n\nfinal class CloudFlareException extends HttpException\n{\n    public static function isCloudFlareResponse(Response $response): bool\n    {\n        $cloudflareTitles = [\n            '<title>Just a moment...',\n            '<title>Please Wait...',\n            '<title>Attention Required!',\n            '<title>Security | Glassdoor',\n            '<title>Access denied</title>', // cf as seen on patreon.com\n        ];\n        foreach ($cloudflareTitles as $cloudflareTitle) {\n            if (str_contains($response->getBody(), $cloudflareTitle)) {\n                return true;\n            }\n        }\n        return false;\n    }\n}\n\ninterface HttpClient\n{\n    public function request(string $url, array $config = []): Response;\n}\n\nfinal class CurlHttpClient implements HttpClient\n{\n    public function request(string $url, array $config = []): Response\n    {\n        $ch = curl_init($url);\n\n        $defaultConfig = [\n            'useragent' => null,\n            'timeout' => 5,\n            'headers' => [],\n            'proxy' => null,\n            'curl_options' => [],\n            'if_not_modified_since' => null,\n            'retries' => 2,\n            'max_filesize' => null,\n            'max_redirections' => 5,\n        ];\n\n        // Snagged from https://github.com/lwthiker/curl-impersonate/blob/main/firefox/curl_ff102\n        $defaultHeaders = [\n            'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',\n            'Accept-Language' => 'en-US,en;q=0.5',\n            'Upgrade-Insecure-Requests' => '1',\n            'Sec-Fetch-Dest' => 'document',\n            'Sec-Fetch-Mode' => 'navigate',\n            'Sec-Fetch-Site' => 'none',\n            'Sec-Fetch-User' => '?1',\n            'TE' => 'trailers',\n        ];\n\n        if (curl_version()['ssl_version'] == 'BoringSSL') {\n            $config = array_merge($defaultConfig, $config);\n        } else {\n            $defaultConfig['useragent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0';\n            curl_setopt($ch, CURLOPT_HEADER, false);\n            $headers = array_merge($defaultHeaders, $config['headers']);\n            $config = array_merge($defaultConfig, $config);\n            $config['headers'] = $headers;\n        }\n        unset($headers);\n\n        $httpHeaders = [];\n        foreach ($config['headers'] as $name => $value) {\n            $httpHeaders[] = sprintf('%s: %s', $name, $value);\n        }\n        curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);\n        if ($config['useragent']) {\n            curl_setopt($ch, CURLOPT_USERAGENT, $config['useragent']);\n        }\n        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);\n        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);\n        curl_setopt($ch, CURLOPT_MAXREDIRS, $config['max_redirections']);\n        curl_setopt($ch, CURLOPT_TIMEOUT, $config['timeout']);\n        curl_setopt($ch, CURLOPT_ENCODING, '');\n        curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);\n\n        if ($config['max_filesize']) {\n            // This option inspects the Content-Length header\n            curl_setopt($ch, CURLOPT_MAXFILESIZE, $config['max_filesize']);\n            curl_setopt($ch, CURLOPT_NOPROGRESS, false);\n            // This progress function will monitor responses who omit the Content-Length header\n            curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($ch, $downloadSize, $downloaded, $uploadSize, $uploaded) use ($config) {\n                if ($downloaded > $config['max_filesize']) {\n                    // Return a non-zero value to abort the transfer\n                    return -1;\n                }\n                return 0;\n            });\n        }\n\n        if ($config['proxy']) {\n            curl_setopt($ch, CURLOPT_PROXY, $config['proxy']);\n        }\n\n        if (curl_setopt_array($ch, $config['curl_options']) === false) {\n            throw new \\Exception('Tried to set an illegal curl option');\n        }\n\n        if ($config['if_not_modified_since']) {\n            curl_setopt($ch, CURLOPT_TIMEVALUE, $config['if_not_modified_since']);\n            curl_setopt($ch, CURLOPT_TIMECONDITION, CURL_TIMECOND_IFMODSINCE);\n        }\n\n        $responseStatusLines = [];\n        $responseHeaders = [];\n        curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $rawHeader) use (&$responseHeaders, &$responseStatusLines) {\n            $len = strlen($rawHeader);\n            if ($rawHeader === \"\\r\\n\") {\n                return $len;\n            }\n            if (preg_match('#^HTTP/(2|1.1|1.0)#', $rawHeader)) {\n                $responseStatusLines[] = trim($rawHeader);\n                return $len;\n            }\n            $header = explode(':', $rawHeader);\n            if (count($header) === 1) {\n                return $len;\n            }\n            $name = mb_strtolower(trim($header[0]));\n            $value = trim(implode(':', array_slice($header, 1)));\n            if (!isset($responseHeaders[$name])) {\n                $responseHeaders[$name] = [];\n            }\n            $responseHeaders[$name][] = $value;\n            return $len;\n        });\n\n        // This retry logic is a bit hard to understand, but it works\n        $tries = 0;\n        while (true) {\n            $tries++;\n            $body = curl_exec($ch);\n            if ($body !== false) {\n                // The network call was successful, so break out of the loop\n                break;\n            }\n            if ($tries <= $config['retries']) {\n                continue;\n            }\n            // Max retries reached, give up\n            $curl_error = curl_error($ch);\n            $curl_errno = curl_errno($ch);\n            throw new HttpException(sprintf(\n                'cURL error %s: %s (%s) for %s',\n                $curl_error,\n                $curl_errno,\n                'https://curl.haxx.se/libcurl/c/libcurl-errors.html',\n                $url\n            ));\n        }\n\n        $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);\n        curl_close($ch);\n        return new Response($body, $statusCode, $responseHeaders);\n    }\n}\n\nfinal class Request\n{\n    private array $get;\n    private array $server;\n    private array $attributes;\n\n    private function __construct()\n    {\n    }\n\n    public static function fromGlobals(): self\n    {\n        $self = new self();\n        $self->get = $_GET;\n        $self->server = $_SERVER;\n        $self->attributes = [];\n        return $self;\n    }\n\n    public static function fromCli(array $cliArgs): self\n    {\n        $self = new self();\n        $self->get = $cliArgs;\n        return $self;\n    }\n\n    public function get(string $key, $default = null): ?string\n    {\n        return $this->get[$key] ?? $default;\n    }\n\n    public function server(string $key, ?string $default = null): ?string\n    {\n        return $this->server[$key] ?? $default;\n    }\n\n    public function withAttribute(string $name, $value = true): self\n    {\n        $clone = clone $this;\n        $clone->attributes[$name] = $value;\n        return $clone;\n    }\n\n    public function getAttribute(string $key, $default = null)\n    {\n        return $this->attributes[$key] ?? $default;\n    }\n\n    public function toArray(): array\n    {\n        return $this->get;\n    }\n}\n\nfinal class Response\n{\n    public const STATUS_CODES = [\n        '100' => 'Continue',\n        '101' => 'Switching Protocols',\n        '200' => 'OK',\n        '201' => 'Created',\n        '202' => 'Accepted',\n        '203' => 'Non-Authoritative Information',\n        '204' => 'No Content',\n        '205' => 'Reset Content',\n        '206' => 'Partial Content',\n        '300' => 'Multiple Choices',\n        '301' => 'Moved Permanently',\n        '302' => 'Found',\n        '303' => 'See Other',\n        '304' => 'Not Modified',\n        '305' => 'Use Proxy',\n        '400' => 'Bad Request',\n        '401' => 'Unauthorized',\n        '402' => 'Payment Required',\n        '403' => 'Forbidden',\n        '404' => 'Not Found',\n        '405' => 'Method Not Allowed',\n        '406' => 'Not Acceptable',\n        '407' => 'Proxy Authentication Required',\n        '408' => 'Request Timeout',\n        '409' => 'Conflict',\n        '410' => 'Gone',\n        '411' => 'Length Required',\n        '412' => 'Precondition Failed',\n        '413' => 'Request Entity Too Large',\n        '414' => 'Request-URI Too Long',\n        '415' => 'Unsupported Media Type',\n        '416' => 'Requested Range Not Satisfiable',\n        '417' => 'Expectation Failed',\n        '429' => 'Too Many Requests',\n        '500' => 'Internal Server Error',\n        '501' => 'Not Implemented',\n        '502' => 'Bad Gateway',\n        '503' => 'Service Unavailable',\n        '504' => 'Gateway Timeout',\n        '505' => 'HTTP Version Not Supported'\n    ];\n    private string $body;\n    private int $code;\n    private array $headers;\n\n    public function __construct(\n        string $body = '',\n        int $code = 200,\n        array $headers = []\n    ) {\n        $this->body = $body;\n        $this->code = $code;\n        $this->headers = [];\n\n        foreach ($headers as $name => $value) {\n            $name = mb_strtolower($name);\n            if (!isset($this->headers[$name])) {\n                $this->headers[$name] = [];\n            }\n            if (is_string($value)) {\n                $this->headers[$name][] = $value;\n            }\n            if (is_array($value)) {\n                $this->headers[$name] = $value;\n            }\n        }\n    }\n\n    public function getBody(): string\n    {\n        return $this->body;\n    }\n\n    public function getCode(): int\n    {\n        return $this->code;\n    }\n\n    public function getStatusLine(): string\n    {\n        return self::STATUS_CODES[$this->code] ?? '';\n    }\n\n    public function getHeaders(): array\n    {\n        return $this->headers;\n    }\n\n    /**\n     * HTTP response may have multiple headers with the same name.\n     *\n     * This method by default, returns only the last header.\n     *\n     * @return string[]|string|null\n     */\n    public function getHeader(string $name, bool $all = false)\n    {\n        $name = mb_strtolower($name);\n        $header = $this->headers[$name] ?? null;\n        if (!$header) {\n            return null;\n        }\n        if ($all) {\n            return $header;\n        }\n        return array_pop($header);\n    }\n\n    public function withHeader(string $name, string $value): self\n    {\n        $clone = clone $this;\n        $clone->headers[$name] = [$value];\n        return $clone;\n    }\n\n    public function withBody(string $body): self\n    {\n        $clone = clone $this;\n        $clone->body = $body;\n        return $clone;\n    }\n\n    public function send(): void\n    {\n        http_response_code($this->code);\n        foreach ($this->headers as $name => $values) {\n            foreach ($values as $value) {\n                header(sprintf('%s: %s', $name, $value));\n            }\n        }\n        print $this->body;\n    }\n}\n"
  },
  {
    "path": "lib/logger.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\ninterface Logger\n{\n    public const DEBUG      = 10;\n    public const INFO       = 20;\n    public const WARNING    = 30;\n    public const ERROR      = 40;\n\n    public const LEVEL_NAMES = [\n        self::DEBUG     => 'DEBUG',\n        self::INFO      => 'INFO',\n        self::WARNING   => 'WARNING',\n        self::ERROR     => 'ERROR',\n    ];\n\n    public function debug(string $message, array $context = []);\n\n    public function info(string $message, array $context = []): void;\n\n    public function warning(string $message, array $context = []): void;\n\n    public function error(string $message, array $context = []): void;\n}\n\nfinal class SimpleLogger implements Logger\n{\n    private string $name;\n    private array $handlers;\n\n    /**\n     * @param callable[] $handlers\n     */\n    public function __construct(\n        string $name,\n        array $handlers = []\n    ) {\n        $this->name = $name;\n        $this->handlers = $handlers;\n    }\n\n    public function addHandler(callable $fn)\n    {\n        $this->handlers[] = $fn;\n    }\n\n    public function debug(string $message, array $context = [])\n    {\n        $this->log(self::DEBUG, $message, $context);\n    }\n\n    public function info(string $message, array $context = []): void\n    {\n        $this->log(self::INFO, $message, $context);\n    }\n\n    public function warning(string $message, array $context = []): void\n    {\n        $this->log(self::WARNING, $message, $context);\n    }\n\n    public function error(string $message, array $context = []): void\n    {\n        $this->log(self::ERROR, $message, $context);\n    }\n\n    private function log(int $level, string $message, array $context = []): void\n    {\n        if (isset($context['e'])) {\n            /** @var \\Throwable $e */\n            $e = $context['e'];\n\n            if ($e instanceof RateLimitException) {\n                return;\n            }\n            $ignoredMessages = [\n                'Format name invalid',\n                'Unknown format given',\n                'Unable to find',\n            ];\n            foreach ($ignoredMessages as $ignoredMessage) {\n                if (str_starts_with($e->getMessage(), $ignoredMessage)) {\n                    return;\n                }\n            }\n        }\n\n        foreach ($this->handlers as $handler) {\n            $handler([\n                'name'          => $this->name,\n                'created_at'    => now(),\n                'level'         => $level,\n                'level_name'    => self::LEVEL_NAMES[$level],\n                'message'       => $message,\n                'context'       => $context,\n            ]);\n        }\n    }\n}\n\nfinal class StreamHandler\n{\n    private string $stream;\n    private int $level;\n\n    public function __construct(string $stream, int $level = Logger::DEBUG)\n    {\n        $this->stream = $stream;\n        $this->level = $level;\n    }\n\n    public function __invoke(array $record)\n    {\n        if ($record['level'] < $this->level) {\n            return;\n        }\n        if (isset($record['context']['e'])) {\n            /** @var \\Throwable $e */\n            $e = $record['context']['e'];\n            unset($record['context']['e']);\n            $record['context']['type'] = get_class($e);\n            $record['context']['code'] = $e->getCode();\n            $record['context']['message'] = sanitize_root($e->getMessage());\n            $record['context']['file'] = sanitize_root($e->getFile());\n            $record['context']['line'] = $e->getLine();\n            $record['context']['url'] = get_current_url();\n            $record['context']['trace'] = trace_to_call_points(trace_from_exception($e));\n        }\n        $context = '';\n        if ($record['context']) {\n            try {\n                $context = Json::encode($record['context']);\n            } catch (\\JsonException $e) {\n                $record['context']['message'] = null;\n                $context = Json::encode($record['context']);\n            }\n        }\n        $text = sprintf(\n            \"[%s] %s.%s %s %s\\n\",\n            $record['created_at']->format('Y-m-d H:i:s'),\n            $record['name'],\n            $record['level_name'],\n            $record['message'],\n            $context\n        );\n        $bytes = file_put_contents($this->stream, $text, FILE_APPEND);\n    }\n}\n\nfinal class ErrorLogHandler\n{\n    private int $level;\n\n    public function __construct(int $level = Logger::DEBUG)\n    {\n        $this->level = $level;\n    }\n\n    public function __invoke(array $record)\n    {\n        if ($record['level'] < $this->level) {\n            return;\n        }\n        if (isset($record['context']['e'])) {\n            /** @var \\Throwable $e */\n            $e = $record['context']['e'];\n            unset($record['context']['e']);\n            $record['context']['type'] = get_class($e);\n            $record['context']['code'] = $e->getCode();\n            $record['context']['message'] = sanitize_root($e->getMessage());\n            $record['context']['file'] = sanitize_root($e->getFile());\n            $record['context']['line'] = $e->getLine();\n            $record['context']['url'] = get_current_url();\n            $record['context']['trace'] = trace_to_call_points(trace_from_exception($e));\n        }\n        $context = '';\n        if ($record['context']) {\n            try {\n                $context = Json::encode($record['context']);\n            } catch (\\JsonException $e) {\n                $record['context']['message'] = null;\n                $context = Json::encode($record['context']);\n            }\n        }\n        // Intentionally omitting newline\n        $text = sprintf(\n            '[%s] %s.%s %s %s',\n            $record['created_at']->format('Y-m-d H:i:s'),\n            $record['name'],\n            $record['level_name'],\n            $record['message'],\n            $context\n        );\n        error_log($text);\n    }\n}\n\nfinal class NullLogger implements Logger\n{\n    public function debug(string $message, array $context = [])\n    {\n    }\n\n    public function info(string $message, array $context = []): void\n    {\n    }\n\n    public function warning(string $message, array $context = []): void\n    {\n    }\n\n    public function error(string $message, array $context = []): void\n    {\n    }\n}\n"
  },
  {
    "path": "lib/parsedown/LICENSE.txt",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2013-2018 Emanuil Rusev, erusev.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "lib/parsedown/Parsedown.php",
    "content": "<?php\n\n#\n#\n# Parsedown\n# http://parsedown.org\n#\n# (c) Emanuil Rusev\n# http://erusev.com\n#\n# For the full license information, view the LICENSE file that was distributed\n# with this source code.\n#\n#\n\nclass Parsedown\n{\n    # ~\n\n    const version = '1.7.4';\n\n    # ~\n\n    function text($text)\n    {\n        # make sure no definitions are set\n        $this->DefinitionData = array();\n\n        # standardize line breaks\n        $text = str_replace(array(\"\\r\\n\", \"\\r\"), \"\\n\", $text);\n\n        # remove surrounding line breaks\n        $text = trim($text, \"\\n\");\n\n        # split text into lines\n        $lines = explode(\"\\n\", $text);\n\n        # iterate through lines to identify blocks\n        $markup = $this->lines($lines);\n\n        # trim line breaks\n        $markup = trim($markup, \"\\n\");\n\n        return $markup;\n    }\n\n    #\n    # Setters\n    #\n\n    function setBreaksEnabled($breaksEnabled)\n    {\n        $this->breaksEnabled = $breaksEnabled;\n\n        return $this;\n    }\n\n    protected $breaksEnabled;\n\n    function setMarkupEscaped($markupEscaped)\n    {\n        $this->markupEscaped = $markupEscaped;\n\n        return $this;\n    }\n\n    protected $markupEscaped;\n\n    function setUrlsLinked($urlsLinked)\n    {\n        $this->urlsLinked = $urlsLinked;\n\n        return $this;\n    }\n\n    protected $urlsLinked = true;\n\n    function setSafeMode($safeMode)\n    {\n        $this->safeMode = (bool) $safeMode;\n\n        return $this;\n    }\n\n    protected $safeMode;\n\n    protected $safeLinksWhitelist = array(\n        'http://',\n        'https://',\n        'ftp://',\n        'ftps://',\n        'mailto:',\n        'data:image/png;base64,',\n        'data:image/gif;base64,',\n        'data:image/jpeg;base64,',\n        'irc:',\n        'ircs:',\n        'git:',\n        'ssh:',\n        'news:',\n        'steam:',\n    );\n\n    #\n    # Lines\n    #\n\n    protected $BlockTypes = array(\n        '#' => array('Header'),\n        '*' => array('Rule', 'List'),\n        '+' => array('List'),\n        '-' => array('SetextHeader', 'Table', 'Rule', 'List'),\n        '0' => array('List'),\n        '1' => array('List'),\n        '2' => array('List'),\n        '3' => array('List'),\n        '4' => array('List'),\n        '5' => array('List'),\n        '6' => array('List'),\n        '7' => array('List'),\n        '8' => array('List'),\n        '9' => array('List'),\n        ':' => array('Table'),\n        '<' => array('Comment', 'Markup'),\n        '=' => array('SetextHeader'),\n        '>' => array('Quote'),\n        '[' => array('Reference'),\n        '_' => array('Rule'),\n        '`' => array('FencedCode'),\n        '|' => array('Table'),\n        '~' => array('FencedCode'),\n    );\n\n    # ~\n\n    protected $unmarkedBlockTypes = array(\n        'Code',\n    );\n\n    #\n    # Blocks\n    #\n\n    protected function lines(array $lines)\n    {\n        $CurrentBlock = null;\n\n        foreach ($lines as $line)\n        {\n            if (chop($line) === '')\n            {\n                if (isset($CurrentBlock))\n                {\n                    $CurrentBlock['interrupted'] = true;\n                }\n\n                continue;\n            }\n\n            if (strpos($line, \"\\t\") !== false)\n            {\n                $parts = explode(\"\\t\", $line);\n\n                $line = $parts[0];\n\n                unset($parts[0]);\n\n                foreach ($parts as $part)\n                {\n                    $shortage = 4 - mb_strlen($line, 'utf-8') % 4;\n\n                    $line .= str_repeat(' ', $shortage);\n                    $line .= $part;\n                }\n            }\n\n            $indent = 0;\n\n            while (isset($line[$indent]) and $line[$indent] === ' ')\n            {\n                $indent ++;\n            }\n\n            $text = $indent > 0 ? substr($line, $indent) : $line;\n\n            # ~\n\n            $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);\n\n            # ~\n\n            if (isset($CurrentBlock['continuable']))\n            {\n                $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock);\n\n                if (isset($Block))\n                {\n                    $CurrentBlock = $Block;\n\n                    continue;\n                }\n                else\n                {\n                    if ($this->isBlockCompletable($CurrentBlock['type']))\n                    {\n                        $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);\n                    }\n                }\n            }\n\n            # ~\n\n            $marker = $text[0];\n\n            # ~\n\n            $blockTypes = $this->unmarkedBlockTypes;\n\n            if (isset($this->BlockTypes[$marker]))\n            {\n                foreach ($this->BlockTypes[$marker] as $blockType)\n                {\n                    $blockTypes []= $blockType;\n                }\n            }\n\n            #\n            # ~\n\n            foreach ($blockTypes as $blockType)\n            {\n                $Block = $this->{'block'.$blockType}($Line, $CurrentBlock);\n\n                if (isset($Block))\n                {\n                    $Block['type'] = $blockType;\n\n                    if ( ! isset($Block['identified']))\n                    {\n                        $Blocks []= $CurrentBlock;\n\n                        $Block['identified'] = true;\n                    }\n\n                    if ($this->isBlockContinuable($blockType))\n                    {\n                        $Block['continuable'] = true;\n                    }\n\n                    $CurrentBlock = $Block;\n\n                    continue 2;\n                }\n            }\n\n            # ~\n\n            if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted']))\n            {\n                $CurrentBlock['element']['text'] .= \"\\n\".$text;\n            }\n            else\n            {\n                $Blocks []= $CurrentBlock;\n\n                $CurrentBlock = $this->paragraph($Line);\n\n                $CurrentBlock['identified'] = true;\n            }\n        }\n\n        # ~\n\n        if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))\n        {\n            $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);\n        }\n\n        # ~\n\n        $Blocks []= $CurrentBlock;\n\n        unset($Blocks[0]);\n\n        # ~\n\n        $markup = '';\n\n        foreach ($Blocks as $Block)\n        {\n            if (isset($Block['hidden']))\n            {\n                continue;\n            }\n\n            $markup .= \"\\n\";\n            $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']);\n        }\n\n        $markup .= \"\\n\";\n\n        # ~\n\n        return $markup;\n    }\n\n    protected function isBlockContinuable($Type)\n    {\n        return method_exists($this, 'block'.$Type.'Continue');\n    }\n\n    protected function isBlockCompletable($Type)\n    {\n        return method_exists($this, 'block'.$Type.'Complete');\n    }\n\n    #\n    # Code\n\n    protected function blockCode($Line, $Block = null)\n    {\n        if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted']))\n        {\n            return;\n        }\n\n        if ($Line['indent'] >= 4)\n        {\n            $text = substr($Line['body'], 4);\n\n            $Block = array(\n                'element' => array(\n                    'name' => 'pre',\n                    'handler' => 'element',\n                    'text' => array(\n                        'name' => 'code',\n                        'text' => $text,\n                    ),\n                ),\n            );\n\n            return $Block;\n        }\n    }\n\n    protected function blockCodeContinue($Line, $Block)\n    {\n        if ($Line['indent'] >= 4)\n        {\n            if (isset($Block['interrupted']))\n            {\n                $Block['element']['text']['text'] .= \"\\n\";\n\n                unset($Block['interrupted']);\n            }\n\n            $Block['element']['text']['text'] .= \"\\n\";\n\n            $text = substr($Line['body'], 4);\n\n            $Block['element']['text']['text'] .= $text;\n\n            return $Block;\n        }\n    }\n\n    protected function blockCodeComplete($Block)\n    {\n        $text = $Block['element']['text']['text'];\n\n        $Block['element']['text']['text'] = $text;\n\n        return $Block;\n    }\n\n    #\n    # Comment\n\n    protected function blockComment($Line)\n    {\n        if ($this->markupEscaped or $this->safeMode)\n        {\n            return;\n        }\n\n        if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!')\n        {\n            $Block = array(\n                'markup' => $Line['body'],\n            );\n\n            if (preg_match('/-->$/', $Line['text']))\n            {\n                $Block['closed'] = true;\n            }\n\n            return $Block;\n        }\n    }\n\n    protected function blockCommentContinue($Line, array $Block)\n    {\n        if (isset($Block['closed']))\n        {\n            return;\n        }\n\n        $Block['markup'] .= \"\\n\" . $Line['body'];\n\n        if (preg_match('/-->$/', $Line['text']))\n        {\n            $Block['closed'] = true;\n        }\n\n        return $Block;\n    }\n\n    #\n    # Fenced Code\n\n    protected function blockFencedCode($Line)\n    {\n        if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([^`]+)?[ ]*$/', $Line['text'], $matches))\n        {\n            $Element = array(\n                'name' => 'code',\n                'text' => '',\n            );\n\n            if (isset($matches[1]))\n            {\n                /**\n                 * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes\n                 * Every HTML element may have a class attribute specified.\n                 * The attribute, if specified, must have a value that is a set\n                 * of space-separated tokens representing the various classes\n                 * that the element belongs to.\n                 * [...]\n                 * The space characters, for the purposes of this specification,\n                 * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),\n                 * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and\n                 * U+000D CARRIAGE RETURN (CR).\n                 */\n                $language = substr($matches[1], 0, strcspn($matches[1], \" \\t\\n\\f\\r\"));\n\n                $class = 'language-'.$language;\n\n                $Element['attributes'] = array(\n                    'class' => $class,\n                );\n            }\n\n            $Block = array(\n                'char' => $Line['text'][0],\n                'element' => array(\n                    'name' => 'pre',\n                    'handler' => 'element',\n                    'text' => $Element,\n                ),\n            );\n\n            return $Block;\n        }\n    }\n\n    protected function blockFencedCodeContinue($Line, $Block)\n    {\n        if (isset($Block['complete']))\n        {\n            return;\n        }\n\n        if (isset($Block['interrupted']))\n        {\n            $Block['element']['text']['text'] .= \"\\n\";\n\n            unset($Block['interrupted']);\n        }\n\n        if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text']))\n        {\n            $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1);\n\n            $Block['complete'] = true;\n\n            return $Block;\n        }\n\n        $Block['element']['text']['text'] .= \"\\n\".$Line['body'];\n\n        return $Block;\n    }\n\n    protected function blockFencedCodeComplete($Block)\n    {\n        $text = $Block['element']['text']['text'];\n\n        $Block['element']['text']['text'] = $text;\n\n        return $Block;\n    }\n\n    #\n    # Header\n\n    protected function blockHeader($Line)\n    {\n        if (isset($Line['text'][1]))\n        {\n            $level = 1;\n\n            while (isset($Line['text'][$level]) and $Line['text'][$level] === '#')\n            {\n                $level ++;\n            }\n\n            if ($level > 6)\n            {\n                return;\n            }\n\n            $text = trim($Line['text'], '# ');\n\n            $Block = array(\n                'element' => array(\n                    'name' => 'h' . min(6, $level),\n                    'text' => $text,\n                    'handler' => 'line',\n                ),\n            );\n\n            return $Block;\n        }\n    }\n\n    #\n    # List\n\n    protected function blockList($Line)\n    {\n        list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]');\n\n        if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches))\n        {\n            $Block = array(\n                'indent' => $Line['indent'],\n                'pattern' => $pattern,\n                'element' => array(\n                    'name' => $name,\n                    'handler' => 'elements',\n                ),\n            );\n\n            if($name === 'ol')\n            {\n                $listStart = stristr($matches[0], '.', true);\n\n                if($listStart !== '1')\n                {\n                    $Block['element']['attributes'] = array('start' => $listStart);\n                }\n            }\n\n            $Block['li'] = array(\n                'name' => 'li',\n                'handler' => 'li',\n                'text' => array(\n                    $matches[2],\n                ),\n            );\n\n            $Block['element']['text'] []= & $Block['li'];\n\n            return $Block;\n        }\n    }\n\n    protected function blockListContinue($Line, array $Block)\n    {\n        if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches))\n        {\n            if (isset($Block['interrupted']))\n            {\n                $Block['li']['text'] []= '';\n\n                $Block['loose'] = true;\n\n                unset($Block['interrupted']);\n            }\n\n            unset($Block['li']);\n\n            $text = isset($matches[1]) ? $matches[1] : '';\n\n            $Block['li'] = array(\n                'name' => 'li',\n                'handler' => 'li',\n                'text' => array(\n                    $text,\n                ),\n            );\n\n            $Block['element']['text'] []= & $Block['li'];\n\n            return $Block;\n        }\n\n        if ($Line['text'][0] === '[' and $this->blockReference($Line))\n        {\n            return $Block;\n        }\n\n        if ( ! isset($Block['interrupted']))\n        {\n            $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);\n\n            $Block['li']['text'] []= $text;\n\n            return $Block;\n        }\n\n        if ($Line['indent'] > 0)\n        {\n            $Block['li']['text'] []= '';\n\n            $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);\n\n            $Block['li']['text'] []= $text;\n\n            unset($Block['interrupted']);\n\n            return $Block;\n        }\n    }\n\n    protected function blockListComplete(array $Block)\n    {\n        if (isset($Block['loose']))\n        {\n            foreach ($Block['element']['text'] as &$li)\n            {\n                if (end($li['text']) !== '')\n                {\n                    $li['text'] []= '';\n                }\n            }\n        }\n\n        return $Block;\n    }\n\n    #\n    # Quote\n\n    protected function blockQuote($Line)\n    {\n        if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))\n        {\n            $Block = array(\n                'element' => array(\n                    'name' => 'blockquote',\n                    'handler' => 'lines',\n                    'text' => (array) $matches[1],\n                ),\n            );\n\n            return $Block;\n        }\n    }\n\n    protected function blockQuoteContinue($Line, array $Block)\n    {\n        if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))\n        {\n            if (isset($Block['interrupted']))\n            {\n                $Block['element']['text'] []= '';\n\n                unset($Block['interrupted']);\n            }\n\n            $Block['element']['text'] []= $matches[1];\n\n            return $Block;\n        }\n\n        if ( ! isset($Block['interrupted']))\n        {\n            $Block['element']['text'] []= $Line['text'];\n\n            return $Block;\n        }\n    }\n\n    #\n    # Rule\n\n    protected function blockRule($Line)\n    {\n        if (preg_match('/^(['.$Line['text'][0].'])([ ]*\\1){2,}[ ]*$/', $Line['text']))\n        {\n            $Block = array(\n                'element' => array(\n                    'name' => 'hr'\n                ),\n            );\n\n            return $Block;\n        }\n    }\n\n    #\n    # Setext\n\n    protected function blockSetextHeader($Line, ?array $Block = null)\n    {\n        if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))\n        {\n            return;\n        }\n\n        if (chop($Line['text'], $Line['text'][0]) === '')\n        {\n            $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';\n\n            return $Block;\n        }\n    }\n\n    #\n    # Markup\n\n    protected function blockMarkup($Line)\n    {\n        if ($this->markupEscaped or $this->safeMode)\n        {\n            return;\n        }\n\n        if (preg_match('/^<(\\w[\\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\\/)?>/', $Line['text'], $matches))\n        {\n            $element = strtolower($matches[1]);\n\n            if (in_array($element, $this->textLevelElements))\n            {\n                return;\n            }\n\n            $Block = array(\n                'name' => $matches[1],\n                'depth' => 0,\n                'markup' => $Line['text'],\n            );\n\n            $length = strlen($matches[0]);\n\n            $remainder = substr($Line['text'], $length);\n\n            if (trim($remainder) === '')\n            {\n                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))\n                {\n                    $Block['closed'] = true;\n\n                    $Block['void'] = true;\n                }\n            }\n            else\n            {\n                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))\n                {\n                    return;\n                }\n\n                if (preg_match('/<\\/'.$matches[1].'>[ ]*$/i', $remainder))\n                {\n                    $Block['closed'] = true;\n                }\n            }\n\n            return $Block;\n        }\n    }\n\n    protected function blockMarkupContinue($Line, array $Block)\n    {\n        if (isset($Block['closed']))\n        {\n            return;\n        }\n\n        if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open\n        {\n            $Block['depth'] ++;\n        }\n\n        if (preg_match('/(.*?)<\\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close\n        {\n            if ($Block['depth'] > 0)\n            {\n                $Block['depth'] --;\n            }\n            else\n            {\n                $Block['closed'] = true;\n            }\n        }\n\n        if (isset($Block['interrupted']))\n        {\n            $Block['markup'] .= \"\\n\";\n\n            unset($Block['interrupted']);\n        }\n\n        $Block['markup'] .= \"\\n\".$Line['body'];\n\n        return $Block;\n    }\n\n    #\n    # Reference\n\n    protected function blockReference($Line)\n    {\n        if (preg_match('/^\\[(.+?)\\]:[ ]*<?(\\S+?)>?(?:[ ]+[\"\\'(](.+)[\"\\')])?[ ]*$/', $Line['text'], $matches))\n        {\n            $id = strtolower($matches[1]);\n\n            $Data = array(\n                'url' => $matches[2],\n                'title' => null,\n            );\n\n            if (isset($matches[3]))\n            {\n                $Data['title'] = $matches[3];\n            }\n\n            $this->DefinitionData['Reference'][$id] = $Data;\n\n            $Block = array(\n                'hidden' => true,\n            );\n\n            return $Block;\n        }\n    }\n\n    #\n    # Table\n\n    protected function blockTable($Line, ?array $Block = null)\n    {\n        if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))\n        {\n            return;\n        }\n\n        if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '')\n        {\n            $alignments = array();\n\n            $divider = $Line['text'];\n\n            $divider = trim($divider);\n            $divider = trim($divider, '|');\n\n            $dividerCells = explode('|', $divider);\n\n            foreach ($dividerCells as $dividerCell)\n            {\n                $dividerCell = trim($dividerCell);\n\n                if ($dividerCell === '')\n                {\n                    continue;\n                }\n\n                $alignment = null;\n\n                if ($dividerCell[0] === ':')\n                {\n                    $alignment = 'left';\n                }\n\n                if (substr($dividerCell, - 1) === ':')\n                {\n                    $alignment = $alignment === 'left' ? 'center' : 'right';\n                }\n\n                $alignments []= $alignment;\n            }\n\n            # ~\n\n            $HeaderElements = array();\n\n            $header = $Block['element']['text'];\n\n            $header = trim($header);\n            $header = trim($header, '|');\n\n            $headerCells = explode('|', $header);\n\n            foreach ($headerCells as $index => $headerCell)\n            {\n                $headerCell = trim($headerCell);\n\n                $HeaderElement = array(\n                    'name' => 'th',\n                    'text' => $headerCell,\n                    'handler' => 'line',\n                );\n\n                if (isset($alignments[$index]))\n                {\n                    $alignment = $alignments[$index];\n\n                    $HeaderElement['attributes'] = array(\n                        'style' => 'text-align: '.$alignment.';',\n                    );\n                }\n\n                $HeaderElements []= $HeaderElement;\n            }\n\n            # ~\n\n            $Block = array(\n                'alignments' => $alignments,\n                'identified' => true,\n                'element' => array(\n                    'name' => 'table',\n                    'handler' => 'elements',\n                ),\n            );\n\n            $Block['element']['text'] []= array(\n                'name' => 'thead',\n                'handler' => 'elements',\n            );\n\n            $Block['element']['text'] []= array(\n                'name' => 'tbody',\n                'handler' => 'elements',\n                'text' => array(),\n            );\n\n            $Block['element']['text'][0]['text'] []= array(\n                'name' => 'tr',\n                'handler' => 'elements',\n                'text' => $HeaderElements,\n            );\n\n            return $Block;\n        }\n    }\n\n    protected function blockTableContinue($Line, array $Block)\n    {\n        if (isset($Block['interrupted']))\n        {\n            return;\n        }\n\n        if ($Line['text'][0] === '|' or strpos($Line['text'], '|'))\n        {\n            $Elements = array();\n\n            $row = $Line['text'];\n\n            $row = trim($row);\n            $row = trim($row, '|');\n\n            preg_match_all('/(?:(\\\\\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches);\n\n            foreach ($matches[0] as $index => $cell)\n            {\n                $cell = trim($cell);\n\n                $Element = array(\n                    'name' => 'td',\n                    'handler' => 'line',\n                    'text' => $cell,\n                );\n\n                if (isset($Block['alignments'][$index]))\n                {\n                    $Element['attributes'] = array(\n                        'style' => 'text-align: '.$Block['alignments'][$index].';',\n                    );\n                }\n\n                $Elements []= $Element;\n            }\n\n            $Element = array(\n                'name' => 'tr',\n                'handler' => 'elements',\n                'text' => $Elements,\n            );\n\n            $Block['element']['text'][1]['text'] []= $Element;\n\n            return $Block;\n        }\n    }\n\n    #\n    # ~\n    #\n\n    protected function paragraph($Line)\n    {\n        $Block = array(\n            'element' => array(\n                'name' => 'p',\n                'text' => $Line['text'],\n                'handler' => 'line',\n            ),\n        );\n\n        return $Block;\n    }\n\n    #\n    # Inline Elements\n    #\n\n    protected $InlineTypes = array(\n        '\"' => array('SpecialCharacter'),\n        '!' => array('Image'),\n        '&' => array('SpecialCharacter'),\n        '*' => array('Emphasis'),\n        ':' => array('Url'),\n        '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'),\n        '>' => array('SpecialCharacter'),\n        '[' => array('Link'),\n        '_' => array('Emphasis'),\n        '`' => array('Code'),\n        '~' => array('Strikethrough'),\n        '\\\\' => array('EscapeSequence'),\n    );\n\n    # ~\n\n    protected $inlineMarkerList = '!\"*_&[:<>`~\\\\';\n\n    #\n    # ~\n    #\n\n    public function line($text, $nonNestables=array())\n    {\n        $markup = '';\n\n        # $excerpt is based on the first occurrence of a marker\n\n        while ($excerpt = strpbrk($text, $this->inlineMarkerList))\n        {\n            $marker = $excerpt[0];\n\n            $markerPosition = strpos($text, $marker);\n\n            $Excerpt = array('text' => $excerpt, 'context' => $text);\n\n            foreach ($this->InlineTypes[$marker] as $inlineType)\n            {\n                # check to see if the current inline type is nestable in the current context\n\n                if ( ! empty($nonNestables) and in_array($inlineType, $nonNestables))\n                {\n                    continue;\n                }\n\n                $Inline = $this->{'inline'.$inlineType}($Excerpt);\n\n                if ( ! isset($Inline))\n                {\n                    continue;\n                }\n\n                # makes sure that the inline belongs to \"our\" marker\n\n                if (isset($Inline['position']) and $Inline['position'] > $markerPosition)\n                {\n                    continue;\n                }\n\n                # sets a default inline position\n\n                if ( ! isset($Inline['position']))\n                {\n                    $Inline['position'] = $markerPosition;\n                }\n\n                # cause the new element to 'inherit' our non nestables\n\n                foreach ($nonNestables as $non_nestable)\n                {\n                    $Inline['element']['nonNestables'][] = $non_nestable;\n                }\n\n                # the text that comes before the inline\n                $unmarkedText = substr($text, 0, $Inline['position']);\n\n                # compile the unmarked text\n                $markup .= $this->unmarkedText($unmarkedText);\n\n                # compile the inline\n                $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']);\n\n                # remove the examined text\n                $text = substr($text, $Inline['position'] + $Inline['extent']);\n\n                continue 2;\n            }\n\n            # the marker does not belong to an inline\n\n            $unmarkedText = substr($text, 0, $markerPosition + 1);\n\n            $markup .= $this->unmarkedText($unmarkedText);\n\n            $text = substr($text, $markerPosition + 1);\n        }\n\n        $markup .= $this->unmarkedText($text);\n\n        return $markup;\n    }\n\n    #\n    # ~\n    #\n\n    protected function inlineCode($Excerpt)\n    {\n        $marker = $Excerpt['text'][0];\n\n        if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(?<!'.$marker.')\\1(?!'.$marker.')/s', $Excerpt['text'], $matches))\n        {\n            $text = $matches[2];\n            $text = preg_replace(\"/[ ]*\\n/\", ' ', $text);\n\n            return array(\n                'extent' => strlen($matches[0]),\n                'element' => array(\n                    'name' => 'code',\n                    'text' => $text,\n                ),\n            );\n        }\n    }\n\n    protected function inlineEmailTag($Excerpt)\n    {\n        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\\S+?@\\S+?)>/i', $Excerpt['text'], $matches))\n        {\n            $url = $matches[1];\n\n            if ( ! isset($matches[2]))\n            {\n                $url = 'mailto:' . $url;\n            }\n\n            return array(\n                'extent' => strlen($matches[0]),\n                'element' => array(\n                    'name' => 'a',\n                    'text' => $matches[1],\n                    'attributes' => array(\n                        'href' => $url,\n                    ),\n                ),\n            );\n        }\n    }\n\n    protected function inlineEmphasis($Excerpt)\n    {\n        if ( ! isset($Excerpt['text'][1]))\n        {\n            return;\n        }\n\n        $marker = $Excerpt['text'][0];\n\n        if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))\n        {\n            $emphasis = 'strong';\n        }\n        elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))\n        {\n            $emphasis = 'em';\n        }\n        else\n        {\n            return;\n        }\n\n        return array(\n            'extent' => strlen($matches[0]),\n            'element' => array(\n                'name' => $emphasis,\n                'handler' => 'line',\n                'text' => $matches[1],\n            ),\n        );\n    }\n\n    protected function inlineEscapeSequence($Excerpt)\n    {\n        if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))\n        {\n            return array(\n                'markup' => $Excerpt['text'][1],\n                'extent' => 2,\n            );\n        }\n    }\n\n    protected function inlineImage($Excerpt)\n    {\n        if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')\n        {\n            return;\n        }\n\n        $Excerpt['text']= substr($Excerpt['text'], 1);\n\n        $Link = $this->inlineLink($Excerpt);\n\n        if ($Link === null)\n        {\n            return;\n        }\n\n        $Inline = array(\n            'extent' => $Link['extent'] + 1,\n            'element' => array(\n                'name' => 'img',\n                'attributes' => array(\n                    'src' => $Link['element']['attributes']['href'],\n                    'alt' => $Link['element']['text'],\n                ),\n            ),\n        );\n\n        $Inline['element']['attributes'] += $Link['element']['attributes'];\n\n        unset($Inline['element']['attributes']['href']);\n\n        return $Inline;\n    }\n\n    protected function inlineLink($Excerpt)\n    {\n        $Element = array(\n            'name' => 'a',\n            'handler' => 'line',\n            'nonNestables' => array('Url', 'Link'),\n            'text' => null,\n            'attributes' => array(\n                'href' => null,\n                'title' => null,\n            ),\n        );\n\n        $extent = 0;\n\n        $remainder = $Excerpt['text'];\n\n        if (preg_match('/\\[((?:[^][]++|(?R))*+)\\]/', $remainder, $matches))\n        {\n            $Element['text'] = $matches[1];\n\n            $extent += strlen($matches[0]);\n\n            $remainder = substr($remainder, $extent);\n        }\n        else\n        {\n            return;\n        }\n\n        if (preg_match('/^[(]\\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+(\"[^\"]*\"|\\'[^\\']*\\'))?\\s*[)]/', $remainder, $matches))\n        {\n            $Element['attributes']['href'] = $matches[1];\n\n            if (isset($matches[2]))\n            {\n                $Element['attributes']['title'] = substr($matches[2], 1, - 1);\n            }\n\n            $extent += strlen($matches[0]);\n        }\n        else\n        {\n            if (preg_match('/^\\s*\\[(.*?)\\]/', $remainder, $matches))\n            {\n                $definition = strlen($matches[1]) ? $matches[1] : $Element['text'];\n                $definition = strtolower($definition);\n\n                $extent += strlen($matches[0]);\n            }\n            else\n            {\n                $definition = strtolower($Element['text']);\n            }\n\n            if ( ! isset($this->DefinitionData['Reference'][$definition]))\n            {\n                return;\n            }\n\n            $Definition = $this->DefinitionData['Reference'][$definition];\n\n            $Element['attributes']['href'] = $Definition['url'];\n            $Element['attributes']['title'] = $Definition['title'];\n        }\n\n        return array(\n            'extent' => $extent,\n            'element' => $Element,\n        );\n    }\n\n    protected function inlineMarkup($Excerpt)\n    {\n        if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)\n        {\n            return;\n        }\n\n        if ($Excerpt['text'][1] === '/' and preg_match('/^<\\/\\w[\\w-]*[ ]*>/s', $Excerpt['text'], $matches))\n        {\n            return array(\n                'markup' => $matches[0],\n                'extent' => strlen($matches[0]),\n            );\n        }\n\n        if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?[^-])*-->/s', $Excerpt['text'], $matches))\n        {\n            return array(\n                'markup' => $matches[0],\n                'extent' => strlen($matches[0]),\n            );\n        }\n\n        if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\\w[\\w-]*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\\/?>/s', $Excerpt['text'], $matches))\n        {\n            return array(\n                'markup' => $matches[0],\n                'extent' => strlen($matches[0]),\n            );\n        }\n    }\n\n    protected function inlineSpecialCharacter($Excerpt)\n    {\n        if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\\w+;/', $Excerpt['text']))\n        {\n            return array(\n                'markup' => '&amp;',\n                'extent' => 1,\n            );\n        }\n\n        $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '\"' => 'quot');\n\n        if (isset($SpecialCharacter[$Excerpt['text'][0]]))\n        {\n            return array(\n                'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';',\n                'extent' => 1,\n            );\n        }\n    }\n\n    protected function inlineStrikethrough($Excerpt)\n    {\n        if ( ! isset($Excerpt['text'][1]))\n        {\n            return;\n        }\n\n        if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\\S)(.+?)(?<=\\S)~~/', $Excerpt['text'], $matches))\n        {\n            return array(\n                'extent' => strlen($matches[0]),\n                'element' => array(\n                    'name' => 'del',\n                    'text' => $matches[1],\n                    'handler' => 'line',\n                ),\n            );\n        }\n    }\n\n    protected function inlineUrl($Excerpt)\n    {\n        if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')\n        {\n            return;\n        }\n\n        if (preg_match('/\\bhttps?:[\\/]{2}[^\\s<]+\\b\\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE))\n        {\n            $url = $matches[0][0];\n\n            $Inline = array(\n                'extent' => strlen($matches[0][0]),\n                'position' => $matches[0][1],\n                'element' => array(\n                    'name' => 'a',\n                    'text' => $url,\n                    'attributes' => array(\n                        'href' => $url,\n                    ),\n                ),\n            );\n\n            return $Inline;\n        }\n    }\n\n    protected function inlineUrlTag($Excerpt)\n    {\n        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\\w+:\\/{2}[^ >]+)>/i', $Excerpt['text'], $matches))\n        {\n            $url = $matches[1];\n\n            return array(\n                'extent' => strlen($matches[0]),\n                'element' => array(\n                    'name' => 'a',\n                    'text' => $url,\n                    'attributes' => array(\n                        'href' => $url,\n                    ),\n                ),\n            );\n        }\n    }\n\n    # ~\n\n    protected function unmarkedText($text)\n    {\n        if ($this->breaksEnabled)\n        {\n            $text = preg_replace('/[ ]*\\n/', \"<br />\\n\", $text);\n        }\n        else\n        {\n            $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\\\\\)\\n/', \"<br />\\n\", $text);\n            $text = str_replace(\" \\n\", \"\\n\", $text);\n        }\n\n        return $text;\n    }\n\n    #\n    # Handlers\n    #\n\n    protected function element(array $Element)\n    {\n        if ($this->safeMode)\n        {\n            $Element = $this->sanitiseElement($Element);\n        }\n\n        $markup = '<'.$Element['name'];\n\n        if (isset($Element['attributes']))\n        {\n            foreach ($Element['attributes'] as $name => $value)\n            {\n                if ($value === null)\n                {\n                    continue;\n                }\n\n                $markup .= ' '.$name.'=\"'.self::escape($value).'\"';\n            }\n        }\n\n        $permitRawHtml = false;\n\n        if (isset($Element['text']))\n        {\n            $text = $Element['text'];\n        }\n        // very strongly consider an alternative if you're writing an\n        // extension\n        elseif (isset($Element['rawHtml']))\n        {\n            $text = $Element['rawHtml'];\n            $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];\n            $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;\n        }\n\n        if (isset($text))\n        {\n            $markup .= '>';\n\n            if (!isset($Element['nonNestables']))\n            {\n                $Element['nonNestables'] = array();\n            }\n\n            if (isset($Element['handler']))\n            {\n                $markup .= $this->{$Element['handler']}($text, $Element['nonNestables']);\n            }\n            elseif (!$permitRawHtml)\n            {\n                $markup .= self::escape($text, true);\n            }\n            else\n            {\n                $markup .= $text;\n            }\n\n            $markup .= '</'.$Element['name'].'>';\n        }\n        else\n        {\n            $markup .= ' />';\n        }\n\n        return $markup;\n    }\n\n    protected function elements(array $Elements)\n    {\n        $markup = '';\n\n        foreach ($Elements as $Element)\n        {\n            $markup .= \"\\n\" . $this->element($Element);\n        }\n\n        $markup .= \"\\n\";\n\n        return $markup;\n    }\n\n    # ~\n\n    protected function li($lines)\n    {\n        $markup = $this->lines($lines);\n\n        $trimmedMarkup = trim($markup);\n\n        if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '<p>')\n        {\n            $markup = $trimmedMarkup;\n            $markup = substr($markup, 3);\n\n            $position = strpos($markup, \"</p>\");\n\n            $markup = substr_replace($markup, '', $position, 4);\n        }\n\n        return $markup;\n    }\n\n    #\n    # Deprecated Methods\n    #\n\n    function parse($text)\n    {\n        $markup = $this->text($text);\n\n        return $markup;\n    }\n\n    protected function sanitiseElement(array $Element)\n    {\n        static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';\n        static $safeUrlNameToAtt  = array(\n            'a'   => 'href',\n            'img' => 'src',\n        );\n\n        if (isset($safeUrlNameToAtt[$Element['name']]))\n        {\n            $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);\n        }\n\n        if ( ! empty($Element['attributes']))\n        {\n            foreach ($Element['attributes'] as $att => $val)\n            {\n                # filter out badly parsed attribute\n                if ( ! preg_match($goodAttribute, $att))\n                {\n                    unset($Element['attributes'][$att]);\n                }\n                # dump onevent attribute\n                elseif (self::striAtStart($att, 'on'))\n                {\n                    unset($Element['attributes'][$att]);\n                }\n            }\n        }\n\n        return $Element;\n    }\n\n    protected function filterUnsafeUrlInAttribute(array $Element, $attribute)\n    {\n        foreach ($this->safeLinksWhitelist as $scheme)\n        {\n            if (self::striAtStart($Element['attributes'][$attribute], $scheme))\n            {\n                return $Element;\n            }\n        }\n\n        $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);\n\n        return $Element;\n    }\n\n    #\n    # Static Methods\n    #\n\n    protected static function escape($text, $allowQuotes = false)\n    {\n        return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');\n    }\n\n    protected static function striAtStart($string, $needle)\n    {\n        $len = strlen($needle);\n\n        if ($len > strlen($string))\n        {\n            return false;\n        }\n        else\n        {\n            return strtolower(substr($string, 0, $len)) === strtolower($needle);\n        }\n    }\n\n    static function instance($name = 'default')\n    {\n        if (isset(self::$instances[$name]))\n        {\n            return self::$instances[$name];\n        }\n\n        $instance = new static();\n\n        self::$instances[$name] = $instance;\n\n        return $instance;\n    }\n\n    private static $instances = array();\n\n    #\n    # Fields\n    #\n\n    protected $DefinitionData;\n\n    #\n    # Read-Only\n\n    protected $specialCharacters = array(\n        '\\\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|',\n    );\n\n    protected $StrongRegex = array(\n        '*' => '/^[*]{2}((?:\\\\\\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s',\n        '_' => '/^__((?:\\\\\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us',\n    );\n\n    protected $EmRegex = array(\n        '*' => '/^[*]((?:\\\\\\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',\n        '_' => '/^_((?:\\\\\\\\_|[^_]|__[^_]*__)+?)_(?!_)\\b/us',\n    );\n\n    protected $regexHtmlAttribute = '[a-zA-Z_:][\\w:.-]*(?:\\s*=\\s*(?:[^\"\\'=<>`\\s]+|\"[^\"]*\"|\\'[^\\']*\\'))?';\n\n    protected $voidElements = array(\n        'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',\n    );\n\n    protected $textLevelElements = array(\n        'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',\n        'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',\n        'i', 'rp', 'del', 'code',          'strike', 'marquee',\n        'q', 'rt', 'ins', 'font',          'strong',\n        's', 'tt', 'kbd', 'mark',\n        'u', 'xm', 'sub', 'nobr',\n                   'sup', 'ruby',\n                   'var', 'span',\n                   'wbr', 'time',\n    );\n}\n"
  },
  {
    "path": "lib/php-urljoin/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2018 j. shagam <fluffy@beesbuzz.biz>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "lib/php-urljoin/src/urljoin.php",
    "content": "<?php\n\n/*\n\nA spiritual port of Python's urlparse.urljoin() function to PHP. Why this isn't in the standard library is anyone's guess.\n\nAuthor: fluffy, http://beesbuzz.biz/\nLatest version at: https://github.com/plaidfluff/php-urljoin\n\n */\n\nfunction urljoin($base, $rel) {\n\tif (!$base) {\n\t\treturn $rel;\n\t}\n\n\tif (!$rel) {\n\t\treturn $base;\n\t}\n\n\t$uses_relative = array('', 'ftp', 'http', 'gopher', 'nntp', 'imap',\n\t\t'wais', 'file', 'https', 'shttp', 'mms',\n\t\t'prospero', 'rtsp', 'rtspu', 'sftp',\n\t\t'svn', 'svn+ssh', 'ws', 'wss');\n\n\t$pbase = parse_url($base);\n\t$prel = parse_url($rel);\n\n\tif ($prel === false || preg_match('/^[a-z0-9\\-.]*[^a-z0-9\\-.:][a-z0-9\\-.]*:/i', $rel)) {\n\t\t/*\n\t\t\tEither parse_url couldn't parse this, or the original URL\n\t\t\tfragment had an invalid scheme character before the first :,\n\t\t\twhich can confuse parse_url\n\t\t*/\n\t\t$prel = array('path' => $rel);\n\t}\n\n\tif (array_key_exists('path', $pbase) && $pbase['path'] === '/') {\n\t\tunset($pbase['path']);\n\t}\n\n\tif (isset($prel['scheme'])) {\n\t\tif (\n\t\t\t$prel['scheme'] != ($pbase['scheme'] ?? null)\n\t\t\t|| in_array($prel['scheme'], $uses_relative) == false\n\t\t) {\n\t\t\treturn $rel;\n\t\t}\n\t}\n\n\t$merged = array_merge($pbase, $prel);\n\n\t// Handle relative paths:\n\t//   'path/to/file.ext'\n\t// './path/to/file.ext'\n\tif (array_key_exists('path', $prel) && substr($prel['path'], 0, 1) != '/') {\n\n\t\t// Normalize: './path/to/file.ext' => 'path/to/file.ext'\n\t\tif (substr($prel['path'], 0, 2) === './') {\n\t\t\t$prel['path'] = substr($prel['path'], 2);\n\t\t}\n\n\t\tif (array_key_exists('path', $pbase)) {\n\t\t\t$dir = preg_replace('@/[^/]*$@', '', $pbase['path']);\n\t\t\t$merged['path'] = $dir . '/' . $prel['path'];\n\t\t} else {\n\t\t\t$merged['path'] = '/' . $prel['path'];\n\t\t}\n\n\t}\n\n\tif(array_key_exists('path', $merged)) {\n\t\t// Get the path components, and remove the initial empty one\n\t\t$pathParts = explode('/', $merged['path']);\n\t\tarray_shift($pathParts);\n\n\t\t$path = [];\n\t\t$prevPart = '';\n\t\tforeach ($pathParts as $part) {\n\t\t\tif ($part == '..' && count($path) > 0) {\n\t\t\t\t// Cancel out the parent directory (if there's a parent to cancel)\n\t\t\t\t$parent = array_pop($path);\n\t\t\t\t// But if it was also a parent directory, leave it in\n\t\t\t\tif ($parent == '..') {\n\t\t\t\t\tarray_push($path, $parent);\n\t\t\t\t\tarray_push($path, $part);\n\t\t\t\t}\n\t\t\t} else if ($prevPart != '' || ($part != '.' && $part != '')) {\n\t\t\t\t// Don't include empty or current-directory components\n\t\t\t\tif ($part == '.') {\n\t\t\t\t\t$part = '';\n\t\t\t\t}\n\t\t\t\tarray_push($path, $part);\n\t\t\t}\n\t\t\t$prevPart = $part;\n\t\t}\n\t\t$merged['path'] = '/' . implode('/', $path);\n\t}\n\n\t$ret = '';\n\tif (isset($merged['scheme'])) {\n\t\t$ret .= $merged['scheme'] . ':';\n\t}\n\n\tif (isset($merged['scheme']) || isset($merged['host'])) {\n\t\t$ret .= '//';\n\t}\n\n\tif (isset($prel['host'])) {\n\t\t$hostSource = $prel;\n\t} else {\n\t\t$hostSource = $pbase;\n\t}\n\n\t// username, password, and port are associated with the hostname, not merged\n\tif (isset($hostSource['host'])) {\n\t\tif (isset($hostSource['user'])) {\n\t\t\t$ret .= $hostSource['user'];\n\t\t\tif (isset($hostSource['pass'])) {\n\t\t\t\t$ret .= ':' . $hostSource['pass'];\n\t\t\t}\n\t\t\t$ret .= '@';\n\t\t}\n\t\t$ret .= $hostSource['host'];\n\t\tif (isset($hostSource['port'])) {\n\t\t\t$ret .= ':' . $hostSource['port'];\n\t\t}\n\t}\n\n\tif (isset($merged['path'])) {\n\t\t$ret .= $merged['path'];\n\t}\n\n\tif (isset($prel['query'])) {\n\t\t$ret .= '?' . $prel['query'];\n\t}\n\n\tif (isset($prel['fragment'])) {\n\t\t$ret .= '#' . $prel['fragment'];\n\t}\n\n\treturn $ret;\n}\n"
  },
  {
    "path": "lib/php8backports.php",
    "content": "<?php\n\nif (!function_exists('str_starts_with')) {\n    function str_starts_with($haystack, $needle)\n    {\n        return (string)$needle !== '' && strncmp($haystack, $needle, strlen($needle)) === 0;\n    }\n}\n\nif (!function_exists('str_ends_with')) {\n    function str_ends_with($haystack, $needle)\n    {\n        return $needle !== '' && substr($haystack, -strlen($needle)) === (string)$needle;\n    }\n}\n\nif (!function_exists('str_contains')) {\n    function str_contains($haystack, $needle)\n    {\n        return $needle !== '' && mb_strpos($haystack, $needle) !== false;\n    }\n}\n\nif (!function_exists('array_is_list')) {\n    function array_is_list(array $arr)\n    {\n        if ($arr === []) {\n            return true;\n        }\n        return array_keys($arr) === range(0, count($arr) - 1);\n    }\n}\n"
  },
  {
    "path": "lib/seotags.php",
    "content": "<?php\n\n/**\n * Process HTML DOM to retrieve standard metadata intended for social media embeds and SEO.\n * @param string|object $html Webpage HTML. Supports HTML objects or string objects.\n * @return array Entry generated from Metadata: 'title', 'author', 'timestamp', etc.\n */\nfunction html_find_seo_metadata($html)\n{\n    if (is_string($html)) {\n        $html = getSimpleHTMLDOM($html);\n    }\n\n    $item = [];\n\n    // == First source of metadata: Meta tags ==\n    // Facebook Open Graph (og:KEY) - https://developers.facebook.com/docs/sharing/webmasters\n    // Twitter (twitter:KEY) - https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started\n    // Standard meta tags - https://www.w3schools.com/tags/tag_meta.asp\n    // Standard time tag - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time\n\n    // Each Entry field mapping defines a list of possible <meta> tags names that contains the expected value\n    // There are various source candidates per type of data, listed from most reliable to least reliable\n    static $meta_mappings = [\n        // <meta property=\"article:KEY\" content=\"VALUE\" />\n        // <meta property=\"og:KEY\" content=\"VALUE\" />\n        // <meta property=\"KEY\" content=\"VALUE\" />\n        // <meta name=\"twitter:KEY\" content=\"VALUE\" />\n        // <meta name=\"KEY\" content=\"VALUE\">\n        // <link rel=\"canonical\" href=\"URL\" />\n        // <time datetime=\"VALUE\">text</time>\n        'uri' => [\n            'og:url',\n            'twitter:url',\n            'canonical',\n        ],\n        'title' => [\n            'og:title',\n            'twitter:title',\n        ],\n        'content' => [\n            'og:description',\n            'twitter:description',\n            'description',\n        ],\n        'timestamp' => [\n            'article:published_time',\n            'og:article:published_time',\n            'releaseDate',\n            'releasedate',\n            'article:modified_time',\n            'og:article:modified_time',\n            'lastModified',\n            'lastmodified',\n            'time',\n        ],\n        'enclosures' => [\n            'og:image:secure_url',\n            'og:image:url',\n            'og:image',\n            'twitter:image',\n            'thumbnailImg',\n            'thumbnailimg',\n        ],\n        'author' => [\n            'article:author',\n            'og:article:author',\n            'author',\n            'article:author:username',\n            'profile:first_name',\n            'profile:last_name',\n            'article:author:first_name',\n            'article:author:last_name',\n            'twitter:creator',\n        ],\n    ];\n\n    $author_first_name = null;\n    $author_last_name = null;\n\n    // For each Entry property, look for corresponding HTML tags using a list of candidates\n    foreach ($meta_mappings as $property => $field_list) {\n        foreach ($field_list as $field) {\n            // Look for HTML meta tag\n            $element = null;\n            if ($field === 'canonical') {\n                $element = $html->find('link[rel=canonical]');\n            } else if ($field === 'time') {\n                $element = $html->find('time[datetime]');\n            } else {\n                $element = $html->find(\"meta[property=$field], meta[name=$field]\");\n            }\n            // Found something? Extract the value and populate Entry field\n            if (!empty($element)) {\n                $element = $element[0];\n                $field_value = '';\n                if ($field === 'canonical') {\n                    $field_value = $element->href;\n                } else if ($field === 'time') {\n                    $field_value = $element->datetime;\n                } else {\n                    $field_value = $element->content;\n                }\n                if (!empty($field_value)) {\n                    if ($field === 'article:author:first_name' || $field === 'profile:first_name') {\n                        $author_first_name = $field_value;\n                    } else if ($field === 'article:author:last_name' || $field === 'profile:last_name') {\n                        $author_last_name = $field_value;\n                    } else {\n                        $item[$property] = $field_value;\n                        break; // Stop on first match, e.g. og:url has priority over canonical url.\n                    }\n                }\n            }\n        }\n    }\n\n    // Populate author from first name and last name if all we have is nothing or Twitter @username\n    if ((!isset($item['author']) || $item['author'][0] === '@') && (is_string($author_first_name) || is_string($author_last_name))) {\n        $author = '';\n        if (is_string($author_first_name)) {\n            $author = $author_first_name;\n        }\n        if (is_string($author_last_name)) {\n            $author = $author . ' ' . $author_last_name;\n        }\n        $item['author'] = trim($author);\n    }\n\n    // == Second source of metadata: Embedded JSON ==\n    // JSON linked data - https://www.w3.org/TR/2014/REC-json-ld-20140116/\n    // JSON linked data is COMPLEX and MAY BE LESS RELIABLE than <meta> tags. Used for fields not found as <meta> tags.\n    // The implementation below will load all ld+json we can understand and attempt to extract relevant information.\n\n    // ld+json object types that hold article metadata\n    // Each mapping define item fields and a list of possible JSON field for this field\n    // Each candiate JSON field is either a string (field name) or a list (path to nested field)\n    static $ldjson_article_types = ['webpage', 'article', 'newsarticle', 'blogposting'];\n    static $ldjson_article_mappings = [\n        'uri' => ['url', 'mainEntityOfPage'],\n        'title' => ['headline'],\n        'content' => ['description'],\n        'timestamp' => ['dateModified', 'datePublished'],\n        'enclosures' => ['image'],\n        'author' => [['author', 'name'], ['author', '@id'], 'author'],\n    ];\n\n    // ld+json object types that hold author metadata\n    $ldjson_author_types = ['person', 'organization'];\n    $ldjson_author_mappings = []; // ID => Name\n    $ldjson_author_id = null;\n\n    // Utility function for checking if JSON array matches one of the desired ld+json object types\n    // A JSON object may have a single ld+json @type as a string OR several types at once as a list\n    $ldjson_is_of_type = function ($json, $allowed_types) {\n        if (isset($json['@type'])) {\n            $json_types = $json['@type'];\n            if (!is_array($json_types)) {\n                $json_types = [ $json_types ];\n            }\n            foreach ($json_types as $item_type) {\n                if (in_array(strtolower($item_type), $allowed_types)) {\n                    return true;\n                }\n            }\n        }\n        return false;\n    };\n\n    // Process ld+json objects embedded in the HTML DOM\n    foreach ($html->find('script[type=application/ld+json]') as $html_ldjson_node) {\n        $json_raw = json_decode($html_ldjson_node->innertext, true);\n        if (is_array($json_raw)) {\n            // The JSON we just loaded may contain directly a single ld+json object AND/OR several ones under the '@graph' key\n            $json_items = [ $json_raw ];\n            if (isset($json_raw['@graph'])) {\n                foreach ($json_raw['@graph'] as $json_raw_sub_item) {\n                    $json_items[] = $json_raw_sub_item;\n                }\n            }\n            // Now that we have a list of distinct JSON items, we can process them individually\n            foreach ($json_items as $json) {\n                // JSON item that holds an ld+json Article object (or a variant)\n                if ($ldjson_is_of_type($json, $ldjson_article_types)) {\n                    // For each item property, look for corresponding JSON fields and populate the item\n                    foreach ($ldjson_article_mappings as $property => $field_list) {\n                        // Skip fields already found as <meta> tags, except Twitter @username (because we might find a better name)\n                        if (!isset($item[$property]) || ($property === 'author' && $item['author'][0] === '@')) {\n                            foreach ($field_list as $field) {\n                                $json_root = $json;\n                                // If necessary, navigate inside the JSON object to access a nested field\n                                if (is_array($field)) {\n                                    // At this point, $field = ['author', 'name'] and $json_root = {\"author\": {\"name\": \"John Doe\"}}\n                                    $json_navigate_ok = true;\n                                    while (count($field) > 1) {\n                                        $sub_field = array_shift($field);\n                                        if (array_key_exists($sub_field, $json_root)) {\n                                            $json_root = $json_root[$sub_field];\n                                            if (array_is_list($json_root) && count($json_root) === 1) {\n                                                $json_root = $json_root[0]; // Unwrap list of single item e.g. {\"author\":[{\"name\":\"John Doe\"}]}\n                                            }\n                                        } else {\n                                            // Desired path not found in JSON, stop navigating\n                                            $json_navigate_ok = false;\n                                            break;\n                                        }\n                                    }\n                                    if (!$json_navigate_ok) {\n                                        continue; //Desired path not found in JSON, skip this field\n                                    }\n                                    $field = $field[0];\n                                    // At this point, $field = \"name\" and $json_root = {\"name\": \"John Doe\"}\n                                }\n                                // Now we can check for desired field in JSON and populate $item accordingly\n                                if (isset($json_root[$field])) {\n                                    $field_value = $json_root[$field];\n                                    if (is_array($field_value) && isset($field_value[0])) {\n                                        $field_value = $field_value[0]; // Different versions of the same enclosure? Take the first one\n                                    }\n                                    if (is_string($field_value) && !empty($field_value)) {\n                                        if ($property === 'author' && $field === '@id') {\n                                            $ldjson_author_id = $field_value; // Author is referred to by its ID: We'll see later if we can resolve it\n                                        } else {\n                                            $item[$property] = $field_value;\n                                            break; // Stop on first match, e.g. {\"author\":{\"name\":\"John Doe\"}} has priority over {\"author\":\"John Doe\"}\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    }\n                // JSON item that holds an ld+json Author object (or a variant)\n                } else if ($ldjson_is_of_type($json, $ldjson_author_types)) {\n                    if (isset($json['@id']) && isset($json['name'])) {\n                        $ldjson_author_mappings[$json['@id']] = $json['name'];\n                    }\n                }\n            }\n        }\n    }\n\n    // Attempt to resolve ld+json author if all we have is nothing or Twitter @username\n    if ((!isset($item['author']) || $item['author'][0] === '@') && !is_null($ldjson_author_id) && isset($ldjson_author_mappings[$ldjson_author_id])) {\n        $item['author'] = $ldjson_author_mappings[$ldjson_author_id];\n    }\n\n    // Adjust item field types\n    if (isset($item['enclosures'])) {\n        $item['enclosures'] = [ $item['enclosures'] ];\n    }\n    if (isset($item['timestamp'])) {\n        $item['timestamp'] = strtotime($item['timestamp']);\n    }\n\n    return $item;\n}\n"
  },
  {
    "path": "lib/simplehtmldom/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019 S.C. Chen, John Schlick, logmanoriginal\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "lib/simplehtmldom/simple_html_dom.php",
    "content": "<?php\n/**\n * Website: http://sourceforge.net/projects/simplehtmldom/\n * Additional projects: http://sourceforge.net/projects/debugobject/\n * Acknowledge: Jose Solorzano (https://sourceforge.net/projects/php-html/)\n *\n * Licensed under The MIT License\n * See the LICENSE file in the project root for more information.\n *\n * Authors:\n *   S.C. Chen\n *   John Schlick\n *   Rus Carroll\n *   logmanoriginal\n *\n * Contributors:\n *   Yousuke Kumakura\n *   Vadim Voituk\n *   Antcs\n *\n * Version Rev. 1.9.1 (291)\n */\n\ndefine('HDOM_TYPE_ELEMENT', 1);\ndefine('HDOM_TYPE_COMMENT', 2);\ndefine('HDOM_TYPE_TEXT', 3);\ndefine('HDOM_TYPE_ENDTAG', 4);\ndefine('HDOM_TYPE_ROOT', 5);\ndefine('HDOM_TYPE_UNKNOWN', 6);\ndefine('HDOM_QUOTE_DOUBLE', 0);\ndefine('HDOM_QUOTE_SINGLE', 1);\ndefine('HDOM_QUOTE_NO', 3);\ndefine('HDOM_INFO_BEGIN', 0);\ndefine('HDOM_INFO_END', 1);\ndefine('HDOM_INFO_QUOTE', 2);\ndefine('HDOM_INFO_SPACE', 3);\ndefine('HDOM_INFO_TEXT', 4);\ndefine('HDOM_INFO_INNER', 5);\ndefine('HDOM_INFO_OUTER', 6);\ndefine('HDOM_INFO_ENDSPACE', 7);\n\ndefined('DEFAULT_TARGET_CHARSET') || define('DEFAULT_TARGET_CHARSET', 'UTF-8');\ndefined('DEFAULT_BR_TEXT') || define('DEFAULT_BR_TEXT', \"\\r\\n\");\ndefined('DEFAULT_SPAN_TEXT') || define('DEFAULT_SPAN_TEXT', ' ');\ndefined('MAX_FILE_SIZE') || define('MAX_FILE_SIZE', 600000);\ndefine('HDOM_SMARTY_AS_TEXT', 1);\n\nfunction file_get_html(\n\t$url,\n\t$use_include_path = false,\n\t$context = null,\n\t$offset = 0,\n\t$maxLen = -1,\n\t$lowercase = true,\n\t$forceTagsClosed = true,\n\t$target_charset = DEFAULT_TARGET_CHARSET,\n\t$stripRN = true,\n\t$defaultBRText = DEFAULT_BR_TEXT,\n\t$defaultSpanText = DEFAULT_SPAN_TEXT)\n{\n\tif($maxLen <= 0) { $maxLen = MAX_FILE_SIZE; }\n\n\t$dom = new simple_html_dom(\n\t\tnull,\n\t\t$lowercase,\n\t\t$forceTagsClosed,\n\t\t$target_charset,\n\t\t$stripRN,\n\t\t$defaultBRText,\n\t\t$defaultSpanText\n\t);\n\n\t/**\n\t * For sourceforge users: uncomment the next line and comment the\n\t * retrieve_url_contents line 2 lines down if it is not already done.\n\t */\n\t$contents = file_get_contents(\n\t\t$url,\n\t\t$use_include_path,\n\t\t$context,\n\t\t$offset,\n\t\t$maxLen\n\t);\n\t// $contents = retrieve_url_contents($url);\n\n\tif (empty($contents) || strlen($contents) > $maxLen) {\n\t\t$dom->clear();\n\t\treturn false;\n\t}\n\n\treturn $dom->load($contents, $lowercase, $stripRN);\n}\n\nfunction str_get_html(\n\t$str,\n\t$lowercase = true,\n\t$forceTagsClosed = true,\n\t$target_charset = DEFAULT_TARGET_CHARSET,\n\t$stripRN = true,\n\t$defaultBRText = DEFAULT_BR_TEXT,\n\t$defaultSpanText = DEFAULT_SPAN_TEXT)\n{\n\t$dom = new simple_html_dom(\n\t\tnull,\n\t\t$lowercase,\n\t\t$forceTagsClosed,\n\t\t$target_charset,\n\t\t$stripRN,\n\t\t$defaultBRText,\n\t\t$defaultSpanText\n\t);\n\n    // The following two if statements are rss-bridge patch\n    if (empty($str)) {\n        throw new \\Exception('Refusing to parse empty string input');\n    }\n\n    if (strlen($str) > Configuration::getConfig('system', 'max_file_size')) {\n        throw new \\Exception('simple_html_dom: Refusing to parse too big input: ' . strlen($str));\n    }\n\n\treturn $dom->load($str, $lowercase, $stripRN);\n}\n\nfunction dump_html_tree($node, $show_attr = true, $deep = 0)\n{\n\t$node->dump($node);\n}\n\nclass simple_html_dom_node\n{\n\tpublic $nodetype = HDOM_TYPE_TEXT;\n\tpublic $tag = 'text';\n\tpublic $attr = array();\n\tpublic $children = array();\n\tpublic $nodes = array();\n\tpublic $parent = null;\n\tpublic $_ = array();\n\tpublic $tag_start = 0;\n\tprivate $dom = null;\n\n\tfunction __construct($dom)\n\t{\n\t\t$this->dom = $dom;\n\t\t$dom->nodes[] = $this;\n\t}\n\n\tfunction __destruct()\n\t{\n\t\t$this->clear();\n\t}\n\n\tfunction __toString()\n\t{\n\t\treturn $this->outertext();\n\t}\n\n\tfunction clear()\n\t{\n\t\t$this->dom = null;\n\t\t$this->nodes = null;\n\t\t$this->parent = null;\n\t\t$this->children = null;\n\t}\n\n\tfunction dump($show_attr = true, $depth = 0)\n\t{\n\t\techo str_repeat(\"\\t\", $depth) . $this->tag;\n\n\t\tif ($show_attr && count($this->attr) > 0) {\n\t\t\techo '(';\n\t\t\tforeach ($this->attr as $k => $v) {\n\t\t\t\techo \"[$k]=>\\\"$v\\\", \";\n\t\t\t}\n\t\t\techo ')';\n\t\t}\n\n\t\techo \"\\n\";\n\n\t\tif ($this->nodes) {\n\t\t\tforeach ($this->nodes as $node) {\n\t\t\t\t$node->dump($show_attr, $depth + 1);\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction dump_node($echo = true)\n\t{\n\t\t$string = $this->tag;\n\n\t\tif (count($this->attr) > 0) {\n\t\t\t$string .= '(';\n\t\t\tforeach ($this->attr as $k => $v) {\n\t\t\t\t$string .= \"[$k]=>\\\"$v\\\", \";\n\t\t\t}\n\t\t\t$string .= ')';\n\t\t}\n\n\t\tif (count($this->_) > 0) {\n\t\t\t$string .= ' $_ (';\n\t\t\tforeach ($this->_ as $k => $v) {\n\t\t\t\tif (is_array($v)) {\n\t\t\t\t\t$string .= \"[$k]=>(\";\n\t\t\t\t\tforeach ($v as $k2 => $v2) {\n\t\t\t\t\t\t$string .= \"[$k2]=>\\\"$v2\\\", \";\n\t\t\t\t\t}\n\t\t\t\t\t$string .= ')';\n\t\t\t\t} else {\n\t\t\t\t\t$string .= \"[$k]=>\\\"$v\\\", \";\n\t\t\t\t}\n\t\t\t}\n\t\t\t$string .= ')';\n\t\t}\n\n\t\tif (isset($this->text)) {\n\t\t\t$string .= \" text: ({$this->text})\";\n\t\t}\n\n\t\t$string .= ' HDOM_INNER_INFO: ';\n\n\t\tif (isset($node->_[HDOM_INFO_INNER])) {\n\t\t\t$string .= \"'\" . $node->_[HDOM_INFO_INNER] . \"'\";\n\t\t} else {\n\t\t\t$string .= ' NULL ';\n\t\t}\n\n\t\t$string .= ' children: ' . count($this->children);\n\t\t$string .= ' nodes: ' . count($this->nodes);\n\t\t$string .= ' tag_start: ' . $this->tag_start;\n\t\t$string .= \"\\n\";\n\n\t\tif ($echo) {\n\t\t\techo $string;\n\t\t\treturn;\n\t\t} else {\n\t\t\treturn $string;\n\t\t}\n\t}\n\n\tfunction parent($parent = null)\n\t{\n\t\t// I am SURE that this doesn't work properly.\n\t\t// It fails to unset the current node from it's current parents nodes or\n\t\t// children list first.\n\t\tif ($parent !== null) {\n\t\t\t$this->parent = $parent;\n\t\t\t$this->parent->nodes[] = $this;\n\t\t\t$this->parent->children[] = $this;\n\t\t}\n\n\t\treturn $this->parent;\n\t}\n\n\tfunction has_child()\n\t{\n\t\treturn !empty($this->children);\n\t}\n\n\tfunction children($idx = -1)\n\t{\n\t\tif ($idx === -1) {\n\t\t\treturn $this->children;\n\t\t}\n\n\t\tif (isset($this->children[$idx])) {\n\t\t\treturn $this->children[$idx];\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tfunction first_child()\n\t{\n\t\tif (count($this->children) > 0) {\n\t\t\treturn $this->children[0];\n\t\t}\n\t\treturn null;\n\t}\n\n\tfunction last_child()\n\t{\n\t\tif (count($this->children) > 0) {\n\t\t\treturn end($this->children);\n\t\t}\n\t\treturn null;\n\t}\n\n\tfunction next_sibling()\n\t{\n\t\tif ($this->parent === null) {\n\t\t\treturn null;\n\t\t}\n\n\t\t$idx = array_search($this, $this->parent->children, true);\n\n\t\tif ($idx !== false && isset($this->parent->children[$idx + 1])) {\n\t\t\treturn $this->parent->children[$idx + 1];\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tfunction prev_sibling()\n\t{\n\t\tif ($this->parent === null) {\n\t\t\treturn null;\n\t\t}\n\n\t\t$idx = array_search($this, $this->parent->children, true);\n\n\t\tif ($idx !== false && $idx > 0) {\n\t\t\treturn $this->parent->children[$idx - 1];\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tfunction find_ancestor_tag($tag)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\tif ($this->parent === null) {\n\t\t\treturn null;\n\t\t}\n\n\t\t$ancestor = $this->parent;\n\n\t\twhile (!is_null($ancestor)) {\n\t\t\tif (is_object($debug_object)) {\n\t\t\t\t$debug_object->debug_log(2, 'Current tag is: ' . $ancestor->tag);\n\t\t\t}\n\n\t\t\tif ($ancestor->tag === $tag) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\t$ancestor = $ancestor->parent;\n\t\t}\n\n\t\treturn $ancestor;\n\t}\n\n\tfunction innertext()\n\t{\n\t\tif (isset($this->_[HDOM_INFO_INNER])) {\n\t\t\treturn $this->_[HDOM_INFO_INNER];\n\t\t}\n\n\t\tif (isset($this->_[HDOM_INFO_TEXT])) {\n\t\t\treturn $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);\n\t\t}\n\n\t\t$ret = '';\n\n\t\tforeach ($this->nodes as $n) {\n\t\t\t$ret .= $n->outertext();\n\t\t}\n\n\t\treturn $ret;\n\t}\n\n\tfunction outertext()\n\t{\n\t\tglobal $debug_object;\n\n\t\tif (is_object($debug_object)) {\n\t\t\t$text = '';\n\n\t\t\tif ($this->tag === 'text') {\n\t\t\t\tif (!empty($this->text)) {\n\t\t\t\t\t$text = ' with text: ' . $this->text;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t$debug_object->debug_log(1, 'Innertext of tag: ' . $this->tag . $text);\n\t\t}\n\n\t\tif ($this->tag === 'root') {\n\t\t\treturn $this->innertext();\n\t\t}\n\n\t\t// todo: What is the use of this callback? Remove?\n\t\tif ($this->dom && $this->dom->callback !== null) {\n\t\t\tcall_user_func_array($this->dom->callback, array($this));\n\t\t}\n\n\t\tif (isset($this->_[HDOM_INFO_OUTER])) {\n\t\t\treturn $this->_[HDOM_INFO_OUTER];\n\t\t}\n\n\t\tif (isset($this->_[HDOM_INFO_TEXT])) {\n\t\t\treturn $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);\n\t\t}\n\n\t\t$ret = '';\n\n\t\tif ($this->dom && $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]) {\n\t\t\t$ret = $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]->makeup();\n\t\t}\n\n\t\tif (isset($this->_[HDOM_INFO_INNER])) {\n\t\t\t// todo: <br> should either never have HDOM_INFO_INNER or always\n\t\t\tif ($this->tag !== 'br') {\n\t\t\t\t$ret .= $this->_[HDOM_INFO_INNER];\n\t\t\t}\n\t\t} elseif ($this->nodes) {\n\t\t\tforeach ($this->nodes as $n) {\n\t\t\t\t$ret .= $this->convert_text($n->outertext());\n\t\t\t}\n\t\t}\n\n\t\tif (isset($this->_[HDOM_INFO_END]) && $this->_[HDOM_INFO_END] != 0) {\n\t\t\t$ret .= '</' . $this->tag . '>';\n\t\t}\n\n\t\treturn $ret;\n\t}\n\n\tfunction text()\n\t{\n\t\tif (isset($this->_[HDOM_INFO_INNER])) {\n\t\t\treturn $this->_[HDOM_INFO_INNER];\n\t\t}\n\n\t\tswitch ($this->nodetype) {\n\t\t\tcase HDOM_TYPE_TEXT: return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);\n\t\t\tcase HDOM_TYPE_COMMENT: return '';\n\t\t\tcase HDOM_TYPE_UNKNOWN: return '';\n\t\t}\n\n\t\tif (strcasecmp($this->tag, 'script') === 0) { return ''; }\n\t\tif (strcasecmp($this->tag, 'style') === 0) { return ''; }\n\n\t\t$ret = '';\n\n\t\t// In rare cases, (always node type 1 or HDOM_TYPE_ELEMENT - observed\n\t\t// for some span tags, and some p tags) $this->nodes is set to NULL.\n\t\t// NOTE: This indicates that there is a problem where it's set to NULL\n\t\t// without a clear happening.\n\t\t// WHY is this happening?\n\t\tif (!is_null($this->nodes)) {\n\t\t\tforeach ($this->nodes as $n) {\n\t\t\t\t// Start paragraph after a blank line\n\t\t\t\tif ($n->tag === 'p') {\n\t\t\t\t\t$ret = trim($ret) . \"\\n\\n\";\n\t\t\t\t}\n\n\t\t\t\t$ret .= $this->convert_text($n->text());\n\n\t\t\t\t// If this node is a span... add a space at the end of it so\n\t\t\t\t// multiple spans don't run into each other.  This is plaintext\n\t\t\t\t// after all.\n\t\t\t\tif ($n->tag === 'span') {\n\t\t\t\t\t$ret .= $this->dom->default_span_text;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn $ret;\n\t}\n\n\tfunction xmltext()\n\t{\n\t\t$ret = $this->innertext();\n\t\t$ret = str_ireplace('<![CDATA[', '', $ret);\n\t\t$ret = str_replace(']]>', '', $ret);\n\t\treturn $ret;\n\t}\n\n\tfunction makeup()\n\t{\n\t\t// text, comment, unknown\n\t\tif (isset($this->_[HDOM_INFO_TEXT])) {\n\t\t\treturn $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]);\n\t\t}\n\n\t\t$ret = '<' . $this->tag;\n\t\t$i = -1;\n\n\t\tforeach ($this->attr as $key => $val) {\n\t\t\t++$i;\n\n\t\t\t// skip removed attribute\n\t\t\tif ($val === null || $val === false) { continue; }\n\n\t\t\t$ret .= $this->_[HDOM_INFO_SPACE][$i][0];\n\n\t\t\t//no value attr: nowrap, checked selected...\n\t\t\tif ($val === true) {\n\t\t\t\t$ret .= $key;\n\t\t\t} else {\n\t\t\t\tswitch ($this->_[HDOM_INFO_QUOTE][$i])\n\t\t\t\t{\n\t\t\t\t\tcase HDOM_QUOTE_DOUBLE: $quote = '\"'; break;\n\t\t\t\t\tcase HDOM_QUOTE_SINGLE: $quote = '\\''; break;\n\t\t\t\t\tdefault: $quote = '';\n\t\t\t\t}\n\n\t\t\t\t$ret .= $key\n\t\t\t\t. $this->_[HDOM_INFO_SPACE][$i][1]\n\t\t\t\t. '='\n\t\t\t\t. $this->_[HDOM_INFO_SPACE][$i][2]\n\t\t\t\t. $quote\n\t\t\t\t. $val\n\t\t\t\t. $quote;\n\t\t\t}\n\t\t}\n\n\t\t$ret = $this->dom->restore_noise($ret);\n\t\treturn $ret . $this->_[HDOM_INFO_ENDSPACE] . '>';\n\t}\n\n\tfunction find($selector, $idx = null, $lowercase = false)\n\t{\n\t\t$selectors = $this->parse_selector($selector);\n\t\tif (($count = count($selectors)) === 0) { return array(); }\n\t\t$found_keys = array();\n\n\t\t// find each selector\n\t\tfor ($c = 0; $c < $count; ++$c) {\n\t\t\t// The change on the below line was documented on the sourceforge\n\t\t\t// code tracker id 2788009\n\t\t\t// used to be: if (($levle=count($selectors[0]))===0) return array();\n\t\t\tif (($levle = count($selectors[$c])) === 0) { return array(); }\n\t\t\tif (!isset($this->_[HDOM_INFO_BEGIN])) { return array(); }\n\n\t\t\t$head = array($this->_[HDOM_INFO_BEGIN] => 1);\n\t\t\t$cmd = ' '; // Combinator\n\n\t\t\t// handle descendant selectors, no recursive!\n\t\t\tfor ($l = 0; $l < $levle; ++$l) {\n\t\t\t\t$ret = array();\n\n\t\t\t\tforeach ($head as $k => $v) {\n\t\t\t\t\t$n = ($k === -1) ? $this->dom->root : $this->dom->nodes[$k];\n\t\t\t\t\t//PaperG - Pass this optional parameter on to the seek function.\n\t\t\t\t\t$n->seek($selectors[$c][$l], $ret, $cmd, $lowercase);\n\t\t\t\t}\n\n\t\t\t\t$head = $ret;\n\t\t\t\t$cmd = $selectors[$c][$l][4]; // Next Combinator\n\t\t\t}\n\n\t\t\tforeach ($head as $k => $v) {\n\t\t\t\tif (!isset($found_keys[$k])) {\n\t\t\t\t\t$found_keys[$k] = 1;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// sort keys\n\t\tksort($found_keys);\n\n\t\t$found = array();\n\t\tforeach ($found_keys as $k => $v) {\n\t\t\t$found[] = $this->dom->nodes[$k];\n\t\t}\n\n\t\t// return nth-element or array\n\t\tif (is_null($idx)) { return $found; }\n\t\telseif ($idx < 0) { $idx = count($found) + $idx; }\n\t\treturn (isset($found[$idx])) ? $found[$idx] : null;\n\t}\n\n\tprotected function seek($selector, &$ret, $parent_cmd, $lowercase = false)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\tlist($tag, $id, $class, $attributes, $cmb) = $selector;\n\t\t$nodes = array();\n\n\t\tif ($parent_cmd === ' ') { // Descendant Combinator\n\t\t\t// Find parent closing tag if the current element doesn't have a closing\n\t\t\t// tag (i.e. void element)\n\t\t\t$end = (!empty($this->_[HDOM_INFO_END])) ? $this->_[HDOM_INFO_END] : 0;\n\t\t\tif ($end == 0) {\n\t\t\t\t$parent = $this->parent;\n\t\t\t\twhile (!isset($parent->_[HDOM_INFO_END]) && $parent !== null) {\n\t\t\t\t\t$end -= 1;\n\t\t\t\t\t$parent = $parent->parent;\n\t\t\t\t}\n\t\t\t\t$end += $parent->_[HDOM_INFO_END];\n\t\t\t}\n\n\t\t\t// Get list of target nodes\n\t\t\t$nodes_start = $this->_[HDOM_INFO_BEGIN] + 1;\n\t\t\t$nodes_count = $end - $nodes_start;\n\t\t\t$nodes = array_slice($this->dom->nodes, $nodes_start, $nodes_count, true);\n\t\t} elseif ($parent_cmd === '>') { // Child Combinator\n\t\t\t$nodes = $this->children;\n\t\t} elseif ($parent_cmd === '+'\n\t\t\t&& $this->parent\n\t\t\t&& in_array($this, $this->parent->children)) { // Next-Sibling Combinator\n\t\t\t\t$index = array_search($this, $this->parent->children, true) + 1;\n\t\t\t\tif ($index < count($this->parent->children))\n\t\t\t\t\t$nodes[] = $this->parent->children[$index];\n\t\t} elseif ($parent_cmd === '~'\n\t\t\t&& $this->parent\n\t\t\t&& in_array($this, $this->parent->children)) { // Subsequent Sibling Combinator\n\t\t\t\t$index = array_search($this, $this->parent->children, true);\n\t\t\t\t$nodes = array_slice($this->parent->children, $index);\n\t\t}\n\n\t\t// Go throgh each element starting at this element until the end tag\n\t\t// Note: If this element is a void tag, any previous void element is\n\t\t// skipped.\n\t\tforeach($nodes as $node) {\n\t\t\t$pass = true;\n\n\t\t\t// Skip root nodes\n\t\t\tif(!$node->parent) {\n\t\t\t\t$pass = false;\n\t\t\t}\n\n\t\t\t// Handle 'text' selector\n\t\t\tif($pass && $tag === 'text' && $node->tag === 'text') {\n\t\t\t\t$ret[array_search($node, $this->dom->nodes, true)] = 1;\n\t\t\t\tunset($node);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Skip if node isn't a child node (i.e. text nodes)\n\t\t\tif($pass && !in_array($node, $node->parent->children, true)) {\n\t\t\t\t$pass = false;\n\t\t\t}\n\n\t\t\t// Skip if tag doesn't match\n\t\t\tif ($pass && $tag !== '' && $tag !== $node->tag && $tag !== '*') {\n\t\t\t\t$pass = false;\n\t\t\t}\n\n\t\t\t// Skip if ID doesn't exist\n\t\t\tif ($pass && $id !== '' && !isset($node->attr['id'])) {\n\t\t\t\t$pass = false;\n\t\t\t}\n\n\t\t\t// Check if ID matches\n\t\t\tif ($pass && $id !== '' && isset($node->attr['id'])) {\n\t\t\t\t// Note: Only consider the first ID (as browsers do)\n\t\t\t\t$node_id = explode(' ', trim($node->attr['id']))[0];\n\n\t\t\t\tif($id !== $node_id) { $pass = false; }\n\t\t\t}\n\n\t\t\t// Check if all class(es) exist\n\t\t\tif ($pass && $class !== '' && is_array($class) && !empty($class)) {\n\t\t\t\tif (isset($node->attr['class'])) {\n\t\t\t\t\t$node_classes = explode(' ', $node->attr['class']);\n\n\t\t\t\t\tif ($lowercase) {\n\t\t\t\t\t\t$node_classes = array_map('strtolower', $node_classes);\n\t\t\t\t\t}\n\n\t\t\t\t\tforeach($class as $c) {\n\t\t\t\t\t\tif(!in_array($c, $node_classes)) {\n\t\t\t\t\t\t\t$pass = false;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t$pass = false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check attributes\n\t\t\tif ($pass\n\t\t\t\t&& $attributes !== ''\n\t\t\t\t&& is_array($attributes)\n\t\t\t\t&& !empty($attributes)) {\n\t\t\t\t\tforeach($attributes as $a) {\n\t\t\t\t\t\tlist (\n\t\t\t\t\t\t\t$att_name,\n\t\t\t\t\t\t\t$att_expr,\n\t\t\t\t\t\t\t$att_val,\n\t\t\t\t\t\t\t$att_inv,\n\t\t\t\t\t\t\t$att_case_sensitivity\n\t\t\t\t\t\t) = $a;\n\n\t\t\t\t\t\t// Handle indexing attributes (i.e. \"[2]\")\n\t\t\t\t\t\t/**\n\t\t\t\t\t\t * Note: This is not supported by the CSS Standard but adds\n\t\t\t\t\t\t * the ability to select items compatible to XPath (i.e.\n\t\t\t\t\t\t * the 3rd element within it's parent).\n\t\t\t\t\t\t *\n\t\t\t\t\t\t * Note: This doesn't conflict with the CSS Standard which\n\t\t\t\t\t\t * doesn't work on numeric attributes anyway.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tif (is_numeric($att_name)\n\t\t\t\t\t\t\t&& $att_expr === ''\n\t\t\t\t\t\t\t&& $att_val === '') {\n\t\t\t\t\t\t\t\t$count = 0;\n\n\t\t\t\t\t\t\t\t// Find index of current element in parent\n\t\t\t\t\t\t\t\tforeach ($node->parent->children as $c) {\n\t\t\t\t\t\t\t\t\tif ($c->tag === $node->tag) ++$count;\n\t\t\t\t\t\t\t\t\tif ($c === $node) break;\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t// If this is the correct node, continue with next\n\t\t\t\t\t\t\t\t// attribute\n\t\t\t\t\t\t\t\tif ($count === (int)$att_name) continue;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check attribute availability\n\t\t\t\t\t\tif ($att_inv) { // Attribute should NOT be set\n\t\t\t\t\t\t\tif (isset($node->attr[$att_name])) {\n\t\t\t\t\t\t\t\t$pass = false;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} else { // Attribute should be set\n\t\t\t\t\t\t\t// todo: \"plaintext\" is not a valid CSS selector!\n\t\t\t\t\t\t\tif ($att_name !== 'plaintext'\n\t\t\t\t\t\t\t\t&& !isset($node->attr[$att_name])) {\n\t\t\t\t\t\t\t\t\t$pass = false;\n\t\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Continue with next attribute if expression isn't defined\n\t\t\t\t\t\tif ($att_expr === '') continue;\n\n\t\t\t\t\t\t// If they have told us that this is a \"plaintext\"\n\t\t\t\t\t\t// search then we want the plaintext of the node - right?\n\t\t\t\t\t\t// todo \"plaintext\" is not a valid CSS selector!\n\t\t\t\t\t\tif ($att_name === 'plaintext') {\n\t\t\t\t\t\t\t$nodeKeyValue = $node->text();\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t$nodeKeyValue = $node->attr[$att_name];\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t\t\t$debug_object->debug_log(2,\n\t\t\t\t\t\t\t\t'testing node: '\n\t\t\t\t\t\t\t\t. $node->tag\n\t\t\t\t\t\t\t\t. ' for attribute: '\n\t\t\t\t\t\t\t\t. $att_name\n\t\t\t\t\t\t\t\t. $att_expr\n\t\t\t\t\t\t\t\t. $att_val\n\t\t\t\t\t\t\t\t. ' where nodes value is: '\n\t\t\t\t\t\t\t\t. $nodeKeyValue\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If lowercase is set, do a case insensitive test of\n\t\t\t\t\t\t// the value of the selector.\n\t\t\t\t\t\tif ($lowercase) {\n\t\t\t\t\t\t\t$check = $this->match(\n\t\t\t\t\t\t\t\t$att_expr,\n\t\t\t\t\t\t\t\tstrtolower($att_val),\n\t\t\t\t\t\t\t\tstrtolower($nodeKeyValue),\n\t\t\t\t\t\t\t\t$att_case_sensitivity\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t$check = $this->match(\n\t\t\t\t\t\t\t\t$att_expr,\n\t\t\t\t\t\t\t\t$att_val,\n\t\t\t\t\t\t\t\t$nodeKeyValue,\n\t\t\t\t\t\t\t\t$att_case_sensitivity\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t\t\t$debug_object->debug_log(2,\n\t\t\t\t\t\t\t\t'after match: '\n\t\t\t\t\t\t\t\t. ($check ? 'true' : 'false')\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (!$check) {\n\t\t\t\t\t\t\t$pass = false;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Found a match. Add to list and clear node\n\t\t\tif ($pass) $ret[$node->_[HDOM_INFO_BEGIN]] = 1;\n\t\t\tunset($node);\n\t\t}\n\t\t// It's passed by reference so this is actually what this function returns.\n\t\tif (is_object($debug_object)) {\n\t\t\t$debug_object->debug_log(1, 'EXIT - ret: ', $ret);\n\t\t}\n\t}\n\n\tprotected function match($exp, $pattern, $value, $case_sensitivity)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) {$debug_object->debug_log_entry(1);}\n\n\t\tif ($case_sensitivity === 'i') {\n\t\t\t$pattern = strtolower($pattern);\n\t\t\t$value = strtolower($value);\n\t\t}\n\n\t\tswitch ($exp) {\n\t\t\tcase '=':\n\t\t\t\treturn ($value === $pattern);\n\t\t\tcase '!=':\n\t\t\t\treturn ($value !== $pattern);\n\t\t\tcase '^=':\n\t\t\t\treturn preg_match('/^' . preg_quote($pattern, '/') . '/', $value);\n\t\t\tcase '$=':\n\t\t\t\treturn preg_match('/' . preg_quote($pattern, '/') . '$/', $value);\n\t\t\tcase '*=':\n\t\t\t\treturn preg_match('/' . preg_quote($pattern, '/') . '/', $value);\n\t\t\tcase '|=':\n\t\t\t\t/**\n\t\t\t\t * [att|=val]\n\t\t\t\t *\n\t\t\t\t * Represents an element with the att attribute, its value\n\t\t\t\t * either being exactly \"val\" or beginning with \"val\"\n\t\t\t\t * immediately followed by \"-\" (U+002D).\n\t\t\t\t */\n\t\t\t\treturn strpos($value, $pattern) === 0;\n\t\t\tcase '~=':\n\t\t\t\t/**\n\t\t\t\t * [att~=val]\n\t\t\t\t *\n\t\t\t\t * Represents an element with the att attribute whose value is a\n\t\t\t\t * whitespace-separated list of words, one of which is exactly\n\t\t\t\t * \"val\". If \"val\" contains whitespace, it will never represent\n\t\t\t\t * anything (since the words are separated by spaces). Also if\n\t\t\t\t * \"val\" is the empty string, it will never represent anything.\n\t\t\t\t */\n\t\t\t\treturn in_array($pattern, explode(' ', trim($value)), true);\n\t\t}\n\t\treturn false;\n\t}\n\n\tprotected function parse_selector($selector_string)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\t/**\n\t\t * Pattern of CSS selectors, modified from mootools (https://mootools.net/)\n\t\t *\n\t\t * Paperg: Add the colon to the attribute, so that it properly finds\n\t\t * <tag attr:ibute=\"something\" > like google does.\n\t\t *\n\t\t * Note: if you try to look at this attribute, you MUST use getAttribute\n\t\t * since $dom->x:y will fail the php syntax check.\n\t\t *\n\t\t * Notice the \\[ starting the attribute? and the @? following? This\n\t\t * implies that an attribute can begin with an @ sign that is not\n\t\t * captured. This implies that an html attribute specifier may start\n\t\t * with an @ sign that is NOT captured by the expression. Farther study\n\t\t * is required to determine of this should be documented or removed.\n\t\t *\n\t\t * Matches selectors in this order:\n\t\t *\n\t\t * [0] - full match\n\t\t *\n\t\t * [1] - tag name\n\t\t *     ([\\w:\\*-]*)\n\t\t *     Matches the tag name consisting of zero or more words, colons,\n\t\t *     asterisks and hyphens.\n\t\t *\n\t\t * [2] - id name\n\t\t *     (?:\\#([\\w-]+))\n\t\t *     Optionally matches a id name, consisting of an \"#\" followed by\n\t\t *     the id name (one or more words and hyphens).\n\t\t *\n\t\t * [3] - class names (including dots)\n\t\t *     (?:\\.([\\w\\.-]+))?\n\t\t *     Optionally matches a list of classs, consisting of an \".\"\n\t\t *     followed by the class name (one or more words and hyphens)\n\t\t *     where multiple classes can be chained (i.e. \".foo.bar.baz\")\n\t\t *\n\t\t * [4] - attributes\n\t\t *     ((?:\\[@?(?:!?[\\w:-]+)(?:(?:[!*^$|~]?=)[\\\"']?(?:.*?)[\\\"']?)?(?:\\s*?(?:[iIsS])?)?\\])+)?\n\t\t *     Optionally matches the attributes list\n\t\t *\n\t\t * [5] - separator\n\t\t *     ([\\/, >+~]+)\n\t\t *     Matches the selector list separator\n\t\t */\n\t\t// phpcs:ignore Generic.Files.LineLength\n\t\t$pattern = \"/([\\w:\\*-]*)(?:\\#([\\w-]+))?(?:|\\.([\\w\\.-]+))?((?:\\[@?(?:!?[\\w:-]+)(?:(?:[!*^$|~]?=)[\\\"']?(?:.*?)[\\\"']?)?(?:\\s*?(?:[iIsS])?)?\\])+)?([\\/, >+~]+)/is\";\n\n\t\tpreg_match_all(\n\t\t\t$pattern,\n\t\t\ttrim($selector_string) . ' ', // Add final ' ' as pseudo separator\n\t\t\t$matches,\n\t\t\tPREG_SET_ORDER\n\t\t);\n\n\t\tif (is_object($debug_object)) {\n\t\t\t$debug_object->debug_log(2, 'Matches Array: ', $matches);\n\t\t}\n\n\t\t$selectors = array();\n\t\t$result = array();\n\n\t\tforeach ($matches as $m) {\n\t\t\t$m[0] = trim($m[0]);\n\n\t\t\t// Skip NoOps\n\t\t\tif ($m[0] === '' || $m[0] === '/' || $m[0] === '//') { continue; }\n\n\t\t\t// Convert to lowercase\n\t\t\tif ($this->dom->lowercase) {\n\t\t\t\t$m[1] = strtolower($m[1]);\n\t\t\t}\n\n\t\t\t// Extract classes\n\t\t\tif ($m[3] !== '') { $m[3] = explode('.', $m[3]); }\n\n\t\t\t/* Extract attributes (pattern based on the pattern above!)\n\n\t\t\t * [0] - full match\n\t\t\t * [1] - attribute name\n\t\t\t * [2] - attribute expression\n\t\t\t * [3] - attribute value\n\t\t\t * [4] - case sensitivity\n\t\t\t *\n\t\t\t * Note: Attributes can be negated with a \"!\" prefix to their name\n\t\t\t */\n\t\t\tif($m[4] !== '') {\n\t\t\t\tpreg_match_all(\n\t\t\t\t\t\"/\\[@?(!?[\\w:-]+)(?:([!*^$|~]?=)[\\\"']?(.*?)[\\\"']?)?(?:\\s+?([iIsS])?)?\\]/is\",\n\t\t\t\t\ttrim($m[4]),\n\t\t\t\t\t$attributes,\n\t\t\t\t\tPREG_SET_ORDER\n\t\t\t\t);\n\n\t\t\t\t// Replace element by array\n\t\t\t\t$m[4] = array();\n\n\t\t\t\tforeach($attributes as $att) {\n\t\t\t\t\t// Skip empty matches\n\t\t\t\t\tif(trim($att[0]) === '') { continue; }\n\n\t\t\t\t\t$inverted = (isset($att[1][0]) && $att[1][0] === '!');\n\t\t\t\t\t$m[4][] = array(\n\t\t\t\t\t\t$inverted ? substr($att[1], 1) : $att[1], // Name\n\t\t\t\t\t\t(isset($att[2])) ? $att[2] : '', // Expression\n\t\t\t\t\t\t(isset($att[3])) ? $att[3] : '', // Value\n\t\t\t\t\t\t$inverted, // Inverted Flag\n\t\t\t\t\t\t(isset($att[4])) ? strtolower($att[4]) : '', // Case-Sensitivity\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Sanitize Separator\n\t\t\tif ($m[5] !== '' && trim($m[5]) === '') { // Descendant Separator\n\t\t\t\t$m[5] = ' ';\n\t\t\t} else { // Other Separator\n\t\t\t\t$m[5] = trim($m[5]);\n\t\t\t}\n\n\t\t\t// Clear Separator if it's a Selector List\n\t\t\tif ($is_list = ($m[5] === ',')) { $m[5] = ''; }\n\n\t\t\t// Remove full match before adding to results\n\t\t\tarray_shift($m);\n\t\t\t$result[] = $m;\n\n\t\t\tif ($is_list) { // Selector List\n\t\t\t\t$selectors[] = $result;\n\t\t\t\t$result = array();\n\t\t\t}\n\t\t}\n\n\t\tif (count($result) > 0) { $selectors[] = $result; }\n\t\treturn $selectors;\n\t}\n\n\tfunction __get($name)\n\t{\n\t\tif (isset($this->attr[$name])) {\n\t\t\treturn $this->convert_text($this->attr[$name]);\n\t\t}\n\t\tswitch ($name) {\n\t\t\tcase 'outertext': return $this->outertext();\n\t\t\tcase 'innertext': return $this->innertext();\n\t\t\tcase 'plaintext': return $this->text();\n\t\t\tcase 'xmltext': return $this->xmltext();\n\t\t\tdefault: return array_key_exists($name, $this->attr);\n\t\t}\n\t}\n\n\tfunction __set($name, $value)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\tswitch ($name) {\n\t\t\tcase 'outertext': return $this->_[HDOM_INFO_OUTER] = $value;\n\t\t\tcase 'innertext':\n\t\t\t\tif (isset($this->_[HDOM_INFO_TEXT])) {\n\t\t\t\t\treturn $this->_[HDOM_INFO_TEXT] = $value;\n\t\t\t\t}\n\t\t\t\treturn $this->_[HDOM_INFO_INNER] = $value;\n\t\t}\n\n\t\tif (!isset($this->attr[$name])) {\n\t\t\t$this->_[HDOM_INFO_SPACE][] = array(' ', '', '');\n\t\t\t$this->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_DOUBLE;\n\t\t}\n\n\t\t$this->attr[$name] = $value;\n\t}\n\n\tfunction __isset($name)\n\t{\n\t\tswitch ($name) {\n\t\t\tcase 'outertext': return true;\n\t\t\tcase 'innertext': return true;\n\t\t\tcase 'plaintext': return true;\n\t\t}\n\t\t//no value attr: nowrap, checked selected...\n\t\treturn (array_key_exists($name, $this->attr)) ? true : isset($this->attr[$name]);\n\t}\n\n\tfunction __unset($name)\n\t{\n\t\tif (isset($this->attr[$name])) { unset($this->attr[$name]); }\n\t}\n\n\tfunction convert_text($text)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\t$converted_text = $text;\n\n\t\t$sourceCharset = '';\n\t\t$targetCharset = '';\n\n\t\tif ($this->dom) {\n\t\t\t$sourceCharset = strtoupper($this->dom->_charset);\n\t\t\t$targetCharset = strtoupper($this->dom->_target_charset);\n\t\t}\n\n\t\tif (is_object($debug_object)) {\n\t\t\t$debug_object->debug_log(3,\n\t\t\t\t'source charset: '\n\t\t\t\t. $sourceCharset\n\t\t\t\t. ' target charaset: '\n\t\t\t\t. $targetCharset\n\t\t\t);\n\t\t}\n\n\t\tif (!empty($sourceCharset)\n\t\t\t&& !empty($targetCharset)\n\t\t\t&& (strcasecmp($sourceCharset, $targetCharset) != 0)) {\n\t\t\t// Check if the reported encoding could have been incorrect and the text is actually already UTF-8\n\t\t\tif ((strcasecmp($targetCharset, 'UTF-8') == 0)\n\t\t\t\t&& ($this->is_utf8($text))) {\n\t\t\t\t$converted_text = $text;\n\t\t\t} else {\n\t\t\t\t$converted_text = iconv($sourceCharset, $targetCharset, $text);\n\t\t\t}\n\t\t}\n\n\t\t// Lets make sure that we don't have that silly BOM issue with any of the utf-8 text we output.\n\t\tif ($targetCharset === 'UTF-8') {\n\t\t\tif (substr($converted_text, 0, 3) === \"\\xef\\xbb\\xbf\") {\n\t\t\t\t$converted_text = substr($converted_text, 3);\n\t\t\t}\n\n\t\t\tif (substr($converted_text, -3) === \"\\xef\\xbb\\xbf\") {\n\t\t\t\t$converted_text = substr($converted_text, 0, -3);\n\t\t\t}\n\t\t}\n\n\t\treturn $converted_text;\n\t}\n\n\tstatic function is_utf8($str)\n\t{\n\t\t$c = 0; $b = 0;\n\t\t$bits = 0;\n\t\t$len = strlen($str);\n\t\tfor($i = 0; $i < $len; $i++) {\n\t\t\t$c = ord($str[$i]);\n\t\t\tif($c > 128) {\n\t\t\t\tif(($c >= 254)) { return false; }\n\t\t\t\telseif($c >= 252) { $bits = 6; }\n\t\t\t\telseif($c >= 248) { $bits = 5; }\n\t\t\t\telseif($c >= 240) { $bits = 4; }\n\t\t\t\telseif($c >= 224) { $bits = 3; }\n\t\t\t\telseif($c >= 192) { $bits = 2; }\n\t\t\t\telse { return false; }\n\t\t\t\tif(($i + $bits) > $len) { return false; }\n\t\t\t\twhile($bits > 1) {\n\t\t\t\t\t$i++;\n\t\t\t\t\t$b = ord($str[$i]);\n\t\t\t\t\tif($b < 128 || $b > 191) { return false; }\n\t\t\t\t\t$bits--;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn true;\n\t}\n\n\tfunction get_display_size()\n\t{\n\t\tglobal $debug_object;\n\n\t\t$width = -1;\n\t\t$height = -1;\n\n\t\tif ($this->tag !== 'img') {\n\t\t\treturn false;\n\t\t}\n\n\t\t// See if there is aheight or width attribute in the tag itself.\n\t\tif (isset($this->attr['width'])) {\n\t\t\t$width = $this->attr['width'];\n\t\t}\n\n\t\tif (isset($this->attr['height'])) {\n\t\t\t$height = $this->attr['height'];\n\t\t}\n\n\t\t// Now look for an inline style.\n\t\tif (isset($this->attr['style'])) {\n\t\t\t// Thanks to user gnarf from stackoverflow for this regular expression.\n\t\t\t$attributes = array();\n\n\t\t\tpreg_match_all(\n\t\t\t\t'/([\\w-]+)\\s*:\\s*([^;]+)\\s*;?/',\n\t\t\t\t$this->attr['style'],\n\t\t\t\t$matches,\n\t\t\t\tPREG_SET_ORDER\n\t\t\t);\n\n\t\t\tforeach ($matches as $match) {\n\t\t\t\t$attributes[$match[1]] = $match[2];\n\t\t\t}\n\n\t\t\t// If there is a width in the style attributes:\n\t\t\tif (isset($attributes['width']) && $width == -1) {\n\t\t\t\t// check that the last two characters are px (pixels)\n\t\t\t\tif (strtolower(substr($attributes['width'], -2)) === 'px') {\n\t\t\t\t\t$proposed_width = substr($attributes['width'], 0, -2);\n\t\t\t\t\t// Now make sure that it's an integer and not something stupid.\n\t\t\t\t\tif (filter_var($proposed_width, FILTER_VALIDATE_INT)) {\n\t\t\t\t\t\t$width = $proposed_width;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If there is a width in the style attributes:\n\t\t\tif (isset($attributes['height']) && $height == -1) {\n\t\t\t\t// check that the last two characters are px (pixels)\n\t\t\t\tif (strtolower(substr($attributes['height'], -2)) == 'px') {\n\t\t\t\t\t$proposed_height = substr($attributes['height'], 0, -2);\n\t\t\t\t\t// Now make sure that it's an integer and not something stupid.\n\t\t\t\t\tif (filter_var($proposed_height, FILTER_VALIDATE_INT)) {\n\t\t\t\t\t\t$height = $proposed_height;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t}\n\n\t\t// Future enhancement:\n\t\t// Look in the tag to see if there is a class or id specified that has\n\t\t// a height or width attribute to it.\n\n\t\t// Far future enhancement\n\t\t// Look at all the parent tags of this image to see if they specify a\n\t\t// class or id that has an img selector that specifies a height or width\n\t\t// Note that in this case, the class or id will have the img subselector\n\t\t// for it to apply to the image.\n\n\t\t// ridiculously far future development\n\t\t// If the class or id is specified in a SEPARATE css file thats not on\n\t\t// the page, go get it and do what we were just doing for the ones on\n\t\t// the page.\n\n\t\t$result = array(\n\t\t\t'height' => $height,\n\t\t\t'width' => $width\n\t\t);\n\n\t\treturn $result;\n\t}\n\n\tfunction save($filepath = '')\n\t{\n\t\t$ret = $this->outertext();\n\n\t\tif ($filepath !== '') {\n\t\t\tfile_put_contents($filepath, $ret, LOCK_EX);\n\t\t}\n\n\t\treturn $ret;\n\t}\n\n\tfunction addClass($class)\n\t{\n\t\tif (is_string($class)) {\n\t\t\t$class = explode(' ', $class);\n\t\t}\n\n\t\tif (is_array($class)) {\n\t\t\tforeach($class as $c) {\n\t\t\t\tif (isset($this->class)) {\n\t\t\t\t\tif ($this->hasClass($c)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t$this->class .= ' ' . $c;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t$this->class = $c;\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tif (is_object($debug_object)) {\n\t\t\t\t$debug_object->debug_log(2, 'Invalid type: ', gettype($class));\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction hasClass($class)\n\t{\n\t\tif (is_string($class)) {\n\t\t\tif (isset($this->class)) {\n\t\t\t\treturn in_array($class, explode(' ', $this->class), true);\n\t\t\t}\n\t\t} else {\n\t\t\tif (is_object($debug_object)) {\n\t\t\t\t$debug_object->debug_log(2, 'Invalid type: ', gettype($class));\n\t\t\t}\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tfunction removeClass($class = null)\n\t{\n\t\tif (!isset($this->class)) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (is_null($class)) {\n\t\t\t$this->removeAttribute('class');\n\t\t\treturn;\n\t\t}\n\n\t\tif (is_string($class)) {\n\t\t\t$class = explode(' ', $class);\n\t\t}\n\n\t\tif (is_array($class)) {\n\t\t\t$class = array_diff(explode(' ', $this->class), $class);\n\t\t\tif (empty($class)) {\n\t\t\t\t$this->removeAttribute('class');\n\t\t\t} else {\n\t\t\t\t$this->class = implode(' ', $class);\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction getAllAttributes()\n\t{\n\t\treturn $this->attr;\n\t}\n\n\tfunction getAttribute($name)\n\t{\n\t\treturn $this->__get($name);\n\t}\n\n\tfunction setAttribute($name, $value)\n\t{\n\t\t$this->__set($name, $value);\n\t}\n\n\tfunction hasAttribute($name)\n\t{\n\t\treturn $this->__isset($name);\n\t}\n\n\tfunction removeAttribute($name)\n\t{\n\t\t$this->__set($name, null);\n\t}\n\n\tfunction remove()\n\t{\n\t\tif ($this->parent) {\n\t\t\t$this->parent->removeChild($this);\n\t\t}\n\t}\n\n\tfunction removeChild($node)\n\t{\n\t\t$nidx = array_search($node, $this->nodes, true);\n\t\t$cidx = array_search($node, $this->children, true);\n\t\t$didx = array_search($node, $this->dom->nodes, true);\n\n\t\tif ($nidx !== false && $cidx !== false && $didx !== false) {\n\n\t\t\tforeach($node->children as $child) {\n\t\t\t\t$node->removeChild($child);\n\t\t\t}\n\n\t\t\tforeach($node->nodes as $entity) {\n\t\t\t\t$enidx = array_search($entity, $node->nodes, true);\n\t\t\t\t$edidx = array_search($entity, $node->dom->nodes, true);\n\n\t\t\t\tif ($enidx !== false && $edidx !== false) {\n\t\t\t\t\tunset($node->nodes[$enidx]);\n\t\t\t\t\tunset($node->dom->nodes[$edidx]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tunset($this->nodes[$nidx]);\n\t\t\tunset($this->children[$cidx]);\n\t\t\tunset($this->dom->nodes[$didx]);\n\n\t\t\t$node->clear();\n\n\t\t}\n\t}\n\n\tfunction getElementById($id)\n\t{\n\t\treturn $this->find(\"#$id\", 0);\n\t}\n\n\tfunction getElementsById($id, $idx = null)\n\t{\n\t\treturn $this->find(\"#$id\", $idx);\n\t}\n\n\tfunction getElementByTagName($name)\n\t{\n\t\treturn $this->find($name, 0);\n\t}\n\n\tfunction getElementsByTagName($name, $idx = null)\n\t{\n\t\treturn $this->find($name, $idx);\n\t}\n\n\tfunction parentNode()\n\t{\n\t\treturn $this->parent();\n\t}\n\n\tfunction childNodes($idx = -1)\n\t{\n\t\treturn $this->children($idx);\n\t}\n\n\tfunction firstChild()\n\t{\n\t\treturn $this->first_child();\n\t}\n\n\tfunction lastChild()\n\t{\n\t\treturn $this->last_child();\n\t}\n\n\tfunction nextSibling()\n\t{\n\t\treturn $this->next_sibling();\n\t}\n\n\tfunction previousSibling()\n\t{\n\t\treturn $this->prev_sibling();\n\t}\n\n\tfunction hasChildNodes()\n\t{\n\t\treturn $this->has_child();\n\t}\n\n\tfunction nodeName()\n\t{\n\t\treturn $this->tag;\n\t}\n\n\tfunction appendChild($node)\n\t{\n\t\t$node->parent($this);\n\t\treturn $node;\n\t}\n\n}\n\nclass simple_html_dom\n{\n\tpublic $root = null;\n\tpublic $nodes = array();\n\tpublic $callback = null;\n\tpublic $lowercase = false;\n\tpublic $original_size;\n\tpublic $size;\n\n\tprotected $pos;\n\tprotected $doc;\n\tprotected $char;\n\n\tprotected $cursor;\n\tprotected $parent;\n\tprotected $noise = array();\n\tprotected $token_blank = \" \\t\\r\\n\";\n\tprotected $token_equal = ' =/>';\n\tprotected $token_slash = \" />\\r\\n\\t\";\n\tprotected $token_attr = ' >';\n\n\tpublic $_charset = '';\n\tpublic $_target_charset = '';\n\n\tprotected $default_br_text = '';\n\n\tpublic $default_span_text = '';\n\n\tprotected $self_closing_tags = array(\n\t\t'area' => 1,\n\t\t'base' => 1,\n\t\t'br' => 1,\n\t\t'col' => 1,\n\t\t'embed' => 1,\n\t\t'hr' => 1,\n\t\t'img' => 1,\n\t\t'input' => 1,\n\t\t'link' => 1,\n\t\t'meta' => 1,\n\t\t'param' => 1,\n\t\t'source' => 1,\n\t\t'track' => 1,\n\t\t'wbr' => 1\n\t);\n\tprotected $block_tags = array(\n\t\t'body' => 1,\n\t\t'div' => 1,\n\t\t'form' => 1,\n\t\t'root' => 1,\n\t\t'span' => 1,\n\t\t'table' => 1\n\t);\n\tprotected $optional_closing_tags = array(\n\t\t// Not optional, see\n\t\t// https://www.w3.org/TR/html/textlevel-semantics.html#the-b-element\n\t\t'b' => array('b' => 1),\n\t\t'dd' => array('dd' => 1, 'dt' => 1),\n\t\t// Not optional, see\n\t\t// https://www.w3.org/TR/html/grouping-content.html#the-dl-element\n\t\t'dl' => array('dd' => 1, 'dt' => 1),\n\t\t'dt' => array('dd' => 1, 'dt' => 1),\n\t\t'li' => array('li' => 1),\n\t\t'optgroup' => array('optgroup' => 1, 'option' => 1),\n\t\t'option' => array('optgroup' => 1, 'option' => 1),\n\t\t'p' => array('p' => 1),\n\t\t'rp' => array('rp' => 1, 'rt' => 1),\n\t\t'rt' => array('rp' => 1, 'rt' => 1),\n\t\t'td' => array('td' => 1, 'th' => 1),\n\t\t'th' => array('td' => 1, 'th' => 1),\n\t\t'tr' => array('td' => 1, 'th' => 1, 'tr' => 1),\n\t);\n\n\tfunction __construct(\n\t\t$str = null,\n\t\t$lowercase = true,\n\t\t$forceTagsClosed = true,\n\t\t$target_charset = DEFAULT_TARGET_CHARSET,\n\t\t$stripRN = true,\n\t\t$defaultBRText = DEFAULT_BR_TEXT,\n\t\t$defaultSpanText = DEFAULT_SPAN_TEXT,\n\t\t$options = 0)\n\t{\n\t\tif ($str) {\n\t\t\tif (preg_match('/^http:\\/\\//i', $str) || is_file($str)) {\n\t\t\t\t$this->load_file($str);\n\t\t\t} else {\n\t\t\t\t$this->load(\n\t\t\t\t\t$str,\n\t\t\t\t\t$lowercase,\n\t\t\t\t\t$stripRN,\n\t\t\t\t\t$defaultBRText,\n\t\t\t\t\t$defaultSpanText,\n\t\t\t\t\t$options\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\t// Forcing tags to be closed implies that we don't trust the html, but\n\t\t// it can lead to parsing errors if we SHOULD trust the html.\n\t\tif (!$forceTagsClosed) {\n\t\t\t$this->optional_closing_array = array();\n\t\t}\n\n\t\t$this->_target_charset = $target_charset;\n\t}\n\n\tfunction __destruct()\n\t{\n\t\t$this->clear();\n\t}\n\n\tfunction load(\n\t\t$str,\n\t\t$lowercase = true,\n\t\t$stripRN = true,\n\t\t$defaultBRText = DEFAULT_BR_TEXT,\n\t\t$defaultSpanText = DEFAULT_SPAN_TEXT,\n\t\t$options = 0)\n\t{\n\t\tglobal $debug_object;\n\n\t\t// prepare\n\t\t$this->prepare($str, $lowercase, $defaultBRText, $defaultSpanText);\n\n\t\t// Per sourceforge http://sourceforge.net/tracker/?func=detail&aid=2949097&group_id=218559&atid=1044037\n\t\t// Script tags removal now preceeds style tag removal.\n\t\t// strip out <script> tags\n\t\t$this->remove_noise(\"'<\\s*script[^>]*[^/]>(.*?)<\\s*/\\s*script\\s*>'is\");\n\t\t$this->remove_noise(\"'<\\s*script\\s*>(.*?)<\\s*/\\s*script\\s*>'is\");\n\n\t\t// strip out the \\r \\n's if we are told to.\n\t\tif ($stripRN) {\n\t\t\t$this->doc = str_replace(\"\\r\", ' ', $this->doc);\n\t\t\t$this->doc = str_replace(\"\\n\", ' ', $this->doc);\n\n\t\t\t// set the length of content since we have changed it.\n\t\t\t$this->size = strlen($this->doc);\n\t\t}\n\n\t\t// strip out cdata\n\t\t$this->remove_noise(\"'<!\\[CDATA\\[(.*?)\\]\\]>'is\", true);\n\t\t// strip out comments\n\t\t$this->remove_noise(\"'<!--(.*?)-->'is\");\n\t\t// strip out <style> tags\n\t\t$this->remove_noise(\"'<\\s*style[^>]*[^/]>(.*?)<\\s*/\\s*style\\s*>'is\");\n\t\t$this->remove_noise(\"'<\\s*style\\s*>(.*?)<\\s*/\\s*style\\s*>'is\");\n\t\t// strip out preformatted tags\n\t\t$this->remove_noise(\"'<\\s*(?:code)[^>]*>(.*?)<\\s*/\\s*(?:code)\\s*>'is\");\n\t\t// strip out server side scripts\n\t\t$this->remove_noise(\"'(<\\?)(.*?)(\\?>)'s\", true);\n\n\t\tif($options & HDOM_SMARTY_AS_TEXT) { // Strip Smarty scripts\n\t\t\t$this->remove_noise(\"'(\\{\\w)(.*?)(\\})'s\", true);\n\t\t}\n\n\t\t// parsing\n\t\t$this->parse();\n\t\t// end\n\t\t$this->root->_[HDOM_INFO_END] = $this->cursor;\n\t\t$this->parse_charset();\n\n\t\t// make load function chainable\n\t\treturn $this;\n\t}\n\n\tfunction load_file()\n\t{\n\t\t$args = func_get_args();\n\n\t\tif(($doc = call_user_func_array('file_get_contents', $args)) !== false) {\n\t\t\t$this->load($doc, true);\n\t\t} else {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tfunction set_callback($function_name)\n\t{\n\t\t$this->callback = $function_name;\n\t}\n\n\tfunction remove_callback()\n\t{\n\t\t$this->callback = null;\n\t}\n\n\tfunction save($filepath = '')\n\t{\n\t\t$ret = $this->root->innertext();\n\t\tif ($filepath !== '') { file_put_contents($filepath, $ret, LOCK_EX); }\n\t\treturn $ret;\n\t}\n\n\tfunction find($selector, $idx = null, $lowercase = false)\n\t{\n\t\treturn $this->root->find($selector, $idx, $lowercase);\n\t}\n\n\tfunction clear()\n\t{\n\t\tif (isset($this->nodes)) {\n\t\t\tforeach ($this->nodes as $n) {\n\t\t\t\t$n->clear();\n\t\t\t\t$n = null;\n\t\t\t}\n\t\t}\n\n\t\t// This add next line is documented in the sourceforge repository.\n\t\t// 2977248 as a fix for ongoing memory leaks that occur even with the\n\t\t// use of clear.\n\t\tif (isset($this->children)) {\n\t\t\tforeach ($this->children as $n) {\n\t\t\t\t$n->clear();\n\t\t\t\t$n = null;\n\t\t\t}\n\t\t}\n\n\t\tif (isset($this->parent)) {\n\t\t\t$this->parent->clear();\n\t\t\tunset($this->parent);\n\t\t}\n\n\t\tif (isset($this->root)) {\n\t\t\t$this->root->clear();\n\t\t\tunset($this->root);\n\t\t}\n\n\t\tunset($this->doc);\n\t\tunset($this->noise);\n\t}\n\n\tfunction dump($show_attr = true)\n\t{\n\t\t$this->root->dump($show_attr);\n\t}\n\n\tprotected function prepare(\n\t\t$str, $lowercase = true,\n\t\t$defaultBRText = DEFAULT_BR_TEXT,\n\t\t$defaultSpanText = DEFAULT_SPAN_TEXT)\n\t{\n\t\t$this->clear();\n\n\t\t$this->doc = trim($str);\n\t\t$this->size = strlen($this->doc);\n\t\t$this->original_size = $this->size; // original size of the html\n\t\t$this->pos = 0;\n\t\t$this->cursor = 1;\n\t\t$this->noise = array();\n\t\t$this->nodes = array();\n\t\t$this->lowercase = $lowercase;\n\t\t$this->default_br_text = $defaultBRText;\n\t\t$this->default_span_text = $defaultSpanText;\n\t\t$this->root = new simple_html_dom_node($this);\n\t\t$this->root->tag = 'root';\n\t\t$this->root->_[HDOM_INFO_BEGIN] = -1;\n\t\t$this->root->nodetype = HDOM_TYPE_ROOT;\n\t\t$this->parent = $this->root;\n\t\tif ($this->size > 0) { $this->char = $this->doc[0]; }\n\t}\n\n\tprotected function parse()\n\t{\n\t\twhile (true) {\n\t\t\t// Read next tag if there is no text between current position and the\n\t\t\t// next opening tag.\n\t\t\tif (($s = $this->copy_until_char('<')) === '') {\n\t\t\t\tif($this->read_tag()) {\n\t\t\t\t\tcontinue;\n\t\t\t\t} else {\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add a text node for text between tags\n\t\t\t$node = new simple_html_dom_node($this);\n\t\t\t++$this->cursor;\n\t\t\t$node->_[HDOM_INFO_TEXT] = $s;\n\t\t\t$this->link_nodes($node, false);\n\t\t}\n\t}\n\n\tprotected function parse_charset()\n\t{\n\t\tglobal $debug_object;\n\n\t\t$charset = null;\n\n\t\tif (function_exists('get_last_retrieve_url_contents_content_type')) {\n\t\t\t$contentTypeHeader = get_last_retrieve_url_contents_content_type();\n\t\t\t$success = preg_match('/charset=(.+)/', $contentTypeHeader, $matches);\n\t\t\tif ($success) {\n\t\t\t\t$charset = $matches[1];\n\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t$debug_object->debug_log(2,\n\t\t\t\t\t\t'header content-type found charset of: '\n\t\t\t\t\t\t. $charset\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (empty($charset)) {\n\t\t\t// https://www.w3.org/TR/html/document-metadata.html#statedef-http-equiv-content-type\n\t\t\t$el = $this->root->find('meta[http-equiv=Content-Type]', 0, true);\n\n\t\t\tif (!empty($el)) {\n\t\t\t\t$fullvalue = $el->content;\n\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t$debug_object->debug_log(2,\n\t\t\t\t\t\t'meta content-type tag found'\n\t\t\t\t\t\t. $fullvalue\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tif (!empty($fullvalue)) {\n\t\t\t\t\t$success = preg_match(\n\t\t\t\t\t\t'/charset=(.+)/i',\n\t\t\t\t\t\t$fullvalue,\n\t\t\t\t\t\t$matches\n\t\t\t\t\t);\n\n\t\t\t\t\tif ($success) {\n\t\t\t\t\t\t$charset = $matches[1];\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// If there is a meta tag, and they don't specify the\n\t\t\t\t\t\t// character set, research says that it's typically\n\t\t\t\t\t\t// ISO-8859-1\n\t\t\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t\t\t$debug_object->debug_log(2,\n\t\t\t\t\t\t\t\t'meta content-type tag couldn\\'t be parsed. using iso-8859 default.'\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t$charset = 'ISO-8859-1';\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (empty($charset)) {\n\t\t\t// https://www.w3.org/TR/html/document-metadata.html#character-encoding-declaration\n\t\t\tif ($meta = $this->root->find('meta[charset]', 0)) {\n\t\t\t\t$charset = $meta->charset;\n\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t$debug_object->debug_log(2, 'meta charset: ' . $charset);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (empty($charset)) {\n\t\t\t// Try to guess the charset based on the content\n\t\t\t// Requires Multibyte String (mbstring) support (optional)\n\t\t\tif (function_exists('mb_detect_encoding')) {\n\t\t\t\t/**\n\t\t\t\t * mb_detect_encoding() is not intended to distinguish between\n\t\t\t\t * charsets, especially single-byte charsets. Its primary\n\t\t\t\t * purpose is to detect which multibyte encoding is in use,\n\t\t\t\t * i.e. UTF-8, UTF-16, shift-JIS, etc.\n\t\t\t\t *\n\t\t\t\t * -- https://bugs.php.net/bug.php?id=38138\n\t\t\t\t *\n\t\t\t\t * Adding both CP1251/ISO-8859-5 and CP1252/ISO-8859-1 will\n\t\t\t\t * always result in CP1251/ISO-8859-5 and vice versa.\n\t\t\t\t *\n\t\t\t\t * Thus, only detect if it's either UTF-8 or CP1252/ISO-8859-1\n\t\t\t\t * to stay compatible.\n\t\t\t\t */\n\t\t\t\t$encoding = mb_detect_encoding(\n\t\t\t\t\t$this->doc,\n\t\t\t\t\tarray( 'UTF-8', 'CP1252', 'ISO-8859-1' )\n\t\t\t\t);\n\n\t\t\t\tif ($encoding === 'CP1252' || $encoding === 'ISO-8859-1') {\n\t\t\t\t\t// Due to a limitation of mb_detect_encoding\n\t\t\t\t\t// 'CP1251'/'ISO-8859-5' will be detected as\n\t\t\t\t\t// 'CP1252'/'ISO-8859-1'. This will cause iconv to fail, in\n\t\t\t\t\t// which case we can simply assume it is the other charset.\n\t\t\t\t\tif (!@iconv('CP1252', 'UTF-8', $this->doc)) {\n\t\t\t\t\t\t$encoding = 'CP1251';\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif ($encoding !== false) {\n\t\t\t\t\t$charset = $encoding;\n\t\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t\t$debug_object->debug_log(2, 'mb_detect: ' . $charset);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (empty($charset)) {\n\t\t\t// Assume it's UTF-8 as it is the most likely charset to be used\n\t\t\t$charset = 'UTF-8';\n\t\t\tif (is_object($debug_object)) {\n\t\t\t\t$debug_object->debug_log(2, 'No match found, assume ' . $charset);\n\t\t\t}\n\t\t}\n\n\t\t// Since CP1252 is a superset, if we get one of it's subsets, we want\n\t\t// it instead.\n\t\tif ((strtolower($charset) == 'iso-8859-1')\n\t\t\t|| (strtolower($charset) == 'latin1')\n\t\t\t|| (strtolower($charset) == 'latin-1')) {\n\t\t\t$charset = 'CP1252';\n\t\t\tif (is_object($debug_object)) {\n\t\t\t\t$debug_object->debug_log(2,\n\t\t\t\t\t'replacing ' . $charset . ' with CP1252 as its a superset'\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tif (is_object($debug_object)) {\n\t\t\t$debug_object->debug_log(1, 'EXIT - ' . $charset);\n\t\t}\n\n\t\treturn $this->_charset = $charset;\n\t}\n\n\tprotected function read_tag()\n\t{\n\t\t// Set end position if no further tags found\n\t\tif ($this->char !== '<') {\n\t\t\t$this->root->_[HDOM_INFO_END] = $this->cursor;\n\t\t\treturn false;\n\t\t}\n\n\t\t$begin_tag_pos = $this->pos;\n\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\n\t\t// end tag\n\t\tif ($this->char === '/') {\n\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\n\t\t\t// Skip whitespace in end tags (i.e. in \"</   html>\")\n\t\t\t$this->skip($this->token_blank);\n\t\t\t$tag = $this->copy_until_char('>');\n\n\t\t\t// Skip attributes in end tags\n\t\t\tif (($pos = strpos($tag, ' ')) !== false) {\n\t\t\t\t$tag = substr($tag, 0, $pos);\n\t\t\t}\n\n\t\t\t$parent_lower = strtolower($this->parent->tag);\n\t\t\t$tag_lower = strtolower($tag);\n\n\t\t\t// The end tag is supposed to close the parent tag. Handle situations\n\t\t\t// when it doesn't\n\t\t\tif ($parent_lower !== $tag_lower) {\n\t\t\t\t// Parent tag does not have to be closed necessarily (optional closing tag)\n\t\t\t\t// Current tag is a block tag, so it may close an ancestor\n\t\t\t\tif (isset($this->optional_closing_tags[$parent_lower])\n\t\t\t\t\t&& isset($this->block_tags[$tag_lower])) {\n\n\t\t\t\t\t$this->parent->_[HDOM_INFO_END] = 0;\n\t\t\t\t\t$org_parent = $this->parent;\n\n\t\t\t\t\t// Traverse ancestors to find a matching opening tag\n\t\t\t\t\t// Stop at root node\n\t\t\t\t\twhile (($this->parent->parent)\n\t\t\t\t\t\t&& strtolower($this->parent->tag) !== $tag_lower\n\t\t\t\t\t){\n\t\t\t\t\t\t$this->parent = $this->parent->parent;\n\t\t\t\t\t}\n\n\t\t\t\t\t// If we don't have a match add current tag as text node\n\t\t\t\t\tif (strtolower($this->parent->tag) !== $tag_lower) {\n\t\t\t\t\t\t$this->parent = $org_parent; // restore origonal parent\n\n\t\t\t\t\t\tif ($this->parent->parent) {\n\t\t\t\t\t\t\t$this->parent = $this->parent->parent;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t$this->parent->_[HDOM_INFO_END] = $this->cursor;\n\t\t\t\t\t\treturn $this->as_text_node($tag);\n\t\t\t\t\t}\n\t\t\t\t} elseif (($this->parent->parent)\n\t\t\t\t\t&& isset($this->block_tags[$tag_lower])\n\t\t\t\t) {\n\t\t\t\t\t// Grandparent exists and current tag is a block tag, so our\n\t\t\t\t\t// parent doesn't have an end tag\n\t\t\t\t\t$this->parent->_[HDOM_INFO_END] = 0; // No end tag\n\t\t\t\t\t$org_parent = $this->parent;\n\n\t\t\t\t\t// Traverse ancestors to find a matching opening tag\n\t\t\t\t\t// Stop at root node\n\t\t\t\t\twhile (($this->parent->parent)\n\t\t\t\t\t\t&& strtolower($this->parent->tag) !== $tag_lower\n\t\t\t\t\t) {\n\t\t\t\t\t\t$this->parent = $this->parent->parent;\n\t\t\t\t\t}\n\n\t\t\t\t\t// If we don't have a match add current tag as text node\n\t\t\t\t\tif (strtolower($this->parent->tag) !== $tag_lower) {\n\t\t\t\t\t\t$this->parent = $org_parent; // restore origonal parent\n\t\t\t\t\t\t$this->parent->_[HDOM_INFO_END] = $this->cursor;\n\t\t\t\t\t\treturn $this->as_text_node($tag);\n\t\t\t\t\t}\n\t\t\t\t} elseif (($this->parent->parent)\n\t\t\t\t\t&& strtolower($this->parent->parent->tag) === $tag_lower\n\t\t\t\t) { // Grandparent exists and current tag closes it\n\t\t\t\t\t$this->parent->_[HDOM_INFO_END] = 0;\n\t\t\t\t\t$this->parent = $this->parent->parent;\n\t\t\t\t} else { // Random tag, add as text node\n\t\t\t\t\treturn $this->as_text_node($tag);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Set end position of parent tag to current cursor position\n\t\t\t$this->parent->_[HDOM_INFO_END] = $this->cursor;\n\n\t\t\tif ($this->parent->parent) {\n\t\t\t\t$this->parent = $this->parent->parent;\n\t\t\t}\n\n\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\treturn true;\n\t\t}\n\n\t\t// start tag\n\t\t$node = new simple_html_dom_node($this);\n\t\t$node->_[HDOM_INFO_BEGIN] = $this->cursor;\n\t\t++$this->cursor;\n\t\t$tag = $this->copy_until($this->token_slash); // Get tag name\n\t\t$node->tag_start = $begin_tag_pos;\n\n\t\t// doctype, cdata & comments...\n\t\t// <!DOCTYPE html>\n\t\t// <![CDATA[ ... ]]>\n\t\t// <!-- Comment -->\n\t\tif (isset($tag[0]) && $tag[0] === '!') {\n\t\t\t$node->_[HDOM_INFO_TEXT] = '<' . $tag . $this->copy_until_char('>');\n\n\t\t\tif (isset($tag[2]) && $tag[1] === '-' && $tag[2] === '-') { // Comment (\"<!--\")\n\t\t\t\t$node->nodetype = HDOM_TYPE_COMMENT;\n\t\t\t\t$node->tag = 'comment';\n\t\t\t} else { // Could be doctype or CDATA but we don't care\n\t\t\t\t$node->nodetype = HDOM_TYPE_UNKNOWN;\n\t\t\t\t$node->tag = 'unknown';\n\t\t\t}\n\n\t\t\tif ($this->char === '>') { $node->_[HDOM_INFO_TEXT] .= '>'; }\n\n\t\t\t$this->link_nodes($node, true);\n\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\treturn true;\n\t\t}\n\n\t\t// The start tag cannot contain another start tag, if so add as text\n\t\t// i.e. \"<<html>\"\n\t\tif ($pos = strpos($tag, '<') !== false) {\n\t\t\t$tag = '<' . substr($tag, 0, -1);\n\t\t\t$node->_[HDOM_INFO_TEXT] = $tag;\n\t\t\t$this->link_nodes($node, false);\n\t\t\t$this->char = $this->doc[--$this->pos]; // prev\n\t\t\treturn true;\n\t\t}\n\n\t\t// Handle invalid tag names (i.e. \"<html#doc>\")\n\t\tif (!preg_match('/^\\w[\\w:-]*$/', $tag)) {\n\t\t\t$node->_[HDOM_INFO_TEXT] = '<' . $tag . $this->copy_until('<>');\n\n\t\t\t// Next char is the beginning of a new tag, don't touch it.\n\t\t\tif ($this->char === '<') {\n\t\t\t\t$this->link_nodes($node, false);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\t// Next char closes current tag, add and be done with it.\n\t\t\tif ($this->char === '>') { $node->_[HDOM_INFO_TEXT] .= '>'; }\n\t\t\t$this->link_nodes($node, false);\n\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\treturn true;\n\t\t}\n\n\t\t// begin tag, add new node\n\t\t$node->nodetype = HDOM_TYPE_ELEMENT;\n\t\t$tag_lower = strtolower($tag);\n\t\t$node->tag = ($this->lowercase) ? $tag_lower : $tag;\n\n\t\t// handle optional closing tags\n\t\tif (isset($this->optional_closing_tags[$tag_lower])) {\n\t\t\t// Traverse ancestors to close all optional closing tags\n\t\t\twhile (isset($this->optional_closing_tags[$tag_lower][strtolower($this->parent->tag)])) {\n\t\t\t\t$this->parent->_[HDOM_INFO_END] = 0;\n\t\t\t\t$this->parent = $this->parent->parent;\n\t\t\t}\n\t\t\t$node->parent = $this->parent;\n\t\t}\n\n\t\t$guard = 0; // prevent infinity loop\n\n\t\t// [0] Space between tag and first attribute\n\t\t$space = array($this->copy_skip($this->token_blank), '', '');\n\n\t\t// attributes\n\t\tdo {\n\t\t\t// Everything until the first equal sign should be the attribute name\n\t\t\t$name = $this->copy_until($this->token_equal);\n\n\t\t\tif ($name === '' && $this->char !== null && $space[0] === '') {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif ($guard === $this->pos) { // Escape infinite loop\n\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t$guard = $this->pos;\n\n\t\t\t// handle endless '<'\n\t\t\t// Out of bounds before the tag ended\n\t\t\tif ($this->pos >= $this->size - 1 && $this->char !== '>') {\n\t\t\t\t$node->nodetype = HDOM_TYPE_TEXT;\n\t\t\t\t$node->_[HDOM_INFO_END] = 0;\n\t\t\t\t$node->_[HDOM_INFO_TEXT] = '<' . $tag . $space[0] . $name;\n\t\t\t\t$node->tag = 'text';\n\t\t\t\t$this->link_nodes($node, false);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\t// handle mismatch '<'\n\t\t\t// Attributes cannot start after opening tag\n\t\t\tif ($this->doc[$this->pos - 1] == '<') {\n\t\t\t\t$node->nodetype = HDOM_TYPE_TEXT;\n\t\t\t\t$node->tag = 'text';\n\t\t\t\t$node->attr = array();\n\t\t\t\t$node->_[HDOM_INFO_END] = 0;\n\t\t\t\t$node->_[HDOM_INFO_TEXT] = substr(\n\t\t\t\t\t$this->doc,\n\t\t\t\t\t$begin_tag_pos,\n\t\t\t\t\t$this->pos - $begin_tag_pos - 1\n\t\t\t\t);\n\t\t\t\t$this->pos -= 2;\n\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\t$this->link_nodes($node, false);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tif ($name !== '/' && $name !== '') { // this is a attribute name\n\t\t\t\t// [1] Whitespace after attribute name\n\t\t\t\t$space[1] = $this->copy_skip($this->token_blank);\n\n\t\t\t\t$name = $this->restore_noise($name); // might be a noisy name\n\n\t\t\t\tif ($this->lowercase) { $name = strtolower($name); }\n\n\t\t\t\tif ($this->char === '=') { // attribute with value\n\t\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\t\t$this->parse_attr($node, $name, $space); // get attribute value\n\t\t\t\t} else {\n\t\t\t\t\t//no value attr: nowrap, checked selected...\n\t\t\t\t\t$node->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_NO;\n\t\t\t\t\t$node->attr[$name] = true;\n\t\t\t\t\tif ($this->char != '>') { $this->char = $this->doc[--$this->pos]; } // prev\n\t\t\t\t}\n\n\t\t\t\t$node->_[HDOM_INFO_SPACE][] = $space;\n\n\t\t\t\t// prepare for next attribute\n\t\t\t\t$space = array(\n\t\t\t\t\t$this->copy_skip($this->token_blank),\n\t\t\t\t\t'',\n\t\t\t\t\t''\n\t\t\t\t);\n\t\t\t} else { // no more attributes\n\t\t\t\tbreak;\n\t\t\t}\n\t\t} while ($this->char !== '>' && $this->char !== '/'); // go until the tag ended\n\n\t\t$this->link_nodes($node, true);\n\t\t$node->_[HDOM_INFO_ENDSPACE] = $space[0];\n\n\t\t// handle empty tags (i.e. \"<div/>\")\n\t\tif ($this->copy_until_char('>') === '/') {\n\t\t\t$node->_[HDOM_INFO_ENDSPACE] .= '/';\n\t\t\t$node->_[HDOM_INFO_END] = 0;\n\t\t} else {\n\t\t\t// reset parent\n\t\t\tif (!isset($this->self_closing_tags[strtolower($node->tag)])) {\n\t\t\t\t$this->parent = $node;\n\t\t\t}\n\t\t}\n\n\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\n\t\t// If it's a BR tag, we need to set it's text to the default text.\n\t\t// This way when we see it in plaintext, we can generate formatting that the user wants.\n\t\t// since a br tag never has sub nodes, this works well.\n\t\tif ($node->tag === 'br') {\n\t\t\t$node->_[HDOM_INFO_INNER] = $this->default_br_text;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\tprotected function parse_attr($node, $name, &$space)\n\t{\n\t\t$is_duplicate = isset($node->attr[$name]);\n\n\t\tif (!$is_duplicate) // Copy whitespace between \"=\" and value\n\t\t\t$space[2] = $this->copy_skip($this->token_blank);\n\n\t\tswitch ($this->char) {\n\t\t\tcase '\"':\n\t\t\t\t$quote_type = HDOM_QUOTE_DOUBLE;\n\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\t$value = $this->copy_until_char('\"');\n\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\tbreak;\n\t\t\tcase '\\'':\n\t\t\t\t$quote_type = HDOM_QUOTE_SINGLE;\n\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\t$value = $this->copy_until_char('\\'');\n\t\t\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\t$quote_type = HDOM_QUOTE_NO;\n\t\t\t\t$value = $this->copy_until($this->token_attr);\n\t\t}\n\n\t\t$value = $this->restore_noise($value);\n\n\t\t// PaperG: Attributes should not have \\r or \\n in them, that counts as\n\t\t// html whitespace.\n\t\t$value = str_replace(\"\\r\", '', $value);\n\t\t$value = str_replace(\"\\n\", '', $value);\n\n\t\t// PaperG: If this is a \"class\" selector, lets get rid of the preceeding\n\t\t// and trailing space since some people leave it in the multi class case.\n\t\tif ($name === 'class') {\n\t\t\t$value = trim($value);\n\t\t}\n\n\t\tif (!$is_duplicate) {\n\t\t\t$node->_[HDOM_INFO_QUOTE][] = $quote_type;\n\t\t\t$node->attr[$name] = $value;\n\t\t}\n\t}\n\n\tprotected function link_nodes(&$node, $is_child)\n\t{\n\t\t$node->parent = $this->parent;\n\t\t$this->parent->nodes[] = $node;\n\t\tif ($is_child) {\n\t\t\t$this->parent->children[] = $node;\n\t\t}\n\t}\n\n\tprotected function as_text_node($tag)\n\t{\n\t\t$node = new simple_html_dom_node($this);\n\t\t++$this->cursor;\n\t\t$node->_[HDOM_INFO_TEXT] = '</' . $tag . '>';\n\t\t$this->link_nodes($node, false);\n\t\t$this->char = (++$this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\treturn true;\n\t}\n\n\tprotected function skip($chars)\n\t{\n\t\t$this->pos += strspn($this->doc, $chars, $this->pos);\n\t\t$this->char = ($this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t}\n\n\tprotected function copy_skip($chars)\n\t{\n\t\t$pos = $this->pos;\n\t\t$len = strspn($this->doc, $chars, $pos);\n\t\t$this->pos += $len;\n\t\t$this->char = ($this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\tif ($len === 0) { return ''; }\n\t\treturn substr($this->doc, $pos, $len);\n\t}\n\n\tprotected function copy_until($chars)\n\t{\n\t\t$pos = $this->pos;\n\t\t$len = strcspn($this->doc, $chars, $pos);\n\t\t$this->pos += $len;\n\t\t$this->char = ($this->pos < $this->size) ? $this->doc[$this->pos] : null; // next\n\t\treturn substr($this->doc, $pos, $len);\n\t}\n\n\tprotected function copy_until_char($char)\n\t{\n\t\tif ($this->char === null) { return ''; }\n\n\t\tif (($pos = strpos($this->doc, $char, $this->pos)) === false) {\n\t\t\t$ret = substr($this->doc, $this->pos, $this->size - $this->pos);\n\t\t\t$this->char = null;\n\t\t\t$this->pos = $this->size;\n\t\t\treturn $ret;\n\t\t}\n\n\t\tif ($pos === $this->pos) { return ''; }\n\n\t\t$pos_old = $this->pos;\n\t\t$this->char = $this->doc[$pos];\n\t\t$this->pos = $pos;\n\t\treturn substr($this->doc, $pos_old, $pos - $pos_old);\n\t}\n\n\tprotected function remove_noise($pattern, $remove_tag = false)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\t$count = preg_match_all(\n\t\t\t$pattern,\n\t\t\t$this->doc,\n\t\t\t$matches,\n\t\t\tPREG_SET_ORDER | PREG_OFFSET_CAPTURE\n\t\t);\n\n\t\tfor ($i = $count - 1; $i > -1; --$i) {\n\t\t\t$key = '___noise___' . sprintf('% 5d', count($this->noise) + 1000);\n\n\t\t\tif (is_object($debug_object)) {\n\t\t\t\t$debug_object->debug_log(2, 'key is: ' . $key);\n\t\t\t}\n\n\t\t\t$idx = ($remove_tag) ? 0 : 1; // 0 = entire match, 1 = submatch\n\t\t\t$this->noise[$key] = $matches[$i][$idx][0];\n\t\t\t$this->doc = substr_replace($this->doc, $key, $matches[$i][$idx][1], strlen($matches[$i][$idx][0]));\n\t\t}\n\n\t\t// reset the length of content\n\t\t$this->size = strlen($this->doc);\n\n\t\tif ($this->size > 0) {\n\t\t\t$this->char = $this->doc[0];\n\t\t}\n\t}\n\n\tfunction restore_noise($text)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\twhile (($pos = strpos($text, '___noise___')) !== false) {\n\t\t\t// Sometimes there is a broken piece of markup, and we don't GET the\n\t\t\t// pos+11 etc... token which indicates a problem outside of us...\n\n\t\t\t// todo: \"___noise___1000\" (or any number with four or more digits)\n\t\t\t// in the DOM causes an infinite loop which could be utilized by\n\t\t\t// malicious software\n\t\t\tif (strlen($text) > $pos + 15) {\n\t\t\t\t$key = '___noise___'\n\t\t\t\t. $text[$pos + 11]\n\t\t\t\t. $text[$pos + 12]\n\t\t\t\t. $text[$pos + 13]\n\t\t\t\t. $text[$pos + 14]\n\t\t\t\t. $text[$pos + 15];\n\n\t\t\t\tif (is_object($debug_object)) {\n\t\t\t\t\t$debug_object->debug_log(2, 'located key of: ' . $key);\n\t\t\t\t}\n\n\t\t\t\tif (isset($this->noise[$key])) {\n\t\t\t\t\t$text = substr($text, 0, $pos)\n\t\t\t\t\t. $this->noise[$key]\n\t\t\t\t\t. substr($text, $pos + 16);\n\t\t\t\t} else {\n\t\t\t\t\t// do this to prevent an infinite loop.\n\t\t\t\t\t$text = substr($text, 0, $pos)\n\t\t\t\t\t. 'UNDEFINED NOISE FOR KEY: '\n\t\t\t\t\t. $key\n\t\t\t\t\t. substr($text, $pos + 16);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// There is no valid key being given back to us... We must get\n\t\t\t\t// rid of the ___noise___ or we will have a problem.\n\t\t\t\t$text = substr($text, 0, $pos)\n\t\t\t\t. 'NO NUMERIC NOISE KEY'\n\t\t\t\t. substr($text, $pos + 11);\n\t\t\t}\n\t\t}\n\t\treturn $text;\n\t}\n\n\tfunction search_noise($text)\n\t{\n\t\tglobal $debug_object;\n\t\tif (is_object($debug_object)) { $debug_object->debug_log_entry(1); }\n\n\t\tforeach($this->noise as $noiseElement) {\n\t\t\tif (strpos($noiseElement, $text) !== false) {\n\t\t\t\treturn $noiseElement;\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction __toString()\n\t{\n\t\treturn $this->root->innertext();\n\t}\n\n\tfunction __get($name)\n\t{\n\t\tswitch ($name) {\n\t\t\tcase 'outertext':\n\t\t\t\treturn $this->root->innertext();\n\t\t\tcase 'innertext':\n\t\t\t\treturn $this->root->innertext();\n\t\t\tcase 'plaintext':\n\t\t\t\treturn $this->root->text();\n\t\t\tcase 'charset':\n\t\t\t\treturn $this->_charset;\n\t\t\tcase 'target_charset':\n\t\t\t\treturn $this->_target_charset;\n\t\t}\n\t}\n\n\tfunction childNodes($idx = -1)\n\t{\n\t\treturn $this->root->childNodes($idx);\n\t}\n\n\tfunction firstChild()\n\t{\n\t\treturn $this->root->first_child();\n\t}\n\n\tfunction lastChild()\n\t{\n\t\treturn $this->root->last_child();\n\t}\n\n\tfunction createElement($name, $value = null)\n\t{\n\t\treturn @str_get_html(\"<$name>$value</$name>\")->firstChild();\n\t}\n\n\tfunction createTextNode($value)\n\t{\n\t\treturn @end(str_get_html($value)->nodes);\n\t}\n\n\tfunction getElementById($id)\n\t{\n\t\treturn $this->find(\"#$id\", 0);\n\t}\n\n\tfunction getElementsById($id, $idx = null)\n\t{\n\t\treturn $this->find(\"#$id\", $idx);\n\t}\n\n\tfunction getElementByTagName($name)\n\t{\n\t\treturn $this->find($name, 0);\n\t}\n\n\tfunction getElementsByTagName($name, $idx = -1)\n\t{\n\t\treturn $this->find($name, $idx);\n\t}\n\n\tfunction loadFile()\n\t{\n\t\t$args = func_get_args();\n\t\t$this->load_file($args);\n\t}\n}\n"
  },
  {
    "path": "lib/url.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nfinal class UrlException extends \\Exception\n{\n}\n\n/**\n * Intentionally restrictive url parser.\n *\n * Only absolute http/https urls.\n */\nfinal class Url\n{\n    private string $scheme;\n    private string $host;\n    private int $port;\n    private string $path;\n    private ?string $queryString;\n\n    private function __construct()\n    {\n    }\n\n    public static function fromString(string $url): self\n    {\n        if (!self::validate($url)) {\n            throw new UrlException(sprintf('Illegal url: \"%s\"', $url));\n        }\n\n        $parts = parse_url($url);\n        if ($parts === false) {\n            throw new UrlException(sprintf('Failed to parse_url(): %s', $url));\n        }\n\n        return (new self())\n            ->withScheme($parts['scheme'] ?? '')\n            ->withHost($parts['host'])\n            ->withPort($parts['port'] ?? 80)\n            ->withPath($parts['path'] ?? '/')\n            ->withQueryString($parts['query'] ?? null);\n            // todo: add fragment\n    }\n\n    public static function validate(string $url): bool\n    {\n        if (strlen($url) > 1500) {\n            return false;\n        }\n        $pattern = '#^https?://'   // scheme\n            . '([a-z0-9-]+\\.?)+'   // one or more domain names\n            . '(\\.[a-z]{1,24})?'   // optional global tld\n            . '(:\\d+)?'            // optional port\n            . '($|/|\\?)#i';        // end of string or slash or question mark\n\n        return preg_match($pattern, $url) === 1;\n    }\n\n    public function getScheme(): string\n    {\n        return $this->scheme;\n    }\n\n    public function getHost(): string\n    {\n        return $this->host;\n    }\n\n    public function getPort(): int\n    {\n        return $this->port;\n    }\n\n    public function getPath(): string\n    {\n        return $this->path;\n    }\n\n    public function getQueryString(): string\n    {\n        return $this->queryString;\n    }\n\n    public function withScheme(string $scheme): self\n    {\n        if (!in_array($scheme, ['http', 'https'])) {\n            throw new UrlException(sprintf('Invalid scheme %s', $scheme));\n        }\n        $clone = clone $this;\n        $clone->scheme = $scheme;\n        return $clone;\n    }\n\n    public function withHost(string $host): self\n    {\n        $clone = clone $this;\n        $clone->host = $host;\n        return $clone;\n    }\n\n    public function withPort(int $port)\n    {\n        $clone = clone $this;\n        $clone->port = $port;\n        return $clone;\n    }\n\n    public function withPath(string $path): self\n    {\n        if (!str_starts_with($path, '/')) {\n            throw new UrlException(sprintf('Path must start with forward slash: %s', $path));\n        }\n        if (str_starts_with($path, '//')) {\n            throw new UrlException(sprintf('Illegal path (too many forward slashes): %s', $path));\n        }\n        $clone = clone $this;\n        $clone->path = $path;\n        return $clone;\n    }\n\n    public function withQueryString(?string $queryString): self\n    {\n        $clone = clone $this;\n        $clone->queryString = $queryString;\n        return $clone;\n    }\n\n    public function __toString()\n    {\n        if ($this->port === 80) {\n            $port = '';\n        } else {\n            $port = ':' . $this->port;\n        }\n        if ($this->queryString) {\n            $queryString = '?' . $this->queryString;\n        } else {\n            $queryString = '';\n        }\n\n        return sprintf(\n            '%s://%s%s%s%s',\n            $this->scheme,\n            $this->host,\n            $port,\n            $this->path,\n            $queryString\n        );\n    }\n}\n"
  },
  {
    "path": "lib/utils.php",
    "content": "<?php\n\n// https://github.com/nette/utils/blob/master/src/Utils/Json.php\nfinal class Json\n{\n    public static function encode($value, $pretty = true, bool $asciiSafe = false): string\n    {\n        $flags = JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES;\n        if (!$asciiSafe) {\n            $flags = $flags | JSON_UNESCAPED_UNICODE;\n        }\n        if ($pretty) {\n            $flags = $flags | JSON_PRETTY_PRINT;\n        }\n        return \\json_encode($value, $flags);\n    }\n\n    public static function decode(string $json, bool $assoc = true)\n    {\n        return \\json_decode($json, $assoc, 512, JSON_THROW_ON_ERROR);\n    }\n}\n\n/**\n * Get the home page url of rss-bridge e.g. 'https://example.com/' or 'https://example.com/bridge/'\n */\nfunction get_home_page_url(): string\n{\n    $https = $_SERVER['HTTPS'] ?? '';\n    $host = $_SERVER['HTTP_HOST'] ?? '';\n    $uri = $_SERVER['REQUEST_URI'] ?? '';\n    if (($pos = strpos($uri, '?')) !== false) {\n        $uri = substr($uri, 0, $pos);\n    }\n    $scheme = $https === 'on' ? 'https' : 'http';\n    return \"$scheme://$host$uri\";\n}\n\n/**\n * Get the full current url e.g. 'http://example.com/?action=display&bridge=FooBridge'\n */\nfunction get_current_url(): string\n{\n    $https = $_SERVER['HTTPS'] ?? '';\n    $host = $_SERVER['HTTP_HOST'] ?? '';\n    $uri = $_SERVER['REQUEST_URI'] ?? '';\n    $scheme = $https === 'on' ? 'https' : 'http';\n    return \"$scheme://$host$uri\";\n}\n\nfunction create_sane_exception_message(\\Throwable $e): string\n{\n    $sanitizedMessage = sanitize_root($e->getMessage());\n    $sanitizedFilepath = sanitize_root($e->getFile());\n    return sprintf(\n        '%s: %s in %s line %s',\n        get_class($e),\n        $sanitizedMessage,\n        $sanitizedFilepath,\n        $e->getLine()\n    );\n}\n\n/**\n * Returns e.g. https://github.com/RSS-Bridge/rss-bridge/blob/master/bridges/AO3Bridge.php#L8\n */\nfunction render_github_url(string $file, int $line, string $revision = 'master'): string\n{\n    return sprintf('https://github.com/RSS-Bridge/rss-bridge/blob/%s/%s#L%s', $revision, $file, $line);\n}\n\nfunction trace_from_exception(\\Throwable $e): array\n{\n    $frames = array_reverse($e->getTrace());\n    $frames[] = [\n        'file' => $e->getFile(),\n        'line' => $e->getLine(),\n    ];\n    $trace = [];\n    foreach ($frames as $frame) {\n        $trace[] = [\n            'file'      => sanitize_root($frame['file'] ?? ''),\n            'line'      => $frame['line'] ?? null,\n            'class'     => $frame['class'] ?? null,\n            'type'      => $frame['type'] ?? null,\n            'function'  => $frame['function'] ?? null,\n        ];\n    }\n    return $trace;\n}\n\nfunction trace_to_call_points(array $trace): array\n{\n    return array_map(fn($frame) => frame_to_call_point($frame), $trace);\n}\n\nfunction frame_to_call_point(array $frame): string\n{\n    if ($frame['class']) {\n        return sprintf(\n            '%s(%s): %s%s%s()',\n            $frame['file'],\n            $frame['line'],\n            $frame['class'],\n            $frame['type'],\n            $frame['function'],\n        );\n    } elseif ($frame['function']) {\n        return sprintf(\n            '%s(%s): %s()',\n            $frame['file'],\n            $frame['line'],\n            $frame['function'],\n        );\n    } else {\n        return sprintf(\n            '%s(%s)',\n            $frame['file'],\n            $frame['line'],\n        );\n    }\n}\n\n/**\n * Trim path prefix for privacy/security reasons\n *\n * Example: \"/home/davidsf/rss-bridge/index.php\" => \"index.php\"\n */\nfunction sanitize_root(string $filePath): string\n{\n    // Root folder of the project e.g. /home/satoshi/repos/rss-bridge\n    $root = dirname(__DIR__);\n    return _sanitize_path_name($filePath, $root);\n}\n\nfunction _sanitize_path_name(string $s, string $pathName): string\n{\n    // Remove all occurrences of $pathName in the string\n    return str_replace([\"$pathName/\", $pathName], '', $s);\n}\n\n/**\n * This is buggy because strip_tags() removes a lot that isn't html\n */\nfunction is_html(string $text): bool\n{\n    return strlen(strip_tags($text)) !== strlen($text);\n}\n\n/**\n * Determines the MIME type from a URL/Path file extension.\n *\n * _Remarks_:\n *\n * * The built-in functions `mime_content_type` and `fileinfo` require fetching\n * remote contents.\n * * A caller can hint for a MIME type by appending `#.ext` to the URL (i.e. `#.image`).\n *\n * Based on https://stackoverflow.com/a/1147952\n *\n * @param string $url The URL or path to the file.\n * @return string The MIME type of the file.\n */\nfunction parse_mime_type($url)\n{\n    static $mime = null;\n\n    if (is_null($mime)) {\n        // Default values, overriden by /etc/mime.types when present\n        $mime = [\n            'jpg' => 'image/jpeg',\n            'gif' => 'image/gif',\n            'png' => 'image/png',\n            'webp' => 'image/webp',\n            'image' => 'image/*',\n            'mp3' => 'audio/mpeg',\n        ];\n        // if-check to avoid excessive php errors about open_basedir restriction (#4502)\n        $open_basedir = ini_get('open_basedir');\n        if (! $open_basedir) {\n            // '@' is used to mute open_basedir warning, see issue #818\n            if (@is_readable('/etc/mime.types')) {\n                $file = fopen('/etc/mime.types', 'r');\n                while (($line = fgets($file)) !== false) {\n                    $line = trim(preg_replace('/#.*/', '', $line));\n                    if (!$line) {\n                        continue;\n                    }\n                    $parts = preg_split('/\\s+/', $line);\n                    if (count($parts) == 1) {\n                        continue;\n                    }\n                    $type = array_shift($parts);\n                    foreach ($parts as $part) {\n                        $mime[$part] = $type;\n                    }\n                }\n                fclose($file);\n            }\n        }\n    }\n\n    if (strpos($url, '?') !== false) {\n        $url_temp = substr($url, 0, strpos($url, '?'));\n        if (strpos($url, '#') !== false) {\n            $anchor = substr($url, strpos($url, '#'));\n            $url_temp .= $anchor;\n        }\n        $url = $url_temp;\n    }\n\n    $ext = strtolower(pathinfo($url, PATHINFO_EXTENSION));\n    if (!empty($mime[$ext])) {\n        return $mime[$ext];\n    }\n\n    return 'application/octet-stream';\n}\n\n/**\n * https://stackoverflow.com/a/2510459\n */\nfunction format_bytes(int $bytes, $precision = 2)\n{\n    $units = ['B', 'KB', 'MB', 'GB', 'TB'];\n\n    $bytes = max($bytes, 0);\n    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));\n    $pow = min($pow, count($units) - 1);\n    $bytes /= pow(1024, $pow);\n\n    return round($bytes, $precision) . ' ' . $units[$pow];\n}\n\nfunction now(): \\DateTimeImmutable\n{\n    return new \\DateTimeImmutable();\n}\n\nfunction create_random_string(int $bytes = 16): string\n{\n    return bin2hex(openssl_random_pseudo_bytes($bytes));\n}\n\n/**\n * Thrown by bridges to indicate user failure. Will not be logged.\n */\nfinal class ClientException extends \\Exception\n{\n}\n\nfunction throwClientException(string $message = '')\n{\n    throw new ClientException($message, 400);\n}\n\nfunction throwServerException(string $message = '')\n{\n    throw new \\Exception($message, 500);\n}\n\nfunction throwRateLimitException(string $message = '')\n{\n    throw new RateLimitException($message);\n}\n\n/**\n * @deprecated Use throwClientException() instead\n */\nfunction returnClientError(string $message = '')\n{\n    throw new \\Exception($message);\n}\n\n/**\n * @deprecated Use throwServerException() instead\n */\nfunction returnServerError(string $message = '')\n{\n    throw new \\Exception($message);\n}\n"
  },
  {
    "path": "middlewares/BasicAuthMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * HTTP Basic auth check\n */\nclass BasicAuthMiddleware implements Middleware\n{\n    public function __invoke(Request $request, $next): Response\n    {\n        if (!Configuration::getConfig('authentication', 'enable')) {\n            return $next($request);\n        }\n\n        if (Configuration::getConfig('authentication', 'password') === '') {\n            return new Response('The authentication password cannot be the empty string', 500);\n        }\n        $user = $request->server('PHP_AUTH_USER');\n        $password = $request->server('PHP_AUTH_PW');\n        if ($user === null || $password === null) {\n            $html = render(__DIR__ . '/../templates/error.html.php', [\n                'message' => 'Please authenticate in order to access this instance!',\n            ]);\n            return new Response($html, 401, ['WWW-Authenticate' => 'Basic realm=\"RSS-Bridge\"']);\n        }\n        if (\n            (Configuration::getConfig('authentication', 'username') !== $user)\n            || (!hash_equals(Configuration::getConfig('authentication', 'password'), $password))\n        ) {\n            $html = render(__DIR__ . '/../templates/error.html.php', [\n                'message' => 'Please authenticate in order to access this instance!',\n            ]);\n            return new Response($html, 401, ['WWW-Authenticate' => 'Basic realm=\"RSS-Bridge\"']);\n        }\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "middlewares/CacheMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass CacheMiddleware implements Middleware\n{\n    private CacheInterface $cache;\n\n    public function __construct(CacheInterface $cache)\n    {\n        $this->cache = $cache;\n    }\n\n    public function __invoke(Request $request, $next): Response\n    {\n        $action = $request->getAttribute('action');\n\n        if ($action !== 'DisplayAction') {\n            // We only cache DisplayAction (for now)\n            return $next($request);\n        }\n\n        // TODO: might want to remove som params from query\n        $cacheKey = 'http_' . json_encode($request->toArray());\n        $cachedResponse = $this->cache->get($cacheKey);\n\n        if ($cachedResponse) {\n            $ifModifiedSince = $request->server('HTTP_IF_MODIFIED_SINCE');\n            $lastModified = $cachedResponse->getHeader('last-modified');\n            if ($ifModifiedSince && $lastModified) {\n                $lastModified = new \\DateTimeImmutable($lastModified);\n                $lastModifiedTimestamp = $lastModified->getTimestamp();\n                $modifiedSince = strtotime($ifModifiedSince);\n                // TODO: \\DateTimeImmutable can be compared directly\n                if ($lastModifiedTimestamp <= $modifiedSince) {\n                    $modificationTimeGMT = gmdate('D, d M Y H:i:s ', $lastModifiedTimestamp);\n                    return new Response('', 304, ['last-modified' => $modificationTimeGMT . 'GMT']);\n                }\n            }\n            return $cachedResponse;\n        }\n\n        /** @var Response $response */\n        $response = $next($request);\n\n        if ($response->getCode() === 200) {\n            // Do nothing because DisplayAction has already cached this on $cacheKey\n        } elseif (in_array($response->getCode(), [400, 403, 404, 429, 500, 503])) {\n            // Cache these responses for about ~10 mins on average\n            $this->cache->set($cacheKey, $response, 60 * 5 + rand(1, 60 * 10));\n        } else {\n            // Should never happen\n            $this->cache->set($cacheKey, $response, 60 * 5);\n        }\n\n        // For 1% of requests, prune cache\n        if (rand(1, 100) === 1) {\n            // This might be resource intensive!\n            $this->cache->prune();\n        }\n\n        return $response;\n    }\n}"
  },
  {
    "path": "middlewares/ExceptionMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass ExceptionMiddleware implements Middleware\n{\n    private Logger $logger;\n\n    public function __construct(Logger $logger)\n    {\n        $this->logger = $logger;\n    }\n\n    public function __invoke(Request $request, $next): Response\n    {\n        try {\n            return $next($request);\n        } catch (\\Throwable $e) {\n            $this->logger->error('Exception in ExceptionMiddleware', ['e' => $e]);\n\n            return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 500);\n        }\n    }\n}"
  },
  {
    "path": "middlewares/MaintenanceMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass MaintenanceMiddleware implements Middleware\n{\n    public function __invoke(Request $request, $next): Response\n    {\n        if (!Configuration::getConfig('system', 'enable_maintenance_mode')) {\n            return $next($request);\n        }\n        return new Response(render(__DIR__ . '/../templates/error.html.php', [\n            'title' => '503 Service Unavailable',\n            'message' => 'RSS-Bridge is down for maintenance.',\n        ]), 503);\n    }\n}\n"
  },
  {
    "path": "middlewares/Middleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\ninterface Middleware\n{\n    public function __invoke(Request $request, $next): Response;\n}\n"
  },
  {
    "path": "middlewares/SecurityMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\n/**\n * Make sure that only strings are allowed in GET parameters\n */\nclass SecurityMiddleware implements Middleware\n{\n    public function __invoke(Request $request, $next): Response\n    {\n        foreach ($request->toArray() as $key => $value) {\n            if (!is_string($value)) {\n                return new Response(render(__DIR__ . '/../templates/error.html.php', [\n                    'message' => \"Query parameter \\\"$key\\\" is not a string.\",\n                ]), 400);\n            }\n        }\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "middlewares/TokenAuthenticationMiddleware.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nclass TokenAuthenticationMiddleware implements Middleware\n{\n    public function __invoke(Request $request, $next): Response\n    {\n        if (! Configuration::getConfig('authentication', 'token')) {\n            return $next($request);\n        }\n\n        $token = $request->get('token');\n\n        if (! $token) {\n            return new Response(render(__DIR__ . '/../templates/token.html.php', [\n                'message'   => 'Missing token',\n                'token'     => '',\n            ]), 401);\n        }\n\n        if (! hash_equals(Configuration::getConfig('authentication', 'token'), $token)) {\n            return new Response(render(__DIR__ . '/../templates/token.html.php', [\n                'message'   => 'Invalid token',\n                'token'     => $token,\n            ]), 401);\n        }\n\n        $request = $request->withAttribute('token', $token);\n\n        return $next($request);\n    }\n}\n"
  },
  {
    "path": "phpcompatibility.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ruleset name=\"RSS-Bridge PHPCompatibility\">\n  <description>Defines rules for PHPCompatibility</description>\n  <exclude-pattern>./static</exclude-pattern>\n  <exclude-pattern>./vendor</exclude-pattern>\n\n  <!-- Run against the PHPCompatibility ruleset -->\n  <!--\n\n  -->\n  <config name=\"testVersion\" value=\"7.4-\"/>\n  <rule ref=\"PHPCompatibility\">\n    <!-- This sniff is very overzealous and inaccurate, so we'll disable it -->\n    <exclude name=\"PHPCompatibility.Extensions.RemovedExtensions\"/>\n  </rule>\n\n</ruleset>\n"
  },
  {
    "path": "phpcs.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ruleset name=\"RSS-Bridge Ruleset\">\n  <description>\n    Originally created with the PHP Coding Standard Generator.\n    But later manually tweaked.\n    http://edorian.github.com/php-coding-standard-generator/\n  </description>\n\n  <exclude-pattern>./static</exclude-pattern>\n  <exclude-pattern>./vendor</exclude-pattern>\n  <exclude-pattern>./lib/parsedown</exclude-pattern>\n  <exclude-pattern>./lib/php-urljoin</exclude-pattern>\n  <exclude-pattern>./lib/simplehtmldom</exclude-pattern>\n  <exclude-pattern>./templates</exclude-pattern>\n  <exclude-pattern>./config.default.ini.php</exclude-pattern>\n  <exclude-pattern>./config.ini.php</exclude-pattern>\n\n  <rule ref=\"PSR12\">\n    <exclude name=\"PSR1.Classes.ClassDeclaration.MissingNamespace\"/>\n    <exclude name=\"PSR1.Classes.ClassDeclaration.MultipleClasses\"/>\n    <exclude name=\"PSR1.Files.SideEffects.FoundWithSymbols\"/>\n    <exclude name=\"PSR2.Files.EndFileNewline\"/>\n    <exclude name=\"PSR12.Properties.ConstantVisibility.NotFound\"/>\n  </rule>\n\n  <rule ref=\"Generic.Arrays.DisallowLongArraySyntax\" />\n\n  <rule ref=\"Squiz.WhiteSpace.FunctionOpeningBraceSpace\" />\n\n  <rule ref=\"Generic.Files.LineLength\">\n    <properties>\n      <property name=\"lineLimit\" value=\"180\"/>\n      <property name=\"absoluteLineLimit\" value=\"180\"/>\n      <property name=\"ignoreComments\" value=\"true\"/>\n    </properties>\n  </rule>\n\n  <rule ref=\"Generic.PHP.ForbiddenFunctions\">\n    <properties>\n      <property name=\"forbiddenFunctions\" type=\"array\">\n        <element key=\"sizeof\" value=\"count\"/>\n        <element key=\"returnClientError\" value=\"throwClientException\"/>\n        <element key=\"returnServerError\" value=\"throwServerException\"/>\n      </property>\n    </properties>\n  </rule>\n\n  <!-- Duplicate class names are not allowed -->\n  <rule ref=\"Generic.Classes.DuplicateClassName\"/>\n\n  <!-- Unconditional if-statements are not allowed -->\n  <rule ref=\"Generic.CodeAnalysis.UnconditionalIfStatement\"/>\n  <!-- Do not use final statements inside final classes -->\n  <rule ref=\"Generic.CodeAnalysis.UnnecessaryFinalModifier\"/>\n  <!-- Do not override methods to call their parent -->\n  <rule ref=\"Generic.CodeAnalysis.UselessOverridingMethod\"/>\n  <!-- Do not allow UTF-8 byte-order mark -->\n  <rule ref=\"Generic.Files.ByteOrderMark\"/>\n  <!-- Make sure the concatenation operator has spaces around it -->\n  <rule ref=\"Squiz.Strings.ConcatenationSpacing\">\n    <properties>\n      <property name=\"spacing\" value=\"1\"/>\n      <property name=\"ignoreNewlines\" value=\"true\"/>\n    </properties>\n  </rule>\n\n  <!-- When calling a function: -->\n  <!-- Do not add a space before the opening parenthesis -->\n  <!-- Do not add a space after the opening parenthesis -->\n  <!-- Do not add a space before the closing parenthesis -->\n  <!-- Do not add a space before a comma -->\n  <!-- Add a space after a comma -->\n  <rule ref=\"Generic.Functions.FunctionCallArgumentSpacing\"/>\n  <!-- Use UPPERCARE for constants -->\n  <rule ref=\"Generic.NamingConventions.UpperCaseConstantName\"/>\n  <!-- Use lowercase for 'true', 'false' and 'null' -->\n  <rule ref=\"Generic.PHP.LowerCaseConstant\"/>\n  <!-- Use a single string instead of concating -->\n  <rule ref=\"Generic.Strings.UnnecessaryStringConcat\"/>\n\n  <!-- Do not add spaces when casting -->\n  <rule ref=\"Squiz.WhiteSpace.CastSpacing\"/>\n  <!-- Operators must have a space around them -->\n  <rule ref=\"Squiz.WhiteSpace.OperatorSpacing\"/>\n  <!-- Do not add a whitespace before a semicolon -->\n  <rule ref=\"Squiz.WhiteSpace.SemicolonSpacing\"/>\n  <!-- Do not add whitespace at start or end of a file or end of a line -->\n  <rule ref=\"Squiz.WhiteSpace.SuperfluousWhitespace\">\n    <properties>\n      <!--\n      This fixes an issue in combination with PSR2\n      https://github.com/squizlabs/PHP_CodeSniffer/issues/600\n      -->\n      <property name=\"ignoreBlankLines\" value=\"false\"/>\n    </properties>\n  </rule>\n\n  <!-- Whenever possible use single quote strings -->\n  <rule ref=\"Squiz.Strings.DoubleQuoteUsage\">\n    <exclude name=\"Squiz.Strings.DoubleQuoteUsage.ContainsVar\" />\n  </rule>\n</ruleset>\n"
  },
  {
    "path": "phpunit.xml",
    "content": "<phpunit\n\txmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n\txsi:noNamespaceSchemaLocation=\"http://schema.phpunit.de/4.5/phpunit.xsd\"\n\tbootstrap=\"./lib/bootstrap.php\"\n\tcolors=\"true\"\n\tprocessIsolation=\"false\"\n\ttimeoutForSmallTests=\"1\"\n\ttimeoutForMediumTests=\"1\"\n\ttimeoutForLargeTests=\"6\" >\n\n\t<testsuites>\n\t\t<testsuite name=\"rss-bridge\">\n\t\t\t<directory>./tests</directory>\n\t\t</testsuite>\n\t</testsuites>\n\n</phpunit>\n"
  },
  {
    "path": "scalingo.json",
    "content": "{\n\t\"name\": \"RSS Bridge\",\n\t\"description\": \"rss-bridge is a PHP project capable of generating ATOM feeds for websites which don't have one.\",\n\t\"repository\": \"https://github.com/sebsauvage/rss-bridge\",\n\t\"website\": \"https://github.com/sebsauvage/rss-bridge\"\n}\n"
  },
  {
    "path": "static/connectivity.css",
    "content": "input:focus::-webkit-input-placeholder { opacity: 0; }\ninput:focus::-moz-placeholder { opacity: 0; }\ninput:focus::placeholder { opacity: 0; }\ninput:focus:-moz-placeholder { opacity: 0; }\ninput:focus:-ms-input-placeholder { opacity: 0; }\n\n.progress { height: 2px; }\n.progressbar { width: 0%; }"
  },
  {
    "path": "static/connectivity.js",
    "content": "var remote = location.href.substring(0, location.href.lastIndexOf(\"/\"));\nvar bridges = [];\nvar abort = false;\n\nwindow.onload = function() {\n\n\tfetch(remote + '/?action=list').then(function(response) {\n\t\treturn response.text()\n\t}).then(function(data){\n\t\tprocessBridgeList(data);\n\t}).catch(console.log.bind(console)\n\t);\n\n}\n\nfunction processBridgeList(data) {\n\n\tvar list = JSON.parse(data);\n\n\tbuildTable(list);\n\tbuildBridgeQueue(list);\n\tcheckNextBridgeAsync();\n\n}\n\nfunction buildTable(bridgeList) {\n\n\tvar table = document.createElement('table');\n\ttable.classList.add('table');\n\n\tvar thead = document.createElement('thead');\n\tthead.innerHTML = `\n\t<tr>\n\t\t<th scope=\"col\">Bridge</th>\n\t\t<th scope=\"col\">Result</th>\n\t</tr>`;\n\n\tvar tbody = document.createElement('tbody');\n\n\tfor (var bridge in bridgeList.bridges) {\n\n\t\tvar tr = document.createElement('tr');\n\t\ttr.classList.add('bg-secondary');\n\t\ttr.id = bridge;\n\n\t\tvar td_bridge = document.createElement('td');\n\t\ttd_bridge.innerText = bridgeList.bridges[bridge].name;\n\n\t\t// Link to the actual bridge on frontpage\n\t\tvar a = document.createElement('a');\n\t\ta.href = remote + \"/?#bridge-\" + bridge;\n\t\ta.target = '_blank';\n\t\ta.innerText = '[Show]';\n\t\ta.style.marginLeft = '5px';\n\t\ta.style.color = 'black';\n\n\t\ttd_bridge.appendChild(a);\n\t\ttr.appendChild(td_bridge);\n\n\t\tvar td_result = document.createElement('td');\n\n\t\tif (bridgeList.bridges[bridge].status === 'active') {\n\t\t\ttd_result.innerHTML = '<i title=\"Scheduled\" class=\"fas fa-hourglass-start\"></i>';\n\t\t} else {\n\t\t\ttd_result.innerHTML = '<i title=\"Inactive\" class=\"fas fa-times-circle\"></i>';\n\t\t}\n\n\t\ttr.appendChild(td_result);\n\t\ttbody.appendChild(tr);\n\n\t}\n\n\ttable.appendChild(thead);\n\ttable.appendChild(tbody);\n\n\tvar content = document.getElementById('main-content');\n\tcontent.appendChild(table);\n\n}\n\nfunction buildBridgeQueue(bridgeList) {\n\tfor (var bridge in bridgeList.bridges) {\n\t\tif (bridgeList.bridges[bridge].status !== 'active')\n\t\t\tcontinue;\n\t\tbridges.push(bridge);\n\t}\n}\n\n\nfunction checkNextBridgeAsync() {\n\treturn new Promise((resolve) => {\n\t\tvar msg = document.getElementById('status-message');\n\t\tvar icon = document.getElementById('status-icon');\n\n\t\tif (bridges.length === 0) {\n\t\t\tmsg.classList.remove('alert-primary');\n\t\t\tmsg.classList.add('alert-success');\n\t\t\tmsg.getElementsByTagName('span')[0].textContent = 'Done';\n\n\t\t\ticon.classList.remove('fa-sync');\n\t\t\ticon.classList.add('fa-check');\n\t\t} else {\n\t\t\tvar bridge = bridges.shift();\n\n\t\t\tmsg.getElementsByTagName('span')[0].textContent = 'Processing ' + bridge + '...';\n\n\t\t\tfetch(remote + '/?action=Connectivity&bridge=' + bridge)\n\t\t\t.then(function(response) { return response.text() })\n\t\t\t.then(JSON.parse)\n\t\t\t.then(processBridgeResultAsync)\n\t\t\t.then(markBridgeSuccessful, markBridgeFailed)\n\t\t\t.then(checkAbortAsync)\n\t\t\t.then(checkNextBridgeAsync, abortChecks)\n\t\t\t.catch(console.log.bind(console));\n\n\t\t\tsearch(); // Dynamically update search results\n\t\t\tupdateProgressBar();\n\n\t\t}\n\n\t\tresolve();\n\t});\n}\n\nfunction abortChecks() {\n\treturn new Promise((resolve) => {\n\t\tvar msg = document.getElementById('status-message');\n\n\t\tmsg.classList.remove('alert-primary');\n\t\tmsg.classList.add('alert-warning');\n\t\tmsg.getElementsByTagName('span')[0].textContent = 'Aborted';\n\n\t\tvar icon = document.getElementById('status-icon');\n\t\ticon.classList.remove('fa-sync');\n\t\ticon.classList.add('fa-ban');\n\n\t\tbridges.forEach((bridge) => {\n\t\t\tmarkBridgeAborted(bridge);\n\t\t})\n\n\t\tresolve();\n\t});\n}\n\nfunction processBridgeResultAsync(result) {\n\treturn new Promise((resolve, reject) => {\n\t\tif (result.successful) {\n\t\t\tresolve(result);\n\t\t} else {\n\t\t\treject(result);\n\t\t}\n\t});\n}\n\nfunction markBridgeSuccessful(result) {\n\treturn new Promise((resolve) => {\n\t\tvar tr = document.getElementById(result.bridge);\n\t\ttr.classList.remove('bg-secondary');\n\t\tif (result.http_code == 200) {\n\t\t\ttr.classList.add('bg-success');\n\t\t\ttr.children[1].innerHTML = '<i title=\"Successful\" class=\"fas fa-check\"></i>';\n\t\t} else {\n\t\t\ttr.classList.add('bg-primary');\n\t\t\ttr.children[1].innerHTML = '<i title=\"Redirected\" class=\"fas fa-directions\"></i>';\n\t\t}\n\n\t\tresolve();\n\t});\n}\n\nfunction markBridgeFailed(result) {\n\treturn new Promise((resolve) => {\n\t\tvar tr = document.getElementById(result.bridge);\n\t\ttr.classList.remove('bg-secondary');\n\t\ttr.classList.add('bg-danger');\n\t\ttr.children[1].innerHTML = '<i title=\"Failed\" class=\"fas fa-exclamation-triangle\"></i>';\n\n\t\tresolve();\n\t});\n}\n\nfunction markBridgeAborted(bridge) {\n\treturn new Promise((resolve) => {\n\t\tvar tr = document.getElementById(bridge);\n\t\ttr.classList.remove('bg-secondary');\n\t\ttr.classList.add('bg-warning');\n\t\ttr.children[1].innerHTML = '<i title=\"Aborted\" class=\"fas fa-ban\"></i>';\n\n\t\tresolve();\n\t});\n}\n\nfunction checkAbortAsync() {\n\treturn new Promise((resolve, reject) => {\n\t\tif (abort) {\n\t\t\treject();\n\t\t\treturn;\n\t\t}\n\n\t\tresolve();\n\t});\n}\n\nfunction updateProgressBar() {\n\n\t// This will break if the table changes\n\tvar total = document.getElementsByTagName('tr').length - 1;\n\tvar current = bridges.length;\n\tvar progress = (total - current) * 100 / total;\n\n\tvar progressBar = document.getElementsByClassName('progress-bar')[0];\n\n\tif(progressBar){\n\t\tprogressBar.setAttribute('aria-valuenow', progress.toFixed(0));\n\t\tprogressBar.style.width = progress.toFixed(0) + '%';\n\t}\n\n}\n\nfunction stopConnectivityChecks() {\n\tabort = true;\n}\n\nfunction search() {\n\n\tvar input = document.getElementById('search');\n\tvar filter = input.value.toUpperCase();\n\tvar table = document.getElementsByTagName('table')[0];\n\tvar tr = table.getElementsByTagName('tr');\n\n\tfor (var i = 0; i < tr.length; i++) {\n\n\t\tvar td1 = tr[i].getElementsByTagName('td')[0];\n\t\tvar td2 = tr[i].getElementsByTagName('td')[1];\n\n\t\tif (td1) {\n\n\t\t\tvar txtValue = td1.textContent || td1.innerText;\n\n\t\t\tvar title = '';\n\t\t\tif(td2.getElementsByTagName('i')[0]) {\n\t\t\t\ttitle = td2.getElementsByTagName('i')[0].title;\n\t\t\t}\n\n\t\t\tif (txtValue.toUpperCase().indexOf(filter) > -1\n\t\t\t|| title.toUpperCase().indexOf(filter) > -1) {\n\t\t\t\ttr[i].style.display = '';\n\t\t\t} else {\n\t\t\t\ttr[i].style.display = 'none';\n\t\t\t}\n\n\t\t}\n\n\t}\n\n}"
  },
  {
    "path": "static/rss-bridge.js",
    "content": "function rssbridge_list_search() {\n    var search = document.getElementById('searchfield').value;\n\n    var bridgeCards = document.querySelectorAll('section.bridge-card');\n    for (var i = 0; i < bridgeCards.length; i++) {\n        var bridgeName = bridgeCards[i].getAttribute('data-ref');\n        var bridgeShortName = bridgeCards[i].getAttribute('data-short-name');\n        var bridgeDescription = bridgeCards[i].querySelector('.description');\n        var bridgeUrlElement = bridgeCards[i].getElementsByTagName('a')[0];\n        var bridgeUrl = bridgeUrlElement.toString();\n\n        bridgeCards[i].style.display = 'none';\n        if (!bridgeName || !bridgeUrl) {\n            continue;\n        }\n        var searchRegex = new RegExp(search, 'i');\n        if (bridgeName.match(searchRegex)) {\n            bridgeCards[i].style.display = 'block';\n        }\n        if (bridgeShortName.match(searchRegex)) {\n            bridgeCards[i].style.display = 'block';\n        }\n        if (bridgeDescription.textContent.match(searchRegex)) {\n            bridgeCards[i].style.display = 'block';\n        }\n        if (bridgeUrl.match(searchRegex)) {\n            bridgeCards[i].style.display = 'block';\n        }\n    }\n}\n\nfunction rssbridge_toggle_bridge(){\n    var fragment = window.location.hash.substr(1);\n    var bridge = document.getElementById(fragment);\n\n    if(bridge !== null) {\n        bridge.getElementsByClassName('showmore-box')[0].checked = true;\n    }\n}\n\nfunction rssbridge_use_placeholder_value(sender) {\n    let inputId = sender.getAttribute('data-for');\n    let inputElement = document.getElementById(inputId);\n    inputElement.value = inputElement.getAttribute(\"placeholder\");\n}\n\nvar rssbridge_feed_finder = (function() {\n    /*\n     * Code for \"Find feed by URL\" feature\n     */\n\n    // Start the Feed search\n    async function rssbridge_feed_search(event) {\n        const input = document.getElementById('searchfield');\n        let content = encodeURIComponent(input.value);\n        if (content) {\n            const findfeedresults = document.getElementById('findfeedresults');\n            findfeedresults.innerHTML = 'Searching for matching feeds ...';\n            let baseurl = window.location.protocol + window.location.pathname;\n            let url = baseurl + '?action=findfeed&format=Html&url=' + content;\n            const response = await fetch(url);\n            if (response.ok) {\n                const data = await response.json();\n                rss_bridge_feed_display_found_feed(data);\n            } else {\n                rss_bridge_feed_display_feed_search_fail();\n            }\n        } else {\n            rss_bridge_feed_display_find_feed_empty();\n        }\n    }\n\n    // Display the found feeds\n    function rss_bridge_feed_display_found_feed(obj) {\n        const findfeedresults = document.getElementById('findfeedresults');\n\n        let content = 'Found Feed(s) :';\n\n        // Let's go throug every Feed found\n        for (const element of obj) {\n            content += `<div class=\"search-result\">\n                        <div class=\"icon\">\n                            <img src=\"${element.bridgeMeta.icon}\" width=\"60\" />\n                        </div>\n                        <div class=\"content\">\n                        <h2><a href=\"${element.url}\">${element.bridgeMeta.name}</a></h2>\n                        <p>\n                        <span class=\"description\"><a href=\"${element.url}\">${element.bridgeMeta.description}</a></span>\n                        </p>\n                        <div>\n                            <ul>`;\n\n            // Now display every Feed parameter\n            for (const param in element.bridgeData) {\n                content += `<li>${element.bridgeData[param].name} : ${element.bridgeData[param].value}</li>`;\n            }\n            content += `</div>\n              </div>\n            </div>`;\n        }\n        content += '<p><div class=\"alert alert-info\" role=\"alert\">This feed may be only one of the possible feeds. You may find more feeds using one of the bridges with different parameters, for example.</div></p>';\n        findfeedresults.innerHTML = content;\n    }\n\n    // Display an error if no feed were found\n    function rss_bridge_feed_display_feed_search_fail() {\n        const findfeedresults = document.getElementById('findfeedresults');\n        findfeedresults.innerHTML = 'No Feed found !<div class=\"alert alert-info\" role=\"alert\">Not every bridge supports feed detection. You can check below within the bridge parameters to create a feed.</div>';\n    }\n\n    // Empty the Found Feed section\n    function rss_bridge_feed_display_find_feed_empty() {\n        const findfeedresults = document.getElementById('findfeedresults');\n        findfeedresults.innerHTML = '';\n    }\n\n    // Add Event to 'Detect Feed\" button\n    var rssbridge_feed_finder = function() {\n        const button = document.getElementById('findfeed');\n        button.addEventListener(\"click\", rssbridge_feed_search);\n        button.addEventListener(\"keyup\", rssbridge_feed_search);\n    };\n    return rssbridge_feed_finder;\n}());\n"
  },
  {
    "path": "static/style.css",
    "content": "html {\n    box-sizing: border-box;\n    font-size: 16px;\n}\n\n*, *:before, *:after {\n    box-sizing: inherit;\n}\n\nbody, h1, h2, h3, h4, h5, h6, p, ol, ul {\n    margin: 0;\n    padding: 0;\n    font-weight: normal;\n}\n\nol, ul {\n    list-style: none;\n}\n\nimg {\n    max-width: 100%;\n    height: auto;\n}\n\n\n/* HTML5 display-role reset for older browsers */\narticle, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section {\n    display: block;\n}\n\n/* Adjust parameters for browsers that don't support the grid layout */\n\n.parameters label:before {\n    content: \" \";\n    display: block;\n}\n\n/* Let's go for the actual style */\nbody {\n    background-color: #f0f0f0;\n    font-family: sans-serif;\n    font-size: 14px;\n}\n\na, a:link, a:visited {\n    color: #2196F3;\n    text-decoration: none;\n}\n\na:hover {\n    text-decoration: underline;\n}\n\nh1,h2 {\n    margin-bottom: 10px;\n}\nh5 {\n    margin: 20px;\n    font-weight: bold;\n    display: none;\n}\n\np {\n    margin-bottom: 10px;\n}\n\n/* Header */\nheader {\n    padding: 15px;\n    color: #1182DB;\n    text-align: center;\n}\n\n.alert-info {\n    margin-bottom: 15px;\n    color: white;\n    font-weight: bold;\n    background-color: rgb(33, 150, 243);\n    padding: 15px;\n    border-radius: 4px;\n    text-align: center;\n}\n\n.alert-warning {\n    background-color: #ffc600;\n    color: #5f5f5f;\n    margin-bottom: 15px;\n    font-weight: bold;\n    padding: 15px;\n    border-radius: 4px;\n    text-align: center;\n}\n\n.alert-error {\n    background-color: #cf3e3e;\n    font-weight: bold;\n    color: white;\n    margin-bottom: 15px;\n    padding: 15px;\n    border-radius: 4px;\n    text-align: center;\n}\n\nselect,\ninput[type=\"text\"],\ninput[type=\"number\"] {\n    background-color: white;\n    color: #404552;\n    border: 1px solid #dedede;\n    margin-left: 8px;\n    margin-bottom: 10px;\n    padding: 5px 10px;\n}\n\nselect:focus,\ninput[type=\"text\"]:focus,\ninput[type=\"number\"]:focus {\n    outline: none;\n    border-color: #888;\n}\n\ninput:focus::-webkit-input-placeholder { opacity: 0; }\ninput:focus::-moz-placeholder { opacity: 0; }\ninput:focus::placeholder { opacity: 0; }\ninput:focus:-moz-placeholder { opacity: 0; }\ninput:focus:-ms-input-placeholder { opacity: 0; }\n\n.searchbar {\n    width: 60%;\n    text-align: center;\n    margin: 0 auto 50px;\n}\n\n.searchbar input[type=\"text\"] {\n    width: 90%;\n    font-size: 1.1em;\n    text-align: center;\n    margin: auto auto 10px;\n}\n\n.searchbar input[type=\"text\"]::placeholder {\n    text-align: center;\n}\n\n.searchbar > h3 {\n    font-size: 200%;\n    font-weight: bold;\n    color: #1182DB;\n    margin-bottom: 10px;\n}\n\n.container {\n    width: 60%;\n    margin: 0 auto 30px auto;\n}\n\n/* Section */\nsection {\n    margin-bottom: 10px;\n    background-color: #FFFFFF;\n    padding: 15px;\n    box-shadow: 0 6px 15px rgba(0, 0, 0, 0.09);\n    border-radius: 4px;\n}\nsection > time,\nsection > p.author {\n    color: #888;\n    font-size: 80%;\n    padding: 10px;\n}\nsection.footer {\n    opacity: 0.5;\n    text-align: center;\n}\n\nsection.footer:hover {\n    opacity: 1;\n}\n\nsection > h2 {\n    font-size: 200%;\n    font-weight: bold;\n    text-align: center;\n}\nsection li {\n    margin-left: 1em;\n}\n.bridge-card {\n    position: relative;\n    text-align: center;\n}\n\n/* Buttons */\nbutton.small {\n    width: auto;\n    line-height: 1.2em;\n}\n\nbutton:hover {\n    background: #49afff;\n}\n\n.description {\n    margin: 10px;\n}\n\nform {\n    margin-bottom: 6px;\n}\n\n.parameters label::first-letter {\n    text-transform: capitalize;\n}\n\n.parameters label::after {\n    content: ' :';\n}\n\n.info {\n    cursor: pointer;\n    opacity: 0.5;\n    width: 24px;\n    height: 24px;\n    font-size: 16px;\n    font-weight: bold;\n    font-style: italic;\n    line-height: 22px;\n    text-align: center;\n    color: #fff;\n    background-image: radial-gradient(#49afff, #1182DB);\n    -webkit-border-radius: 16px;\n    -moz-border-radius: 16px;\n    border-radius: 16px;\n}\n\n.info:hover {\n    opacity: 1;\n}\n\n@supports (display: grid) {\n\n    .parameters {\n        display: grid;\n        padding: 12px 0;\n        grid-template-columns: 40% max-content 24px;\n        grid-column-gap: 10px;\n        grid-row-gap: 5px;\n    }\n\n    .parameters label {\n        text-align: right;\n        line-height: 1.5em;\n    }\n\n    .parameters label::before {\n        content: none;\n    }\n\n    .parameters input[type=\"text\"],\n    .parameters input[type=\"number\"],\n    .parameters input[type=\"checkbox\"],\n    .parameters select {\n        margin-left: 0;\n    }\n\n    .parameters input[type=\"text\"],\n    .parameters input[type=\"number\"] {\n        width: auto;\n        color: #404552;\n    }\n\n    .parameters input[type=\"checkbox\"] {\n        width: 20px;\n        height: 20px;\n    }\n\n} /* @supports (display: grid) */\n\np.maintainer {\n    color: #888888;\n    font-size: 70%;\n    text-align: right;\n}\n\n.error strong {\n    display: inline-block;\n    width: 100px;\n}\n\n/* Hide all forms on the frontpage by default */\nform.bridge-form {\n    display: none;\n}\n\nselect {\n    padding: 5px 10px;\n    margin-left: 8px;\n}\n\n/* Show more/less */\n.showmore-box {\n    display: none;\n}\n\n.showmore, .showless {\n    color: #888888;\n    cursor: pointer;\n}\n.showmore:hover, .showless:hover {\n    color: #000;\n    cursor: pointer;\n}\n\n.showmore-box:checked ~ .showmore {\n    display: none;\n}\n\n.showmore-box:not(:checked) ~ .showless {\n    display: none;\n}\n\n.showmore-box:checked ~ form, .showmore-box:checked ~ h5 {\n    display: block;\n}\n\n/* html format */\nh1.pagetitle {\n    margin: 40px 0 20px;\n    font-size: 300%;\n    font-weight: bold;\n    text-align: center;\n    color: #2196F3;\n}\nh1.pagetitle > a {\n    color: #2196F3;\n}\n.buttons {\n    text-align: center;\n    margin-bottom: 15px;\n}\nbutton {\n    line-height: 1.9em;\n    color: #FFF;\n    font-weight: bold;\n    vertical-align: middle;\n    padding: 6px 12px;\n    margin: 12px auto 0px;\n    border-radius: 4px;\n    border: 1px solid transparent;\n    background: #2196F3 none repeat scroll 0% 0%;\n    cursor: pointer;\n    width: 200px;\n}\n\n@media screen and (max-width: 767px) {\n    .container {\n        width: 100%;\n        padding: 5px;\n    }\n\n    .searchbar {\n        margin-bottom: 5px;\n    }\n\n    button {\n        display: inline-block;\n        width: 40%;\n        padding: 5px auto;\n        margin: 3px auto 0;\n    }\n\n    .info, .no-info {\n        display: none;\n    }\n\n    @supports (display: grid) {\n\n        .parameters {\n            grid-template-columns: auto auto;\n            grid-column-gap: 5px;\n        }\n\n        .parameters label {\n            line-height: 2em;\n            word-break: break-word;\n        }\n\n    } /* @supports (display: grid) */\n}\n\n/* Dark theme */\n@media (prefers-color-scheme: dark){\n    * {\n        scrollbar-color: #202324 #454a4d;\n    }\n\n    body {\n        background-color: #202325;\n        color: #e8e6e3;\n    }\n\n    a, a:link, a:visited {\n        color: #0A6AB6;\n    }\n\n    /* Header */\n    select,\n    input[type=\"text\"],\n    input[type=\"number\"] {\n        background-color: #181A1B;\n        /* does not apply to placeholder text without !important */\n        color: white !important;\n\n        border: 1px solid #393E40;\n    }\n\n    /* Section */\n    section {\n        background-color: #181A1B;\n    }\n\n    /* Buttons */\n    button {\n        background: #0A6AB6 none repeat scroll 0% 0%;\n    }\n\n    button:hover {\n        background: #004daa;\n    }\n\n    @supports (display: grid){\n        .parameters input[type=\"number\"] {\n            color: #BAB4AB;\n        }\n    }\n\n    /* Show more / less */\n    .showmore:hover, .showless:hover {\n        color: #d8d3cb;\n    }\n}\n\n/* find-feed */\n.search-result {\n  background-color: #f0f0f0;\n  border-radius: 5px;\n  padding: 15px;\n  display: flex;\n  position: relative;\n  text-align: left;\n  margin-bottom: 15px;\n}\n@media (prefers-color-scheme: dark) {\n    .search-result {\n        background-color: #202325;\n    }\n}\n.search-result h2 {\n  color: #288cfc;\n}\n\n.search-result a {\n  text-decoration: none;\n  color: #248afa;\n}\n.search-result .icon {\n  margin: 0 15px 0 0;\n}\n.search-result span {\n  margin-right: 10px;\n}\n.search-result .description {\n  font-size: 110%;\n  margin-right: 0 !important;\n  margin-top: 5px !important;\n}\n/* end find-feed */\n"
  },
  {
    "path": "templates/base.html.php",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"description\" content=\"RSS-Bridge\" />\n    <title>RSS-Bridge</title>\n    <link href=\"static/style.css?2023-03-24\" rel=\"stylesheet\">\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon.png\">\n\n    <script src=\"static/rss-bridge.js\"></script>\n</head>\n\n<body>\n    <div class=\"container\">\n        <header>\n            <a href=\"./\">\n                <img width=\"400\" src=\"static/logo_600px.png\">\n            </a>\n        </header>\n\n        <?php foreach ($messages as $message): ?>\n            <div class=\"alert-<?= raw($message['level'] ?? 'info') ?>\">\n                <?= raw($message['body']) ?>\n            </div>\n        <?php endforeach; ?>\n\n        <?= raw($page) ?>\n    </div>\n</body>\n</html>\n\n"
  },
  {
    "path": "templates/bridge-error.html.php",
    "content": "\n<?= raw($error) ?>\n\n<a href=\"<?= raw($searchUrl) ?>\" title=\"Opens GitHub to search for similar issues\">\n    <button>Find similar bugs</button>\n</a>\n\n<a href=\"<?= raw($issueUrl) ?>\" title=\"After clicking this button you can review the issue before submitting it\">\n    <button>Create GitHub Issue</button>\n</a>\n\n<p class=\"maintainer\">\n    <?= e($maintainer) ?>\n</p>"
  },
  {
    "path": "templates/connectivity.html.php",
    "content": "<!DOCTYPE html>\n\n<html>\n<head>\n    <link rel=\"stylesheet\" href=\"static/bootstrap.min.css\">\n    <link\n        rel=\"stylesheet\"\n        href=\"https://use.fontawesome.com/releases/v5.6.3/css/all.css\"\n        integrity=\"sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/\"\n        crossorigin=\"anonymous\">\n    <link rel=\"stylesheet\" href=\"static/connectivity.css\">\n    <script src=\"static/connectivity.js\" type=\"text/javascript\"></script>\n</head>\n<body>\n<div id=\"main-content\" class=\"container\">\n    <div class=\"progress\">\n        <div class=\"progress-bar\" role=\"progressbar\" aria-valuenow=\"75\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n    </div>\n    <div id=\"status-message\" class=\"sticky-top alert alert-primary alert-dismissible fade show\" role=\"alert\">\n        <i id=\"status-icon\" class=\"fas fa-sync\"></i>\n        <span>...</span>\n        <button type=\"button\" class=\"close\" data-dismiss=\"alert\" aria-label=\"Close\" onclick=\"stopConnectivityChecks()\">\n            <span aria-hidden=\"true\">&times;</span>\n        </button>\n    </div>\n    <input type=\"text\" class=\"form-control\" id=\"search\" onkeyup=\"search()\" placeholder=\"Search for bridge..\">\n</div>\n</body>\n</html>"
  },
  {
    "path": "templates/error.html.php",
    "content": "<?php\n/**\n * This template is for rendering error messages (not exceptions)\n */\n?>\n\n<?php if (isset($title)): ?>\n    <h1>\n        <?= e($title) ?>\n    </h1>\n<?php endif; ?>\n\n<p>\n    <?= e($message) ?>\n</p>\n"
  },
  {
    "path": "templates/exception.html.php",
    "content": "<?php\n/**\n * This template is used for rendering exceptions\n */\n?>\n<div class=\"error\">\n\n    <?php if ($e instanceof HttpException): ?>\n        <?php if ($e instanceof CloudFlareException): ?>\n            <h2>The website is protected by CloudFlare</h2>\n            <p>\n                RSS-Bridge tried to fetch a website.\n                The fetching was blocked by CloudFlare.\n                CloudFlare is anti-bot software.\n                Its purpose is to block non-humans.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 400): ?>\n            <h2>400 Bad Request</h2>\n            <p>\n                This is usually caused by an incorrectly constructed http request.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 403): ?>\n            <h2>403 Forbidden</h2>\n            <p>\n                The HTTP 403 Forbidden response status code indicates that the\n                server understands the request but refuses to authorize it.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 404): ?>\n            <h2>404 Page Not Found</h2>\n            <p>\n                RSS-Bridge tried to fetch a page on a website.\n                But it doesn't exists.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 429): ?>\n            <h2>429 Too Many Requests</h2>\n            <p>\n                RSS-Bridge tried to fetch a website.\n                They told us to try again later.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 503): ?>\n            <h2>503 Service Unavailable</h2>\n            <p>\n                Common causes are a server that is down for maintenance\n                or that is overloaded.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 0): ?>\n            <p>\n                See\n                <a href=\"https://curl.haxx.se/libcurl/c/libcurl-errors.html\">\n                    https://curl.haxx.se/libcurl/c/libcurl-errors.html\n                </a>\n                for description of the curl error code.\n            </p>\n        <?php else: ?>\n            <p>\n                <a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/<?= raw($e->getCode()) ?>\">\n                    https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/<?= raw($e->getCode()) ?>\n                </a>\n            </p>\n        <?php endif; ?>\n\n    <?php else: ?>\n        <?php if ($e->getCode() === 10): ?>\n            <h2>The rss feed is completely empty</h2>\n            <p>\n                RSS-Bridge tried parse the empty string as xml.\n                The fetched url is not pointing to real xml.\n            </p>\n        <?php endif; ?>\n\n        <?php if ($e->getCode() === 11): ?>\n            <h2>There is something wrong with the rss feed</h2>\n            <p>\n                RSS-Bridge tried parse xml. It failed. The xml is probably broken.\n            </p>\n        <?php endif; ?>\n    <?php endif; ?>\n\n    <h2>Details</h2>\n\n    <div style=\"margin-bottom: 15px\">\n        <div class=\"error-type\">\n            <strong>Type:</strong> <?= e(get_class($e)) ?>\n        </div>\n\n        <div>\n            <strong>Code:</strong> <?= e($e->getCode()) ?>\n        </div>\n\n        <div class=\"error-message\">\n            <strong>Message:</strong> <?= e(sanitize_root($e->getMessage())) ?>\n        </div>\n\n        <div>\n            <strong>File:</strong> <?= e(sanitize_root($e->getFile())) ?>\n        </div>\n\n        <div>\n            <strong>Line:</strong> <?= e($e->getLine()) ?>\n        </div>\n    </div>\n\n    <h2>Trace</h2>\n\n    <?php foreach (trace_from_exception($e) as $i => $frame) : ?>\n        <code>\n            #<?= $i ?> <?= e(frame_to_call_point($frame)) ?>\n        </code>\n        <br>\n    <?php endforeach; ?>\n\n    <br>\n\n    <h2>Context</h2>\n\n    <div>\n        <strong>Query:</strong> <?= e(urldecode($_SERVER['QUERY_STRING'] ?? '')) ?>\n    </div>\n\n    <div>\n        <strong>Version:</strong> <?= raw(Configuration::getVersion()) ?>\n    </div>\n\n    <div>\n        <strong>OS:</strong> <?= raw(PHP_OS_FAMILY) ?>\n    </div>\n\n    <div>\n        <strong>PHP:</strong> <?= raw(PHP_VERSION ?: 'Unknown') ?>\n    </div>\n\n    <br>\n\n    <a href=\"/\">Go back</a>\n</div>\n\n"
  },
  {
    "path": "templates/frontpage.html.php",
    "content": "\n<script>\n    document.addEventListener('DOMContentLoaded', rssbridge_toggle_bridge);\n    document.addEventListener('DOMContentLoaded', rssbridge_list_search);\n    document.addEventListener('DOMContentLoaded', rssbridge_feed_finder);\n</script>\n\n<section class=\"searchbar\">\n    <h3>Search</h3>\n    <input\n        type=\"text\"\n        name=\"searchfield\"\n        id=\"searchfield\"\n        placeholder=\"Insert URL or bridge name\"\n        onchange=\"rssbridge_list_search()\"\n        onkeyup=\"rssbridge_list_search()\"\n        value=\"\"\n    >\n    <button\n        type=\"button\"\n\t    id=\"findfeed\"\n        name=\"findfeed\"\n    >Find Feed from URL</button>\n    <section id=\"findfeedresults\">\n    </section>\n\n</section>\n\n<?= raw($bridges) ?>\n\n<section class=\"footer\">\n    <a href=\"https://github.com/RSS-Bridge/rss-bridge\">\n        https://github.com/RSS-Bridge/rss-bridge\n    </a>\n\n    <br>\n    <br>\n\n    <p class=\"version\">\n        <?= e(Configuration::getVersion()) ?>\n    </p>\n\n    <?= $active_bridges ?>/<?= $total_bridges ?> active bridges.<br>\n\n    <br>\n\n    <?php if ($admin_email): ?>\n        <div>\n            Email: <a href=\"mailto:<?= e($admin_email) ?>\"><?= e($admin_email) ?></a>\n        </div>\n    <?php endif; ?>\n\n    <?php if ($admin_telegram): ?>\n        <div>\n            Url: <a href=\"<?= e($admin_telegram) ?>\"><?= e($admin_telegram) ?></a>\n        </div>\n    <?php endif; ?>\n\n</section>\n"
  },
  {
    "path": "templates/html-format.html.php",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/ >\n    <meta name=\"description\" content=\"RSS-Bridge\" />\n    <title><?= e($title) ?></title>\n    <link href=\"static/style.css?2023-03-24\" rel=\"stylesheet\">\n    <link rel=\"icon\" type=\"image/png\" href=\"static/favicon.png\">\n\n    <?php foreach ($formats as $format): ?>\n\n        <link\n            href=\"<?= e($format['url']) ?>\"\n            title=\"<?= e($format['name']) ?>\"\n            rel=\"alternate\"\n            type=\"<?= e($format['type']) ?>\"\n        >\n\t<?php endforeach; ?>\n\n    <meta name=\"robots\" content=\"noindex, follow\">\n</head>\n\n<body>\n    <div class=\"container\">\n\n        <h1 class=\"pagetitle\">\n            <a href=\"<?= e($uri) ?>\" target=\"_blank\"><?= e($title) ?></a>\n        </h1>\n\n        <div class=\"buttons\">\n            <a href=\"./#bridge-<?= e($bridge_name) ?>\">\n                <button class=\"backbutton\">← back to rss-bridge</button>\n            </a>\n\n            <?php foreach ($formats as $format): ?>\n                <a href=\"<?= e($format['url']) ?>\">\n                    <button class=\"rss-feed\">\n                        <?= e($format['name']) ?>\n                    </button>\n                </a>\n            <?php endforeach; ?>\n\n            <?php if ($donation_uri): ?>\n                <a href=\"<?= e($donation_uri) ?>\">\n                    <button class=\"rss-feed\">\n                        Donate to maintainer\n                    </button>\n                </a>\n            <?php endif; ?>\n        </div>\n\n        <?php foreach ($items as $item): ?>\n            <section class=\"feeditem\">\n                <h2>\n                    <a\n                        class=\"itemtitle\"\n                        href=\"<?= e($item['url']) ?>\"\n                    ><?= strip_tags($item['title']) ?></a>\n                </h2>\n\n                <?php if ($item['timestamp']): ?>\n                    <time datetime=\"<?= date('Y-m-d H:i:s', $item['timestamp']) ?>\">\n                        <?= date('Y-m-d H:i:s', $item['timestamp']) ?>\n                    </time>\n                    <p></p>\n                <?php endif; ?>\n\n                <?php if ($item['author']): ?>\n                    <p class=\"author\">by: <?= e($item['author']) ?></p>\n                <?php endif; ?>\n\n                <!-- Intentionally not escaping for html context -->\n                <?= break_annoying_html_tags($item['content']) ?>\n\n                <?php if ($item['enclosures']): ?>\n                    <div class=\"attachments\">\n                        <p>Attachments:</p>\n                        <?php foreach ($item['enclosures'] as $enclosure): ?>\n                            <li class=\"enclosure\">\n                                <a href=\"<?= e($enclosure) ?>\" rel=\"noopener noreferrer nofollow\">\n                                    <?= e(substr($enclosure, strrpos($enclosure, '/') + 1)) ?>\n                                </a>\n                            </li>\n                        <?php endforeach; ?>\n                    </div>\n                <?php endif; ?>\n\n                <?php if ($item['categories']): ?>\n                    <div class=\"categories\">\n                        <p>Categories:</p>\n                        <?php foreach ($item['categories'] as $category): ?>\n                            <li class=\"category\"><?= e($category) ?></li>\n                        <?php endforeach; ?>\n                    </div>\n                <?php endif; ?>\n            </section>\n        <?php endforeach; ?>\n\n    </div>\n </body>\n</html>\n"
  },
  {
    "path": "templates/token.html.php",
    "content": "<?php\n/**\n * This template renders a form for user to enter a auth token if it's enabled\n */\n\n?>\n\n<h1>\n    Authentication with token required\n</h1>\n\n<p>\n    <?= e($message) ?>\n</p>\n\n<form action=\"\" method=\"get\" autocomplete=\"off\">\n    <label for=\"token\">Token:</label>\n    <input type=\"text\" name=\"token\" id=\"token\" placeholder=\"token\" value=\"<?= e($token) ?>\">\n    <input type=\"submit\" value=\"OK\">\n</form>\n"
  },
  {
    "path": "tests/BridgeCardTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse FrontpageAction;\nuse PHPUnit\\Framework\\TestCase;\n\nclass BridgeCardTest extends TestCase\n{\n    public function test()\n    {\n        $entry = [\n            'values' => [],\n        ];\n        $this->assertSame('<select id=\"id\" name=\"name\">' . \"\\n\" . '</select>' . \"\\n\", FrontpageAction::getListInput($entry, 'id', 'name'));\n\n        $entry = [\n            'defaultValue' => 2,\n            'values' => [\n                'foo' => 'bar',\n            ],\n        ];\n        $this->assertSame(\n            '<select id=\"id\" name=\"name\">' . \"\\n\" . '<option value=\"bar\">foo</option>' . \"\\n\" . '</select>' . \"\\n\",\n            FrontpageAction::getListInput($entry, 'id', 'name')\n        );\n\n        // optgroup\n        $entry = [\n            'defaultValue' => 2,\n            'values' => ['kek' => [\n                'f' => 'b',\n            ]],\n        ];\n        $this->assertSame(\n            '<select id=\"id\" name=\"name\">' . \"\\n\" . '<optgroup label=\"kek\"><option value=\"b\">f</option>' . \"\\n\" . '</optgroup></select>' . \"\\n\",\n            FrontpageAction::getListInput($entry, 'id', 'name')\n        );\n    }\n\n    public function test2()\n    {\n        $this->assertSame('<input />', html_input([\n        ]));\n\n        $this->assertSame('<input type=\"text\" />', html_input([\n            'type' => 'text',\n        ]));\n\n        $this->assertSame('<input type=\"text\" required />', html_input([\n            'type'      => 'text',\n            'required'  => true,\n        ]));\n\n        $this->assertSame('<input type=\"text\" />', html_input([\n            'type'      => 'text',\n            'required'  => false,\n        ]));\n\n        $this->assertSame('<input type=\"text\" id=\"id\" name=\"name\" value=\"val\" placeholder=\"placeholder\" pattern=\"\\d\\d\" checked required />', html_input([\n            'type'          => 'text',\n            'id'            => 'id',\n            'name'          => 'name',\n            'value'         => 'val',\n            'placeholder'   => 'placeholder',\n            'pattern'       => '\\d\\d',\n            'checked'       => true,\n            'required'      => true,\n        ]));\n\n        // test self-closing\n        $this->assertSame('<p />', html_tag('p'));\n        $this->assertSame('<p>hello</p>', html_tag('p', 'hello'));\n        $this->assertSame('<option value=\"AAA\">QQQ</option>', html_tag('option', 'QQQ', ['value' => 'AAA']));\n        $this->assertSame('<p class=\"red\">hello</p>', html_tag('p', 'hello', ['class' => 'red']));\n\n        // test escaping\n        $this->assertSame('<input type=\"text\" value=\"&lt;h1&gt;\" />', html_input([\n            'type'  => 'text',\n            'value' => '<h1>'\n        ]));\n\n        // test option\n        $this->assertSame('<option value=\"val\">name</option>', html_option('name', 'val'));\n        $this->assertSame('<option value=\"val\">name</option>', html_option('name', 'val', false));\n        $this->assertSame('<option value=\"val\" selected>name</option>', html_option('name', 'val', true));\n\n        // test label\n        $this->assertSame('<label class=\"showless\" for=\"for2\">Show less</label>', html_tag('label', 'Show less', [\n            'class' => 'showless',\n            'for' => 'for2',\n        ]));\n    }\n}\n"
  },
  {
    "path": "tests/BridgeFactoryTest.php",
    "content": "<?php\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass BridgeFactoryTest extends TestCase\n{\n    public function testNormalizeBridgeName()\n    {\n        $this->assertSame('TwitterBridge', \\BridgeFactory::normalizeBridgeName('TwitterBridge'));\n        $this->assertSame('TwitterBridge', \\BridgeFactory::normalizeBridgeName('TwitterBridge.php'));\n        $this->assertSame('TwitterBridge', \\BridgeFactory::normalizeBridgeName('Twitter'));\n//        $this->assertSame('TwitterBridge', $sut->createBridgeClassName('twitterbridge'));\n//        $this->assertSame('TwitterBridge', $sut->createBridgeClassName('twitter'));\n//        $this->assertSame('TwitterBridge', $sut->createBridgeClassName('tWitTer'));\n//        $this->assertSame('TwitterBridge', $sut->createBridgeClassName('TWITTERBRIDGE'));\n    }\n}\n"
  },
  {
    "path": "tests/BridgeImplementationTest.php",
    "content": "<?php\n\nnamespace RssBridge\\Tests;\n\nuse BridgeAbstract;\nuse FeedExpander;\nuse PHPUnit\\Framework\\TestCase;\n\nclass BridgeImplementationTest extends TestCase\n{\n    private string $className;\n    private BridgeAbstract $bridge;\n\n    /**\n     * @dataProvider dataBridgesProvider\n     */\n    public function testClassName($path)\n    {\n        $this->setBridge($path);\n        $this->assertTrue($this->className === ucfirst($this->className), 'class name must start with uppercase character');\n        $this->assertEquals(0, substr_count($this->className, ' '), 'class name must not contain spaces');\n        $this->assertStringEndsWith('Bridge', $this->className, 'class name must end with \"Bridge\"');\n    }\n\n    /**\n     * @dataProvider dataBridgesProvider\n     */\n    public function testClassType($path)\n    {\n        $this->setBridge($path);\n        $this->assertInstanceOf(BridgeAbstract::class, $this->bridge);\n    }\n\n    /**\n     * @dataProvider dataBridgesProvider\n     */\n    public function testConstants($path)\n    {\n        $this->setBridge($path);\n\n        $this->assertIsString($this->bridge::NAME, 'class::NAME');\n        $this->assertNotEmpty($this->bridge::NAME, 'class::NAME');\n        $this->assertIsString($this->bridge::URI, 'class::URI');\n        $this->assertNotEmpty($this->bridge::URI, 'class::URI');\n        $this->assertIsString($this->bridge::DESCRIPTION, 'class::DESCRIPTION');\n        $this->assertNotEmpty($this->bridge::DESCRIPTION, 'class::DESCRIPTION');\n        $this->assertIsString($this->bridge::MAINTAINER, 'class::MAINTAINER');\n        $this->assertNotEmpty($this->bridge::MAINTAINER, 'class::MAINTAINER');\n\n        $this->assertIsArray($this->bridge::PARAMETERS, 'class::PARAMETERS');\n        $this->assertIsInt($this->bridge::CACHE_TIMEOUT, 'class::CACHE_TIMEOUT');\n        $this->assertGreaterThanOrEqual(0, $this->bridge::CACHE_TIMEOUT, 'class::CACHE_TIMEOUT');\n    }\n\n    /**\n     * @dataProvider dataBridgesProvider\n     */\n    public function testParameters($path)\n    {\n        $this->setBridge($path);\n\n        $multiMinimum = 2;\n        if (isset($this->bridge::PARAMETERS['global'])) {\n            ++$multiMinimum;\n        }\n        $multiContexts = (count($this->bridge::PARAMETERS) >= $multiMinimum);\n        $paramsSeen = [];\n\n        $allowedTypes = [\n            'text',\n            'number',\n            'list',\n            'checkbox',\n        ];\n\n        foreach ($this->bridge::PARAMETERS as $context => $params) {\n            if ($multiContexts) {\n                $this->assertIsString($context, 'invalid context name');\n                $this->assertNotEmpty($context, 'The context name cannot be empty');\n            }\n\n            if (empty($params)) {\n                continue;\n            }\n\n            foreach ($paramsSeen as $seen) {\n                $this->assertNotEquals($seen, $params, 'same set of parameters not allowed');\n            }\n            $paramsSeen[] = $params;\n\n            foreach ($params as $field => $options) {\n                $this->assertIsString($field, $field . ': invalid id');\n                $this->assertNotEmpty($field, $field . ':empty id');\n\n                $this->assertIsString($options['name'], $field . ': invalid name');\n                $this->assertNotEmpty($options['name'], $field . ': empty name');\n\n                if (isset($options['type'])) {\n                    $this->assertIsString($options['type'], $field . ': invalid type');\n                    $this->assertContains($options['type'], $allowedTypes, $field . ': unknown type');\n\n                    if ($options['type'] == 'list') {\n                        $this->assertArrayHasKey('values', $options, $field . ': missing list values');\n                        $this->assertIsArray($options['values'], $field . ': invalid list values');\n                        $this->assertNotEmpty($options['values'], $field . ': empty list values');\n\n                        foreach ($options['values'] as $valueName => $value) {\n                            $this->assertIsString($valueName, $field . ': invalid value name');\n                        }\n                    }\n                }\n\n                if (isset($options['required'])) {\n                    $this->assertIsBool($options['required'], $field . ': invalid required');\n\n                    if ($options['required'] === true && isset($options['type'])) {\n                        switch ($options['type']) {\n                            case 'list':\n                            case 'checkbox':\n                                $this->assertArrayNotHasKey(\n                                    'required',\n                                    $options,\n                                    $field . ': \"required\" attribute not supported for ' . $options['type']\n                                );\n                                break;\n                        }\n                    }\n                }\n\n                if (isset($options['title'])) {\n                    $this->assertIsString($options['title'], $field . ': invalid title');\n                    $this->assertNotEmpty($options['title'], $field . ': empty title');\n                }\n\n                if (isset($options['pattern'])) {\n                    $this->assertIsString($options['pattern'], $field . ': invalid pattern');\n                    $this->assertNotEquals('', $options['pattern'], $field . ': empty pattern');\n                }\n\n                if (isset($options['exampleValue'])) {\n                    if (is_string($options['exampleValue'])) {\n                        $this->assertNotEquals('', $options['exampleValue'], $field . ': empty exampleValue');\n                    }\n                }\n\n                if (isset($options['defaultValue'])) {\n                    if (is_string($options['defaultValue'])) {\n                        $this->assertNotEquals('', $options['defaultValue'], $field . ': empty defaultValue');\n                    }\n                }\n            }\n        }\n\n        foreach ($this->bridge::TEST_DETECT_PARAMETERS as $url => $params) {\n            $detectedParameters = $this->bridge->detectParameters($url);\n            $this->assertEquals($detectedParameters, $params);\n        }\n    }\n\n    /**\n     * @dataProvider dataBridgesProvider\n     */\n    public function testMethodValues($path)\n    {\n        $this->setBridge($path);\n\n        $value = $this->bridge->getDescription();\n        $this->assertIsString($value, '$class->getDescription()');\n        $this->assertNotEmpty($value, '$class->getDescription()');\n\n        $value = $this->bridge->getMaintainer();\n        $this->assertIsString($value, '$class->getMaintainer()');\n        $this->assertNotEmpty($value, '$class->getMaintainer()');\n\n        $value = $this->bridge->getName();\n        $this->assertIsString($value, '$class->getName()');\n        $this->assertNotEmpty($value, '$class->getName()');\n\n        $value = $this->bridge->getURI();\n        $this->assertIsString($value, '$class->getURI()');\n        $this->assertNotEmpty($value, '$class->getURI()');\n\n        $value = $this->bridge->getIcon();\n        $this->assertIsString($value, '$class->getIcon()');\n    }\n\n    /**\n     * @dataProvider dataBridgesProvider\n     */\n    public function testUri($path)\n    {\n        $this->setBridge($path);\n\n        $this->assertNotFalse(filter_var($this->bridge::URI, FILTER_VALIDATE_URL));\n        $this->assertNotFalse(filter_var($this->bridge->getURI(), FILTER_VALIDATE_URL));\n    }\n\n    public function dataBridgesProvider()\n    {\n        $bridges = [];\n        foreach (glob(__DIR__ . '/../bridges/*Bridge.php') as $path) {\n            $bridges[basename($path, '.php')] = [$path];\n        }\n        return $bridges;\n    }\n\n    private function setBridge($path)\n    {\n        $this->className = '\\\\' . basename($path, '.php');\n        $this->assertTrue(class_exists($this->className), 'class ' . $this->className . ' doesn\\'t exist');\n        $this->bridge = new $this->className(\n            new \\NullCache(),\n            new \\NullLogger(),\n        );\n    }\n}\n"
  },
  {
    "path": "tests/CacheImplementationTest.php",
    "content": "<?php\n\nnamespace RssBridge\\Tests;\n\nuse CacheInterface;\nuse PHPUnit\\Framework\\TestCase;\n\nclass CacheImplementationTest extends TestCase\n{\n    public function getCacheClassNames()\n    {\n        $caches = [];\n        foreach (glob(PATH_LIB_CACHES . '*.php') as $path) {\n            $caches[] = [basename($path, '.php')];\n        }\n        return $caches;\n    }\n\n    /**\n     * @dataProvider getCacheClassNames\n     */\n    public function testClassName($path)\n    {\n        $this->assertTrue($path === ucfirst($path), 'class name must start with uppercase character');\n        $this->assertEquals(0, substr_count($path, ' '), 'class name must not contain spaces');\n        $this->assertStringEndsWith('Cache', $path, 'class name must end with \"Cache\"');\n    }\n\n    /**\n     * @dataProvider getCacheClassNames\n     */\n    public function testClassType($path)\n    {\n        $this->assertTrue(is_subclass_of($path, CacheInterface::class), 'class must be subclass of CacheInterface');\n    }\n}\n"
  },
  {
    "path": "tests/CacheTest.php",
    "content": "<?php\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass CacheTest extends TestCase\n{\n    public function testConfig()\n    {\n        $sut = new \\FileCache(new \\NullLogger(), ['path' => '/tmp/']);\n        $this->assertSame(['path' => '/tmp/', 'enable_purge' => true], $sut->getConfig());\n\n        $sut = new \\FileCache(new \\NullLogger(), ['path' => '/', 'enable_purge' => false]);\n        $this->assertSame(['path' => '/', 'enable_purge' => false], $sut->getConfig());\n\n        $sut = new \\FileCache(new \\NullLogger(), ['path' => '/tmp', 'enable_purge' => true]);\n        $this->assertSame(['path' => '/tmp/', 'enable_purge' => true], $sut->getConfig());\n    }\n\n    public function testFileCache()\n    {\n        $temporaryFolder = sprintf('%s/rss_bridge_%s/', sys_get_temp_dir(), create_random_string());\n        mkdir($temporaryFolder);\n\n        $sut = new \\FileCache(new \\NullLogger(), [\n            'path' => $temporaryFolder,\n            'enable_purge' => true,\n        ]);\n        $sut->clear();\n\n        $this->assertNull($sut->get('key'));\n\n        $sut->set('key', 'data', 5);\n        $this->assertSame('data', $sut->get('key'));\n        $sut->clear();\n\n        // Intentionally not deleting the temp folder\n    }\n}\n"
  },
  {
    "path": "tests/ConfigurationTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse Configuration;\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class ConfigurationTest extends TestCase\n{\n    public function testValueFromDefaultConfig()\n    {\n        Configuration::loadConfiguration();\n        $this->assertSame(null, Configuration::getConfig('foobar', ''));\n        $this->assertSame(null, Configuration::getConfig('foo', 'bar'));\n        $this->assertSame('baz', Configuration::getConfig('foo', 'bar', 'baz'));\n        $this->assertSame(null, Configuration::getConfig('cache', ''));\n        $this->assertSame('UTC', Configuration::getConfig('system', 'timezone'));\n    }\n\n    public function testValueFromCustomConfig()\n    {\n        Configuration::loadConfiguration(['system' => ['timezone' => 'Europe/Berlin']]);\n        $this->assertSame('Europe/Berlin', Configuration::getConfig('system', 'timezone'));\n    }\n\n    public function testValueFromEnv()\n    {\n        $env = [\n            'RSSBRIDGE_system_timezone' => 'Europe/Berlin',\n            'RSSBRIDGE_SYSTEM_MESSAGE' => 'hello',\n            'RSSBRIDGE_system_enabled_bridges' => 'TwitterBridge,GettrBridge',\n            'RSSBRIDGE_system_enable_debug_mode' => 'true',\n            'RSSBRIDGE_fileCache_path' => '/tmp/kek',\n        ];\n        Configuration::loadConfiguration([], $env);\n        $this->assertSame('Europe/Berlin', Configuration::getConfig('system', 'timezone'));\n        $this->assertSame('hello', Configuration::getConfig('system', 'message'));\n        $this->assertSame(true, Configuration::getConfig('system', 'enable_debug_mode'));\n        $this->assertSame('/tmp/kek', Configuration::getConfig('FileCache', 'path'));\n        $this->assertSame(['TwitterBridge', 'GettrBridge'], Configuration::getConfig('system', 'enabled_bridges'));\n    }\n}\n"
  },
  {
    "path": "tests/FeedItemTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass FeedItemTest extends TestCase\n{\n    public function test()\n    {\n        $item = [\n            'title' => 'kek',\n        ];\n        $feedItem = \\FeedItem::fromArray($item);\n        $this->assertSame('kek', $feedItem->getTitle());\n    }\n}\n"
  },
  {
    "path": "tests/FeedParserTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass FeedParserTest extends TestCase\n{\n    private \\FeedParser $sut;\n\n    public function setUp(): void\n    {\n        $this->sut = new \\FeedParser();\n    }\n\n    public function testRss1()\n    {\n        $xml = <<<XML\n        <?xml version=\"1.0\" encoding=\"utf-8\"?> \n        <rdf:RDF \n          xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" \n          xmlns:cc=\"http://creativecommons.org/ns#\"\n          xmlns=\"http://purl.org/rss/1.0/\"\n        > \n        <channel rdf:about=\"http://meerkat.oreillynet.com/?_fl=rss1.0\">\n            <title>hello feed</title>\n            <link>http://meerkat.oreillynet.com</link>\n            <description>Meerkat: An Open Wire Service</description>\n            \n            <items>\n                <rdf:Seq>\n                    <rdf:li resource=\"http://c.moreover.com/click/here.pl?r123\" />\n                </rdf:Seq>\n            </items>\n        </channel>\n\n        <item rdf:about=\"http://c.moreover.com/click/here.pl?r123\">\n            <title>XML: A Disruptive Technology</title> \n            <link>http://c.moreover.com/click/here.pl?r123</link>\n            <description>desc</description>\n        </item> \n        </rdf:RDF>\n        XML;\n\n        $feed = $this->sut->parseFeed($xml);\n\n        $this->assertSame('hello feed', $feed['title']);\n        $this->assertSame('http://meerkat.oreillynet.com', $feed['uri']);\n        $this->assertSame(null, $feed['icon']);\n\n        $item = $feed['items'][0];\n        $this->assertSame('XML: A Disruptive Technology', $item['title']);\n        $this->assertSame('http://c.moreover.com/click/here.pl?r123', $item['uri']);\n        $this->assertSame('desc', $item['content']);\n    }\n\n    public function testRss2()\n    {\n        $xml = <<<XML\n        <?xml version=\"1.0\"?>\n        <rss version=\"2.0\">\n            <channel>\n                <title>hello feed</title>\n                <link>https://example.com/</link>\n                <image>\n                    <url>https://example.com/2.ico</url>\n                </image>\n\n                <item>\n                    <title>hello world</title>\n                    <link>https://example.com/1</link>\n                    <description>desc2</description>\n                    <pubDate>Tue, 26 Apr 2022 00:00:00 +0200</pubDate>\n                    <author>root</author>\n                    <enclosure url=\"https://example.com/1.png\"></enclosure>\n                </item>\n            </channel>\n        </rss>\n        XML;\n\n        $feed = $this->sut->parseFeed($xml);\n\n        $this->assertSame('hello feed', $feed['title']);\n        $this->assertSame('https://example.com/', $feed['uri']);\n        $this->assertSame('https://example.com/2.ico', $feed['icon']);\n\n        $item = $feed['items'][0];\n        $this->assertSame('hello world', $item['title']);\n        $this->assertSame('https://example.com/1', $item['uri']);\n        $this->assertSame(1650924000, $item['timestamp']);\n        $this->assertSame('root', $item['author']);\n        $this->assertSame('desc2', $item['content']);\n        $this->assertSame(['https://example.com/1.png'], $item['enclosures']);\n    }\n\n    public function testAtom()\n    {\n        $xml = <<<XML\n        <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n            <title>hello feed</title>\n            <link href=\"https://example.com/1\"></link>\n            <icon>https://example.com/2.ico</icon>\n\n            <entry>\n                <title>hello world</title>\n                <link href=\"https://example.com/1\"></link>\n                <author>\n                    <name>root</name>\n                </author>\n                <content type=\"html\">html</content>\n                <updated>2015-11-05T14:38:49+01:00</updated>\n            </entry>\n        </feed>\n        XML;\n\n        $feed = $this->sut->parseFeed($xml);\n\n        $this->assertSame('hello feed', $feed['title']);\n        $this->assertSame('https://example.com/1', $feed['uri']);\n        $this->assertSame('https://example.com/2.ico', $feed['icon']);\n\n        $item = $feed['items'][0];\n        $this->assertSame('hello world', $item['title']);\n        $this->assertSame('https://example.com/1', $item['uri']);\n        $this->assertSame(1446730729, $item['timestamp']);\n        $this->assertSame('root', $item['author']);\n        $this->assertSame('html', $item['content']);\n    }\n\n    public function testAppleItunesModule()\n    {\n        $xml = <<<XML\n        <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <rss\n            version=\"2.0\"\n            xmlns:atom=\"http://www.w3.org/2005/Atom\"\n            xmlns:cc=\"http://web.resource.org/cc/\"\n            xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\"\n            xmlns:media=\"http://search.yahoo.com/mrss/\"\n            xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"\n            xmlns:podcast=\"https://podcastindex.org/namespace/1.0\"\n            xmlns:googleplay=\"http://www.google.com/schemas/play-podcasts/1.0\"\n            xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n        >\n            <channel>\n\n                <item>\n                    <itunes:duration>30:05</itunes:duration>\n                    <enclosure length=\"48123248\" type=\"audio/mpeg\" url=\"https://example.com/1.mp3\" />\n                </item>\n            </channel>\n        </rss>\n        XML;\n\n        $feed = $this->sut->parseFeed($xml);\n        $expected = [\n            'title' => '',\n            'uri' => '',\n            'icon' => '',\n            'items' => [\n                [\n                    'uri' => '',\n                    'title' => '',\n                    'content' => '',\n                    'timestamp' => '',\n                    'author' => '',\n                    'itunes' => [\n                        'duration' => '30:05',\n                    ],\n                    'enclosure' => [\n                        'url' => 'https://example.com/1.mp3',\n                        'length' => '48123248',\n                        'type' => 'audio/mpeg',\n                    ],\n                    'enclosures' => [\n                        'https://example.com/1.mp3',\n                    ],\n                ]\n            ],\n        ];\n        $this->assertEquals($expected, $feed);\n    }\n\n    public function testYoutubeMediaModule()\n    {\n        $xml = <<<XML\n        <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <feed xmlns:yt=\"http://www.youtube.com/xml/schemas/2015\" xmlns:media=\"http://search.yahoo.com/mrss/\" xmlns=\"http://www.w3.org/2005/Atom\">\n         <link rel=\"self\" href=\"http://www.youtube.com/feeds/videos.xml?channel_id=UCuCkxoKLYO_EQ2GeFtbM_bw\"/>\n         <id>yt:channel:uCkxoKLYO_EQ2GeFtbM_bw</id>\n         <yt:channelId>uCkxoKLYO_EQ2GeFtbM_bw</yt:channelId>\n         <title>Half as Interesting</title>\n         <link rel=\"alternate\" href=\"https://www.youtube.com/channel/UCuCkxoKLYO_EQ2GeFtbM_bw\"/>\n         <author>\n          <name>Half as Interesting</name>\n          <uri>https://www.youtube.com/channel/UCuCkxoKLYO_EQ2GeFtbM_bw</uri>\n         </author>\n         <published>2017-08-26T20:06:05+00:00</published>\n         <entry>\n          <id>yt:video:Upjg7F28DJw</id>\n          <yt:videoId>Upjg7F28DJw</yt:videoId>\n          <yt:channelId>UCuCkxoKLYO_EQ2GeFtbM_bw</yt:channelId>\n          <title>The Nuke-Proof US Military Base in a Mountain</title>\n          <link rel=\"alternate\" href=\"https://www.youtube.com/watch?v=Upjg7F28DJw\"/>\n          <author>\n           <name>Half as Interesting</name>\n           <uri>https://www.youtube.com/channel/UCuCkxoKLYO_EQ2GeFtbM_bw</uri>\n          </author>\n          <published>2025-01-24T15:44:18+00:00</published>\n          <updated>2025-01-25T06:55:19+00:00</updated>\n          <media:group>\n           <media:title>The Nuke-Proof US Military Base in a Mountain</media:title>\n           <media:content url=\"https://www.youtube.com/v/Upjg7F28DJw?version=3\" type=\"application/x-shockwave-flash\" width=\"640\" height=\"390\"/>\n           <media:thumbnail url=\"https://i2.ytimg.com/vi/Upjg7F28DJw/hqdefault.jpg\" width=\"480\" height=\"360\"/>\n           <media:description>Receive 10% off anything on bellroy.com: https://bit.ly/3HdOWu9</media:description>\n           <media:community>\n            <media:starRating count=\"10157\" average=\"5.00\" min=\"1\" max=\"5\"/>\n            <media:statistics views=\"228462\"/>\n           </media:community>\n          </media:group>\n         </entry>\n        </feed>\n        XML;\n\n        $feed = $this->sut->parseFeed($xml);\n        $expected = [\n            'title' => 'Half as Interesting',\n            'uri' => 'https://www.youtube.com/channel/UCuCkxoKLYO_EQ2GeFtbM_bw',\n            'icon' => null,\n            'items' => [\n                [\n                    'uri' => 'https://www.youtube.com/watch?v=Upjg7F28DJw',\n                    'title' => 'The Nuke-Proof US Military Base in a Mountain',\n                    'content' => '',\n                    'timestamp' => 1737788119,\n                    'author' => 'Half as Interesting',\n                    'id' => 'yt:video:Upjg7F28DJw',\n                    'published' => '2025-01-24T15:44:18+00:00',\n                    'updated' => '2025-01-25T06:55:19+00:00',\n                    'link' => '',\n                    'yt' => [\n                        'videoId' => 'Upjg7F28DJw',\n                        'channelId' => 'UCuCkxoKLYO_EQ2GeFtbM_bw',\n                    ],\n                    'media' => [\n                        'group' => [\n                            'title' => 'The Nuke-Proof US Military Base in a Mountain',\n                            'content' => '',\n                            'thumbnail' => '',\n                            'description' => 'Receive 10% off anything on bellroy.com: https://bit.ly/3HdOWu9',\n                            'community' => [\n                                'starRating' => '',\n                                'statistics' => '',\n                            ],\n                        ],\n                    ],\n                ]\n            ],\n        ];\n        $this->assertEquals($expected, $feed);\n    }\n}\n"
  },
  {
    "path": "tests/FormatTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass FormatTest extends TestCase\n{\n    public function testBridge()\n    {\n        $sut = new \\MrssFormat();\n\n        $expected = [\n            'name'          => '',\n            'uri'           => '',\n            'icon'          => '',\n            'donationUri'   => '',\n        ];\n        $this->assertEquals([], $sut->getFeed());\n\n        $sut->setFeed([\n            'name'          => '0',\n            'uri'           => '1',\n            'icon'          => '2',\n            'donationUri'   => '3',\n        ]);\n        $expected = [\n            'name'          => '0',\n            'uri'           => '1',\n            'icon'          => '2',\n            'donationUri'   => '3',\n        ];\n        $this->assertEquals($expected, $sut->getFeed());\n\n        $sut->setFeed([]);\n        $expected = [\n            'name'          => '',\n            'uri'           => '',\n            'icon'          => '',\n            'donationUri'   => '',\n        ];\n        $this->assertEquals($expected, $sut->getFeed());\n\n        $sut->setFeed(['foo' => 'bar', 'foo2' => 'bar2']);\n        $expected = [\n            'name'          => '',\n            'uri'           => '',\n            'icon'          => '',\n            'donationUri'   => '',\n            'foo'           => 'bar',\n            'foo2'          => 'bar2',\n        ];\n        $this->assertEquals($expected, $sut->getFeed());\n    }\n}\n\nclass TestFormat extends \\FormatAbstract\n{\n    public function render(): string\n    {\n    }\n}\n\nclass TestBridge extends \\BridgeAbstract\n{\n    public function collectData()\n    {\n        $this->items[] = ['title' => 'kek'];\n    }\n}\n"
  },
  {
    "path": "tests/Formats/AtomFormatTest.php",
    "content": "<?php\n\n/**\n * AtomFormat - RFC 4287: The Atom Syndication Format\n * https://tools.ietf.org/html/rfc4287\n */\n\nnamespace RssBridge\\Tests\\Formats;\n\nrequire_once __DIR__ . '/BaseFormatTest.php';\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass AtomFormatTest extends BaseFormatTest\n{\n    private const PATH_EXPECTED = self::PATH_SAMPLES . 'expectedAtomFormat/';\n\n    /**\n     * @dataProvider sampleProvider\n     * @runInSeparateProcess\n     */\n    public function testOutput(string $name, string $path)\n    {\n        $data = $this->formatData('Atom', $this->loadSample($path));\n        $this->assertNotFalse(simplexml_load_string($data));\n\n        $expected = self::PATH_EXPECTED . $name . '.xml';\n        $this->assertXmlStringEqualsXmlFile($expected, $data);\n    }\n}\n"
  },
  {
    "path": "tests/Formats/BaseFormatTest.php",
    "content": "<?php\n\nnamespace RssBridge\\Tests\\Formats;\n\nuse PHPUnit\\Framework\\TestCase;\nuse FormatFactory;\n\nabstract class BaseFormatTest extends TestCase\n{\n    protected const PATH_SAMPLES = __DIR__ . '/samples/';\n\n    /**\n     * @return array<string, array{string, string}>\n     */\n    public function sampleProvider()\n    {\n        $samples = [];\n        foreach (glob(self::PATH_SAMPLES . '*.json') as $path) {\n            $name = basename($path, '.json');\n            $samples[$name] = [\n                $name,\n                $path,\n            ];\n        }\n        return $samples;\n    }\n\n    /**\n     * Cannot be part of the sample returned by sampleProvider since this modifies $_SERVER\n     * and thus needs to be run in a separate process to avoid side effects.\n     */\n    protected function loadSample(string $path): \\stdClass\n    {\n        $data = json_decode(file_get_contents($path), true);\n        if (isset($data['meta']) && isset($data['items'])) {\n            if (!empty($data['server'])) {\n                $this->setServerVars($data['server']);\n            }\n\n            $items = [];\n            foreach ($data['items'] as $item) {\n                $items[] = ($item);\n            }\n\n            return (object)[\n                'meta' => $data['meta'],\n                'items' => $items,\n            ];\n        } else {\n            $this->fail('invalid test sample: ' . basename($path, '.json'));\n        }\n    }\n\n    private function setServerVars(array $list): void\n    {\n        $_SERVER = array_merge($_SERVER, $list);\n    }\n\n    protected function formatData(string $formatName, \\stdClass $sample): string\n    {\n        $formatFactory = new FormatFactory();\n        $format = $formatFactory->create($formatName);\n        $format->setItems($sample->items);\n        $format->setFeed($sample->meta);\n        $format->setLastModified(strtotime('2000-01-01 12:00:00 UTC'));\n\n        return $format->render();\n    }\n}\n"
  },
  {
    "path": "tests/Formats/FormatImplementationTest.php",
    "content": "<?php\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass FormatImplementationTest extends TestCase\n{\n    private $class;\n    private $obj;\n\n    /**\n     * @dataProvider dataFormatsProvider\n     */\n    public function testClassName($path)\n    {\n        $this->setFormat($path);\n        $this->assertTrue($this->class === ucfirst($this->class), 'class name must start with uppercase character');\n        $this->assertEquals(0, substr_count($this->class, ' '), 'class name must not contain spaces');\n        $this->assertStringEndsWith('Format', $this->class, 'class name must end with \"Format\"');\n    }\n\n    /**\n     * @dataProvider dataFormatsProvider\n     */\n    public function testClassType($path)\n    {\n        $this->setFormat($path);\n        $this->assertInstanceOf(FormatAbstract::class, $this->obj);\n    }\n\n    public function dataFormatsProvider()\n    {\n        $formats = [];\n        foreach (glob(__DIR__ . '/../formats/*.php') as $path) {\n            $formats[basename($path, '.php')] = [$path];\n        }\n        return $formats;\n    }\n\n    private function setFormat($path)\n    {\n        $this->class = basename($path, '.php');\n        $this->assertTrue(class_exists($this->class), 'class ' . $this->class . ' doesn\\'t exist');\n        $this->obj = new $this->class();\n    }\n}\n"
  },
  {
    "path": "tests/Formats/JsonFormatTest.php",
    "content": "<?php\n\n/**\n * JsonFormat - JSON Feed Version 1\n * https://jsonfeed.org/version/1\n */\n\nnamespace RssBridge\\Tests\\Formats;\n\nrequire_once __DIR__ . '/BaseFormatTest.php';\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass JsonFormatTest extends BaseFormatTest\n{\n    private const PATH_EXPECTED = self::PATH_SAMPLES . 'expectedJsonFormat/';\n\n    /**\n     * @dataProvider sampleProvider\n     * @runInSeparateProcess\n     */\n    public function testOutput(string $name, string $path)\n    {\n        $data = $this->formatData('Json', $this->loadSample($path));\n        $this->assertNotNull(json_decode($data), 'invalid JSON output: ' . json_last_error_msg());\n\n        $expected = self::PATH_EXPECTED . $name . '.json';\n        $this->assertJsonStringEqualsJsonFile($expected, $data);\n    }\n}\n"
  },
  {
    "path": "tests/Formats/MrssFormatTest.php",
    "content": "<?php\n\n/**\n * MrssFormat - RSS 2.0 + Media RSS\n * http://www.rssboard.org/rss-specification\n * http://www.rssboard.org/media-rss\n */\n\nnamespace RssBridge\\Tests\\Formats;\n\nrequire_once __DIR__ . '/BaseFormatTest.php';\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass MrssFormatTest extends BaseFormatTest\n{\n    private const PATH_EXPECTED = self::PATH_SAMPLES . 'expectedMrssFormat/';\n\n    /**\n     * @dataProvider sampleProvider\n     * @runInSeparateProcess\n     */\n    public function testOutput(string $name, string $path)\n    {\n        $data = $this->formatData('Mrss', $this->loadSample($path));\n        $this->assertNotFalse(simplexml_load_string($data));\n\n        $expected = self::PATH_EXPECTED . $name . '.xml';\n        $this->assertXmlStringEqualsXmlFile($expected, $data);\n    }\n}\n"
  },
  {
    "path": "tests/Formats/samples/expectedAtomFormat/feed.common.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\n\t<title type=\"text\">Sample feed with common data</title>\n\t<link href=\"https://example.com/blog/\" rel=\"alternate\" type=\"text/html\"/>\n\t<link href=\"https://example.com/feed?type=common&amp;items=4\" rel=\"self\" type=\"application/atom+xml\"/>\n\t<icon>https://example.com/logo.png</icon>\n\t<logo>https://example.com/logo.png</logo>\n\t<id>https://example.com/feed?type=common&amp;items=4</id>\n\t<updated>2000-01-01T12:00:00+00:00</updated>\n\t<author>\n\t\t<name>RSS-Bridge</name>\n\t</author>\n\n\t<entry>\n\t\t<title type=\"html\">Test Entry</title>\n\t\t<published>2018-12-01T12:00:00+00:00</published>\n\t\t<updated>2018-12-01T12:00:00+00:00</updated>\n\t\t<id>http://example.com/blog/test-entry</id>\n\t\t<link href=\"http://example.com/blog/test-entry\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<author>\n\t\t\t<name>fulmeek</name>\n\t\t</author>\n\t\t<content type=\"html\">Hello world, this is a test entry.</content>\n\t\t<category term=\"test\"/>\n\t\t<category term=\"Hello World\"/>\n\t\t<category term=\"example\"/>\n\t</entry>\n\t<entry>\n\t\t<title type=\"html\">Announcing JSON Feed</title>\n\t\t<published>2017-05-17T13:02:12+00:00</published>\n\t\t<updated>2017-05-17T13:02:12+00:00</updated>\n\t\t<id>https://jsonfeed.org/2017/05/17/announcing_json_feed</id>\n\t\t<link href=\"https://jsonfeed.org/2017/05/17/announcing_json_feed\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<author>\n\t\t\t<name>Brent Simmons and Manton Reece</name>\n\t\t</author>\n\t\t<content type=\"html\">&lt;p&gt;We — Manton Reece and Brent Simmons — have noticed that JSON has become the developers’ choice for APIs, and that developers will often go out of their way to avoid XML. JSON is simpler to read and write, and it’s less prone to bugs.&lt;/p&gt;\n\n&lt;p&gt;So we developed JSON Feed, a format similar to &lt;a href=\"http://cyber.harvard.edu/rss/rss.html\"&gt;RSS&lt;/a&gt; and &lt;a href=\"https://tools.ietf.org/html/rfc4287\"&gt;Atom&lt;/a&gt; but in JSON. It reflects the lessons learned from our years of work reading and publishing feeds.&lt;/p&gt;\n\n&lt;p&gt;&lt;a href=\"https://jsonfeed.org/version/1\"&gt;See the spec&lt;/a&gt;. It’s at version 1, which may be the only version ever needed. If future versions are needed, version 1 feeds will still be valid feeds.&lt;/p&gt;\n\n&lt;h4&gt;Notes&lt;/h4&gt;\n\n&lt;p&gt;We have a &lt;a href=\"https://github.com/manton/jsonfeed-wp\"&gt;WordPress plugin&lt;/a&gt; and, coming soon, a JSON Feed Parser for Swift. As more code is written, by us and others, we’ll update the &lt;a href=\"https://jsonfeed.org/code\"&gt;code&lt;/a&gt; page.&lt;/p&gt;\n\n&lt;p&gt;See &lt;a href=\"https://jsonfeed.org/mappingrssandatom\"&gt;Mapping RSS and Atom to JSON Feed&lt;/a&gt; for more on the similarities between the formats.&lt;/p&gt;\n\n&lt;p&gt;This website — the Markdown files and supporting resources — &lt;a href=\"https://github.com/brentsimmons/JSONFeed\"&gt;is up on GitHub&lt;/a&gt;, and you’re welcome to comment there.&lt;/p&gt;\n\n&lt;p&gt;This website is also a blog, and you can subscribe to the &lt;a href=\"https://jsonfeed.org/xml/rss.xml\"&gt;RSS feed&lt;/a&gt; or the &lt;a href=\"https://jsonfeed.org/feed.json\"&gt;JSON feed&lt;/a&gt; (if your reader supports it).&lt;/p&gt;\n\n&lt;p&gt;We worked with a number of people on this over the course of several months. We list them, and thank them, at the bottom of the &lt;a href=\"https://jsonfeed.org/version/1\"&gt;spec&lt;/a&gt;. But — most importantly — &lt;a href=\"http://furbo.org/\"&gt;Craig Hockenberry&lt;/a&gt; spent a little time making it look pretty. :)&lt;/p&gt;</content>\n\t</entry>\n\t<entry>\n\t\t<title type=\"html\">Atom draft-07 snapshot</title>\n\t\t<published>2005-07-31T12:29:29+00:00</published>\n\t\t<updated>2005-07-31T12:29:29+00:00</updated>\n\t\t<id>urn:sha1:dd6b6c920d3b340ab9e07faf6682f2a7c4f70134</id>\n\t\t<link href=\"http://example.org/2005/04/02/atom\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<author>\n\t\t\t<name>Mark Pilgrim</name>\n\t\t</author>\n\t\t<content type=\"html\">&lt;p&gt;&lt;i&gt;[Update: The Atom draft is finished.]&lt;/i&gt;&lt;/p&gt;</content>\n\t\t<link rel=\"enclosure\" type=\"audio/mpeg\" href=\"http://example.org/audio/ph34r_my_podcast.mp3\"/>\n\t</entry>\n\t<entry>\n\t\t<title type=\"html\">Star City</title>\n\t\t<published>2003-06-03T09:39:21+00:00</published>\n\t\t<updated>2003-06-03T09:39:21+00:00</updated>\n\t\t<id>http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</id>\n\t\t<link href=\"http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<content type=\"html\">How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's &lt;a href=\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\"&gt;Star City&lt;/a&gt;.</content>\n\t</entry>\n\n</feed>\n"
  },
  {
    "path": "tests/Formats/samples/expectedAtomFormat/feed.empty.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\n\t<title type=\"text\">Sample feed with minimum data</title>\n\t<link href=\"https://github.com/RSS-Bridge/rss-bridge/\" rel=\"alternate\" type=\"text/html\"/>\n\t<link href=\"https://example.com/feed\" rel=\"self\" type=\"application/atom+xml\"/>\n\t<id>https://example.com/feed</id>\n\t<updated>2000-01-01T12:00:00+00:00</updated>\n\t<author>\n\t\t<name>RSS-Bridge</name>\n\t</author>\n\n</feed>\n"
  },
  {
    "path": "tests/Formats/samples/expectedAtomFormat/feed.emptyItems.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\n\t<title type=\"text\">Sample feed with minimum data</title>\n\t<link href=\"https://github.com/RSS-Bridge/rss-bridge/\" rel=\"alternate\" type=\"text/html\"/>\n\t<link href=\"https://example.com/feed\" rel=\"self\" type=\"application/atom+xml\"/>\n\t<id>https://example.com/feed</id>\n\t<updated>2000-01-01T12:00:00+00:00</updated>\n\t<author>\n\t\t<name>RSS-Bridge</name>\n\t</author>\n\n\t<entry>\n\t\t<title type=\"html\">Sample Item #1</title>\n\t\t<id>urn:sha1:29f59918d266c56a935da13e4122b524298e5a39</id>\n\t\t<content type=\"html\"> </content>\n\t</entry>\n\t<entry>\n\t\t<title type=\"html\">Sample Item #2</title>\n\t\t<id>urn:sha1:edf358cad1a7ae255d6bc97640dd9d27738f1b7b</id>\n\t\t<content type=\"html\"> </content>\n\t</entry>\n\n</feed>\n"
  },
  {
    "path": "tests/Formats/samples/expectedAtomFormat/feed.microblog.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\n\t<title type=\"text\">Sample microblog feed</title>\n\t<link href=\"https://example.com/blog/\" rel=\"alternate\" type=\"text/html\"/>\n\t<link href=\"https://example.com/feed\" rel=\"self\" type=\"application/atom+xml\"/>\n\t<icon>https://example.com/logo.png</icon>\n\t<logo>https://example.com/logo.png</logo>\n\t<id>https://example.com/feed</id>\n\t<updated>2000-01-01T12:00:00+00:00</updated>\n\t<author>\n\t\t<name>RSS-Bridge</name>\n\t</author>\n\n\t<entry>\n\t\t<title type=\"html\">Oh 😲 I found three monkeys 🙈🙉🙊</title>\n\t\t<published>2018-10-07T16:53:03+00:00</published>\n\t\t<updated>2018-10-07T16:53:03+00:00</updated>\n\t\t<id>urn:sha1:1918f084648b82057c1dd3faa3d091da82a6fac2</id>\n\t\t<content type=\"html\">Oh 😲 I found three monkeys 🙈🙉🙊</content>\n\t</entry>\n\t<entry>\n\t\t<title type=\"html\">Something happened</title>\n\t\t<published>2018-10-07T16:38:17+00:00</published>\n\t\t<updated>2018-10-07T16:38:17+00:00</updated>\n\t\t<id>urn:sha1:e62189168a06dfa74f61c621c79c33c4c8517e1f</id>\n\t\t<content type=\"html\">Something happened</content>\n\t</entry>\n\n</feed>\n"
  },
  {
    "path": "tests/Formats/samples/expectedJsonFormat/feed.common.json",
    "content": "{\n\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\"title\": \"Sample feed with common data\",\n\t\"home_page_url\": \"https://example.com/blog/\",\n\t\"feed_url\": \"https://example.com/feed?type=common&items=4\",\n\t\"icon\": \"https://example.com/logo.png\",\n\t\"favicon\": \"https://example.com/logo.png\",\n\t\"items\": [\n\t\t{\n\t\t\t\"id\": \"http://example.com/blog/test-entry\",\n\t\t\t\"url\": \"http://example.com/blog/test-entry\",\n\t\t\t\"title\": \"Test Entry\",\n\t\t\t\"date_modified\": \"2018-12-01T12:00:00+00:00\",\n\t\t\t\"author\": {\n\t\t\t\t\"name\": \"fulmeek\"\n\t\t\t},\n\t\t\t\"content_text\": \"Hello world, this is a test entry.\",\n\t\t\t\"tags\": [\"test\", \"Hello World\", \"example\"]\n\t\t},{\n\t\t\t\"id\": \"https://jsonfeed.org/2017/05/17/announcing_json_feed\",\n\t\t\t\"url\": \"https://jsonfeed.org/2017/05/17/announcing_json_feed\",\n\t\t\t\"title\": \"Announcing JSON Feed\",\n\t\t\t\"date_modified\": \"2017-05-17T13:02:12+00:00\",\n\t\t\t\"author\": {\n\t\t\t\t\"name\": \"Brent Simmons and Manton Reece\"\n\t\t\t},\n\t\t\t\"content_html\": \"<p>We — Manton Reece and Brent Simmons — have noticed that JSON has become the developers’ choice for APIs, and that developers will often go out of their way to avoid XML. JSON is simpler to read and write, and it’s less prone to bugs.</p>\\n\\n<p>So we developed JSON Feed, a format similar to <a href=\\\"http://cyber.harvard.edu/rss/rss.html\\\">RSS</a> and <a href=\\\"https://tools.ietf.org/html/rfc4287\\\">Atom</a> but in JSON. It reflects the lessons learned from our years of work reading and publishing feeds.</p>\\n\\n<p><a href=\\\"https://jsonfeed.org/version/1\\\">See the spec</a>. It’s at version 1, which may be the only version ever needed. If future versions are needed, version 1 feeds will still be valid feeds.</p>\\n\\n<h4>Notes</h4>\\n\\n<p>We have a <a href=\\\"https://github.com/manton/jsonfeed-wp\\\">WordPress plugin</a> and, coming soon, a JSON Feed Parser for Swift. As more code is written, by us and others, we’ll update the <a href=\\\"https://jsonfeed.org/code\\\">code</a> page.</p>\\n\\n<p>See <a href=\\\"https://jsonfeed.org/mappingrssandatom\\\">Mapping RSS and Atom to JSON Feed</a> for more on the similarities between the formats.</p>\\n\\n<p>This website — the Markdown files and supporting resources — <a href=\\\"https://github.com/brentsimmons/JSONFeed\\\">is up on GitHub</a>, and you’re welcome to comment there.</p>\\n\\n<p>This website is also a blog, and you can subscribe to the <a href=\\\"https://jsonfeed.org/xml/rss.xml\\\">RSS feed</a> or the <a href=\\\"https://jsonfeed.org/feed.json\\\">JSON feed</a> (if your reader supports it).</p>\\n\\n<p>We worked with a number of people on this over the course of several months. We list them, and thank them, at the bottom of the <a href=\\\"https://jsonfeed.org/version/1\\\">spec</a>. But — most importantly — <a href=\\\"http://furbo.org/\\\">Craig Hockenberry</a> spent a little time making it look pretty. :)</p>\"\n\t\t},{\n\t\t\t\"id\": \"dd6b6c920d3b340ab9e07faf6682f2a7c4f70134\",\n\t\t\t\"url\": \"http://example.org/2005/04/02/atom\",\n\t\t\t\"title\": \"Atom draft-07 snapshot\",\n\t\t\t\"date_modified\": \"2005-07-31T12:29:29+00:00\",\n\t\t\t\"author\": {\n\t\t\t\t\"name\": \"Mark Pilgrim\"\n\t\t\t},\n\t\t\t\"content_html\": \"<p><i>[Update: The Atom draft is finished.]</i></p>\",\n\t\t\t\"attachments\": [\n\t\t\t\t{\n\t\t\t\t\t\"url\": \"http://example.org/audio/ph34r_my_podcast.mp3\",\n\t\t\t\t\t\"mime_type\": \"audio/mpeg\"\n\t\t\t\t}\n\t\t\t]\n\t\t},{\n\t\t\t\"id\": \"http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp\",\n\t\t\t\"url\": \"http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp\",\n\t\t\t\"title\": \"Star City\",\n\t\t\t\"date_modified\": \"2003-06-03T09:39:21+00:00\",\n\t\t\t\"content_html\": \"How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's <a href=\\\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\\\">Star City</a>.\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/Formats/samples/expectedJsonFormat/feed.empty.json",
    "content": "{\n\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\"title\": \"Sample feed with minimum data\",\n\t\"home_page_url\": \"https://github.com/RSS-Bridge/rss-bridge/\",\n\t\"feed_url\": \"https://example.com/feed\",\n\t\"items\": []\n}\n"
  },
  {
    "path": "tests/Formats/samples/expectedJsonFormat/feed.emptyItems.json",
    "content": "{\n\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\"title\": \"Sample feed with minimum data\",\n\t\"home_page_url\": \"https://github.com/RSS-Bridge/rss-bridge/\",\n\t\"feed_url\": \"https://example.com/feed\",\n\t\"items\": [\n\t\t{\n\t\t\t\"id\": \"29f59918d266c56a935da13e4122b524298e5a39\",\n\t\t\t\"title\": \"Sample Item #1\"\n\t\t},{\n\t\t\t\"id\": \"edf358cad1a7ae255d6bc97640dd9d27738f1b7b\",\n\t\t\t\"title\": \"Sample Item #2\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/Formats/samples/expectedJsonFormat/feed.microblog.json",
    "content": "{\n\t\"version\": \"https://jsonfeed.org/version/1\",\n\t\"title\": \"Sample microblog feed\",\n\t\"home_page_url\": \"https://example.com/blog/\",\n\t\"feed_url\": \"https://example.com/feed\",\n\t\"icon\": \"https://example.com/logo.png\",\n\t\"favicon\": \"https://example.com/logo.png\",\n\t\"items\": [\n\t\t{\n\t\t\t\"id\": \"1918f084648b82057c1dd3faa3d091da82a6fac2\",\n\t\t\t\"date_modified\": \"2018-10-07T16:53:03+00:00\",\n\t\t\t\"content_text\": \"Oh 😲 I found three monkeys 🙈🙉🙊\"\n\t\t},{\n\t\t\t\"id\": \"e62189168a06dfa74f61c621c79c33c4c8517e1f\",\n\t\t\t\"date_modified\": \"2018-10-07T16:38:17+00:00\",\n\t\t\t\"content_text\": \"Something happened\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/Formats/samples/expectedMrssFormat/feed.common.xml",
    "content": "<?xml version=\"1.0\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t<channel>\n\t\t<title>Sample feed with common data</title>\n\t\t<description>Sample feed with common data</description>\n\t\t<link>https://example.com/blog/</link>\n\t\t<atom:link href=\"https://example.com/blog/\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<atom:link href=\"https://example.com/feed?type=common&amp;items=4\" rel=\"self\" type=\"application/atom+xml\"/>\n\t\t<image>\n\t\t\t<url>https://example.com/logo.png</url>\n\t\t\t<title>Sample feed with common data</title>\n\t\t\t<link>https://example.com/blog/</link>\n\t\t</image>\n\n\t\t<item>\n\t\t\t<title>Test Entry</title>\n\t\t\t<link>http://example.com/blog/test-entry</link>\n\t\t\t<guid isPermaLink=\"true\">http://example.com/blog/test-entry</guid>\n\t\t\t<pubDate>Sat, 01 Dec 2018 12:00:00 +0000</pubDate>\n\t\t\t<description>Hello world, this is a test entry.</description>\n\t\t\t<category>test</category>\n\t\t\t<category>Hello World</category>\n\t\t\t<category>example</category>\n\t\t</item>\n\t\t<item>\n\t\t\t<title>Announcing JSON Feed</title>\n\t\t\t<link>https://jsonfeed.org/2017/05/17/announcing_json_feed</link>\n\t\t\t<guid isPermaLink=\"true\">https://jsonfeed.org/2017/05/17/announcing_json_feed</guid>\n\t\t\t<pubDate>Wed, 17 May 2017 13:02:12 +0000</pubDate>\n\t\t\t<description>&lt;p&gt;We — Manton Reece and Brent Simmons — have noticed that JSON has become the developers’ choice for APIs, and that developers will often go out of their way to avoid XML. JSON is simpler to read and write, and it’s less prone to bugs.&lt;/p&gt;\n\n&lt;p&gt;So we developed JSON Feed, a format similar to &lt;a href=\"http://cyber.harvard.edu/rss/rss.html\"&gt;RSS&lt;/a&gt; and &lt;a href=\"https://tools.ietf.org/html/rfc4287\"&gt;Atom&lt;/a&gt; but in JSON. It reflects the lessons learned from our years of work reading and publishing feeds.&lt;/p&gt;\n\n&lt;p&gt;&lt;a href=\"https://jsonfeed.org/version/1\"&gt;See the spec&lt;/a&gt;. It’s at version 1, which may be the only version ever needed. If future versions are needed, version 1 feeds will still be valid feeds.&lt;/p&gt;\n\n&lt;h4&gt;Notes&lt;/h4&gt;\n\n&lt;p&gt;We have a &lt;a href=\"https://github.com/manton/jsonfeed-wp\"&gt;WordPress plugin&lt;/a&gt; and, coming soon, a JSON Feed Parser for Swift. As more code is written, by us and others, we’ll update the &lt;a href=\"https://jsonfeed.org/code\"&gt;code&lt;/a&gt; page.&lt;/p&gt;\n\n&lt;p&gt;See &lt;a href=\"https://jsonfeed.org/mappingrssandatom\"&gt;Mapping RSS and Atom to JSON Feed&lt;/a&gt; for more on the similarities between the formats.&lt;/p&gt;\n\n&lt;p&gt;This website — the Markdown files and supporting resources — &lt;a href=\"https://github.com/brentsimmons/JSONFeed\"&gt;is up on GitHub&lt;/a&gt;, and you’re welcome to comment there.&lt;/p&gt;\n\n&lt;p&gt;This website is also a blog, and you can subscribe to the &lt;a href=\"https://jsonfeed.org/xml/rss.xml\"&gt;RSS feed&lt;/a&gt; or the &lt;a href=\"https://jsonfeed.org/feed.json\"&gt;JSON feed&lt;/a&gt; (if your reader supports it).&lt;/p&gt;\n\n&lt;p&gt;We worked with a number of people on this over the course of several months. We list them, and thank them, at the bottom of the &lt;a href=\"https://jsonfeed.org/version/1\"&gt;spec&lt;/a&gt;. But — most importantly — &lt;a href=\"http://furbo.org/\"&gt;Craig Hockenberry&lt;/a&gt; spent a little time making it look pretty. :)&lt;/p&gt;</description>\n\t\t</item>\n\t\t<item>\n\t\t\t<title>Atom draft-07 snapshot</title>\n\t\t\t<link>http://example.org/2005/04/02/atom</link>\n\t\t\t<guid isPermaLink=\"false\">dd6b6c920d3b340ab9e07faf6682f2a7c4f70134</guid>\n\t\t\t<pubDate>Sun, 31 Jul 2005 12:29:29 +0000</pubDate>\n\t\t\t<description>&lt;p&gt;&lt;i&gt;[Update: The Atom draft is finished.]&lt;/i&gt;&lt;/p&gt;</description>\n\t\t\t<media:content url=\"http://example.org/audio/ph34r_my_podcast.mp3\" type=\"audio/mpeg\"/>\n\t\t</item>\n\t\t<item>\n\t\t\t<title>Star City</title>\n\t\t\t<link>http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</link>\n\t\t\t<guid isPermaLink=\"true\">http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp</guid>\n\t\t\t<pubDate>Tue, 03 Jun 2003 09:39:21 +0000</pubDate>\n\t\t\t<description>How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's &lt;a href=\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\"&gt;Star City&lt;/a&gt;.</description>\n\t\t</item>\n\t</channel>\n</rss>\n"
  },
  {
    "path": "tests/Formats/samples/expectedMrssFormat/feed.empty.xml",
    "content": "<?xml version=\"1.0\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t<channel>\n\t\t<title>Sample feed with minimum data</title>\n\t\t<description>Sample feed with minimum data</description>\n\t\t<link>https://github.com/RSS-Bridge/rss-bridge/</link>\n\t\t<atom:link href=\"https://github.com/RSS-Bridge/rss-bridge/\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<atom:link href=\"https://example.com/feed\" rel=\"self\" type=\"application/atom+xml\"/>\n\t</channel>\n</rss>\n"
  },
  {
    "path": "tests/Formats/samples/expectedMrssFormat/feed.emptyItems.xml",
    "content": "<?xml version=\"1.0\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t<channel>\n\t\t<title>Sample feed with minimum data</title>\n\t\t<description>Sample feed with minimum data</description>\n\t\t<link>https://github.com/RSS-Bridge/rss-bridge/</link>\n\t\t<atom:link href=\"https://github.com/RSS-Bridge/rss-bridge/\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<atom:link href=\"https://example.com/feed\" rel=\"self\" type=\"application/atom+xml\"/>\n\n\t\t<item>\n\t\t\t<title>Sample Item #1</title>\n\t\t\t<guid isPermaLink=\"false\">29f59918d266c56a935da13e4122b524298e5a39</guid>\n\t\t</item>\n\t\t<item>\n\t\t\t<title>Sample Item #2</title>\n\t\t\t<guid isPermaLink=\"false\">edf358cad1a7ae255d6bc97640dd9d27738f1b7b</guid>\n\t\t</item>\n\t</channel>\n</rss>\n"
  },
  {
    "path": "tests/Formats/samples/expectedMrssFormat/feed.microblog.xml",
    "content": "<?xml version=\"1.0\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:media=\"http://search.yahoo.com/mrss/\">\n\t<channel>\n\t\t<title>Sample microblog feed</title>\n\t\t<description>Sample microblog feed</description>\n\t\t<link>https://example.com/blog/</link>\n\t\t<atom:link href=\"https://example.com/blog/\" rel=\"alternate\" type=\"text/html\"/>\n\t\t<atom:link href=\"https://example.com/feed\" rel=\"self\" type=\"application/atom+xml\"/>\n\t\t<image>\n\t\t\t<url>https://example.com/logo.png</url>\n\t\t\t<title>Sample microblog feed</title>\n\t\t\t<link>https://example.com/blog/</link>\n\t\t</image>\n\n\t\t<item>\n\t\t\t<guid isPermaLink=\"false\">1918f084648b82057c1dd3faa3d091da82a6fac2</guid>\n\t\t\t<pubDate>Sun, 07 Oct 2018 16:53:03 +0000</pubDate>\n\t\t\t<description>Oh 😲 I found three monkeys 🙈🙉🙊</description>\n\t\t</item>\n\t\t<item>\n\t\t\t<guid isPermaLink=\"false\">e62189168a06dfa74f61c621c79c33c4c8517e1f</guid>\n\t\t\t<pubDate>Sun, 07 Oct 2018 16:38:17 +0000</pubDate>\n\t\t\t<description>Something happened</description>\n\t\t</item>\n\t</channel>\n</rss>\n"
  },
  {
    "path": "tests/Formats/samples/feed.common.json",
    "content": "{\n\t\"server\": {\n\t\t\"HTTPS\": \"on\",\n\t\t\"HTTP_HOST\": \"example.com\",\n\t\t\"REQUEST_URI\": \"/feed?type=common&items=4\"\n\t},\n\t\"meta\": {\n\t\t\"name\": \"Sample feed with common data\",\n\t\t\"uri\": \"https://example.com/blog/\",\n\t\t\"icon\": \"https://example.com/logo.png\"\n\t},\n\t\"items\": [\n\t\t{\n\t\t\t\"uri\": \"http://example.com/blog/test-entry\",\n\t\t\t\"title\": \"Test Entry\",\n\t\t\t\"timestamp\": 1543665600,\n\t\t\t\"author\": \"fulmeek\",\n\t\t\t\"content\": \"Hello world, this is a test entry.\",\n\t\t\t\"categories\": [\"test\", \"Hello World\", \"example\"]\n\t\t},{\n\t\t\t\"uri\": \"https://jsonfeed.org/2017/05/17/announcing_json_feed\",\n\t\t\t\"title\": \"Announcing JSON Feed\",\n\t\t\t\"timestamp\": 1495026132,\n\t\t\t\"author\": \"Brent Simmons and Manton Reece\",\n\t\t\t\"content\": \"<p>We — Manton Reece and Brent Simmons — have noticed that JSON has become the developers’ choice for APIs, and that developers will often go out of their way to avoid XML. JSON is simpler to read and write, and it’s less prone to bugs.</p>\\n\\n<p>So we developed JSON Feed, a format similar to <a href=\\\"http://cyber.harvard.edu/rss/rss.html\\\">RSS</a> and <a href=\\\"https://tools.ietf.org/html/rfc4287\\\">Atom</a> but in JSON. It reflects the lessons learned from our years of work reading and publishing feeds.</p>\\n\\n<p><a href=\\\"https://jsonfeed.org/version/1\\\">See the spec</a>. It’s at version 1, which may be the only version ever needed. If future versions are needed, version 1 feeds will still be valid feeds.</p>\\n\\n<h4>Notes</h4>\\n\\n<p>We have a <a href=\\\"https://github.com/manton/jsonfeed-wp\\\">WordPress plugin</a> and, coming soon, a JSON Feed Parser for Swift. As more code is written, by us and others, we’ll update the <a href=\\\"https://jsonfeed.org/code\\\">code</a> page.</p>\\n\\n<p>See <a href=\\\"https://jsonfeed.org/mappingrssandatom\\\">Mapping RSS and Atom to JSON Feed</a> for more on the similarities between the formats.</p>\\n\\n<p>This website — the Markdown files and supporting resources — <a href=\\\"https://github.com/brentsimmons/JSONFeed\\\">is up on GitHub</a>, and you’re welcome to comment there.</p>\\n\\n<p>This website is also a blog, and you can subscribe to the <a href=\\\"https://jsonfeed.org/xml/rss.xml\\\">RSS feed</a> or the <a href=\\\"https://jsonfeed.org/feed.json\\\">JSON feed</a> (if your reader supports it).</p>\\n\\n<p>We worked with a number of people on this over the course of several months. We list them, and thank them, at the bottom of the <a href=\\\"https://jsonfeed.org/version/1\\\">spec</a>. But — most importantly — <a href=\\\"http://furbo.org/\\\">Craig Hockenberry</a> spent a little time making it look pretty. :)</p>\"\n\t\t},{\n\t\t\t\"uri\": \"http://example.org/2005/04/02/atom\",\n\t\t\t\"uid\": \"tag:example.org,2003:3.2397\",\n\t\t\t\"title\": \"Atom draft-07 snapshot\",\n\t\t\t\"timestamp\": 1122812969,\n\t\t\t\"author\": \"Mark Pilgrim\",\n\t\t\t\"content\": \"<p><i>[Update: The Atom draft is finished.]</i></p>\",\n\t\t\t\"enclosures\": [\n\t\t\t\t\"http://example.org/audio/ph34r_my_podcast.mp3\"\n\t\t\t]\n\t\t},{\n\t\t\t\"uri\": \"http://liftoff.msfc.nasa.gov/news/2003/news-starcity.asp\",\n\t\t\t\"title\": \"Star City\",\n\t\t\t\"timestamp\": 1054633161,\n\t\t\t\"content\": \"How do Americans get ready to work with Russians aboard the International Space Station? They take a crash course in culture, language and protocol at Russia's <a href=\\\"http://howe.iki.rssi.ru/GCTC/gctc_e.htm\\\">Star City</a>.\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/Formats/samples/feed.empty.json",
    "content": "{\n\t\"server\": {\n\t\t\"HTTPS\": \"on\",\n\t\t\"HTTP_HOST\": \"example.com\",\n\t\t\"REQUEST_URI\": \"/feed\"\n\t},\n\t\"meta\": {\n\t\t\"name\": \"Sample feed with minimum data\",\n\t\t\"uri\": \"https://github.com/RSS-Bridge/rss-bridge/\",\n\t\t\"icon\": \"\"\n\t},\n\t\"items\": []\n}\n"
  },
  {
    "path": "tests/Formats/samples/feed.emptyItems.json",
    "content": "{\n\t\"server\": {\n\t\t\"HTTPS\": \"on\",\n\t\t\"HTTP_HOST\": \"example.com\",\n\t\t\"REQUEST_URI\": \"/feed\"\n\t},\n\t\"meta\": {\n\t\t\"name\": \"Sample feed with minimum data\",\n\t\t\"uri\": \"https://github.com/RSS-Bridge/rss-bridge/\",\n\t\t\"icon\": \"\"\n\t},\n\t\"items\": [\n\t\t{\n\t\t\t\"title\": \"Sample Item #1\"\n\t\t},{\n\t\t\t\"title\": \"Sample Item #2\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/Formats/samples/feed.microblog.json",
    "content": "{\n\t\"server\": {\n\t\t\"HTTPS\": \"on\",\n\t\t\"HTTP_HOST\": \"example.com\",\n\t\t\"REQUEST_URI\": \"/feed\"\n\t},\n\t\"meta\": {\n\t\t\"name\": \"Sample microblog feed\",\n\t\t\"uri\": \"https://example.com/blog/\",\n\t\t\"icon\": \"https://example.com/logo.png\"\n\t},\n\t\"items\": [\n\t\t{\n\t\t\t\"timestamp\": 1538931183,\n\t\t\t\"content\": \"Oh 😲 I found three monkeys 🙈🙉🙊\"\n\t\t},{\n\t\t\t\"timestamp\": 1538930297,\n\t\t\t\"content\": \"Something happened\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/ParameterValidatorTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass ParameterValidatorTest extends TestCase\n{\n    public function test1()\n    {\n        $sut = new \\ParameterValidator();\n        $input = ['user' => 'joe'];\n        $parameters = [\n            [\n                'user' => [\n                    'name' => 'User',\n                    'type' => 'text',\n                ],\n            ]\n        ];\n        $this->assertSame([], $sut->validateInput($input, $parameters));\n    }\n\n    public function test2()\n    {\n        $sut = new \\ParameterValidator();\n        $input = ['username' => 'joe'];\n        $parameters = [\n            [\n                'user' => [\n                    'name' => 'User',\n                    'type' => 'text',\n                ],\n            ]\n        ];\n        $this->assertNotEmpty($sut->validateInput($input, $parameters));\n    }\n}\n"
  },
  {
    "path": "tests/RedditBridgeTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nuse PHPUnit\\Framework\\TestCase;\n\nclass RedditBridgeTest extends TestCase\n{\n    public function test()\n    {\n        $sut = new RedditBridge(new NullCache(), new NullLogger());\n\n        // https://old.reddit.com/search.json?q=cats dogs hen subreddit:php&sort=hot&include_over_18=on\n        $expected = 'https://old.reddit.com/search.json?q=cats+dogs+hen+subreddit%3Aphp&sort=hot&include_over_18=on&t=all';\n        $actual = RedditBridge::createUrl('cats,dogs hen', '', 'php', false, 'hot', 'all', 'single');\n        $this->assertSame($expected, $actual);\n\n        // https://old.reddit.com/search.json?q=author:RavenousRandy&sort=hot&include_over_18=on\n        $expected = 'https://old.reddit.com/search.json?q=author%3ARavenousRandy&sort=hot&include_over_18=on&t=week';\n        $actual = RedditBridge::createUrl('', '', 'RavenousRandy', true, 'hot', 'week', 'user');\n        $this->assertSame($expected, $actual);\n\n        // https://old.reddit.com/search.json?q=cats dogs hen flair:\"Proxy\" subreddit:php&sort=hot&include_over_18=on\n        $expected = 'https://old.reddit.com/search.json?q=cats+dogs+hen+flair%3A%22Proxy%22+subreddit%3Aphp&sort=hot&include_over_18=on&t=month';\n        $actual = RedditBridge::createUrl('cats,dogs hen', 'Proxy', 'php', false, 'hot', 'month', 'single');\n        $this->assertSame($expected, $actual);\n\n        // https://old.reddit.com/search.json?q=cats dogs hen flair:\"Proxy Linux Server\" subreddit:php&sort=hot&include_over_18=on\n        $expected = 'https://old.reddit.com/search.json?q=cats+dogs+hen+flair%3A%22Proxy+Linux+Server%22+subreddit%3Aphp&sort=hot&include_over_18=on&t=day';\n        $actual = RedditBridge::createUrl('cats,dogs hen', 'Proxy,Linux Server', 'php', false, 'hot', 'day', 'single');\n        $this->assertSame($expected, $actual);\n    }\n}\n"
  },
  {
    "path": "tests/UrlTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\nuse Url;\n\nclass UrlTest extends TestCase\n{\n    public function testBasicUsages()\n    {\n        $urls = [\n            'http://example.com/',\n            'http://example.com:9000/',\n            'https://example.com/',\n            'https://example.com/?foo',\n            'https://example.com/?foo=bar',\n        ];\n        foreach ($urls as $url) {\n            $this->assertSame($url, Url::fromString($url)->__toString());\n        }\n    }\n\n    public function testNormalization()\n    {\n        $urls = [\n            'http://example.com' => 'http://example.com/',\n            'https://example.com/?' => 'https://example.com/',\n            'https://example.com/foo?' => 'https://example.com/foo',\n            'http://example.com:80/' => 'http://example.com/',\n        ];\n        foreach ($urls as $from => $to) {\n            $this->assertSame($to, Url::fromString($from)->__toString());\n        }\n    }\n\n    public function testIllegalPath()\n    {\n        $this->expectException(\\UrlException::class);\n        Url::fromString('https://example.com//foo');\n    }\n\n    public function testMutation()\n    {\n        $this->assertSame('http://example.com/foo', (Url::fromString('http://example.com/'))->withPath('/foo')->__toString());\n        $this->assertSame('http://example.com/foo?a=b', (Url::fromString('http://example.com/?a=b'))->withPath('/foo')->__toString());\n        $this->assertSame('http://example.com/', (Url::fromString('http://example.com/'))->withPath('/')->__toString());\n        $this->assertSame('http://example.com/qqq?foo=bar', (Url::fromString('http://example.com/qqq'))->withQueryString('foo=bar')->__toString());\n        $this->assertSame('http://example.net/qqq?foo=bar', (Url::fromString('http://example.com/qqq?foo=bar'))->withHost('example.net')->__toString());\n    }\n}\n"
  },
  {
    "path": "tests/UtilsTest.php",
    "content": "<?php\n\ndeclare(strict_types=1);\n\nnamespace RssBridge\\Tests;\n\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class UtilsTest extends TestCase\n{\n    public function setUp(): void\n    {\n        \\Configuration::loadConfiguration();\n    }\n\n    public function testConvertLazyLoading()\n    {\n        $html = '<img data-src=\"foo.png\" src=\"\">';\n        $expected = '<img src=\"foo.png\">';\n        $this->assertSame($expected, convertLazyLoading($html));\n    }\n\n    public function testParseSrcSet()\n    {\n        $srcset = 'elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w';\n        $expected = [\n            '480w' => 'elva-fairy-480w.jpg',\n            '800w' => 'elva-fairy-800w.jpg'\n        ];\n        $this->assertSame($expected, parseSrcset($srcset));\n        $this->assertSame('elva-fairy-800w.jpg', parseSrcsetLargestImageUrl($srcset));\n    }\n\n    public function testTruncate()\n    {\n        $this->assertSame('f...', truncate('foo', 1));\n        $this->assertSame('fo...', truncate('foo', 2));\n        $this->assertSame('foo', truncate('foo', 3));\n        $this->assertSame('foo', truncate('foo', 4));\n        $this->assertSame('fo[...]', truncate('foo', 2, '[...]'));\n    }\n\n    public function testFormatBytes()\n    {\n        $this->assertSame('1 B', format_bytes(1));\n        $this->assertSame('1 KB', format_bytes(1024));\n        $this->assertSame('1 MB', format_bytes(1024 ** 2));\n        $this->assertSame('1 GB', format_bytes(1024 ** 3));\n        $this->assertSame('1 TB', format_bytes(1024 ** 4));\n    }\n\n    public function testSanitizePathName()\n    {\n        $this->assertSame('index.php', _sanitize_path_name('/home/satoshi/rss-bridge/index.php', '/home/satoshi/rss-bridge'));\n        $this->assertSame('tests/UtilsTest.php', _sanitize_path_name('/home/satoshi/rss-bridge/tests/UtilsTest.php', '/home/satoshi/rss-bridge'));\n        $this->assertSame('bug in lib/kek.php', _sanitize_path_name('bug in /home/satoshi/rss-bridge/lib/kek.php', '/home/satoshi/rss-bridge'));\n    }\n\n    public function testSanitizePathNameInErrorMessage()\n    {\n        $raw       = 'Error: Argument 1 passed to foo() must be an instance of kk, string given, called in /home/satoshi/rss-bridge/bridges/RumbleBridge.php';\n        $sanitized = 'Error: Argument 1 passed to foo() must be an instance of kk, string given, called in bridges/RumbleBridge.php';\n        $this->assertSame($sanitized, _sanitize_path_name($raw, '/home/satoshi/rss-bridge'));\n    }\n\n    public function testCreateRandomString()\n    {\n        $this->assertSame(2, strlen(create_random_string(1)));\n        $this->assertSame(4, strlen(create_random_string(2)));\n        $this->assertSame(6, strlen(create_random_string(3)));\n    }\n\n    public function testUrljoin()\n    {\n        $base = '/';\n        $rel = 'https://example.com/foo';\n\n        $url = urljoin($base, $rel);\n\n        $this->assertSame($rel, $url);\n    }\n}\n"
  }
]