diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index faec9f09..15297cc3 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,21 @@ FROM rssbridge/rss-bridge:latest -RUN apt-get update && \ - apt-get install --yes --no-install-recommends \ - git && \ - pecl install xdebug && \ - pear install PHP_CodeSniffer && \ - docker-php-ext-enable xdebug \ No newline at end of file +COPY --chmod=755 post-create-command.sh /usr/local/bin/post-create-command + +ADD https://raw.githubusercontent.com/docker-library/php/master/docker-php-ext-enable /usr/local/bin/docker-php-ext-enable +RUN chmod u+x /usr/local/bin/docker-php-ext-enable + +ADD https://getcomposer.org/installer /usr/local/bin/composer-installer.php +RUN chmod u+x /usr/local/bin/composer-installer.php +RUN php /usr/local/bin/composer-installer.php --check && \ + php /usr/local/bin/composer-installer.php --filename=composer --install-dir=/usr/local/bin + +RUN apt-get update && \ + apt-get install -y \ + git \ + php-dev \ + make \ + unzip + +RUN pecl install xdebug && \ + PHP_INI_DIR=/etc/php/8.2/fpm docker-php-ext-enable xdebug diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6e625b8a..564da38a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,9 +6,9 @@ "vscode": { // Set *default* container specific settings.json values on container create. "settings": { - "php.validate.executablePath": "/usr/local/bin/php", - "phpSniffer.executablesFolder": "/usr/local/bin/", - "phpcs.executablePath": "/usr/local/bin/phpcs", + "php.validate.executablePath": "/usr/bin/php", + "phpSniffer.executablesFolder": "/root/.config/composer/vendor/bin", + "phpcs.executablePath": "/root/.config/composer/vendor/bin/phpcs", "phpcs.lintOnType": false }, @@ -22,6 +22,9 @@ ] } }, + "remoteEnv": { + "PATH": "${containerEnv:PATH}:/root/.config/composer/vendor/bin", + }, "forwardPorts": [3100, 9000, 9003], - "postCreateCommand": "cp .devcontainer/nginx.conf /etc/nginx/conf.d/default.conf && cp .devcontainer/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && mkdir .vscode && cp .devcontainer/launch.json .vscode && echo '*' > whitelist.txt && chmod a+x \"$(pwd)\" && rm -rf /var/www/html && ln -s \"$(pwd)\" /var/www/html && nginx && php-fpm -D" + "postCreateCommand": "/usr/local/bin/post-create-command" } \ No newline at end of file diff --git a/.devcontainer/launch.json b/.devcontainer/launch.json index e1b473b8..7fe617e9 100644 --- a/.devcontainer/launch.json +++ b/.devcontainer/launch.json @@ -9,7 +9,8 @@ "type": "php", "request": "launch", "port": 9003, - "auto": true + "auto": true, + "log": true }, { "name": "Launch currently open script", diff --git a/.devcontainer/post-create-command.sh b/.devcontainer/post-create-command.sh new file mode 100755 index 00000000..aa3819eb --- /dev/null +++ b/.devcontainer/post-create-command.sh @@ -0,0 +1,27 @@ +#/bin/sh + +cp .devcontainer/nginx.conf /etc/nginx/conf.d/default.conf +cp .devcontainer/xdebug.ini /etc/php/8.2/fpm/conf.d/xdebug.ini + +# This should download some dev-dependencies, like phpunit and the PHP code sniffers +composer global require "phpunit/phpunit:^9" +composer global require "squizlabs/php_codesniffer:^3.6" +composer global require "phpcompatibility/php-compatibility:^9.3" + +# We need to this manually for running the PHPCompatibility ruleset +phpcs --config-set installed_paths /root/.config/composer/vendor/phpcompatibility/php-compatibility + +mkdir -p .vscode +cp .devcontainer/launch.json .vscode + +echo '*' > whitelist.txt + +chmod a+x $(pwd) +rm -rf /var/www/html +ln -s $(pwd) /var/www/html + +# Solves possible issue of cache directory not being accessible +chown www-data:www-data -R $(pwd)/cache + +nginx +php-fpm8.2 -D \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 5981fb34..28053256 100644 --- a/.gitattributes +++ b/.gitattributes @@ -47,8 +47,6 @@ phpcs.xml export-ignore phpcompatibility.xml export-ignore tests/ export-ignore cache/.gitkeep export-ignore -bridges/DemoBridge.php export-ignore -bridges/FeedExpanderExampleBridge.php export-ignore ## Composer # diff --git a/.github/.gitignore b/.github/.gitignore index 6310b3dd..7ebb4030 100644 --- a/.github/.gitignore +++ b/.github/.gitignore @@ -4,3 +4,4 @@ # Generated files comment*.md comment*.txt +*.html diff --git a/.github/ISSUE_TEMPLATE/bridge-request.md b/.github/ISSUE_TEMPLATE/bridge-request.md index 174dc095..088cc3d6 100644 --- a/.github/ISSUE_TEMPLATE/bridge-request.md +++ b/.github/ISSUE_TEMPLATE/bridge-request.md @@ -49,9 +49,9 @@ Please describe what you expect from the bridge. Whenever possible provide sampl - _Default limit_: 5 - [ ] Load full articles - _Cache articles_ (articles are stored in a local cache on first request): yes - - _Cache timeout_ (max = 24 hours): 24 hours + - _Cache timeout_ : 24 hours - [X] Balance requests (RSS-Bridge uses cached versions to reduce bandwith usage) - - _Timeout_ (default = 5 minutes, max = 24 hours): 5 minutes + - _Timeout_ (default = 5 minutes): 5 minutes diff --git a/.github/prtester.py b/.github/prtester.py index 30a9f43b..c5c5be22 100644 --- a/.github/prtester.py +++ b/.github/prtester.py @@ -4,7 +4,9 @@ import re from bs4 import BeautifulSoup from datetime import datetime from typing import Iterable -import os.path +import os +import glob +import urllib # This script is specifically written to be used in automation for https://github.com/RSS-Bridge/rss-bridge # @@ -13,18 +15,33 @@ import os.path # It also add a tag with the url of em's public instance, so viewing # the HTML file locally will actually work as designed. +ARTIFACT_FILE_EXTENSION = '.html' + class Instance: name = '' url = '' def main(instances: Iterable[Instance], with_upload: bool, with_reduced_upload: bool, title: str, output_file: str): start_date = datetime.now() + + prid = os.getenv('PR') + artifact_base_url = f'https://rss-bridge.github.io/rss-bridge-tests/prs/{prid}' + artifact_directory = os.getcwd() + for file in glob.glob(f'*{ARTIFACT_FILE_EXTENSION}', root_dir=artifact_directory): + os.remove(file) + table_rows = [] for instance in instances: page = requests.get(instance.url) # Use python requests to grab the rss-bridge main page soup = BeautifulSoup(page.content, "html.parser") # use bs4 to turn the page into soup bridge_cards = soup.select('.bridge-card') # get a soup-formatted list of all bridges on the rss-bridge page - table_rows += testBridges(instance, bridge_cards, with_upload, with_reduced_upload) # run the main scraping code with the list of bridges + table_rows += testBridges( + instance=instance, + bridge_cards=bridge_cards, + with_upload=with_upload, + with_reduced_upload=with_reduced_upload, + artifact_directory=artifact_directory, + artifact_base_url=artifact_base_url) # run the main scraping code with the list of bridges with open(file=output_file, mode='w+', encoding='utf-8') as file: table_rows_value = '\n'.join(sorted(table_rows)) file.write(f''' @@ -36,7 +53,7 @@ def main(instances: Iterable[Instance], with_upload: bool, with_reduced_upload: *last change: {start_date.strftime("%A %Y-%m-%d %H:%M:%S")}* '''.strip()) -def testBridges(instance: Instance, bridge_cards: Iterable, with_upload: bool, with_reduced_upload: bool) -> Iterable: +def testBridges(instance: Instance, bridge_cards: Iterable, with_upload: bool, with_reduced_upload: bool, artifact_directory: str, artifact_base_url: str) -> Iterable: instance_suffix = '' if instance.name: instance_suffix = f' ({instance.name})' @@ -45,15 +62,14 @@ def testBridges(instance: Instance, bridge_cards: Iterable, with_upload: bool, w bridgeid = bridge_card.get('id') bridgeid = bridgeid.split('-')[1] # this extracts a readable bridge name from the bridge metadata print(f'{bridgeid}{instance_suffix}') - bridgestring = '/?action=display&bridge=' + bridgeid + '&format=Html' bridge_name = bridgeid.replace('Bridge', '') context_forms = bridge_card.find_all("form") form_number = 1 for context_form in context_forms: # a bridge can have multiple contexts, named 'forms' in html - # this code will produce a fully working formstring that should create a working feed when called + # this code will produce a fully working url that should create a working feed when called # this will create an example feed for every single context, to test them all - formstring = '' + context_parameters = {} error_messages = [] context_name = '*untitled*' context_name_element = context_form.find_previous_sibling('h5') @@ -62,27 +78,27 @@ def testBridges(instance: Instance, bridge_cards: Iterable, with_upload: bool, w parameters = context_form.find_all("input") lists = context_form.find_all("select") # this for/if mess cycles through all available input parameters, checks if it required, then pulls - # the default or examplevalue and then combines it all together into the formstring + # the default or examplevalue and then combines it all together into the url parameters # if an example or default value is missing for a required attribute, it will throw an error # any non-required fields are not tested!!! for parameter in parameters: - if parameter.get('type') == 'hidden' and parameter.get('name') == 'context': - cleanvalue = parameter.get('value').replace(" ","+") - formstring = formstring + '&' + parameter.get('name') + '=' + cleanvalue - if parameter.get('type') == 'number' or parameter.get('type') == 'text': + parameter_type = parameter.get('type') + parameter_name = parameter.get('name') + if parameter_type == 'hidden': + context_parameters[parameter_name] = parameter.get('value') + if parameter_type == 'number' or parameter_type == 'text': if parameter.has_attr('required'): if parameter.get('placeholder') == '': if parameter.get('value') == '': - name_value = parameter.get('name') - error_messages.append(f'Missing example or default value for parameter "{name_value}"') + error_messages.append(f'Missing example or default value for parameter "{parameter_name}"') else: - formstring = formstring + '&' + parameter.get('name') + '=' + parameter.get('value') + context_parameters[parameter_name] = parameter.get('value') else: - formstring = formstring + '&' + parameter.get('name') + '=' + parameter.get('placeholder') - # same thing, just for checkboxes. If a checkbox is checked per default, it gets added to the formstring - if parameter.get('type') == 'checkbox': + context_parameters[parameter_name] = parameter.get('placeholder') + # same thing, just for checkboxes. If a checkbox is checked per default, it gets added to the url parameters + if parameter_type == 'checkbox': if parameter.has_attr('checked'): - formstring = formstring + '&' + parameter.get('name') + '=on' + context_parameters[parameter_name] = 'on' for listing in lists: selectionvalue = '' listname = listing.get('name') @@ -102,15 +118,21 @@ def testBridges(instance: Instance, bridge_cards: Iterable, with_upload: bool, w if 'selected' in selectionentry.attrs: selectionvalue = selectionentry.get('value') break - formstring = formstring + '&' + listname + '=' + selectionvalue - termpad_url = 'about:blank' + context_parameters[listname] = selectionvalue + artifact_url = 'about:blank' if error_messages: status = '
'.join(map(lambda m: f'❌ `{m}`', error_messages)) else: - # if all example/default values are present, form the full request string, run the request, add a tag with + # if all example/default values are present, form the full request url, run the request, add a tag with # the url of em's public instance to the response text (so that relative paths work, e.g. to the static css file) and - # then upload it to termpad.com, a pastebin-like-site. - response = requests.get(instance.url + bridgestring + formstring) + # then save it to a html file. + context_parameters.update({ + 'action': 'display', + 'bridge': bridgeid, + 'format': 'Html', + }) + request_url = f'{instance.url}/?{urllib.parse.urlencode(context_parameters)}' + response = requests.get(request_url) page_text = response.text.replace('','') page_text = page_text.encode("utf_8") soup = BeautifulSoup(page_text, "html.parser") @@ -134,16 +156,18 @@ def testBridges(instance: Instance, bridge_cards: Iterable, with_upload: bool, w if status_is_ok: status = '✔️' if with_upload and (not with_reduced_upload or not status_is_ok): - termpad = requests.post(url="https://termpad.com/", data=page_text) - termpad_url = termpad.text.strip() - termpad_url = termpad_url.replace('termpad.com/','termpad.com/raw/') - table_rows.append(f'| {bridge_name} | [{form_number} {context_name}{instance_suffix}]({termpad_url}) | {status} |') + filename = f'{bridge_name} {form_number}{instance_suffix}{ARTIFACT_FILE_EXTENSION}' + filename = re.sub(r'[^a-z0-9 \_\-\.]', '', filename, flags=re.I).replace(' ', '_') + with open(file=f'{artifact_directory}/{filename}', mode='wb') as file: + file.write(page_text) + artifact_url = f'{artifact_base_url}/{filename}' + table_rows.append(f'| {bridge_name} | [{form_number} {context_name}{instance_suffix}]({artifact_url}) | {status} |') form_number += 1 return table_rows def getFirstLine(value: str) -> str: # trim whitespace and remove text that can break the table or is simply unnecessary - clean_value = re.sub('^\[[^\]]+\]\s*rssbridge\.|[\|`]', '', value.strip()) + clean_value = re.sub(r'^\[[^\]]+\]\s*rssbridge\.|[\|`]', '', value.strip()) first_line = next(iter(clean_value.splitlines()), '') max_length = 250 if (len(first_line) > max_length): @@ -163,8 +187,8 @@ if __name__ == '__main__': for instance_arg in args.instances: instance_arg_parts = instance_arg.split('::') instance = Instance() - instance.name = instance_arg_parts[1] if len(instance_arg_parts) >= 2 else '' - instance.url = instance_arg_parts[0] + instance.name = instance_arg_parts[1].strip() if len(instance_arg_parts) >= 2 else '' + instance.url = instance_arg_parts[0].strip().rstrip("/") instances.append(instance) else: instance = Instance() @@ -181,4 +205,4 @@ if __name__ == '__main__': with_reduced_upload=args.reduced_upload and not args.no_upload, title=args.title, output_file=args.output_file - ); \ No newline at end of file + ); diff --git a/.github/workflows/dockerbuild.yml b/.github/workflows/dockerbuild.yml index 82d8611a..39645558 100644 --- a/.github/workflows/dockerbuild.yml +++ b/.github/workflows/dockerbuild.yml @@ -21,7 +21,7 @@ jobs: - name: Docker meta id: docker_meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | ${{ env.DOCKERHUB_SLUG }} @@ -33,26 +33,26 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/20') }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push - uses: docker/bake-action@v2 + uses: docker/bake-action@v5 with: files: | ./docker-bake.hcl diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index b00c898a..e0201022 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -9,7 +9,7 @@ jobs: documentation: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Setup PHP diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 83911ab6..79201edd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,12 +8,12 @@ on: jobs: phpcs: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php-versions: ['7.4'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} @@ -21,12 +21,12 @@ jobs: - run: phpcs . --standard=phpcs.xml --warning-severity=0 --extensions=php -p phpcompatibility: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php-versions: ['7.4'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} @@ -36,9 +36,9 @@ jobs: - run: ~/.composer/vendor/bin/phpcs . --standard=phpcompatibility.xml --warning-severity=0 --extensions=php -p executable_php_files_check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | if find -name "*.php" -executable -type f -print -exec false {} + then diff --git a/.github/workflows/prhtmlgenerator.yml b/.github/workflows/prhtmlgenerator.yml index 7985250a..bfc89e7a 100644 --- a/.github/workflows/prhtmlgenerator.yml +++ b/.github/workflows/prhtmlgenerator.yml @@ -5,15 +5,30 @@ on: branches: [ master ] jobs: + check-bridges: + name: Check if bridges were changed + runs-on: ubuntu-latest + outputs: + BRIDGES: ${{ steps.check1.outputs.BRIDGES }} + steps: + - name: Check number of bridges + id: check1 + run: | + PR=${{github.event.number}}; + wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch; + bridgeamount=$(cat $PR.patch | grep "\bbridges/[A-Za-z0-9]*Bridge\.php\b" | sed "s=.*\bbridges/\([A-Za-z0-9]*\)Bridge\.php\b.*=\1=g" | sort | uniq | wc -l); + echo "BRIDGES=$bridgeamount" >> "$GITHUB_OUTPUT" test-pr: name: Generate HTML runs-on: ubuntu-latest + needs: check-bridges + if: needs.check-bridges.outputs.BRIDGES > 0 env: PYTHONUNBUFFERED: 1 # Needs additional permissions https://github.com/actions/first-interaction/issues/10#issuecomment-1041402989 steps: - name: Check out self - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{github.event.pull_request.head.ref}} repository: ${{github.event.pull_request.head.repo.full_name}} @@ -33,9 +48,9 @@ jobs: docker build -t prbuild .; docker run -d -v $GITHUB_WORKSPACE/whitelist.txt:/app/whitelist.txt -v $GITHUB_WORKSPACE/DEBUG:/app/DEBUG -p 3001:80 prbuild - name: Setup python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.7' + python-version: '3.13' cache: 'pip' - name: Install requirements run: | @@ -51,9 +66,17 @@ jobs: body="${body//$'\n'/'%0A'}"; body="${body//$'\r'/'%0D'}"; echo "bodylength=${#body}" >> $GITHUB_OUTPUT + env: + PR: ${{ github.event.number }} + - name: Upload generated tests + uses: actions/upload-artifact@v4 + id: upload-generated-tests + with: + name: tests + path: '*.html' - name: Find Comment if: ${{ steps.testrun.outputs.bodylength > 130 }} - uses: peter-evans/find-comment@v2 + uses: peter-evans/find-comment@v3 id: fc with: issue-number: ${{ github.event.pull_request.number }} @@ -61,9 +84,43 @@ jobs: body-includes: Pull request artifacts - name: Create or update comment if: ${{ steps.testrun.outputs.bodylength > 130 }} - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@v4 with: comment-id: ${{ steps.fc.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} body-file: comment.txt edit-mode: replace + upload_tests: + name: Upload tests + runs-on: ubuntu-latest + needs: test-pr + steps: + - uses: actions/checkout@v4 + with: + repository: 'RSS-Bridge/rss-bridge-tests' + ref: 'main' + token: ${{ secrets.RSSTESTER_ACTION }} + + - name: Setup git config + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "<>" + + - name: Download tests + uses: actions/download-artifact@v4 + with: + name: tests + + - name: Move tests + run: | + cd prs + mkdir -p ${{github.event.number}} + cd ${{github.event.number}} + mv -f $GITHUB_WORKSPACE/*.html . + + - name: Commit and push generated tests + run: | + export COMMIT_MESSAGE="Added tests for PR ${{github.event.number}}" + git add . + git commit -m "$COMMIT_MESSAGE" || exit 0 + git push diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e7684e6b..435369fa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,14 +8,16 @@ on: jobs: phpunit8: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-versions: ['7.4', '8.0', '8.1'] + php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} + env: + update: true - run: composer install - run: composer test diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index f1080743..d27421aa 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -15,7 +15,7 @@ * [Astalaseven](https://github.com/Astalaseven) * [Astyan-42](https://github.com/Astyan-42) * [austinhuang0131](https://github.com/austinhuang0131) -* [AxorPL](https://github.com/AxorPL) +* [axor-mst](https://github.com/axor-mst) * [ayacoo](https://github.com/ayacoo) * [az5he6ch](https://github.com/az5he6ch) * [b1nj](https://github.com/b1nj) @@ -23,6 +23,7 @@ * [Binnette](https://github.com/Binnette) * [BoboTiG](https://github.com/BoboTiG) * [Bockiii](https://github.com/Bockiii) +* [brtsos](https://github.com/brtsos) * [captn3m0](https://github.com/captn3m0) * [chemel](https://github.com/chemel) * [Chouchen](https://github.com/Chouchen) @@ -144,6 +145,7 @@ * [Niehztog](https://github.com/Niehztog) * [NikNikYkt](https://github.com/NikNikYkt) * [Nono-m0le](https://github.com/Nono-m0le) +* [NotsoanoNimus](https://github.com/NotsoanoNimus) * [obsiwitch](https://github.com/obsiwitch) * [Ololbu](https://github.com/Ololbu) * [ORelio](https://github.com/ORelio) diff --git a/Dockerfile b/Dockerfile index 2f1f4f3d..fb783344 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,3 @@ -FROM lwthiker/curl-impersonate:0.5-ff-slim-buster AS curlimpersonate - FROM debian:12-slim AS rssbridge LABEL description="RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one." @@ -7,7 +5,8 @@ LABEL repository="https://github.com/RSS-Bridge/rss-bridge" LABEL website="https://github.com/RSS-Bridge/rss-bridge" ARG DEBIAN_FRONTEND=noninteractive -RUN apt-get update && \ +RUN set -xe && \ + apt-get update && \ apt-get install --yes --no-install-recommends \ ca-certificates \ nginx \ @@ -24,18 +23,47 @@ RUN apt-get update && \ php-xml \ php-zip \ # php-zlib is enabled by default with PHP 8.2 in Debian 12 + # for downloading libcurl-impersonate + curl \ + # for patching libcurl-impersonate + patchelf \ && \ + # install curl-impersonate library + curlimpersonate_version=1.0.0rc2 && \ + { \ + { \ + [ $(arch) = 'aarch64' ] && \ + archive="libcurl-impersonate-v${curlimpersonate_version}.aarch64-linux-gnu.tar.gz" && \ + sha512sum="c8add80e7a0430a074edea1a11f73d03044c48e848e164af2d6f362866623e29bede207a50f18f95b1bc5ab3d33f5c31408be60a6da66b74a0d176eebe299116" \ + ; } \ + || { \ + [ $(arch) = 'armv7l' ] && \ + archive="libcurl-impersonate-v${curlimpersonate_version}.arm-linux-gnueabihf.tar.gz" && \ + sha512sum="d0403ca4ad55a8d499b120e5675c7b5a0dc4946af49c933e91fc24455ffe5e122aa21ee95554612ff5d1bd6faea1556e1e1b9c821918e2644cc9bcbddc05747a" \ + ; } \ + || { \ + [ $(arch) = 'x86_64' ] && \ + archive="libcurl-impersonate-v${curlimpersonate_version}.x86_64-linux-gnu.tar.gz" && \ + sha512sum="35cafda2b96df3218a6d8545e0947a899837ede51c90f7ef2980bd2d99dbd67199bc620000df28b186727300b8c7046d506807fb48ee0fbc068dc8ae01986339" \ + ; } \ + } && \ + curl -LO "https://github.com/lexiforest/curl-impersonate/releases/download/v${curlimpersonate_version}/${archive}" && \ + echo "$sha512sum $archive" | sha512sum -c - && \ + mkdir -p /usr/local/lib/curl-impersonate && \ + tar xaf "$archive" -C /usr/local/lib/curl-impersonate && \ + patchelf --set-soname libcurl.so.4 /usr/local/lib/curl-impersonate/libcurl-impersonate.so && \ + rm "$archive" && \ + apt-get purge --assume-yes curl patchelf && \ rm -rf /var/lib/apt/lists/* +ENV LD_PRELOAD /usr/local/lib/curl-impersonate/libcurl-impersonate.so +ENV CURL_IMPERSONATE chrome131 + # logs should go to stdout / stderr RUN ln -sfT /dev/stderr /var/log/nginx/error.log; \ ln -sfT /dev/stdout /var/log/nginx/access.log; \ chown -R --no-dereference www-data:adm /var/log/nginx/ -COPY --from=curlimpersonate /usr/local/lib/libcurl-impersonate-ff.so /usr/local/lib/curl-impersonate/ -ENV LD_PRELOAD /usr/local/lib/curl-impersonate/libcurl-impersonate-ff.so -ENV CURL_IMPERSONATE ff91esr - COPY ./config/nginx.conf /etc/nginx/sites-available/default COPY ./config/php-fpm.conf /etc/php/8.2/fpm/pool.d/rss-bridge.conf COPY ./config/php.ini /etc/php/8.2/fpm/conf.d/90-rss-bridge.ini diff --git a/README.md b/README.md index cadba3b9..05042630 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Requires minimum PHP 7.4. |![Screenshot #3](/static/screenshot-3.png?raw=true)|![Screenshot #4](/static/screenshot-4.png?raw=true)| |![Screenshot #5](/static/screenshot-5.png?raw=true)|![Screenshot #6](/static/screenshot-6.png?raw=true)| -## A subset of bridges (16/447) +## A subset of bridges (15/447) * `CssSelectorBridge`: [Scrape out a feed using CSS selectors](https://rss-bridge.org/bridge01/#bridge-CssSelectorBridge) * `FeedMergeBridge`: [Combine multiple feeds into one](https://rss-bridge.org/bridge01/#bridge-FeedMergeBridge) @@ -44,18 +44,18 @@ Requires minimum PHP 7.4. * `ThePirateBayBridge:` [Fetches torrents by search/user/category](https://rss-bridge.org/bridge01/#bridge-ThePirateBayBridge) * `TikTokBridge`: [Fetches posts by username](https://rss-bridge.org/bridge01/#bridge-TikTokBridge) * `TwitchBridge`: [Fetches videos from channel](https://rss-bridge.org/bridge01/#bridge-TwitchBridge) -* `VkBridge`: [Fetches posts from user/group](https://rss-bridge.org/bridge01/#bridge-VkBridge) * `XPathBridge`: [Scrape out a feed using XPath expressions](https://rss-bridge.org/bridge01/#bridge-XPathBridge) * `YoutubeBridge`: [Fetches videos by username/channel/playlist/search](https://rss-bridge.org/bridge01/#bridge-YoutubeBridge) -* `YouTubeCommunityTabBridge`: [Fetches posts from a channel's community tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge) +* `YouTubeCommunityTabBridge`: [Fetches posts from a channel's Posts tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge) ## Tutorial ### How to install on traditional shared web hosting -RSS-Bridge can basically be unzipped in a web folder. Should be working instantly. +RSS-Bridge can basically be unzipped into a web folder. Should be working instantly. -Latest zip as of Sep 2023: https://github.com/RSS-Bridge/rss-bridge/archive/refs/tags/2023-09-24.zip +Latest zip: +https://github.com/RSS-Bridge/rss-bridge/archive/refs/heads/master.zip (2MB) ### How to install on Debian 12 (nginx + php-fpm) @@ -64,34 +64,34 @@ These instructions have been tested on a fresh Debian 12 VM from Digital Ocean ( ```shell timedatectl set-timezone Europe/Oslo -apt install git nginx php8.2-fpm php-mbstring php-simplexml php-curl +apt install git nginx php8.2-fpm php-mbstring php-simplexml php-curl php-intl -# Create a new user account +# Create a user account useradd --shell /bin/bash --create-home rss-bridge cd /var/www -# Create folder and change ownership +# Create folder and change its ownership to rss-bridge mkdir rss-bridge && chown rss-bridge:rss-bridge rss-bridge/ -# Become user +# Become rss-bridge su rss-bridge -# Fetch latest master +# Clone master branch into existing folder git clone https://github.com/RSS-Bridge/rss-bridge.git rss-bridge/ cd rss-bridge -# Copy over the default config +# Copy over the default config (OPTIONAL) cp -v config.default.ini.php config.ini.php -# Give full permissions only to owner (rss-bridge) -chmod 700 -R ./ +# Recursively give full permissions to user/owner +chmod 700 --recursive ./ -# Give read and execute to others (nginx and php-fpm) +# Give read and execute to others on folder ./static chmod o+rx ./ ./static -# Give read to others (nginx) -chmod o+r -R ./static +# Recursively give give read to others on folder ./static +chmod o+r --recursive ./static ``` Nginx config: @@ -101,37 +101,37 @@ Nginx config: server { listen 80; + + # TODO: change to your own server name server_name example.com; + access_log /var/log/nginx/rss-bridge.access.log; error_log /var/log/nginx/rss-bridge.error.log; + log_not_found off; - # Intentionally not setting a root folder here - - # autoindex is off by default but feels good to explicitly turn off - autoindex off; + # Intentionally not setting a root folder # Static content only served here location /static/ { alias /var/www/rss-bridge/static/; } - # Pass off to php-fpm only when location is exactly / + # Pass off to php-fpm only when location is EXACTLY == / location = / { root /var/www/rss-bridge/; include snippets/fastcgi-php.conf; + fastcgi_read_timeout 45s; fastcgi_pass unix:/run/php/rss-bridge.sock; } - # Reduce spam + # Reduce log noise location = /favicon.ico { access_log off; - log_not_found off; } - # Reduce spam + # Reduce log noise location = /robots.txt { access_log off; - log_not_found off; } } ``` @@ -150,8 +150,11 @@ listen = /run/php/rss-bridge.sock listen.owner = www-data listen.group = www-data +; Create 10 workers standing by to serve requests pm = static pm.max_children = 10 + +; Respawn worker after 500 requests (workaround for memory leaks etc.) pm.max_requests = 500 ``` @@ -167,12 +170,10 @@ Restart fpm and nginx: ```shell # Lint and restart php-fpm -php-fpm8.2 -t -systemctl restart php8.2-fpm +php-fpm8.2 -t && systemctl restart php8.2-fpm # Lint and restart nginx -nginx -t -systemctl restart nginx +nginx -t && systemctl restart nginx ``` ### How to install from Composer @@ -181,7 +182,7 @@ Install the latest release. ```shell cd /var/www -composer create-project -v --no-dev rss-bridge/rss-bridge +composer create-project -v --no-dev --no-scripts rss-bridge/rss-bridge ``` ### How to install with Caddy @@ -194,8 +195,16 @@ Install by downloading the docker image from Docker Hub: ```bash # Create container -docker create --name=rss-bridge --publish 3000:80 rssbridge/rss-bridge +docker create --name=rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rssbridge/rss-bridge +``` +You can put custom `config.ini.php` and bridges into `./config`. + +**You must restart container for custom changes to take effect.** + +See `docker-entrypoint.sh` for details. + +```bash # Start container docker start rss-bridge ``` @@ -209,30 +218,29 @@ Browse http://localhost:3000/ docker build -t rss-bridge . # Create container -docker create --name rss-bridge --publish 3000:80 rss-bridge +docker create --name rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rss-bridge +``` +You can put custom `config.ini.php` and bridges into `./config`. + +**You must restart container for custom changes to take effect.** + +See `docker-entrypoint.sh` for details. + +```bash # Start container docker start rss-bridge ``` Browse http://localhost:3000/ -### Install with docker-compose +### Install with docker-compose (using Docker Hub) -Create a `docker-compose.yml` file locally with with the following content: -```yml -version: '2' -services: - rss-bridge: - image: rssbridge/rss-bridge:latest - volumes: - - :/config - ports: - - 3000:80 - restart: unless-stopped -``` +You can put custom `config.ini.php` and bridges into `./config`. -Then launch with `docker-compose`: +**You must restart container for custom changes to take effect.** + +See `docker-entrypoint.sh` for details. ```bash docker-compose up @@ -313,13 +321,23 @@ The sqlite files (db, wal and shm) are not writeable. rm cache/* -### How to create a new bridge from scratch +### How to create a completely new bridge + +New code files MUST have `declare(strict_types=1);` at the top of file: + +```php +bridgeFactory = new BridgeFactory(); + public function __construct( + BridgeFactory $bridgeFactory + ) { + $this->bridgeFactory = $bridgeFactory; } - public function execute(Request $request) + public function __invoke(Request $request): Response { - if (!Debug::isEnabled()) { - return new Response('This action is only available in debug mode!', 403); + if (Configuration::getConfig('system', 'env') !== 'dev') { + return new Response('This action is only available in dev environment!', 403); } $bridgeName = $request->get('bridge'); if (!$bridgeName) { - return render_template('connectivity.html.php'); + return new Response(render_template('connectivity.html.php')); } $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName); if (!$bridgeClassName) { @@ -54,8 +55,8 @@ class ConnectivityAction implements ActionInterface ]; try { $response = getContents($bridge::URI, [], $curl_opts, true); - $result['http_code'] = $response['code']; - if (in_array($response['code'], [200])) { + $result['http_code'] = $response->getCode(); + if (in_array($result['http_code'], [200])) { $result['successful'] = true; } } catch (\Exception $e) { diff --git a/actions/DetectAction.php b/actions/DetectAction.php index 0c61f1b6..8d3d6263 100644 --- a/actions/DetectAction.php +++ b/actions/DetectAction.php @@ -2,7 +2,15 @@ class DetectAction implements ActionInterface { - public function execute(Request $request) + private BridgeFactory $bridgeFactory; + + public function __construct( + BridgeFactory $bridgeFactory + ) { + $this->bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response { $url = $request->get('url'); $format = $request->get('format'); @@ -14,14 +22,12 @@ class DetectAction implements ActionInterface return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format'])); } - $bridgeFactory = new BridgeFactory(); - - foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { - if (!$bridgeFactory->isEnabled($bridgeClassName)) { + foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { continue; } - $bridge = $bridgeFactory->create($bridgeClassName); + $bridge = $this->bridgeFactory->create($bridgeClassName); $bridgeParams = $bridge->detectParameters($url); diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php index ed063825..97f53caa 100644 --- a/actions/DisplayAction.php +++ b/actions/DisplayAction.php @@ -4,42 +4,28 @@ class DisplayAction implements ActionInterface { private CacheInterface $cache; private Logger $logger; + private BridgeFactory $bridgeFactory; - public function __construct() - { - $this->cache = RssBridge::getCache(); - $this->logger = RssBridge::getLogger(); + public function __construct( + CacheInterface $cache, + Logger $logger, + BridgeFactory $bridgeFactory + ) { + $this->cache = $cache; + $this->logger = $logger; + $this->bridgeFactory = $bridgeFactory; } - public function execute(Request $request) + public function __invoke(Request $request): Response { $bridgeName = $request->get('bridge'); $format = $request->get('format'); $noproxy = $request->get('_noproxy'); - $cacheKey = 'http_' . json_encode($request->toArray()); - /** @var Response $cachedResponse */ - $cachedResponse = $this->cache->get($cacheKey); - if ($cachedResponse) { - $ifModifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? null; - $lastModified = $cachedResponse->getHeader('last-modified'); - if ($ifModifiedSince && $lastModified) { - $lastModified = new \DateTimeImmutable($lastModified); - $lastModifiedTimestamp = $lastModified->getTimestamp(); - $modifiedSince = strtotime($ifModifiedSince); - if ($lastModifiedTimestamp <= $modifiedSince) { - $modificationTimeGMT = gmdate('D, d M Y H:i:s ', $lastModifiedTimestamp); - return new Response('', 304, ['last-modified' => $modificationTimeGMT . 'GMT']); - } - } - return $cachedResponse; - } - if (!$bridgeName) { - return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Missing bridge parameter']), 400); + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Missing bridge name parameter']), 400); } - $bridgeFactory = new BridgeFactory(); - $bridgeClassName = $bridgeFactory->createBridgeClassName($bridgeName); + $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName); if (!$bridgeClassName) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Bridge not found']), 404); } @@ -47,11 +33,11 @@ class DisplayAction implements ActionInterface if (!$format) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format']), 400); } - if (!$bridgeFactory->isEnabled($bridgeClassName)) { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'This bridge is not whitelisted']), 400); } - + // Disable proxy (if enabled and per user's request) if ( Configuration::getConfig('proxy', 'url') && Configuration::getConfig('proxy', 'by_bridge') @@ -61,9 +47,9 @@ class DisplayAction implements ActionInterface define('NOPROXY', true); } - $bridge = $bridgeFactory->create($bridgeClassName); - $formatFactory = new FormatFactory(); - $format = $formatFactory->create($format); + $cacheKey = 'http_' . json_encode($request->toArray()); + + $bridge = $this->bridgeFactory->create($bridgeClassName); $response = $this->createResponse($request, $bridge, $format); @@ -77,26 +63,12 @@ class DisplayAction implements ActionInterface $this->cache->set($cacheKey, $response, $ttl); } - if (in_array($response->getCode(), [403, 429, 503])) { - // Cache these responses for about ~20 mins on average - $this->cache->set($cacheKey, $response, 60 * 15 + rand(1, 60 * 10)); - } - - if ($response->getCode() === 500) { - $this->cache->set($cacheKey, $response, 60 * 15); - } - - if (rand(1, 100) === 2) { - $this->cache->prune(); - } - return $response; } - private function createResponse(Request $request, BridgeAbstract $bridge, FormatAbstract $format) + private function createResponse(Request $request, BridgeAbstract $bridge, string $format) { $items = []; - $feed = []; try { $bridge->loadConfiguration(); @@ -116,28 +88,22 @@ class DisplayAction implements ActionInterface $bridge->setInput($input); $bridge->collectData(); $items = $bridge->getItems(); - if (isset($items[0]) && is_array($items[0])) { - $feedItems = []; - foreach ($items as $item) { - $feedItems[] = FeedItem::fromArray($item); + } catch (\Throwable $e) { + if ($e instanceof ClientException) { + $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); + } elseif ($e instanceof RateLimitException) { + $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); + return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 429); + } elseif ($e instanceof HttpException) { + if (in_array($e->getCode(), [429, 503])) { + // Log with debug, immediately reproduce and return + $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); + return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), $e->getCode()); } - $items = $feedItems; + // Some other status code which we let fail normally (but don't log it) + } else { + $this->logger->error(sprintf('Exception in DisplayAction(%s)', $bridge->getShortName()), ['e' => $e]); } - $feed = $bridge->getFeed(); - } catch (\Exception $e) { - // Probably an exception inside a bridge - if ($e instanceof HttpException) { - // Reproduce (and log) these responses regardless of error output and report limit - if ($e->getCode() === 429) { - $this->logger->info(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); - return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 429); - } - if ($e->getCode() === 503) { - $this->logger->info(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); - return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 503); - } - } - $this->logger->error(sprintf('Exception in DisplayAction(%s)', $bridge->getShortName()), ['e' => $e]); $errorOutput = Configuration::getConfig('error', 'output'); $reportLimit = Configuration::getConfig('error', 'report_limit'); $errorCount = 1; @@ -148,7 +114,7 @@ class DisplayAction implements ActionInterface if ($errorCount >= $reportLimit) { if ($errorOutput === 'feed') { // Render the exception as a feed item - $items[] = $this->createFeedItemFromException($e, $bridge); + $items = [$this->createFeedItemFromException($e, $bridge)]; } elseif ($errorOutput === 'http') { return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 500); } elseif ($errorOutput === 'none') { @@ -157,38 +123,49 @@ class DisplayAction implements ActionInterface } } + $formatFactory = new FormatFactory(); + $format = $formatFactory->create($format); + $format->setItems($items); - $format->setFeed($feed); + $format->setFeed($bridge->getFeed()); $now = time(); $format->setLastModified($now); $headers = [ 'last-modified' => gmdate('D, d M Y H:i:s ', $now) . 'GMT', - 'content-type' => $format->getMimeType() . '; charset=' . $format->getCharset(), + 'content-type' => $format->getMimeType() . '; charset=UTF-8', ]; - return new Response($format->stringify(), 200, $headers); + $body = $format->render(); + + // This is supposed to remove non-utf8 byte sequences, but I'm unsure if it works + ini_set('mbstring.substitute_character', 'none'); + $body = mb_convert_encoding($body, 'UTF-8', 'UTF-8'); + + return new Response($body, 200, $headers); } - private function createFeedItemFromException($e, BridgeAbstract $bridge): FeedItem + private function createFeedItemFromException($e, BridgeAbstract $bridge): array { - $item = new FeedItem(); + $item = []; // Create a unique identifier every 24 hours $uniqueIdentifier = urlencode((int)(time() / 86400)); $title = sprintf('Bridge returned error %s! (%s)', $e->getCode(), $uniqueIdentifier); - $item->setTitle($title); - $item->setURI(get_current_url()); - $item->setTimestamp(time()); + + $item['title'] = $title; + $item['uri'] = get_current_url(); + $item['timestamp'] = time(); // Create an item identifier for feed readers e.g. "staysafetv twitch videos_19389" - $item->setUid($bridge->getName() . '_' . $uniqueIdentifier); + $item['uid'] = $bridge->getName() . '_' . $uniqueIdentifier; $content = render_template(__DIR__ . '/../templates/bridge-error.html.php', [ 'error' => render_template(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 'searchUrl' => self::createGithubSearchUrl($bridge), - 'issueUrl' => self::createGithubIssueUrl($bridge, $e, create_sane_exception_message($e)), + 'issueUrl' => self::createGithubIssueUrl($bridge, $e), 'maintainer' => $bridge->getMaintainer(), ]); - $item->setContent($content); + $item['content'] = $content; + return $item; } @@ -213,22 +190,34 @@ class DisplayAction implements ActionInterface return $report['count']; } - private static function createGithubIssueUrl($bridge, $e, string $message): string + private static function createGithubIssueUrl(BridgeAbstract $bridge, \Throwable $e): string { - return sprintf('https://github.com/RSS-Bridge/rss-bridge/issues/new?%s', http_build_query([ - 'title' => sprintf('%s failed with error %s', $bridge->getName(), $e->getCode()), + $maintainer = $bridge->getMaintainer(); + if (str_contains($maintainer, ',')) { + $maintainers = explode(',', $maintainer); + } else { + $maintainers = [$maintainer]; + } + $maintainers = array_map('trim', $maintainers); + + $queryString = $_SERVER['QUERY_STRING'] ?? ''; + $query = [ + 'title' => $bridge->getName() . ' failed with: ' . $e->getMessage(), 'body' => sprintf( - "```\n%s\n\n%s\n\nQuery string: %s\nVersion: %s\nOs: %s\nPHP version: %s\n```", - $message, + "```\n%s\n\n%s\n\nQuery string: %s\nVersion: %s\nOs: %s\nPHP version: %s\n```\nMaintainer: @%s", + create_sane_exception_message($e), implode("\n", trace_to_call_points(trace_from_exception($e))), - $_SERVER['QUERY_STRING'] ?? '', + $queryString, Configuration::getVersion(), PHP_OS_FAMILY, - phpversion() ?: 'Unknown' + phpversion() ?: 'Unknown', + implode(', @', $maintainers), ), 'labels' => 'Bridge-Broken', - 'assignee' => $bridge->getMaintainer(), - ])); + 'assignee' => $maintainer[0], + ]; + + return 'https://github.com/RSS-Bridge/rss-bridge/issues/new?' . http_build_query($query); } private static function createGithubSearchUrl($bridge): string diff --git a/actions/FindfeedAction.php b/actions/FindfeedAction.php index 94dc6b72..e18c3e1d 100644 --- a/actions/FindfeedAction.php +++ b/actions/FindfeedAction.php @@ -7,7 +7,15 @@ */ class FindfeedAction implements ActionInterface { - public function execute(Request $request) + private BridgeFactory $bridgeFactory; + + public function __construct( + BridgeFactory $bridgeFactory + ) { + $this->bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response { $url = $request->get('url'); $format = $request->get('format'); @@ -19,15 +27,13 @@ class FindfeedAction implements ActionInterface return new Response('You must specify a format', 400); } - $bridgeFactory = new BridgeFactory(); - $results = []; - foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { - if (!$bridgeFactory->isEnabled($bridgeClassName)) { + foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { continue; } - $bridge = $bridgeFactory->create($bridgeClassName); + $bridge = $this->bridgeFactory->create($bridgeClassName); $bridgeParams = $bridge->detectParameters($url); diff --git a/actions/FrontpageAction.php b/actions/FrontpageAction.php index 32795c45..79ffb4f5 100644 --- a/actions/FrontpageAction.php +++ b/actions/FrontpageAction.php @@ -2,15 +2,24 @@ final class FrontpageAction implements ActionInterface { - public function execute(Request $request) + private BridgeFactory $bridgeFactory; + + public function __construct( + BridgeFactory $bridgeFactory + ) { + $this->bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response { + $token = $request->getAttribute('token'); + $messages = []; $activeBridges = 0; - $bridgeFactory = new BridgeFactory(); - $bridgeClassNames = $bridgeFactory->getBridgeClassNames(); + $bridgeClassNames = $this->bridgeFactory->getBridgeClassNames(); - foreach ($bridgeFactory->getMissingEnabledBridges() as $missingEnabledBridge) { + foreach ($this->bridgeFactory->getMissingEnabledBridges() as $missingEnabledBridge) { $messages[] = [ 'body' => sprintf('Warning : Bridge "%s" not found', $missingEnabledBridge), 'level' => 'warning' @@ -19,20 +28,22 @@ final class FrontpageAction implements ActionInterface $body = ''; foreach ($bridgeClassNames as $bridgeClassName) { - if ($bridgeFactory->isEnabled($bridgeClassName)) { - $body .= BridgeCard::render($bridgeClassName, $request); + if ($this->bridgeFactory->isEnabled($bridgeClassName)) { + $body .= BridgeCard::render($this->bridgeFactory, $bridgeClassName, $token); $activeBridges++; } } - // todo: cache this renderered template? - return render(__DIR__ . '/../templates/frontpage.html.php', [ - 'messages' => $messages, - 'admin_email' => Configuration::getConfig('admin', 'email'), - 'admin_telegram' => Configuration::getConfig('admin', 'telegram'), - 'bridges' => $body, - 'active_bridges' => $activeBridges, - 'total_bridges' => count($bridgeClassNames), - ]); + $response = new Response(render(__DIR__ . '/../templates/frontpage.html.php', [ + 'messages' => $messages, + 'admin_email' => Configuration::getConfig('admin', 'email'), + 'admin_telegram' => Configuration::getConfig('admin', 'telegram'), + 'bridges' => $body, + 'active_bridges' => $activeBridges, + 'total_bridges' => count($bridgeClassNames), + ])); + + // TODO: The rendered template could be cached, but beware config changes that changes the html + return $response; } } diff --git a/actions/HealthAction.php b/actions/HealthAction.php index a38879c2..13365a3c 100644 --- a/actions/HealthAction.php +++ b/actions/HealthAction.php @@ -4,7 +4,7 @@ declare(strict_types=1); class HealthAction implements ActionInterface { - public function execute(Request $request) + public function __invoke(Request $request): Response { $response = [ 'code' => 200, diff --git a/actions/ListAction.php b/actions/ListAction.php index 3d9cdd73..f6347f9c 100644 --- a/actions/ListAction.php +++ b/actions/ListAction.php @@ -2,19 +2,25 @@ class ListAction implements ActionInterface { - public function execute(Request $request) + private BridgeFactory $bridgeFactory; + + public function __construct( + BridgeFactory $bridgeFactory + ) { + $this->bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response { $list = new \stdClass(); $list->bridges = []; $list->total = 0; - $bridgeFactory = new BridgeFactory(); - - foreach ($bridgeFactory->getBridgeClassNames() as $bridgeClassName) { - $bridge = $bridgeFactory->create($bridgeClassName); + foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { + $bridge = $this->bridgeFactory->create($bridgeClassName); $list->bridges[$bridgeClassName] = [ - 'status' => $bridgeFactory->isEnabled($bridgeClassName) ? 'active' : 'inactive', + 'status' => $this->bridgeFactory->isEnabled($bridgeClassName) ? 'active' : 'inactive', 'uri' => $bridge->getURI(), 'donationUri' => $bridge->getDonationURI(), 'name' => $bridge->getName(), diff --git a/bin/cache-clear b/bin/cache-clear index 3563abad..2ca84ce6 100755 --- a/bin/cache-clear +++ b/bin/cache-clear @@ -6,9 +6,11 @@ */ require __DIR__ . '/../lib/bootstrap.php'; +require __DIR__ . '/../lib/config.php'; -$rssBridge = new RssBridge(); +$container = require __DIR__ . '/../lib/dependencies.php'; -$cache = RssBridge::getCache(); +/** @var CacheInterface $cache */ +$cache = $container['cache']; $cache->clear(); diff --git a/bin/cache-prune b/bin/cache-prune index 7b7a6031..bb72c4ac 100755 --- a/bin/cache-prune +++ b/bin/cache-prune @@ -6,9 +6,19 @@ */ require __DIR__ . '/../lib/bootstrap.php'; +require __DIR__ . '/../lib/config.php'; -$rssBridge = new RssBridge(); +$container = require __DIR__ . '/../lib/dependencies.php'; -$cache = RssBridge::getCache(); +if ( + Configuration::getConfig('cache', 'type') === 'file' + && !Configuration::getConfig('FileCache', 'enable_purge') +) { + // Override enable_purge for this particular execution + Configuration::setConfig('FileCache', 'enable_purge', true); +} + +/** @var CacheInterface $cache */ +$cache = $container['cache']; $cache->prune(); diff --git a/bin/test b/bin/test new file mode 100755 index 00000000..74692410 --- /dev/null +++ b/bin/test @@ -0,0 +1,20 @@ +#!/usr/bin/env php +debug('This is a test debug message'); + +$logger->info('This is a test info message'); + +$logger->error('This is a test error message'); diff --git a/bridges/ABCNewsBridge.php b/bridges/ABCNewsBridge.php index c00fed1c..154eb489 100644 --- a/bridges/ABCNewsBridge.php +++ b/bridges/ABCNewsBridge.php @@ -31,17 +31,17 @@ class ABCNewsBridge extends BridgeAbstract { $url = sprintf('https://www.abc.net.au/news/%s', $this->getInput('topic')); $dom = getSimpleHTMLDOM($url); - $dom = $dom->find('div[data-component="CardList"]', 0); + $dom = $dom->find('div[data-component="PaginationList"]', 0); if (!$dom) { throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); } $dom = defaultLinkTo($dom, $this->getURI()); - foreach ($dom->find('div[data-component="GenericCard"]') as $article) { + foreach ($dom->find('article[data-component="DetailCard"]') as $article) { $a = $article->find('a', 0); $this->items[] = [ 'title' => $a->plaintext, 'uri' => $a->href, - 'content' => $article->find('[data-component="CardDescription"]', 0)->plaintext, + 'content' => $article->find('p', 0)->plaintext, 'timestamp' => strtotime($article->find('time', 0)->datetime), ]; } diff --git a/bridges/AO3Bridge.php b/bridges/AO3Bridge.php index e30c6b70..2697dbc7 100644 --- a/bridges/AO3Bridge.php +++ b/bridges/AO3Bridge.php @@ -12,9 +12,29 @@ class AO3Bridge extends BridgeAbstract 'url' => [ 'name' => 'url', 'required' => true, - // Example: F/F tag, complete works only - 'exampleValue' => 'https://archiveofourown.org/works?work_search[complete]=T&tag_id=F*s*F', + // Example: F/F tag + 'exampleValue' => 'https://archiveofourown.org/tags/F*s*F/works', ], + 'range' => [ + 'name' => 'Chapter Content', + 'title' => 'Chapter(s) to include in each work\'s feed entry', + 'defaultValue' => null, + 'type' => 'list', + 'values' => [ + 'None' => null, + 'First' => 'first', + 'Latest' => 'last', + 'Entire work' => 'all', + ], + ], + 'unique' => [ + 'name' => 'Make separate entries for new fic chapters', + 'type' => 'checkbox', + 'required' => false, + 'title' => 'Make separate entries for new fic chapters', + 'defaultValue' => 'checked', + ], + 'limit' => self::LIMIT, ], 'Bookmarks' => [ 'user' => [ @@ -39,18 +59,13 @@ class AO3Bridge extends BridgeAbstract { switch ($this->queriedContext) { case 'Bookmarks': - $user = $this->getInput('user'); - $this->title = $user; - $url = self::URI - . '/users/' . $user - . '/bookmarks?bookmark_search[sort_column]=bookmarkable_date'; - $this->collectList($url); + $this->collectList($this->getURI()); break; case 'List': - $this->collectList($this->getInput('url')); + $this->collectList($this->getURI()); break; case 'Work': - $this->collectWork($this->getInput('id')); + $this->collectWork($this->getURI()); break; } } @@ -61,9 +76,24 @@ class AO3Bridge extends BridgeAbstract */ private function collectList($url) { - $html = getSimpleHTMLDOM($url); + $version = 'v0.0.1'; + $headers = [ + "useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)" + ]; + $response = getContents($url, $headers); + + $html = \str_get_html($response); $html = defaultLinkTo($html, self::URI); + // Get list title. Will include page range + count in some cases + $heading = ($html->find('#main h2', 0)); + if ($heading->find('a.tag')) { + $heading = $heading->find('a.tag', 0); + } + $this->title = $heading->plaintext; + + $limit = $this->getInput('limit') ?? 3; + $count = 0; foreach ($html->find('.index.group > li') as $element) { $item = []; @@ -72,16 +102,70 @@ class AO3Bridge extends BridgeAbstract continue; // discard deleted works } $item['title'] = $title->plaintext; - $item['content'] = $element; $item['uri'] = $title->href; $strdate = $element->find('div p.datetime', 0)->plaintext; $item['timestamp'] = strtotime($strdate); + // detach from rest of page because remove() is buggy + $element = str_get_html($element->outertext()); + $tags = $element->find('ul.required-tags', 0); + foreach ($tags->childNodes() as $tag) { + $item['categories'][] = html_entity_decode($tag->plaintext); + } + $tags->remove(); + $tags = $element->find('ul.tags', 0); + foreach ($tags->childNodes() as $tag) { + $item['categories'][] = html_entity_decode($tag->plaintext); + } + $tags->remove(); + + $item['content'] = implode('', $element->childNodes()); + $chapters = $element->find('dl dd.chapters', 0); // bookmarked series and external works do not have a chapters count $chapters = (isset($chapters) ? $chapters->plaintext : 0); - $item['uid'] = $item['uri'] . "/$strdate/$chapters"; + if ($this->getInput('unique')) { + $item['uid'] = $item['uri'] . "/$strdate/$chapters"; + } else { + $item['uid'] = $item['uri']; + } + + + // Fetch workskin of desired chapter(s) in list + if ($this->getInput('range') && ($limit == 0 || $count++ < $limit)) { + $url = $item['uri']; + switch ($this->getInput('range')) { + case ('all'): + $url .= '?view_full_work=true'; + break; + case ('first'): + break; + case ('last'): + // only way to get this is using the navigate page unfortunately + $url .= '/navigate'; + $response = getContents($url, $headers); + $html = \str_get_html($response); + $html = defaultLinkTo($html, self::URI); + $url = $html->find('ol.index.group > li > a', -1)->href; + break; + } + $response = getContents($url, $headers); + + $html = \str_get_html($response); + $html = defaultLinkTo($html, self::URI); + // remove duplicate fic summary + if ($ficsum = $html->find('#workskin > .preface > .summary', 0)) { + $ficsum->remove(); + } + $item['content'] .= $html->find('#workskin', 0); + } + + // Use predictability of download links to generate enclosures + $wid = explode('/', $item['uri'])[4]; + foreach (['azw3', 'epub', 'mobi', 'pdf', 'html'] as $ext) { + $item['enclosures'][] = 'https://archiveofourown.org/downloads/' . $wid . '/work.' . $ext; + } $this->items[] = $item; } @@ -90,26 +174,31 @@ class AO3Bridge extends BridgeAbstract /** * Feed for recent chapters of a specific work. */ - private function collectWork($id) + private function collectWork($url) { - $url = self::URI . "/works/$id/navigate"; - $httpClient = RssBridge::getHttpClient(); - $version = 'v0.0.1'; - $response = $httpClient->request($url, [ - 'useragent' => "rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)", - ]); + $headers = [ + "useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)" + ]; + $response = getContents($url . '/navigate', $headers); - $html = \str_get_html($response->getBody()); + $html = \str_get_html($response); $html = defaultLinkTo($html, self::URI); + $response = getContents($url . '?view_full_work=true', $headers); + + $workhtml = \str_get_html($response); + $workhtml = defaultLinkTo($workhtml, self::URI); + $this->title = $html->find('h2 a', 0)->plaintext; - foreach ($html->find('ol.index.group > li') as $element) { + $nav = $html->find('ol.index.group > li'); + for ($i = 0; $i < count($nav); $i++) { $item = []; + $element = $nav[$i]; $item['title'] = $element->find('a', 0)->plaintext; - $item['content'] = $element; + $item['content'] = $workhtml->find('#chapter-' . ($i + 1), 0); $item['uri'] = $element->find('a', 0)->href; $strdate = $element->find('span.datetime', 0)->plaintext; @@ -138,4 +227,24 @@ class AO3Bridge extends BridgeAbstract { return self::URI . '/favicon.ico'; } + + public function getURI() + { + $url = parent::getURI(); + switch ($this->queriedContext) { + case 'Bookmarks': + $user = $this->getInput('user'); + $url = self::URI + . '/users/' . $user + . '/bookmarks?bookmark_search[sort_column]=bookmarkable_date'; + break; + case 'List': + $url = $this->getInput('url'); + break; + case 'Work': + $url = self::URI . '/works/' . $this->getInput('id'); + break; + } + return $url; + } } diff --git a/bridges/ARDAudiothekBridge.php b/bridges/ARDAudiothekBridge.php index 02b6b007..e6b82775 100644 --- a/bridges/ARDAudiothekBridge.php +++ b/bridges/ARDAudiothekBridge.php @@ -71,7 +71,7 @@ class ARDAudiothekBridge extends BridgeAbstract $pathComponents = explode('/', $path); if (empty($pathComponents)) { - returnClientError('Path may not be empty'); + throwClientException('Path may not be empty'); } if (count($pathComponents) < 2) { $showID = $pathComponents[0]; diff --git a/bridges/ARDMediathekBridge.php b/bridges/ARDMediathekBridge.php index 6de8dad7..6f7272dc 100644 --- a/bridges/ARDMediathekBridge.php +++ b/bridges/ARDMediathekBridge.php @@ -40,6 +40,11 @@ class ARDMediathekBridge extends BridgeAbstract * @const IMAGEWIDTHPLACEHOLDER */ const IMAGEWIDTHPLACEHOLDER = '{width}'; + /** + * Title of the current show + * @var string + */ + private $title; const PARAMETERS = [ [ @@ -60,7 +65,7 @@ class ARDMediathekBridge extends BridgeAbstract $pathComponents = explode('/', $this->getInput('path')); if (empty($pathComponents)) { - returnClientError('Path may not be empty'); + throwClientException('Path may not be empty'); } if (count($pathComponents) < 2) { $showID = $pathComponents[0]; @@ -72,7 +77,7 @@ class ARDMediathekBridge extends BridgeAbstract } } - $url = self::APIENDPOINT . $showID . '/?pageSize=' . self::PAGESIZE; + $url = self::APIENDPOINT . $showID . '?pageSize=' . self::PAGESIZE; $rawJSON = getContents($url); $processedJSON = json_decode($rawJSON); @@ -93,6 +98,17 @@ class ARDMediathekBridge extends BridgeAbstract $this->items[] = $item; } + $this->title = $processedJSON->title; + date_default_timezone_set($oldTz); } + + /** {@inheritdoc} */ + public function getName() + { + if (!empty($this->title)) { + return $this->title; + } + return parent::getName(); + } } diff --git a/bridges/ActivisionResearchBridge.php b/bridges/ActivisionResearchBridge.php new file mode 100644 index 00000000..88af4b46 --- /dev/null +++ b/bridges/ActivisionResearchBridge.php @@ -0,0 +1,45 @@ +find('div[id="home-blog-feed"]', 0); + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('div[class="blog-entry"]') as $article) { + $a = $article->find('a', 0); + + $blogimg = extractFromDelimiters($article->find('div[class="blog-img"]', 0)->style, 'url(', ')'); + + $title = htmlspecialchars_decode($article->find('div[class="title"]', 0)->plaintext); + $author = htmlspecialchars_decode($article->find('div[class="author]', 0)->plaintext); + $date = $article->find('div[class="pubdate"]', 0)->plaintext; + + $entry = getSimpleHTMLDOMCached($a->href, static::CACHE_TIMEOUT * 7 * 4); + $entry = defaultLinkTo($entry, $this->getURI()); + + $content = $entry->find('div[class="blog-body"]', 0); + $tagsremove = ['script', 'iframe', 'input', 'form']; + $content = sanitize($content, $tagsremove); + $content = '' . $content; + + $this->items[] = [ + 'title' => $title, + 'author' => $author, + 'uri' => $a->href, + 'content' => $content, + 'timestamp' => strtotime($date), + ]; + } + } +} diff --git a/bridges/AirBreizhBridge.php b/bridges/AirBreizhBridge.php index a822625f..272c74ee 100644 --- a/bridges/AirBreizhBridge.php +++ b/bridges/AirBreizhBridge.php @@ -32,8 +32,7 @@ class AirBreizhBridge extends BridgeAbstract public function collectData() { $html = ''; - $html = getSimpleHTMLDOM(static::URI . 'publications/?fwp_publications_thematiques=' . $this->getInput('theme')) - or returnClientError('No results for this query.'); + $html = getSimpleHTMLDOM(static::URI . 'publications/?fwp_publications_thematiques=' . $this->getInput('theme')); foreach ($html->find('article') as $article) { $item = []; diff --git a/bridges/AllegroBridge.php b/bridges/AllegroBridge.php index 7cad11f1..3509afa1 100644 --- a/bridges/AllegroBridge.php +++ b/bridges/AllegroBridge.php @@ -13,13 +13,10 @@ class AllegroBridge extends BridgeAbstract 'exampleValue' => 'https://allegro.pl/kategoria/swieze-warzywa-cebula-318660', 'required' => true, ], - 'sessioncookie' => [ - 'name' => 'The \'wdctx\' session cookie', - 'title' => 'Paste the value of the \'wdctx\' cookie from your browser if you want to prevent Allegro imposing rate limits', - 'pattern' => '^.{70,};?$', - // phpcs:ignore - 'exampleValue' => 'v4.1-oCrmXTMqv2ppC21GTUCKLmUwRPP1ssQVALKuqwsZ1VXjcKgL2vO5TTRM5xMxS9GiyqxF1gAeyc-63dl0coUoBKXCXi_nAmr95yyqGpq2RAFoneZ4L399E8n6iYyemcuGARjAoSfjvLHJCEwvvHHynSgaxlFBu7hUnKfuy39zo9sSQdyTUjotJg3CAZ53q9v2raAnPCyGOAR4ytRILd9p24EJnxp7_oR0XbVPIo1hDa4WmjXFOxph8rHaO5tWd', - 'required' => false, + 'cookie' => [ + 'name' => 'The complete cookie value', + 'title' => 'Paste the cookie value from your browser, otherwise 403 gets returned', + 'required' => true, ], 'includeSponsoredOffers' => [ 'type' => 'checkbox', @@ -68,93 +65,56 @@ class AllegroBridge extends BridgeAbstract $url = preg_replace('/([?&])order=[^&]+(&|$)/', '$1', $this->getInput('url')); $url .= (parse_url($url, PHP_URL_QUERY) ? '&' : '?') . 'order=n'; - $opts = []; + $html = getContents($url, [], [CURLOPT_COOKIE => $this->getInput('cookie')]); - // If a session cookie is provided - if ($sessioncookie = $this->getInput('sessioncookie')) { - $opts[CURLOPT_COOKIE] = 'wdctx=' . $sessioncookie; + $storeData = null; + if (preg_match('/]*>\s*(\{\s*?"__listing_StoreState".*\})\s*<\/script>/i', $html, $match)) { + $data = json_decode($match[1], true); + $storeData = $data['__listing_StoreState'] ?? null; } - $html = getSimpleHTMLDOM($url, [], $opts); + foreach ($storeData['items']['elements'] as $elements) { + if (!array_key_exists('offerId', $elements)) { + continue; + } + if (!$this->getInput('includeSponsoredOffers') && $elements['isSponsored']) { + continue; + } + if (!$this->getInput('includePromotedOffers') && $elements['promoted']) { + continue; + } - # if no results found - if ($html->find('.mzmg_6m.m9qz_yo._6a66d_-fJr5')) { - return; - } - - $results = $html->find('article[data-analytics-view-custom-context="REGULAR"]'); - - if (!$this->getInput('includeSponsoredOffers')) { - $results = array_merge($results, $html->find('article[data-analytics-view-custom-context="SPONSORED"]')); - } - - if (!$this->getInput('includePromotedOffers')) { - $results = array_merge($results, $html->find('article[data-analytics-view-custom-context="PROMOTED"]')); - } - - foreach ($results as $post) { $item = []; + $item['uid'] = $elements['offerId']; + $item['uri'] = $elements['url']; + $item['title'] = $elements['alt']; - $item['uid'] = $post->{'data-analytics-view-value'}; - - $item_link = $post->find('a[href*="' . $item['uid'] . '"], a[href*="allegrolokalnie"]', 0); - - $item['uri'] = $item_link->href; - - $item['title'] = $item_link->find('img', 0)->alt; - - $image = $item_link->find('img', 0)->{'data-src'} ?: $item_link->find('img', 0)->src ?? false; - + $image = $elements['photos'][0]['medium']; if ($image) { $item['enclosures'] = [$image . '#.image']; } - $price = $post->{'data-analytics-view-json-custom-price'}; - if ($price) { - $priceDecoded = json_decode(html_entity_decode($price)); - $price = $priceDecoded->amount . ' ' . $priceDecoded->currency; + $price = $elements['price']['mainPrice']['amount']; + $currency = $elements['price']['mainPrice']['currency']; + $sellerType = $elements['seller']['title']; + + $item['categories'] = [$sellerType]; + + $description = ''; + foreach ($elements['parameters'] as $parameter) { + $item['categories'] = array_merge($item['categories'], $parameter['values']); + $description .= '
' . $parameter['name'] . ': ' . implode(',', $parameter['values']) . '
'; } - $descriptionPatterns = ['/<\s*dt[^>]*>\b/', '/<\/dt>/', '/<\s*dd[^>]*>\b/', '/<\/dd>/']; - $descriptionReplacements = ['', ': ', '', '  ']; - $description = $post->find('.m7er_k4.mpof_5r.mpof_z0_s', 0)->innertext; - $descriptionPretty = preg_replace($descriptionPatterns, $descriptionReplacements, $description); - - $pricingExtraInfo = array_filter($post->find('.mqu1_g3.mgn2_12'), function ($node) { - return empty($node->find('.mvrt_0')); - }); - - $pricingExtraInfo = $pricingExtraInfo[0]->plaintext ?? ''; - - $offerExtraInfo = array_map(function ($node) { - return str_contains($node->plaintext, 'zapłać później') ? '' : $node->outertext; - }, $post->find('div.mpof_ki.mwdn_1.mj7a_4.mgn2_12')); - - $isSmart = $post->find('img[alt="Smart!"]', 0) ?? false; - if ($isSmart) { - $pricingExtraInfo .= $isSmart->outertext; - } - - $item['categories'] = []; - $parameters = $post->find('dd'); - foreach ($parameters as $parameter) { - if (in_array(strtolower($parameter->innertext), ['brak', 'nie'])) { - continue; - } - - $item['categories'][] = $parameter->innertext; - } - - $item['content'] = $descriptionPretty - . '
' - . $price - . '
' - . implode('
', $offerExtraInfo) - . '
' - . $pricingExtraInfo + $item['content'] = '
' + . $price . ' ' . $currency + . '
' + . $sellerType . '
' + . $description . '

'; $this->items[] = $item; } } } + diff --git a/bridges/AllocineFRBridge.php b/bridges/AllocineFRBridge.php index e7b2adb2..f5a5b33a 100644 --- a/bridges/AllocineFRBridge.php +++ b/bridges/AllocineFRBridge.php @@ -57,7 +57,7 @@ class AllocineFRBridge extends BridgeAbstract if (array_key_exists($category, $categories)) { return static::URI . $this->getLastSeasonURI($categories[$category]); } else { - returnClientError('Emission inconnue'); + throwClientException('Emission inconnue'); } } diff --git a/bridges/AmazonPriceTrackerBridge.php b/bridges/AmazonPriceTrackerBridge.php index b07bdb7c..45eab097 100644 --- a/bridges/AmazonPriceTrackerBridge.php +++ b/bridges/AmazonPriceTrackerBridge.php @@ -2,7 +2,7 @@ class AmazonPriceTrackerBridge extends BridgeAbstract { - const MAINTAINER = 'captn3m0, sal0max'; + const MAINTAINER = 'captn3m0, sal0max, bagnacauda'; const NAME = 'Amazon Price Tracker'; const URI = 'https://www.amazon.com/'; const CACHE_TIMEOUT = 3600; // 1h @@ -13,7 +13,7 @@ class AmazonPriceTrackerBridge extends BridgeAbstract 'asin' => [ 'name' => 'ASIN', 'required' => true, - 'exampleValue' => 'B071GB1VMQ', + 'exampleValue' => 'B0923XT6K7', // https://stackoverflow.com/a/12827734 'pattern' => 'B[\dA-Z]{9}|\d{9}(X|\d)', ], @@ -146,7 +146,7 @@ EOT; { $uri = $this->getURI(); - return getSimpleHTMLDOM($uri) ?: returnServerError('Could not request Amazon.'); + return getSimpleHTMLDOM($uri); } private function scrapePriceFromMetrics($html) @@ -169,19 +169,23 @@ EOT; private function scrapePriceTwister($html) { - $str = $html->find('.twister-plus-buying-options-price-data', 0); + $json = $html->find('.twister-plus-buying-options-price-data', 0); + if ($json == null) { + return null; + } - $data = json_decode($str->innertext, true); - if (count($data) === 1) { - $data = $data[0]; + $data = json_decode($json->innertext, true); + foreach ($data as $key => $value) { + $value = $value[0]; return [ - 'displayPrice' => $data['displayPrice'], - 'currency' => $data['currency'], - 'shipping' => '0', + 'displayPrice' => $value['displayPrice'], + 'price' => $value['priceAmount'], + 'currency' => $value['currencySymbol'], + 'shipping' => null, ]; } - return false; + return null; } private function scrapePriceGeneric($html) @@ -206,9 +210,21 @@ EOT; } $priceString = str_replace(str_split(self::WHITESPACE), '', $priceDiv->plaintext); - preg_match('/(\d+\.\d{0,2})/', $priceString, $matches); + $price = null; + $priceFound = false; + + // find longest repeated string + for ($offset = 0; $offset < strlen($priceString); $offset++) { + for ($length = 1; substr_count($priceString, substr($priceString, $offset, $length + 1)) >= 2; $length++) { + $priceFound = true; + } + + if ($priceFound) { + $price = substr($priceString, $offset, $length); + break; + } + } - $price = $matches[0] ?? null; $currency = str_replace($price, '', $priceString); if ($price != null && $currency != null) { @@ -216,7 +232,7 @@ EOT; 'price' => $price, 'displayPrice' => null, 'currency' => $currency, - 'shipping' => '0' + 'shipping' => null ]; } return $default; @@ -227,7 +243,7 @@ EOT; $html = $this->getHtml(); $this->title = $this->getTitle($html); $image = $this->getImage($html); - $data = $this->scrapePriceGeneric($html); + $data = $this->scrapePriceTwister($html) ?? $this->scrapePriceGeneric($html); // render $content = ''; @@ -236,7 +252,7 @@ EOT; $price = sprintf('%s %s', $data['price'], $data['currency']); } $content .= sprintf('%s
Price: %s', $image, $price); - if ($data['shipping'] !== '0') { + if ($data['shipping'] !== null) { $content .= sprintf('
Shipping: %s %s
', $data['shipping'], $data['currency']); } diff --git a/bridges/AnfrBridge.php b/bridges/AnfrBridge.php new file mode 100644 index 00000000..391fde77 --- /dev/null +++ b/bridges/AnfrBridge.php @@ -0,0 +1,278 @@ + [ + 'departement' => [ + 'name' => 'Département', + 'type' => 'list', + 'values' => [ + 'Tous' => null, + 'Ain' => '001', + 'Aisne' => '002', + 'Allier' => '003', + 'Alpes-de-Haute-Provence' => '004', + 'Hautes-Alpes' => '005', + 'Alpes-Maritimes' => '006', + 'Ardèche' => '007', + 'Ardennes' => '008', + 'Ariège' => '009', + 'Aube' => '010', + 'Aude' => '011', + 'Aveyron' => '012', + 'Bouches-du-Rhône' => '013', + 'Calvados' => '014', + 'Cantal' => '015', + 'Charente' => '016', + 'Charente-Maritime' => '017', + 'Cher' => '018', + 'Corrèze' => '019', + 'Corse-du-Sud' => '02A', + 'Haute-Corse' => '02B', + 'Côte-d\'Or' => '021', + 'Côtes-d\'Armor' => '022', + 'Creuse' => '023', + 'Dordogne' => '024', + 'Doubs' => '025', + 'Drôme' => '026', + 'Eure' => '027', + 'Eure-et-Loir' => '028', + 'Finistère' => '029', + 'Gard' => '030', + 'Haute-Garonne' => '031', + 'Gers' => '032', + 'Gironde' => '033', + 'Hérault' => '034', + 'Ille-et-Vilaine' => '035', + 'Indre' => '036', + 'Indre-et-Loire' => '037', + 'Isère' => '038', + 'Jura' => '039', + 'Landes' => '040', + 'Loir-et-Cher' => '041', + 'Loire' => '042', + 'Haute-Loire' => '043', + 'Loire-Atlantique' => '044', + 'Loiret' => '045', + 'Lot' => '046', + 'Lot-et-Garonne' => '047', + 'Lozère' => '048', + 'Maine-et-Loire' => '049', + 'Manche' => '050', + 'Marne' => '051', + 'Haute-Marne' => '052', + 'Mayenne' => '053', + 'Meurthe-et-Moselle' => '054', + 'Meuse' => '055', + 'Morbihan' => '056', + 'Moselle' => '057', + 'Nièvre' => '058', + 'Nord' => '059', + 'Oise' => '060', + 'Orne' => '061', + 'Pas-de-Calais' => '062', + 'Puy-de-Dôme' => '063', + 'Pyrénées-Atlantiques' => '064', + 'Hautes-Pyrénées' => '065', + 'Pyrénées-Orientales' => '066', + 'Bas-Rhin' => '067', + 'Haut-Rhin' => '068', + 'Rhône' => '069', + 'Haute-Saône' => '070', + 'Saône-et-Loire' => '071', + 'Sarthe' => '072', + 'Savoie' => '073', + 'Haute-Savoie' => '074', + 'Paris' => '075', + 'Seine-Maritime' => '076', + 'Seine-et-Marne' => '077', + 'Yvelines' => '078', + 'Deux-Sèvres' => '079', + 'Somme' => '080', + 'Tarn' => '081', + 'Tarn-et-Garonne' => '082', + 'Var' => '083', + 'Vaucluse' => '084', + 'Vendée' => '085', + 'Vienne' => '086', + 'Haute-Vienne' => '087', + 'Vosges' => '088', + 'Yonne' => '089', + 'Territoire de Belfort' => '090', + 'Essonne' => '091', + 'Hauts-de-Seine' => '092', + 'Seine-Saint-Denis' => '093', + 'Val-de-Marne' => '094', + 'Val-d\'Oise' => '095', + 'Guadeloupe' => '971', + 'Martinique' => '972', + 'Guyane' => '973', + 'La Réunion' => '974', + 'Saint-Pierre-et-Miquelon' => '975', + 'Mayotte' => '976', + 'Saint-Barthélemy' => '977', + 'Saint-Martin' => '978', + 'Terres australes et antarctiques françaises' => '984', + 'Wallis-et-Futuna' => '986', + 'Polynésie française' => '987', + 'Nouvelle-Calédonie' => '988', + 'Île de Clipperton' => '989' + ] + ], + 'generation' => [ + 'name' => 'Génération', + 'type' => 'list', + 'values' => [ + 'Tous' => null, + '2G' => '2G', + '3G' => '3G', + '4G' => '4G', + '5G' => '5G', + ] + ], + 'operateur' => [ + 'name' => 'Opérateur', + 'type' => 'list', + 'values' => [ + 'Tous' => null, + 'Bouygues Télécom' => 'BOUYGUES TELECOM', + 'Dauphin Télécom' => 'DAUPHIN TELECOM', + 'Digiciel' => 'DIGICEL', + 'Free Caraïbes' => 'FREE CARAIBES', + 'Free Mobile' => 'FREE MOBILE', + 'GLOBALTEL' => 'GLOBALTEL', + 'Office des postes et télécommunications de Nouvelle Calédonie' => 'Gouv Nelle Calédonie (OPT)', + 'Maore Mobile' => 'MAORE MOBILE', + 'ONATi' => 'ONATI', + 'Orange' => 'ORANGE', + 'Outremer Telecom' => 'OUTREMER TELECOM', + 'Vodafone polynésie' => 'PMT/VODAPHONE', + 'SFR' => 'SFR', + 'SPM Télécom' => 'SPM TELECOM', + 'Service des Postes et Télécommunications de Polynésie Française' => 'Gouv Nelle Calédonie (OPT)', + 'SRR' => 'SRR', + 'Station étrangère' => 'Station étrangère', + 'Telco OI' => 'TELCO IO', + 'United Telecommunication Services Caraïbes' => 'UTS Caraibes', + 'Ora Mobile' => 'VITI SAS', + 'Zeop' => 'ZEOP' + ] + ], + 'statut' => [ + 'name' => 'Statut', + 'type' => 'list', + 'values' => [ + 'Tous' => null, + 'En service' => 'En service', + 'Projet approuvé' => 'Projet approuvé', + 'Techniquement opérationnel' => 'Techniquement opérationnel', + ] + ] + ] + ]; + + public function collectData() + { + $urlParts = [ + 'id' => 'observatoire_2g_3g_4g', + 'resource_id' => '88ef0887-6b0f-4d3f-8545-6d64c8f597da', + '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', + 'rows' => 10000 + ]; + + if (!empty($this->getInput('departement'))) { + $urlParts['refine.sta_nm_dpt'] = urlencode($this->getInput('departement')); + } + + if (!empty($this->getInput('generation'))) { + $urlParts['refine.generation'] = $this->getInput('generation'); + } + + if (!empty($this->getInput('operateur'))) { + // http_build_query() already does urlencoding so this call is redundant + $urlParts['refine.adm_lb_nom'] = urlencode($this->getInput('operateur')); + } + + if (!empty($this->getInput('statut'))) { + $urlParts['refine.statut'] = urlencode($this->getInput('statut')); + } + + // API seems to not play well with urlencoded data + $url = urljoin(static::URI, '/d4c/api/records/1.0/download/?' . urldecode(http_build_query($urlParts))); + + $json = getContents($url); + $data = Json::decode($json, false); + $records = $data->records; + $frequenciesByStation = []; + foreach ($records as $record) { + if (!isset($frequenciesByStation[$record->fields->sta_nm_anfr])) { + $street = sprintf( + '%s %s %s', + $record->fields->adr_lb_add1 ?? '', + $record->fields->adr_lb_add2 ?? '', + $record->fields->adr_lb_add3 ?? '' + ); + $frequenciesByStation[$record->fields->sta_nm_anfr] = [ + 'id' => $record->fields->sta_nm_anfr, + 'operator' => $record->fields->adm_lb_nom, + 'frequencies' => [], + 'lastUpdate' => 0, + 'address' => [ + 'street' => trim($street), + 'postCode' => $record->fields->adr_nm_cp, + 'city' => $record->fields->adr_lb_lieu + ] + ]; + } + + $frequenciesByStation[$record->fields->sta_nm_anfr]['frequencies'][] = [ + 'generation' => $record->fields->generation, + 'frequency' => $record->fields->emr_lb_systeme, + 'status' => $record->fields->statut, + 'updatedAt' => strtotime($record->fields->date_maj), + ]; + + $frequenciesByStation[$record->fields->sta_nm_anfr]['lastUpdate'] = max( + $frequenciesByStation[$record->fields->sta_nm_anfr]['lastUpdate'], + strtotime($record->fields->date_maj) + ); + } + + usort($frequenciesByStation, static fn ($a, $b) => $b['lastUpdate'] <=> $a['lastUpdate']); + + foreach ($frequenciesByStation as $station) { + $title = sprintf( + '[%s] Mise à jour de la station n°%s à %s (%s)', + $station['operator'], + $station['id'], + $station['address']['city'], + $station['address']['postCode'] + ); + + $array_reduce = array_reduce($station['frequencies'], static function ($carry, $frequency) { + return sprintf('%s
  • %s : %s
  • ', $carry, $frequency['frequency'], $frequency['status']); + }, ''); + + $content = sprintf( + '

    Adresse complète

    %s
    %s
    %s

    Fréquences

    ', + $station['address']['street'], + $station['address']['postCode'], + $station['address']['city'], + $array_reduce + ); + + $this->items[] = [ + 'uid' => $station['id'], + 'timestamp' => $station['lastUpdate'], + 'title' => $title, + 'content' => $content, + ]; + } + } +} \ No newline at end of file diff --git a/bridges/AnidexBridge.php b/bridges/AnidexBridge.php index 6d41365b..391ee4f8 100644 --- a/bridges/AnidexBridge.php +++ b/bridges/AnidexBridge.php @@ -152,7 +152,7 @@ class AnidexBridge extends BridgeAbstract } } if (empty($results) && empty($this->getInput('q'))) { - returnServerError('No results from Anidex: ' . $search_url); + throwServerException('No results from Anidex: ' . $search_url); } //Process each item individually diff --git a/bridges/AnisearchBridge.php b/bridges/AnisearchBridge.php new file mode 100644 index 00000000..c6f3d291 --- /dev/null +++ b/bridges/AnisearchBridge.php @@ -0,0 +1,87 @@ + [ + 'name' => 'Dub', + 'type' => 'list', + 'values' => [ + 'DE' + => 'https://www.anisearch.de/anime/index/page-1?char=all&synchro=de&sort=date&order=desc&view=4', + 'EN' + => 'https://www.anisearch.de/anime/index/page-1?char=all&synchro=en&sort=date&order=desc&view=4', + 'JP' + => 'https://www.anisearch.de/anime/index/page-1?char=all&synchro=ja&sort=date&order=desc&view=4' + ] + ], + 'trailers' => [ + 'name' => 'Trailers', + 'type' => 'checkbox', + 'title' => 'Will include trailes', + 'defaultValue' => false + ] + ]]; + + public function collectData() + { + $baseurl = 'https://www.anisearch.de/'; + $trailers = false; + $trailers = $this->getInput('trailers'); + $limit = 10; + if ($trailers) { + $limit = 5; + } + + $dom = getSimpleHTMLDOM($this->getInput('category')); + + foreach ($dom->find('li.btype0') as $key => $li) { + if ($key >= $limit) { + break; + } + + $a = $li->find('a', 0); + $title = $a->find('span.title', 0); + $url = $baseurl . $a->href; + + //get article + $domarticle = getSimpleHTMLDOM($url); + $content = $domarticle->find('div.details-text', 0); + + //get header-image and set absolute src + $headerimage = $domarticle->find('img#details-cover', 0); + $src = $headerimage->src; + + foreach ($content->find('.hidden') as $element) { + $element->remove(); + } + + //get trailer + $ytlink = ''; + if ($trailers) { + $trailerlink = $domarticle->find('section#trailers > div > div.swiper > ul.swiper-wrapper > li.swiper-slide > a', 0); + if (isset($trailerlink)) { + $trailersite = getSimpleHTMLDOM($baseurl . $trailerlink->href); + $trailer = $trailersite->find('div#video > iframe', 0); + $trailer = $trailer->{'data-xsrc'}; + $ytlink = << + EOT; + } + } + + $this->items[] = [ + 'title' => $title->plaintext, + 'uri' => $url, + 'content' => $headerimage . '
    ' . $content . $ytlink + ]; + } + } +} diff --git a/bridges/AnnasArchiveBridge.php b/bridges/AnnasArchiveBridge.php index e8a1e8c4..4a1c3e91 100644 --- a/bridges/AnnasArchiveBridge.php +++ b/bridges/AnnasArchiveBridge.php @@ -126,28 +126,36 @@ class AnnasArchiveBridge extends BridgeAbstract return; } - foreach ($list->find('.w-full > .mb-4 > div > a') as $element) { - $item = []; - $item['title'] = $element->find('h3', 0)->plaintext; - $item['author'] = $element->find('div.italic', 0)->plaintext; - $item['uri'] = $element->href; - $item['content'] = $element->plaintext; - $item['uid'] = $item['uri']; + $elements = $list->find('#aarecord-list > div'); + foreach ($elements as $element) { + // stop added entries once partial match list starts + if (str_contains($element->innertext, 'partial match')) { + break; + } + if ($element = $element->find('a', 0)) { + $item = []; + $item['title'] = $element->find('h3', 0)->plaintext; + $item['author'] = $element->find('div.italic', 0)->plaintext; + $item['uri'] = $element->href; + $item['content'] = $element->plaintext; + $item['uid'] = $item['uri']; - if ($item_html = getSimpleHTMLDOMCached($item['uri'])) { - $item_html = defaultLinkTo($item_html, self::URI); - $item['content'] .= $item_html->find('main img', 0); - $item['content'] .= $item_html->find('main .mt-4', 0); // Summary - if ($links = $item_html->find('main ul.mb-4', -1)) { - foreach ($links->find('li > a.js-download-link') as $file) { - $item['enclosures'][] = $file->href; + $item_html = getSimpleHTMLDOMCached($item['uri'], 86400 * 20); + if ($item_html) { + $item_html = defaultLinkTo($item_html, self::URI); + $item['content'] .= $item_html->find('main img', 0); + $item['content'] .= $item_html->find('main .mt-4', 0); // Summary + foreach ($item_html->find('main ul.mb-4 > li > a.js-download-link') as $file) { + if (!str_contains($file->href, 'fast_download')) { + $item['enclosures'][] = $file->href; + } } // Remove bulk torrents from enclosures list $item['enclosures'] = array_diff($item['enclosures'], [self::URI . 'datasets']); } - } - $this->items[] = $item; + $this->items[] = $item; + } } } diff --git a/bridges/AppleAppStoreBridge.php b/bridges/AppleAppStoreBridge.php index a5fab59b..d6872a11 100644 --- a/bridges/AppleAppStoreBridge.php +++ b/bridges/AppleAppStoreBridge.php @@ -52,120 +52,183 @@ class AppleAppStoreBridge extends BridgeAbstract ], 'defaultValue' => 'US', ], + 'debug' => [ + 'name' => 'Debug Mode', + 'type' => 'checkbox', + 'defaultValue' => false + ] ]]; const PLATFORM_MAPPING = [ - 'iphone' => 'ios', - 'ipad' => 'ios', + 'iphone' => 'ios', + 'ipad' => 'ios', + 'mac' => 'osx' ]; - private function makeHtmlUrl($id, $country) + private $name; + + private function makeHtmlUrl() { - return 'https://apps.apple.com/' . $country . '/app/id' . $id; + $id = $this->getInput('id'); + $country = $this->getInput('country'); + return sprintf('https://apps.apple.com/%s/app/id%s', $country, $id); } - private function makeJsonUrl($id, $platform, $country) - { - return "https://amp-api.apps.apple.com/v1/catalog/$country/apps/$id?platform=$platform&extend=versionHistory"; - } - - public function getName() - { - if (isset($this->name)) { - return $this->name . ' - AppStore Updates'; - } - - return parent::getName(); - } - - /** - * In case of some platforms, the data is present in the initial response - */ - private function getDataFromShoebox($id, $platform, $country) - { - $uri = $this->makeHtmlUrl($id, $country); - $html = getSimpleHTMLDOMCached($uri, 3600); - $script = $html->find('script[id="shoebox-ember-data-store"]', 0); - - $json = json_decode($script->innertext, true); - return $json['data']; - } - - private function getJWTToken($id, $platform, $country) - { - $uri = $this->makeHtmlUrl($id, $country); - - $html = getSimpleHTMLDOMCached($uri, 3600); - - $meta = $html->find('meta[name="web-experience-app/config/environment"]', 0); - - $json = urldecode($meta->content); - - $json = json_decode($json); - - return $json->MEDIA_API->token; - } - - private function getAppData($id, $platform, $country, $token) - { - $uri = $this->makeJsonUrl($id, $platform, $country); - - $headers = [ - "Authorization: Bearer $token", - 'Origin: https://apps.apple.com', - ]; - - $json = json_decode(getContents($uri, $headers), true); - - return $json['data'][0]; - } - - /** - * Parses the version history from the data received - * @return array list of versions with details on each element - */ - private function getVersionHistory($data, $platform) - { - switch ($platform) { - case 'mac': - return $data['relationships']['platforms']['data'][0]['attributes']['versionHistory']; - default: - $os = self::PLATFORM_MAPPING[$platform]; - return $data['attributes']['platformAttributes'][$os]['versionHistory']; - } - } - - public function collectData() + private function makeJsonUrl() { $id = $this->getInput('id'); $country = $this->getInput('country'); $platform = $this->getInput('p'); - switch ($platform) { - case 'mac': - $data = $this->getDataFromShoebox($id, $platform, $country); - break; + $platform_param = ($platform === 'mac') ? 'mac' : $platform; - default: - $token = $this->getJWTToken($id, $platform, $country); - $data = $this->getAppData($id, $platform, $country, $token); + return sprintf( + 'https://amp-api-edge.apps.apple.com/v1/catalog/%s/apps/%s?platform=%s&extend=versionHistory', + $country, + $id, + $platform_param + ); + } + + public function getName() + { + if (isset($this->name)) { + return sprintf('%s - AppStore Updates', $this->name); } - $versionHistory = $this->getVersionHistory($data, $platform); - $name = $this->name = $data['attributes']['name']; - $author = $data['attributes']['artistName']; + return parent::getName(); + } + + private function debugLog($message) + { + if ($this->getInput('debug')) { + $this->logger->info(sprintf('[AppleAppStoreBridge] %s', $message)); + } + } + + private function getHtml() + { + $url = $this->makeHtmlUrl(); + $this->debugLog(sprintf('Fetching HTML from: %s', $url)); + + return getSimpleHTMLDOM($url); + } + + private function getJWTToken() + { + $html = $this->getHtml(); + $meta = $html->find('meta[name="web-experience-app/config/environment"]', 0); + + if (!$meta || !isset($meta->content)) { + throw new \Exception('JWT token not found in page content'); + } + + $decoded_content = urldecode($meta->content); + $this->debugLog('Found meta tag content'); + + try { + $decoded_json = Json::decode($decoded_content); + } catch (\Exception $e) { + throw new \Exception(sprintf('Failed to parse JSON from meta tag: %s', $e->getMessage())); + } + + if (!isset($decoded_json['MEDIA_API']['token'])) { + throw new \Exception('Token field not found in JSON structure'); + } + + $token = $decoded_json['MEDIA_API']['token']; + $this->debugLog('Successfully extracted JWT token'); + return $token; + } + + private function getAppData() + { + $token = $this->getJWTToken(); + + $url = $this->makeJsonUrl(); + $this->debugLog(sprintf('Fetching data from API: %s', $url)); + + $headers = [ + 'Authorization: Bearer ' . $token, + 'Origin: https://apps.apple.com', + 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ]; + + $content = getContents($url, $headers); + + try { + $json = Json::decode($content); + } catch (\Exception $e) { + throw new \Exception(sprintf('Failed to parse API response: %s', $e->getMessage())); + } + + if (!isset($json['data']) || empty($json['data'])) { + throw new \Exception('No app data found in API response'); + } + + $this->debugLog('Successfully retrieved app data from API'); + return $json['data'][0]; + } + + private function extractAppDetails($data) + { + if (isset($data['attributes'])) { + $this->name = $data['attributes']['name'] ?? null; + $author = $data['attributes']['artistName'] ?? null; + $this->debugLog(sprintf('Found app details in attributes: %s by %s', $this->name, $author)); + return [$this->name, $author]; + } + + // Fallback to default values if not found + $this->name = sprintf('App %s', $this->getInput('id')); + $this->debugLog(sprintf('App details not found, using default: %s', $this->name)); + return [$this->name, 'Unknown Developer']; + } + + private function getVersionHistory($data) + { + $platform = $this->getInput('p'); + $this->debugLog(sprintf('Extracting version history for platform: %s', $platform)); + + // Get the mapped platform key (ios for iPhone/iPad, osx for Mac) + $platform_key = self::PLATFORM_MAPPING[$platform] ?? $platform; + + $version_history = $data['attributes']['platformAttributes'][$platform_key]['versionHistory'] ?? []; + + if (empty($version_history)) { + $this->debugLog(sprintf('No version history found for %s', $platform)); + } + + return $version_history; + } + + public function collectData() + { + $this->debugLog(sprintf('Getting data for %s app', $this->getInput('p'))); + $data = $this->getAppData(); + + // Get app name and author using array destructuring + [$name, $author] = $this->extractAppDetails($data); + + // Get version history + $version_history = $this->getVersionHistory($data); + $this->debugLog(sprintf('Found %d versions for %s', count($version_history), $name)); + + foreach ($version_history as $entry) { + $version = $entry['versionDisplay'] ?? 'Unknown Version'; + $release_notes = $entry['releaseNotes'] ?? 'No release notes available'; + $release_date = $entry['releaseDate'] ?? 'Unknown Date'; - foreach ($versionHistory as $row) { $item = []; - - $item['content'] = nl2br($row['releaseNotes']); - $item['title'] = $name . ' - ' . $row['versionDisplay']; - $item['timestamp'] = $row['releaseDate']; + $item['title'] = sprintf('%s - %s', $name, $version); + $item['content'] = nl2br($release_notes) ?: 'No release notes available'; + $item['timestamp'] = $release_date; $item['author'] = $author; - - $item['uri'] = $this->makeHtmlUrl($id, $country); + $item['uri'] = $this->makeHtmlUrl(); $this->items[] = $item; } + + $this->debugLog(sprintf('Successfully collected %d items', count($this->items))); } -} +} \ No newline at end of file diff --git a/bridges/AppleMusicBridge.php b/bridges/AppleMusicBridge.php index 900a7009..81558b6d 100644 --- a/bridges/AppleMusicBridge.php +++ b/bridges/AppleMusicBridge.php @@ -18,9 +18,45 @@ class AppleMusicBridge extends BridgeAbstract 'required' => true, ], ]]; - const CACHE_TIMEOUT = 21600; // 6 hours + const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours + + private $title; public function collectData() + { + $items = $this->getJson(); + $artist = $this->getArtist($items); + + $this->title = $artist->artistName; + + foreach ($items as $item) { + if ($item->wrapperType === 'collection') { + $copyright = $item->copyright ?? ''; + $artworkUrl500 = str_replace('/100x100', '/500x500', $item->artworkUrl100); + $artworkUrl2000 = str_replace('/100x100', '/2000x2000', $item->artworkUrl100); + $escapedCollectionName = htmlspecialchars($item->collectionName); + + $this->items[] = [ + 'title' => $item->collectionName, + 'uri' => $item->collectionViewUrl, + 'timestamp' => $item->releaseDate, + 'enclosures' => $artworkUrl500, + 'author' => $item->artistName, + 'content' => "
    + artworkUrl60 60w, $item->artworkUrl100 100w, $artworkUrl500 500w, $artworkUrl2000 2000w\" + sizes=\"100%\" src=\"$artworkUrl2000\" + alt=\"Cover of $escapedCollectionName\" + style=\"display: block; margin: 0 auto;\" /> +
    + from artistLinkUrl\">$item->artistName
    $copyright +
    +
    ", + ]; + } + } + } + + private function getJson() { # Limit the amount of releases to 50 if ($this->getInput('limit') > 50) { @@ -29,31 +65,53 @@ class AppleMusicBridge extends BridgeAbstract $limit = $this->getInput('limit'); } - $url = 'https://itunes.apple.com/lookup?id=' - . $this->getInput('artist') - . '&entity=album&limit=' - . $limit . - '&sort=recent'; + $url = 'https://itunes.apple.com/lookup?id=' . $this->getInput('artist') . '&entity=album&limit=' . $limit . '&sort=recent'; $html = getSimpleHTMLDOM($url); - $json = json_decode($html); + $result = $json->results; - foreach ($json->results as $obj) { - if ($obj->wrapperType === 'collection') { - $copyright = $obj->copyright ?? ''; - - $this->items[] = [ - 'title' => $obj->artistName . ' - ' . $obj->collectionName, - 'uri' => $obj->collectionViewUrl, - 'timestamp' => $obj->releaseDate, - 'enclosures' => $obj->artworkUrl100, - 'content' => '

    ' - . $obj->artistName . ' - ' . $obj->collectionName - . '
    ' - . $copyright, - ]; - } + if (!is_array($result) || count($result) == 0) { + throwServerException('There is no artist with id "' . $this->getInput('artist') . '".'); } + + return $result; + } + + private function getArtist($json) + { + $nameArray = array_filter($json, function ($obj) { + return $obj->wrapperType == 'artist'; + }); + + if (count($nameArray) === 1) { + return $nameArray[0]; + } + + return parent::getName(); + } + + public function getName() + { + if (isset($this->title)) { + return $this->title; + } + + return parent::getName(); + } + + public function getIcon() + { + if (empty($this->getInput('artist'))) { + return parent::getIcon(); + } + + // it isn't necessary to set the correct artist name into the url + $url = 'https://music.apple.com/us/artist/jon-bellion/' . $this->getInput('artist'); + $html = getSimpleHTMLDOMCached($url); + $image = $html->find('meta[property="og:image"]', 0)->content; + + $imageUpdatedSize = preg_replace('/\/\d*x\d*cw/i', '/144x144-999', $image); + + return $imageUpdatedSize; } } diff --git a/bridges/ArsTechnicaBridge.php b/bridges/ArsTechnicaBridge.php index 2c631871..ac722dc9 100644 --- a/bridges/ArsTechnicaBridge.php +++ b/bridges/ArsTechnicaBridge.php @@ -37,35 +37,82 @@ class ArsTechnicaBridge extends FeedExpander { $item_html = getSimpleHTMLDOMCached($item['uri']); $item_html = defaultLinkTo($item_html, self::URI); - $item['content'] = $item_html->find('.article-content', 0); - $pages = $item_html->find('nav.page-numbers > .numbers > a', -2); - if (null !== $pages) { - for ($i = 2; $i <= $pages->innertext; $i++) { - $page_url = $item['uri'] . '&page=' . $i; - $page_html = getSimpleHTMLDOMCached($page_url); - $page_html = defaultLinkTo($page_html, self::URI); - $item['content'] .= $page_html->find('.article-content', 0); + $content = ''; + $header = $item_html->find('article header', 0); + $leading = $header->find('p[class*=leading]', 0); + if ($leading != null) { + $content .= '

    ' . $leading->innertext . '

    '; + } + $intro_image = $header->find('img.intro-image', 0); + if ($intro_image != null) { + $content .= '
    ' . $intro_image; + + $image_caption = $header->find('.caption .caption-content', 0); + if ($image_caption != null) { + $content .= '
    ' . $image_caption->innertext . '
    '; } - $item['content'] = str_get_html($item['content']); + $content .= '
    '; + } + + foreach ($item_html->find('.post-content') as $content_tag) { + $content .= $content_tag->innertext; + } + + $item['content'] = str_get_html($content); + + $parsely = $item_html->find('[name="parsely-page"]', 0); + $parsely_json = json_decode(html_entity_decode($parsely->content), true); + $item['categories'] = $parsely_json['tags']; + + // Some lightboxes are nested in figures. I'd guess that's a + // bug in the website + foreach ($item['content']->find('figure div div.ars-lightbox') as $weird_lightbox) { + $weird_lightbox->parent->parent->outertext = $weird_lightbox; + } + + // It's easier to reconstruct the whole thing than remove + // duplicate reactive tags + foreach ($item['content']->find('.ars-lightbox') as $lightbox) { + $lightbox_content = ''; + foreach ($lightbox->find('.ars-lightbox-item') as $lightbox_item) { + $img = $lightbox_item->find('img', 0); + if ($img != null) { + $lightbox_content .= '
    ' . $img; + $caption = $lightbox_item->find('div.pswp-caption-content', 0); + if ($caption != null) { + $credit = $lightbox_item->find('div.ars-gallery-caption-credit', 0); + if ($credit != null) { + $credit->innertext = 'Credit: ' . $credit->innertext; + } + $lightbox_content .= '
    ' . $caption->innertext . '
    '; + } + $lightbox_content .= '
    '; + } + } + $lightbox->innertext = $lightbox_content; } // remove various ars advertising - $item['content']->find('#social-left', 0)->remove(); - foreach ($item['content']->find('.ars-component-buy-box') as $ad) { + foreach ($item['content']->find('.ars-interlude-container') as $ad) { $ad->remove(); } - foreach ($item['content']->find('.ad_wrapper') as $ad) { - $ad->remove(); + foreach ($item['content']->find('.toc-container') as $toc) { + $toc->remove(); } - foreach ($item['content']->find('.sidebar') as $ad) { - $ad->remove(); + + // Mostly YouTube videos + $iframes = $item['content']->find('iframe'); + foreach ($iframes as $iframe) { + $iframe->outertext = '' . $iframe->src . ''; + } + // This fixed padding around the former iframes and actual inline videos + foreach ($item['content']->find('div[style*=aspect-ratio]') as $styled) { + $styled->removeAttribute('style'); } $item['content'] = backgroundToImg($item['content']); - - $item['uid'] = explode('=', $item['uri'])[1]; - + $item['uid'] = strval($parsely_json['post_id']); return $item; } } diff --git a/bridges/AsahiShimbunAJWBridge.php b/bridges/AsahiShimbunAJWBridge.php index 03bee6ba..873eb351 100644 --- a/bridges/AsahiShimbunAJWBridge.php +++ b/bridges/AsahiShimbunAJWBridge.php @@ -45,7 +45,6 @@ class AsahiShimbunAJWBridge extends BridgeAbstract foreach ($html->find('#MainInner li a') as $element) { if ($element->parent()->class == 'HeadlineTopImage-S') { - Debug::log('Skip Headline, it is repeated below'); continue; } $item = []; diff --git a/bridges/AskfmBridge.php b/bridges/AskfmBridge.php deleted file mode 100644 index d0422890..00000000 --- a/bridges/AskfmBridge.php +++ /dev/null @@ -1,80 +0,0 @@ - [ - 'u' => [ - 'name' => 'Username', - 'required' => true, - 'exampleValue' => 'ApprovedAndReal' - ] - ] - ]; - - public function collectData() - { - $html = getSimpleHTMLDOM($this->getURI()); - - $html = defaultLinkTo($html, self::URI); - - foreach ($html->find('article.streamItem-answer') as $element) { - $item = []; - $item['uri'] = $element->find('a.streamItem_meta', 0)->href; - $question = trim($element->find('header.streamItem_header', 0)->innertext); - - $item['title'] = trim( - htmlspecialchars_decode( - $element->find('header.streamItem_header', 0)->plaintext, - ENT_QUOTES - ) - ); - - $item['timestamp'] = strtotime($element->find('time', 0)->datetime); - - $var = $element->find('div.streamItem_content', 0); - $answer = trim($var->innertext ?? ''); - - // This probably should be cleaned up, especially for YouTube embeds - if ($visual = $element->find('div.streamItem_visual', 0)) { - $visual = $visual->innertext; - } - - // Fix tracking links, also doesn't work - foreach ($element->find('a') as $link) { - if (strpos($link->href, 'l.ask.fm') !== false) { - $link->href = $link->plaintext; - } - } - - $item['content'] = '

    ' . $question - . '

    ' . $answer - . '

    ' . $visual . '

    '; - - $this->items[] = $item; - } - } - - public function getName() - { - if (!is_null($this->getInput('u'))) { - return self::NAME . ' : ' . $this->getInput('u'); - } - - return parent::getName(); - } - - public function getURI() - { - if (!is_null($this->getInput('u'))) { - return self::URI . urlencode($this->getInput('u')); - } - - return parent::getURI(); - } -} diff --git a/bridges/AssociatedPressNewsBridge.php b/bridges/AssociatedPressNewsBridge.php index 0f8846eb..37dff8b9 100644 --- a/bridges/AssociatedPressNewsBridge.php +++ b/bridges/AssociatedPressNewsBridge.php @@ -66,10 +66,10 @@ class AssociatedPressNewsBridge extends BridgeAbstract { switch ($this->getInput('topic')) { case 'Podcasts': - returnClientError('Podcasts topic feed is not supported'); + throwClientException('Podcasts topic feed is not supported'); break; case 'PressReleases': - returnClientError('PressReleases topic feed is not supported'); + throwClientException('PressReleases topic feed is not supported'); break; default: $this->collectCardData(); @@ -105,13 +105,12 @@ class AssociatedPressNewsBridge extends BridgeAbstract private function collectCardData() { - $json = getContents($this->getTagURI()) - or returnServerError('Could not request: ' . $this->getTagURI()); + $json = getContents($this->getTagURI()); $tagContents = json_decode($json, true); if (empty($tagContents['tagObjs'])) { - returnClientError('Topic not found: ' . $this->getInput('topic')); + throwClientException('Topic not found: ' . $this->getInput('topic')); } $this->feedName = $tagContents['tagObjs'][0]['name']; diff --git a/bridges/AuctionetBridge.php b/bridges/AuctionetBridge.php new file mode 100644 index 00000000..fbdee441 --- /dev/null +++ b/bridges/AuctionetBridge.php @@ -0,0 +1,344 @@ + [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'All categories' => '', + 'Art' => [ + 'All' => '25-art', + 'Drawings' => '119-drawings', + 'Engravings & Prints' => '27-engravings-prints', + 'Other' => '30-other', + 'Paintings' => '28-paintings', + 'Photography' => '26-photography', + 'Sculptures & Bronzes' => '29-sculptures-bronzes', + ], + 'Asiatica' => [ + 'All' => '117-asiatica', + ], + 'Books, Maps & Manuscripts' => [ + 'All' => '50-books-maps-manuscripts', + 'Autographs & Manuscripts' => '206-autographs-manuscripts', + 'Books' => '204-books', + 'Maps' => '205-maps', + 'Other' => '207-other', + ], + 'Carpets & Textiles' => [ + 'All' => '35-carpets-textiles', + 'Carpets' => '36-carpets', + 'Textiles' => '37-textiles', + ], + 'Ceramics & Porcelain' => [ + 'All' => '9-ceramics-porcelain', + 'European' => '10-european', + 'Oriental' => '11-oriental', + 'Rest of the world' => '12-rest-of-the-world', + 'Tableware' => '210-tableware', + ], + 'Clocks & Watches' => [ + 'All' => '31-clocks-watches', + 'Carriage & Miniature Clocks' => '258-carriage-miniature-clocks', + 'Longcase clocks' => '32-longcase-clocks', + 'Mantel clocks' => '33-mantel-clocks', + 'Other clocks' => '34-other-clocks', + 'Pocket & Stop Watches' => '110-pocket-stop-watches', + 'Wall Clocks' => '127-wall-clocks', + 'Wristwatches' => '15-wristwatches', + ], + 'Coins, Medals & Stamps' => [ + 'All' => '46-coins-medals-stamps', + 'Coins' => '128-coins', + 'Orders & Medals' => '135-orders-medals', + 'Other' => '131-other', + 'Stamps' => '136-stamps', + ], + 'Folk art' => [ + 'All' => '58-folk-art', + 'Bowls & Boxes' => '121-bowls-boxes', + 'Furniture' => '122-furniture', + 'Other' => '123-other', + 'Tools & Gears' => '120-tools-gears', + ], + 'Furniture' => [ + 'All' => '16-furniture', + 'Armchairs & Chairs' => '18-armchairs-chairs', + 'Chests of drawers' => '24-chests-of-drawers', + 'Cupboards, Cabinets & Shelves' => '23-cupboards-cabinets-shelves', + 'Dining room furniture' => '22-dining-room-furniture', + 'Garden' => '21-garden', + 'Other' => '17-other', + 'Sofas & seatings' => '20-sofas-seatings', + 'Tables' => '19-tables', + ], + 'Glass' => [ + 'All' => '6-glass', + 'Art glass' => '208-art-glass', + 'Other' => '8-other', + 'Tableware' => '7-tableware', + 'Utility glass' => '209-utility-glass', + ], + 'Jewellery & Gemstones' => [ + 'All' => '13-jewellery-gemstones', + 'Alliance rings' => '113-alliance-rings', + 'Bracelets' => '106-bracelets', + 'Brooches & Pendants' => '107-brooches-pendants', + 'Costume Jewellery' => '259-costume-jewellery', + 'Cufflinks & Tie Pins' => '111-cufflinks-tie-pins', + 'Ear studs' => '116-ear-studs', + 'Earrings' => '115-earrings', + 'Gemstones' => '48-gemstones', + 'Jewellery' => '14-jewellery', + 'Jewellery Suites' => '109-jewellery-suites', + 'Necklace' => '104-necklace', + 'Other' => '118-other', + 'Rings' => '112-rings', + 'Signet rings' => '105-signet-rings', + 'Solitaire rings' => '114-solitaire-rings', + ], + 'Licence weapons' => [ + 'All' => '59-licence-weapons', + 'Combi/Combo' => '63-combi-combo', + 'Double express rifles' => '60-double-express-rifles', + 'Rifles' => '61-rifles', + 'Shotguns' => '62-shotguns', + ], + 'Lighting & Lamps' => [ + 'All' => '1-lighting-lamps', + 'Candlesticks' => '4-candlesticks', + 'Ceiling lights' => '3-ceiling-lights', + 'Chandeliers' => '203-chandeliers', + 'Floor lights' => '2-floor-lights', + 'Other lighting' => '5-other-lighting', + 'Table Lamps' => '125-table-lamps', + 'Wall Lights' => '124-wall-lights', + ], + 'Mirrors' => [ + 'All' => '42-mirrors', + ], + 'Miscellaneous' => [ + 'All' => '43-miscellaneous', + 'Fishing equipment' => '54-fishing-equipment', + 'Miscellaneous' => '47-miscellaneous', + 'Modern Tools' => '133-modern-tools', + 'Modern consumer electronics' => '52-modern-consumer-electronics', + 'Musical instruments' => '51-musical-instruments', + 'Technica & Nautica' => '45-technica-nautica', + ], + 'Photo, Cameras & Lenses' => [ + 'All' => '57-photo-cameras-lenses', + 'Cameras & accessories' => '71-cameras-accessories', + 'Optics' => '66-optics', + 'Other' => '72-other', + ], + 'Silver & Metals' => [ + 'All' => '38-silver-metals', + 'Other metals' => '40-other-metals', + 'Pewter, Brass & Copper' => '41-pewter-brass-copper', + 'Silver' => '39-silver', + 'Silver plated' => '213-silver-plated', + ], + 'Toys' => [ + 'All' => '44-toys', + 'Comics' => '211-comics', + 'Toys' => '212-toys', + ], + 'Tribal art' => [ + 'All' => '134-tribal-art', + ], + 'Vehicles, Boats & Parts' => [ + 'All' => '249-vehicles-boats-parts', + 'Automobilia & Transport' => '255-automobilia-transport', + 'Bicycles' => '132-bicycles', + 'Boats & Accessories' => '250-boats-accessories', + 'Car parts' => '253-car-parts', + 'Cars' => '215-cars', + 'Moped parts' => '254-moped-parts', + 'Mopeds' => '216-mopeds', + 'Motorcycle parts' => '252-motorcycle-parts', + 'Motorcycles' => '251-motorcycles', + 'Other' => '256-other', + ], + 'Vintage & Designer Fashion' => [ + 'All' => '49-vintage-designer-fashion', + ], + 'Weapons & Militaria' => [ + 'All' => '137-weapons-militaria', + 'Airguns' => '257-airguns', + 'Armour & Uniform' => '138-armour-uniform', + 'Edged weapons' => '130-edged-weapons', + 'Guns & Rifles' => '129-guns-rifles', + 'Other' => '214-other', + ], + 'Wine, Port & Spirits' => [ + 'All' => '170-wine-port-spirits', + ], + ] + ], + 'sort_order' => [ + 'name' => 'Sort order', + 'type' => 'list', + 'values' => [ + 'Most bids' => 'bids_count_desc', + 'Lowest bid' => 'bid_asc', + 'Highest bid' => 'bid_desc', + 'Last bid on' => 'bid_on', + 'Ending soonest' => 'end_asc_active', + 'Lowest estimate' => 'estimate_asc', + 'Highest estimate' => 'estimate_desc', + 'Recently added' => 'recent' + ], + ], + 'country' => [ + 'name' => 'Country', + 'type' => 'list', + 'values' => [ + 'All' => '', + 'Denmark' => 'DK', + 'Finland' => 'FI', + 'Germany' => 'DE', + 'Spain' => 'ES', + 'Sweden' => 'SE', + 'United Kingdom' => 'GB' + ] + ], + 'language' => [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'English' => 'en', + 'Español' => 'es', + 'Deutsch' => 'de', + 'Svenska' => 'sv', + 'Dansk' => 'da', + 'Suomi' => 'fi', + ], + ], + ]]; + + const CACHE_TIMEOUT = 3600; // 1 hour + + private $title; + + public function collectData() + { + // Each page contains 48 auctions + // So we fetch 10 pages so we decrease the likelihood + // of missing auctions between feed refreshes + + // Fetch first page and use that to get title + { + $url = $this->getUrl(1); + $data = getContents($url); + + $title = $this->getDocumentTitle($data); + + $this->items = array_merge($this->items, $this->parsePageData($data)); + } + + // Fetch remaining pages + for ($page = 2; $page <= 10; $page++) { + $url = $this->getUrl($page); + + $data = getContents($url); + + $this->items = array_merge($this->items, $this->parsePageData($data)); + } + } + + public function getName() + { + return $this->title ?: parent::getName(); + } + + + /* HELPERS */ + + private function getUrl($page) + { + $category = $this->getInput('category'); + $language = $this->getInput('language'); + $sort_order = $this->getInput('sort_order'); + $country = $this->getInput('country'); + + $url = self::URI . '/' . $language . '/search'; + + if ($category) { + $url = $url . '/' . $category; + } + + $query = []; + $query['page'] = $page; + + if ($sort_order) { + $query['order'] = $sort_order; + } + + if ($country) { + $query['country_code'] = $country; + } + + if (count($query) > 0) { + $url = $url . '?' . http_build_query($query); + } + + return $url; + } + + private function getDocumentTitle($data) + { + $title_elem = ''; + $title_elem_length = strlen($title_elem); + $title_start = strpos($data, $title_elem); + $title_end = strpos($data, '', $title_start); + $title_length = $title_end - $title_start + strlen($title_elem); + $title = substr($data, $title_start + strlen($title_elem), $title_length); + + return $title; + } + + /** + * The auction items data is included in the HTML document + * as a HTML entities encoded JSON structure + * which is used to hydrate the React component for the list of auctions + */ + private function parsePageData($data) + { + $key = 'data-react-props="'; + $keyLength = strlen($key); + + $start = strpos($data, $key); + $end = strpos($data, '"', $start + strlen($key)); + $length = $end - ($start + $keyLength); + + $jsonString = substr($data, $start + $keyLength, $length); + + $jsonData = json_decode(htmlspecialchars_decode($jsonString), false); + + $items = []; + + foreach ($jsonData->{'items'} as $item) { + $title = $item->{'longTitle'}; + $relative_url = $item->{'url'}; + $images = $item->{'imageUrls'}; + $id = $item->{'auctionId'}; + + $items[] = [ + 'title' => $title, + 'uri' => self::URI . $relative_url, + 'uid' => $id, + 'content' => count($images) > 0 ? "
    $title" : $title, + 'enclosures' => array_slice($images, 1), + ]; + } + + return $items; + } +} diff --git a/bridges/BAEBridge.php b/bridges/BAEBridge.php index 6807d548..a0e4c536 100644 --- a/bridges/BAEBridge.php +++ b/bridges/BAEBridge.php @@ -29,7 +29,7 @@ class BAEBridge extends BridgeAbstract public function collectData() { $url = $this->getURI(); - $html = getSimpleHTMLDOM($url) or returnClientError('No results for this query.'); + $html = getSimpleHTMLDOM($url); $annonces = $html->find('main article'); foreach ($annonces as $annonce) { diff --git a/bridges/BMDSystemhausBlogBridge.php b/bridges/BMDSystemhausBlogBridge.php index c80f3ff3..98fb2d63 100644 --- a/bridges/BMDSystemhausBlogBridge.php +++ b/bridges/BMDSystemhausBlogBridge.php @@ -8,6 +8,7 @@ class BMDSystemhausBlogBridge extends BridgeAbstract const URI = 'https://www.bmd.com'; const DONATION_URI = 'https://paypal.me/cntools'; const DESCRIPTION = 'BMD Systemhaus - We make business easy'; + const BMD_FAV_ICON = 'https://www.bmd.com/favicon.ico'; const ITEMSTYLE = [ 'ilcr' => '
    {data_img}{data_content}
    ', @@ -53,7 +54,7 @@ class BMDSystemhausBlogBridge extends BridgeAbstract public function collectData() { // get website content - $html = getSimpleHTMLDOM($this->getURI()) or returnServerError('No contents received!'); + $html = getSimpleHTMLDOM($this->getURI()); // Convert relative links in HTML into absolute links $html = defaultLinkTo($html, self::URI); @@ -148,32 +149,73 @@ class BMDSystemhausBlogBridge extends BridgeAbstract return null; } - if ($parsedUrl->getHost() != 'www.bmd.com') { + if (!in_array($parsedUrl->getHost(), ['www.bmd.com', 'bmd.com'])) { return null; } - $path = explode('/', $parsedUrl->getPath()); + $lang = ''; - if ($this->getURIbyCountry($path[1]) == '') { - return null; + // extract language from url + $path = explode('/', $parsedUrl->getPath()); + if (count($path) > 1) { + $lang = $path[1]; + + // validate data + if ($this->getURIbyCountry($lang) == '') { + $lang = ''; + } + } + + // if no country available, find language by browser + if ($lang == '') { + $srvLanguages = explode(';', $_SERVER['HTTP_ACCEPT_LANGUAGE']); + if (count($srvLanguages) > 0) { + $languages = explode(',', $srvLanguages[0]); + if (count($languages) > 0) { + for ($i = 0; $i < count($languages); $i++) { + $langDetails = explode('-', $languages[$i]); + if (count($langDetails) > 1) { + $lang = $langDetails[1]; + } else { + $lang = substr($srvLanguages[0], 0, 2); + } + + // validate data + if ($this->getURIbyCountry($lang) == '') { + $lang = ''; + } + + if ($lang != '') { + break; + } + } + } + } + } + + // if no URL found by language, use AT as default + if ($this->getURIbyCountry($lang) == '') { + $lang = 'at'; } $params = []; - $params['country'] = $path[1]; + $params['country'] = strtolower($lang); + return $params; } //----------------------------------------------------- public function getURI() { - $lURI = $this->getURIbyCountry($this->getInput('country')); + $country = $this->getInput('country') ?? ''; + $lURI = $this->getURIbyCountry($country); return $lURI != '' ? $lURI : parent::getURI(); } //----------------------------------------------------- public function getIcon() { - return 'https://www.bmd.com/favicon.ico'; + return self::BMD_FAV_ICON; } //----------------------------------------------------- @@ -192,7 +234,7 @@ class BMDSystemhausBlogBridge extends BridgeAbstract //----------------------------------------------------- private function getURIbyCountry($country) { - switch ($country) { + switch (strtolower($country)) { case 'at': return 'https://www.bmd.com/at/ueber-bmd/blog-ohne-filter.html'; case 'de': diff --git a/bridges/BadDragonBridge.php b/bridges/BadDragonBridge.php index d38e3408..2249d6f7 100644 --- a/bridges/BadDragonBridge.php +++ b/bridges/BadDragonBridge.php @@ -284,8 +284,7 @@ class BadDragonBridge extends BridgeAbstract case 'Clearance': $toyData = json_decode(getContents($this->inputToURL(true))); - $productList = json_decode(getContents(self::URI - . 'api/inventory-toy/product-list')); + $productList = json_decode(getContents(self::URI . 'api/inventory-toy/product-list')); foreach ($toyData->toys as $toy) { $item = []; diff --git a/bridges/BakaUpdatesMangaReleasesBridge.php b/bridges/BakaUpdatesMangaReleasesBridge.php index 10d59c83..8ba5fe98 100644 --- a/bridges/BakaUpdatesMangaReleasesBridge.php +++ b/bridges/BakaUpdatesMangaReleasesBridge.php @@ -94,7 +94,7 @@ class BakaUpdatesMangaReleasesBridge extends BridgeAbstract // content is an unstructured pile of divs, ugly to parse $cols = $html->find('div#main_content div.row > div.text'); if (!$cols) { - returnServerError('No releases'); + throwServerException('No releases'); } $rows = array_slice( diff --git a/bridges/BandcampBridge.php b/bridges/BandcampBridge.php index a9bd2ea1..814317fb 100644 --- a/bridges/BandcampBridge.php +++ b/bridges/BandcampBridge.php @@ -111,19 +111,19 @@ class BandcampBridge extends BridgeAbstract $url = self::URI . 'api/hub/1/dig_deeper'; $data = $this->buildRequestJson(); $header = [ - 'Content-Type: application/json', - 'Content-Length: ' . strlen($data) + 'Content-Type: application/json', + 'Content-Length: ' . strlen($data), ]; $opts = [ - CURLOPT_CUSTOMREQUEST => 'POST', - CURLOPT_POSTFIELDS => $data + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => $data, ]; $content = getContents($url, $header, $opts); $json = json_decode($content); if ($json->ok !== true) { - returnServerError('Invalid response'); + throwServerException('Invalid response'); } foreach ($json->items as $entry) { @@ -165,7 +165,7 @@ class BandcampBridge extends BridgeAbstract $regex = '/band_id=(\d+)/'; if (preg_match($regex, $html, $matches) == false) { - returnServerError('Unable to find band ID on: ' . $this->getURI()); + throwServerException('Unable to find band ID on: ' . $this->getURI()); } $band_id = $matches[1]; @@ -196,7 +196,7 @@ class BandcampBridge extends BridgeAbstract case 'By album': $regex = '/album=(\d+)/'; if (preg_match($regex, $html, $matches) == false) { - returnServerError('Unable to find album ID on: ' . $this->getURI()); + throwServerException('Unable to find album ID on: ' . $this->getURI()); } $album_id = $matches[1]; @@ -314,7 +314,8 @@ class BandcampBridge extends BridgeAbstract { $url = self::URI . 'api/' . $endpoint . '?' . http_build_query($query_data); // todo: 429 Too Many Requests happens a lot - $data = json_decode(getContents($url)); + $response = getContents($url); + $data = json_decode($response); return $data; } diff --git a/bridges/BandcampDailyBridge.php b/bridges/BandcampDailyBridge.php index 57299a17..1f9a031d 100644 --- a/bridges/BandcampDailyBridge.php +++ b/bridges/BandcampDailyBridge.php @@ -93,8 +93,7 @@ class BandcampDailyBridge extends BridgeAbstract public function collectData() { - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request: ' . $this->getURI()); + $html = getSimpleHTMLDOM($this->getURI()); $html = defaultLinkTo($html, self::URI); @@ -105,8 +104,7 @@ class BandcampDailyBridge extends BridgeAbstract $articlePath = $article->find('a.title', 0)->href; - $articlePageHtml = getSimpleHTMLDOMCached($articlePath, 3600) - or returnServerError('Could not request: ' . $articlePath); + $articlePageHtml = getSimpleHTMLDOMCached($articlePath, 3600); $item['uri'] = $articlePath; $item['title'] = $articlePageHtml->find('article-title', 0)->innertext; diff --git a/bridges/BazarakiBridge.php b/bridges/BazarakiBridge.php new file mode 100644 index 00000000..44e4eb84 --- /dev/null +++ b/bridges/BazarakiBridge.php @@ -0,0 +1,139 @@ + [ + 'name' => 'URL', + 'type' => 'text', + 'required' => true, + 'title' => 'Enter the URL of the Bazaraki page to fetch adverts from.', + 'exampleValue' => 'https://www.bazaraki.com/real-estate-for-sale/houses/?lat=0&lng=0&radius=100000', + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => false, + 'title' => 'Enter the number of adverts to fetch. (max 50)', + 'exampleValue' => '10', + 'defaultValue' => 10, + ] + ] + ]; + + public function collectData() + { + $url = $this->getInput('url'); + if (! str_starts_with($url, 'https://www.bazaraki.com/')) { + throw new \Exception('Nope'); + } + + $html = getSimpleHTMLDOM($url); + + $i = 0; + foreach ($html->find('div.advert') as $element) { + $i++; + if ($i > $this->getInput('limit') || $i > 50) { + break; + } + + $item = []; + + $item['uri'] = 'https://www.bazaraki.com' . $element->find('a.advert__content-title', 0)->href; + + # Get the content + $advert = getSimpleHTMLDOM($item['uri']); + + $price = trim($advert->find('div.announcement-price__cost', 0)->plaintext); + $name = trim($element->find('a.advert__content-title', 0)->plaintext); + + $item['title'] = $name . ' - ' . $price; + + $time = trim($advert->find('span.date-meta', 0)->plaintext); + $time = str_replace('Posted: ', '', $time); + + + $item['content'] = $this->processAdvertContent($advert); + $item['timestamp'] = $this->convertRelativeTime($time); + $item['author'] = trim($advert->find('div.author-name', 0)->plaintext); + $item['uid'] = $advert->find('span.number-announcement', 0)->plaintext; + + $this->items[] = $item; + } + } + + /** + * Process the advert content to clean up HTML + * + * @param simple_html_dom $advert The SimpleHTMLDOM object for the advert page + * @return string Processed HTML content + */ + private function processAdvertContent($advert) + { + // Get the content sections + $header = $advert->find('div.announcement-content-header', 0); + $characteristics = $advert->find('div.announcement-characteristics', 0); + $description = $advert->find('div.js-description', 0); + $images = $advert->find('div.announcement__images', 0); + + // Remove all favorites divs + foreach ($advert->find('div.announcement-meta__favorites') as $favorites) { + $favorites->outertext = ''; + } + + // Replace all tags with their text content + foreach ($advert->find('a') as $a) { + $a->outertext = $a->innertext; + } + + // Format the content with section headers and dividers + $formattedContent = ''; + + // Add header section + $formattedContent .= $header->innertext; + $formattedContent .= '
    '; + + // Add characteristics section with header + $formattedContent .= '

    Details

    '; + $formattedContent .= $characteristics->innertext; + $formattedContent .= '
    '; + + // Add description section with header + $formattedContent .= '

    Description

    '; + $formattedContent .= $description->innertext; + $formattedContent .= '
    '; + + // Add images section with header + $formattedContent .= '

    Images

    '; + $formattedContent .= $images->innertext; + + return $formattedContent; + } + + /** + * Convert relative time strings like "Yesterday 12:32" to proper timestamps + * + * @param string $timeString The relative time string from the website + * @return string Timestamp in a format compatible with strtotime() + */ + private function convertRelativeTime($timeString) + { + if (strpos($timeString, 'Yesterday') !== false) { + // Replace "Yesterday" with actual date + $time = str_replace('Yesterday', date('Y-m-d', strtotime('-1 day')), $timeString); + return date('Y-m-d H:i:s', strtotime($time)); + } elseif (strpos($timeString, 'Today') !== false) { + // Replace "Today" with actual date + $time = str_replace('Today', date('Y-m-d'), $timeString); + return date('Y-m-d H:i:s', strtotime($time)); + } else { + // For other formats, return as is and let strtotime handle it + return $timeString; + } + } +} diff --git a/bridges/BlizzardNewsBridge.php b/bridges/BlizzardNewsBridge.php index 3930e0a4..4d82b318 100644 --- a/bridges/BlizzardNewsBridge.php +++ b/bridges/BlizzardNewsBridge.php @@ -1,6 +1,6 @@ getInput('locale'); if ('zh-cn' === $locale) { - return 'https://cn.news.blizzard.com'; + $baseUrl = 'https://cn.news.blizzard.com' . self::API_PATH; + } else { + $baseUrl = 'https://news.blizzard.com/' . $locale . self::API_PATH; } - return 'https://news.blizzard.com/' . $locale; + return $baseUrl .= http_build_query([ + 'feedCxpProductIds' => self::PRODUCT_IDS + ]); + } + + public function collectData() + { + $feedContent = json_decode(getContents($this->getSourceUrl()), true); + + foreach ($feedContent['feed']['contentItems'] as $entry) { + $properties = $entry['properties']; + + $item = []; + + $item['title'] = $this->filterChars($properties['title']); + $item['content'] = $this->filterChars($properties['summary']); + $item['uri'] = $properties['newsUrl']; + $item['author'] = $this->filterChars($properties['author']); + $item['timestamp'] = strtotime($properties['lastUpdated']); + $item['enclosures'] = [$properties['staticAsset']['imageUrl']]; + $item['categories'] = [$this->filterChars($properties['cxpProduct']['title'])]; + + $this->items[] = $item; + } + } + + private function filterChars($content) + { + return htmlspecialchars($content, ENT_XML1); + } + + public function getIcon() + { + return << [ + 'name' => 'Bluesky Data Source', + 'type' => 'list', + 'defaultValue' => 'Profile', + 'values' => [ + 'Profile' => 'getAuthorFeed', + ], + 'title' => 'Select the type of data source to fetch from Bluesky.' + ], + 'user_id' => [ + 'name' => 'User Handle or DID', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'did:plc:z72i7hdynmk6r22z27h6tvur', + 'title' => 'ATProto / Bsky.app handle or DID' + ], + 'feed_filter' => [ + 'name' => 'Feed type', + 'type' => 'list', + 'defaultValue' => 'posts_and_author_threads', + 'values' => [ + 'Posts feed' => 'posts_and_author_threads', + 'All posts and replies' => 'posts_with_replies', + 'Root posts only' => 'posts_no_replies', + 'Media only' => 'posts_with_media', + ] + ], + + 'include_reposts' => [ + 'name' => 'Include Reposts?', + 'type' => 'checkbox', + 'defaultValue' => 'checked' + ], + + 'include_reply_context' => [ + 'name' => 'Include Reply context?', + 'type' => 'checkbox' + ], + + 'verbose_title' => [ + 'name' => 'Use verbose feed item titles?', + 'type' => 'checkbox' + ] + ] + ]; + + private $profile; + + public function getName() + { + if (isset($this->profile)) { + if ($this->profile['handle'] === 'handle.invalid') { + return sprintf('Bluesky - %s', $this->profile['displayName']); + } else { + return sprintf('Bluesky - %s (@%s)', $this->profile['displayName'], $this->profile['handle']); + } + } + return parent::getName(); + } + + public function getURI() + { + if (isset($this->profile)) { + if ($this->profile['handle'] === 'handle.invalid') { + return self::URI . '/profile/' . $this->profile['did']; + } else { + return self::URI . '/profile/' . $this->profile['handle']; + } + } + return parent::getURI(); + } + + public function getIcon() + { + if (isset($this->profile)) { + return $this->profile['avatar']; + } + return parent::getIcon(); + } + + public function getDescription() + { + if (isset($this->profile)) { + return $this->profile['description']; + } + return parent::getDescription(); + } + + private function parseExternal($external, $did) + { + $description = ''; + $externalUri = $external['uri']; + $externalTitle = e($external['title']); + $externalDescription = e($external['description']); + $thumb = $external['thumb'] ?? null; + + if (preg_match('/http(|s):\/\/media\.tenor\.com/', $externalUri)) { + //tenor gif embed + $tenorInterstitial = str_replace('media.tenor.com', 'media1.tenor.com/m', $externalUri); + $description .= "
    $externalTitle
    "; + } else { + //link embed preview + $host = parse_url($externalUri)['host']; + $thumbDesc = $thumb ? ('') : ''; + $externalDescription = strlen($externalDescription) > 0 ? "
    ($host) $externalDescription
    " : ''; + $description .= '
    ' . $externalTitle . ''; + $description .= '
    ' . $thumbDesc . $externalDescription . '
    '; + } + return $description; + } + + private function textToDescription($record) + { + if (isset($record['value'])) { + $record = $record['value']; + } + $text = $record['text']; + $text_copy = $text; + $text = nl2br(e($text)); + if (isset($record['facets'])) { + $facets = $record['facets']; + foreach ($facets as $facet) { + if ($facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link') { + $substring = substr($text_copy, $facet['index']['byteStart'], $facet['index']['byteEnd'] - $facet['index']['byteStart']); + $text = str_replace($substring, '' . $substring . '', $text); + } + } + } + return $text; + } + + public function collectData() + { + $user_id = $this->getInput('user_id'); + $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] + $did_match = preg_match('/did:plc:[a-z2-7]{24}/', $user_id); //https://github.com/did-method-plc/did-method-plc#identifier-syntax + $exclude = ['alt', 'arpa', 'example', 'internal', 'invalid', 'local', 'localhost', 'onion']; //https://en.wikipedia.org/wiki/Top-level_domain#Reserved_domains + if ($handle_match == true && array_search($handle_res[1], $exclude) == false) { + //valid bsky handle + $did = $this->resolveHandle($user_id); + } elseif ($did_match == true) { + //valid DID + $did = $user_id; + } else { + throwClientException('Invalid ATproto handle or DID provided.'); + } + + $filter = $this->getInput('feed_filter') ?: 'posts_and_author_threads'; + $replyContext = $this->getInput('include_reply_context'); + + $this->profile = $this->getProfile($did); + $authorFeed = $this->getAuthorFeed($did, $filter); + + foreach ($authorFeed['feed'] as $post) { + $postRecord = $post['post']['record']; + + $item = []; + $item['uri'] = self::URI . '/profile/' . $this->fallbackAuthor($post['post']['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $post['post']['uri'])[1]; + $item['title'] = $this->getInput('verbose_title') ? $this->generateVerboseTitle($post) : strtok($postRecord['text'], "\n"); + $item['timestamp'] = strtotime($postRecord['createdAt']); + $item['author'] = $this->fallbackAuthor($post['post']['author'], 'display'); + + $postAuthorDID = $post['post']['author']['did']; + $postAuthorHandle = $post['post']['author']['handle'] !== 'handle.invalid' ? '@' . $post['post']['author']['handle'] . ' ' : ''; + $postDisplayName = $post['post']['author']['displayName'] ?? ''; + $postDisplayName = e($postDisplayName); + $postUri = $item['uri']; + + $url = explode('/', $post['post']['uri']); + $this->logger->debug('https://bsky.app/profile/' . $url[2] . '/post/' . $url[4]); + + $description = ''; + $description .= '

    '; + //post + $description .= $this->getPostDescription( + $postDisplayName, + $postAuthorHandle, + $postUri, + $postRecord, + 'post' + ); + + if (isset($postRecord['embed']['$type'])) { + //post link embed + if ($postRecord['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($postRecord['embed']['external'], $postAuthorDID); + } elseif ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.external' + ) { + $description .= $this->parseExternal($postRecord['embed']['media']['external'], $postAuthorDID); + } + + //post images + if ( + $postRecord['embed']['$type'] === 'app.bsky.embed.images' || + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + $images = $post['post']['embed']['images'] ?? $post['post']['embed']['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + + //post video + if ( + $postRecord['embed']['$type'] === 'app.bsky.embed.video' || + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $postRecord['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $postRecord['embed']['video'] ?? $postRecord['embed']['media']['video'], + $postAuthorDID + ); + } + } + $description .= '

    '; + + //quote post + if ( + isset($postRecord['embed']) && + ( + $postRecord['embed']['$type'] === 'app.bsky.embed.record' || + $postRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' + ) && + isset($post['post']['embed']['record']) + ) { + $description .= '

    '; + $quotedRecord = $post['post']['embed']['record']['record'] ?? $post['post']['embed']['record']; + + if (isset($quotedRecord['notFound']) && $quotedRecord['notFound']) { //deleted post + $description .= 'Quoted post deleted.'; + } elseif (isset($quotedRecord['detached']) && $quotedRecord['detached']) { //detached quote + $uri_explode = explode('/', $quotedRecord['uri']); + $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4]; + $description .= 'Quoted post detached.'; + } elseif (isset($quotedRecord['blocked']) && $quotedRecord['blocked']) { //blocked by quote author + $description .= 'Author of quoted post has blocked OP.'; + } elseif ( + ($quotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView' || + ($quotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#listView' + ) { + $description .= $this->getListFeedDescription($quotedRecord); + } elseif ( + ($quotedRecord['$type'] ?? '') === 'app.bsky.graph.starterpack' || + ($quotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#starterPackViewBasic' + ) { + $description .= $this->getStarterPackDescription($post['post']['embed']['record']); + } else { + $quotedAuthorDid = $quotedRecord['author']['did']; + $quotedDisplayName = $quotedRecord['author']['displayName'] ?? ''; + $quotedDisplayName = e($quotedDisplayName); + $quotedAuthorHandle = $quotedRecord['author']['handle'] !== 'handle.invalid' ? '@' . $quotedRecord['author']['handle'] . '' : ''; + + $parts = explode('/', $quotedRecord['uri']); + $quotedPostId = end($parts); + $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($quotedRecord['author'], 'url') . '/post/' . $quotedPostId; + + //quoted post - post + $description .= $this->getPostDescription( + $quotedDisplayName, + $quotedAuthorHandle, + $quotedPostUri, + $quotedRecord, + 'quote' + ); + + if (isset($quotedRecord['value']['embed']['$type'])) { + //quoted post - post link embed + if ($quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($quotedRecord['value']['embed']['external'], $quotedAuthorDid); + } + + //quoted post - post video + if ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' || + ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $quotedRecord['value']['embed']['video'] ?? $quotedRecord['value']['embed']['media']['video'], + $quotedAuthorDid + ); + } + + //quoted post - post images + if ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' || + ( + $quotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $quotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + foreach ($quotedRecord['embeds'] as $embed) { + if ( + $embed['$type'] === 'app.bsky.embed.images#view' || + ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view') + ) { + $images = $embed['images'] ?? $embed['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + } + } + } + } + $description .= '

    '; + } + + //reply + if ($replyContext && isset($post['reply']) && isset($post['reply']['parent'])) { + $replyPost = $post['reply']['parent']; + $description .= '
    '; + $description .= '

    '; + + if (isset($replyPost['notFound']) && $replyPost['notFound']) { //deleted post + $description .= 'Replied to post was deleted.'; + } elseif (isset($replyPost['blocked']) && $replyPost['blocked']) { //blocked by quote author + $description .= 'Author of replied to post has blocked OP.'; + } else { + $replyPostRecord = $replyPost['record']; + $replyPostAuthorDID = $replyPost['author']['did']; + $replyPostAuthorHandle = $replyPost['author']['handle'] !== 'handle.invalid' ? '@' . $replyPost['author']['handle'] . ' ' : ''; + $replyPostDisplayName = $replyPost['author']['displayName'] ?? ''; + $replyPostDisplayName = e($replyPostDisplayName); + $replyPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyPost['author'], 'url') . '/post/' . explode('app.bsky.feed.post/', $replyPost['uri'])[1]; + + // reply post + $description .= $this->getPostDescription( + $replyPostDisplayName, + $replyPostAuthorHandle, + $replyPostUri, + $replyPostRecord, + 'reply' + ); + + if (isset($replyPostRecord['embed']['$type'])) { + //post link embed + if ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($replyPostRecord['embed']['external'], $replyPostAuthorDID); + } elseif ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.external' + ) { + $description .= $this->parseExternal($replyPostRecord['embed']['media']['external'], $replyPostAuthorDID); + } + + //post images + if ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.images' || + ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + $images = $replyPost['embed']['images'] ?? $replyPost['embed']['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + + //post video + if ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.video' || + ( + $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyPostRecord['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $replyPostRecord['embed']['video'] ?? $replyPostRecord['embed']['media']['video'], + $replyPostAuthorDID + ); + } + } + $description .= '

    '; + + //quote post + if ( + isset($replyPostRecord['embed']) && + ($replyPostRecord['embed']['$type'] === 'app.bsky.embed.record' || $replyPostRecord['embed']['$type'] === 'app.bsky.embed.recordWithMedia') && + isset($replyPost['embed']['record']) + ) { + $description .= '

    '; + $replyQuotedRecord = $replyPost['embed']['record']['record'] ?? $replyPost['embed']['record']; + + if (isset($replyQuotedRecord['notFound']) && $replyQuotedRecord['notFound']) { //deleted post + $description .= 'Quoted post deleted.'; + } elseif (isset($replyQuotedRecord['detached']) && $replyQuotedRecord['detached']) { //detached quote + $uri_explode = explode('/', $replyQuotedRecord['uri']); + $uri_reconstructed = self::URI . '/profile/' . $uri_explode[2] . '/post/' . $uri_explode[4]; + $description .= 'Quoted post detached.'; + } elseif (isset($replyQuotedRecord['blocked']) && $replyQuotedRecord['blocked']) { //blocked by quote author + $description .= 'Author of quoted post has blocked OP.'; + } elseif ( + ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.feed.defs#generatorView' || + ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#listView' + ) { + $description .= $this->getListFeedDescription($replyQuotedRecord); + } elseif ( + ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.starterpack' || + ($replyQuotedRecord['$type'] ?? '') === 'app.bsky.graph.defs#starterPackViewBasic' + ) { + $description .= $this->getStarterPackDescription($replyPost['embed']['record']); + } else { + $quotedAuthorDid = $replyQuotedRecord['author']['did']; + $quotedDisplayName = $replyQuotedRecord['author']['displayName'] ?? ''; + $quotedDisplayName = e($quotedDisplayName); + $quotedAuthorHandle = $replyQuotedRecord['author']['handle'] !== 'handle.invalid' ? '@' . $replyQuotedRecord['author']['handle'] . '' : ''; + + $parts = explode('/', $replyQuotedRecord['uri']); + $quotedPostId = end($parts); + $quotedPostUri = self::URI . '/profile/' . $this->fallbackAuthor($replyQuotedRecord['author'], 'url') . '/post/' . $quotedPostId; + + //quoted post - post + $description .= $this->getPostDescription( + $quotedDisplayName, + $quotedAuthorHandle, + $quotedPostUri, + $replyQuotedRecord, + 'quote' + ); + + if (isset($replyQuotedRecord['value']['embed']['$type'])) { + //quoted post - post link embed + if ($replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.external') { + $description .= $this->parseExternal($replyQuotedRecord['value']['embed']['external'], $quotedAuthorDid); + } + + //quoted post - post video + if ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.video' || + ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.video' + ) + ) { + $description .= $this->getPostVideoDescription( + $replyQuotedRecord['value']['embed']['video'] ?? $replyQuotedRecord['value']['embed']['media']['video'], + $quotedAuthorDid + ); + } + + //quoted post - post images + if ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.images' || + ( + $replyQuotedRecord['value']['embed']['$type'] === 'app.bsky.embed.recordWithMedia' && + $replyQuotedRecord['value']['embed']['media']['$type'] === 'app.bsky.embed.images' + ) + ) { + foreach ($replyQuotedRecord['embeds'] as $embed) { + if ( + $embed['$type'] === 'app.bsky.embed.images#view' || + ($embed['$type'] === 'app.bsky.embed.recordWithMedia#view' && $embed['media']['$type'] === 'app.bsky.embed.images#view') + ) { + $images = $embed['images'] ?? $embed['media']['images']; + foreach ($images as $image) { + $description .= $this->getPostImageDescription($image); + } + } + } + } + } + } + $description .= '

    '; + } + } + } + + $item['content'] = $description; + $this->items[] = $item; + } + } + + private function getPostVideoDescription(array $video, $authorDID) + { + //https://video.bsky.app/watch/$did/$cid/thumbnail.jpg + $videoCID = $video['ref']['$link']; + $videoMime = $video['mimeType']; + $thumbnail = "poster=\"https://video.bsky.app/watch/$authorDID/$videoCID/thumbnail.jpg\"" ?? ''; + $videoURL = "https://bsky.social/xrpc/com.atproto.sync.getBlob?did=$authorDID&cid=$videoCID"; + return "
    "; + } + + private function getPostImageDescription(array $image) + { + $thumbnailUrl = $image['thumb']; + $fullsizeUrl = $image['fullsize']; + $alt = strlen($image['alt']) > 0 ? '
    ' . e($image['alt']) . '
    ' : ''; + return "
    $alt
    "; + } + + private function getPostDescription( + string $postDisplayName, + string $postAuthorHandle, + string $postUri, + array $postRecord, + string $type + ) { + $description = ''; + if ($type === 'quote') { + // Quoted post/reply from bbb @bbb.com: + $postType = isset($postRecord['reply']) ? 'reply' : 'post'; + $description .= "Quoted $postType from $postDisplayName $postAuthorHandle:
    "; + } elseif ($type === 'reply') { + // Replying to aaa @aaa.com's post/reply: + $postType = isset($postRecord['reply']) ? 'reply' : 'post'; + $description .= "Replying to $postDisplayName $postAuthorHandle's $postType:
    "; + } else { + // aaa @aaa.com posted: + $description .= "$postDisplayName $postAuthorHandle posted:
    "; + } + $description .= $this->textToDescription($postRecord); + return $description; + } + + //used if handle verification fails, fallsback to displayName or DID depending on context. + private function fallbackAuthor($author, $reason) + { + if ($author['handle'] === 'handle.invalid') { + switch ($reason) { + case 'url': + return $author['did']; + case 'display': + $displayName = $author['displayName'] ?? ''; + return e($displayName); + } + } + return $author['handle']; + } + + private function generateVerboseTitle($post) + { + //use "Post by A, replying to B, quoting C" instead of post contents + $title = ''; + if (isset($post['reason']) && str_contains($post['reason']['$type'], 'reasonRepost')) { + $title .= 'Repost by ' . $this->fallbackAuthor($post['reason']['by'], 'display') . ', post by ' . $this->fallbackAuthor($post['post']['author'], 'display'); + } else { + $title .= 'Post by ' . $this->fallbackAuthor($post['post']['author'], 'display'); + } + + if (isset($post['reply'])) { + if (isset($post['reply']['parent']['blocked'])) { + $replyAuthor = 'blocked user'; + } elseif (isset($post['reply']['parent']['notFound'])) { + $replyAuthor = 'deleted post'; + } else { + $replyAuthor = $this->fallbackAuthor($post['reply']['parent']['author'], 'display'); + } + $title .= ', replying to ' . $replyAuthor; + } + + if ( + isset($post['post']['embed']) && + isset($post['post']['embed']['record']) && + //if not starter pack, feed or list + ($post['post']['embed']['record']['$type'] ?? '') !== 'app.bsky.feed.defs#generatorView' && + ($post['post']['embed']['record']['$type'] ?? '') !== 'app.bsky.graph.defs#listView' && + ($post['post']['embed']['record']['$type'] ?? '') !== 'app.bsky.graph.defs#starterPackViewBasic' + ) { + if (isset($post['post']['embed']['record']['blocked'])) { + $quotedAuthor = 'blocked user'; + } elseif (isset($post['post']['embed']['record']['notFound'])) { + $quotedAuthor = 'deleted psost'; + } elseif (isset($post['post']['embed']['record']['detached'])) { + $quotedAuthor = 'detached post'; + } else { + $quotedAuthor = $this->fallbackAuthor($post['post']['embed']['record']['record']['author'] ?? $post['post']['embed']['record']['author'], 'display'); + } + $title .= ', quoting ' . $quotedAuthor; + } + return $title; + } + + private function resolveHandle($handle) + { + $uri = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=' . urlencode($handle); + $response = json_decode(getContents($uri), true); + return $response['did']; + } + + private function getProfile($did) + { + $uri = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=' . urlencode($did); + $response = json_decode(getContents($uri), true); + return $response; + } + + private function getAuthorFeed($did, $filter) + { + $uri = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=' . urlencode($did) . '&filter=' . urlencode($filter) . '&limit=30'; + + $this->logger->debug($uri); + + $response = json_decode(getContents($uri), true); + return $response; + } + + //Embed for generated feeds and lists + private function getListFeedDescription(array $record): string + { + $feedViewAvatar = isset($record['avatar']) ? '' : ''; + $feedViewName = e($record['displayName'] ?? $record['name']); + $feedViewDescription = e($record['description'] ?? ''); + $authorDisplayName = e($record['creator']['displayName']); + $authorHandle = e($record['creator']['handle']); + $likeCount = isset($record['likeCount']) ? '
    Liked by ' . e($record['likeCount']) . ' users' : ''; + preg_match('/\/([^\/]+)$/', $record['uri'], $matches); + if (($record['purpose'] ?? '') === 'app.bsky.graph.defs#modlist') { + $typeURL = '/lists/'; + $typeDesc = 'moderation list'; + } elseif (($record['purpose'] ?? '') === 'app.bsky.graph.defs#curatelist') { + $typeURL = '/lists/'; + $typeDesc = 'list'; + } else { + $typeURL = '/feed/'; + $typeDesc = 'feed'; + } + $uri = e('https://bsky.app/profile/' . $record['creator']['did'] . $typeURL . $matches[1]); + + return << +{$feedViewName}
    +Bluesky {$typeDesc} by {$authorDisplayName} @{$authorHandle} +
    +{$feedViewAvatar} +
    {$feedViewDescription}{$likeCount}
    +
    + +END; + } + + private function getStarterPackDescription(array $record): string + { + if (!isset($record['record'])) { + return 'Failed to get starter pack information.'; + } + $starterpackRecord = $record['record']; + $starterpackName = e($starterpackRecord['name']); + $starterpackDescription = e($starterpackRecord['description']); + $creatorDisplayName = e($record['creator']['displayName']); + $creatorHandle = e($record['creator']['handle']); + preg_match('/\/([^\/]+)$/', $starterpackRecord['list'], $matches); + $uri = e('https://bsky.app/starter-pack/' . $record['creator']['did'] . '/' . $matches[1]); + return << +{$starterpackName}
    +Bluesky starter pack by {$creatorDisplayName} @{$creatorHandle}
    +{$starterpackDescription} + +END; + } +} diff --git a/bridges/BodaccBridge.php b/bridges/BodaccBridge.php new file mode 100644 index 00000000..38e5856a --- /dev/null +++ b/bridges/BodaccBridge.php @@ -0,0 +1,218 @@ + [ + 'departement' => [ + 'name' => 'Département', + 'type' => 'list', + 'values' => [ + 'Tous' => null, + 'Ain' => '01', + 'Aisne' => '02', + 'Allier' => '03', + 'Alpes-de-Haute-Provence' => '04', + 'Hautes-Alpes' => '05', + 'Alpes-Maritimes' => '06', + 'Ardèche' => '07', + 'Ardennes' => '08', + 'Ariège' => '09', + 'Aube' => '10', + 'Aude' => '11', + 'Aveyron' => '12', + 'Bouches-du-Rhône' => '13', + 'Calvados' => '14', + 'Cantal' => '15', + 'Charente' => '16', + 'Charente-Maritime' => '17', + 'Cher' => '18', + 'Corrèze' => '19', + 'Corse-du-Sud' => '2A', + 'Haute-Corse' => '2B', + 'Côte-d\'Or' => '21', + 'Côtes-d\'Armor' => '22', + 'Creuse' => '23', + 'Dordogne' => '24', + 'Doubs' => '25', + 'Drôme' => '26', + 'Eure' => '27', + 'Eure-et-Loir' => '28', + 'Finistère' => '29', + 'Gard' => '30', + 'Haute-Garonne' => '31', + 'Gers' => '32', + 'Gironde' => '33', + 'Hérault' => '34', + 'Ille-et-Vilaine' => '35', + 'Indre' => '36', + 'Indre-et-Loire' => '37', + 'Isère' => '38', + 'Jura' => '39', + 'Landes' => '40', + 'Loir-et-Cher' => '41', + 'Loire' => '42', + 'Haute-Loire' => '43', + 'Loire-Atlantique' => '44', + 'Loiret' => '45', + 'Lot' => '46', + 'Lot-et-Garonne' => '47', + 'Lozère' => '48', + 'Maine-et-Loire' => '49', + 'Manche' => '50', + 'Marne' => '51', + 'Haute-Marne' => '52', + 'Mayenne' => '53', + 'Meurthe-et-Moselle' => '54', + 'Meuse' => '55', + 'Morbihan' => '56', + 'Moselle' => '57', + 'Nièvre' => '58', + 'Nord' => '59', + 'Oise' => '60', + 'Orne' => '61', + 'Pas-de-Calais' => '62', + 'Puy-de-Dôme' => '63', + 'Pyrénées-Atlantiques' => '64', + 'Hautes-Pyrénées' => '65', + 'Pyrénées-Orientales' => '66', + 'Bas-Rhin' => '67', + 'Haut-Rhin' => '68', + 'Rhône' => '69', + 'Haute-Saône' => '70', + 'Saône-et-Loire' => '71', + 'Sarthe' => '72', + 'Savoie' => '73', + 'Haute-Savoie' => '74', + 'Paris' => '75', + 'Seine-Maritime' => '76', + 'Seine-et-Marne' => '77', + 'Yvelines' => '78', + 'Deux-Sèvres' => '79', + 'Somme' => '80', + 'Tarn' => '81', + 'Tarn-et-Garonne' => '82', + 'Var' => '83', + 'Vaucluse' => '84', + 'Vendée' => '85', + 'Vienne' => '86', + 'Haute-Vienne' => '87', + 'Vosges' => '88', + 'Yonne' => '89', + 'Territoire de Belfort' => '90', + 'Essonne' => '91', + 'Hauts-de-Seine' => '92', + 'Seine-Saint-Denis' => '93', + 'Val-de-Marne' => '94', + 'Val-d\'Oise' => '95', + 'Guadeloupe' => '971', + 'Martinique' => '972', + 'Guyane' => '973', + 'La Réunion' => '974', + 'Saint-Pierre-et-Miquelon' => '975', + 'Mayotte' => '976', + 'Saint-Barthélemy' => '977', + 'Saint-Martin' => '978', + 'Terres australes et antarctiques françaises' => '984', + 'Wallis-et-Futuna' => '986', + 'Polynésie française' => '987', + 'Nouvelle-Calédonie' => '988', + 'Île de Clipperton' => '989' + ] + ], + 'famille' => [ + 'name' => 'Famille', + 'type' => 'list', + 'values' => [ + 'Toutes' => null, + 'Annonces diverses' => 'divers', + 'Créations' => 'creation', + 'Dépôts des comptes' => 'dpc', + 'Immatriculations' => 'immatriculation', + 'Modifications diverses' => 'modification', + 'Procédures collectives' => 'collective', + 'Procédures de conciliation' => 'conciliation', + 'Procédures de rétablissement professionnel' => 'retablissement_professionnel', + 'Radiations' => 'radiation', + 'Ventes et cessions' => 'vente' + ] + ], + 'type' => [ + 'name' => 'Type', + 'type' => 'list', + 'values' => [ + 'Tous' => null, + 'Avis initial' => 'annonce', + 'Avis d\'annulation' => 'annulation', + 'Avis rectificatif' => 'rectificatif' + ] + ] + ] + ]; + + public function collectData() + { + $parameters = [ + 'select' => 'id,dateparution,typeavis_lib,familleavis_lib,commercant,ville,cp', + 'order_by' => 'id desc', + 'limit' => 50, + ]; + + $where = []; + if (!empty($this->getInput('departement'))) { + $where[] = 'numerodepartement="' . $this->getInput('departement') . '"'; + } + + if (!empty($this->getInput('famille'))) { + $where[] = 'familleavis="' . $this->getInput('famille') . '"'; + } + + if (!empty($this->getInput('type'))) { + $where[] = 'typeavis="' . $this->getInput('type') . '"'; + } + + if ($where !== []) { + $parameters['where'] = implode(' and ', $where); + } + + $url = urljoin(self::URI, '/api/explore/v2.1/catalog/datasets/annonces-commerciales/records?' . http_build_query($parameters)); + + $data = Json::decode(getContents($url), false); + + foreach ($data->results as $result) { + if ( + !isset( + $result->id, + $result->dateparution, + $result->typeavis_lib, + $result->familleavis_lib, + $result->commercant, + $result->ville, + $result->cp + ) + ) { + continue; + } + + $title = sprintf( + '[%s] %s - %s à %s (%s)', + $result->typeavis_lib, + $result->familleavis_lib, + $result->commercant, + $result->ville, + $result->cp + ); + + $this->items[] = [ + 'uid' => $result->id, + 'timestamp' => strtotime($result->dateparution), + 'title' => $title, + ]; + } + } +} diff --git a/bridges/BookMyShowBridge.php b/bridges/BookMyShowBridge.php index 7064df91..6ad02fe2 100644 --- a/bridges/BookMyShowBridge.php +++ b/bridges/BookMyShowBridge.php @@ -1218,14 +1218,15 @@ EOT; $table = $this->generateEventDetailsTable($event); $imgsrc = $event['BannerURL']; + $FShareURL = $event['FShareURL']; return << -
    - $table -
    - More Details are available on the BookMyShow website. -EOT; + +
    + $table +
    + More Details are available on the BookMyShow website. + EOT; } /** @@ -1292,14 +1293,15 @@ EOT; $synopsis = preg_replace(self::SYNOPSIS_REGEX, '', $data['EventSynopsis']); + $eventTrailerURL = $data['EventTrailerURL']; return << -
    $table
    -

    $innerHtml

    -

    ${synopsis}

    - More Details are available on the BookMyShow website and a trailer is available - here -EOT; + +
    $table
    +

    $innerHtml

    +

    $synopsis

    + More Details are available on the BookMyShow website and a trailer is available + here + EOT; } /** diff --git a/bridges/BruegelBridge.php b/bridges/BruegelBridge.php new file mode 100644 index 00000000..b7813dbc --- /dev/null +++ b/bridges/BruegelBridge.php @@ -0,0 +1,63 @@ + [ + 'name' => 'Category', + 'type' => 'list', + 'defaultValue' => '/publications', + 'values' => [ + 'Publications' => '/publications', + 'Commentary' => '/commentary' + ] + ] + ] + ]; + + public function getIcon() + { + return self::URI . '/themes/custom/bruegel/assets/favicon/android-icon-72x72.png'; + } + + public function collectData() + { + $url = self::URI . $this->getInput('category'); + $html = getSimpleHTMLDOM($url); + + $articles = $html->find('.c-listing__content article'); + + foreach ($articles as $article) { + $title = $article->find('.c-list-item__title a span', 0)->plaintext; + $content = trim($article->find('.c-list-item__description', 0)->plaintext); + $publishDate = $article->find('.c-list-item__date', 0)->plaintext; + $href = $article->find('.c-list-item__title a', 0)->getAttribute('href'); + + $item = [ + 'title' => $title, + 'content' => $content, + 'timestamp' => strtotime($publishDate), + 'uri' => self::URI . $href, + 'author' => $this->getAuthor($article), + ]; + + $this->items[] = $item; + } + } + + private function getAuthor($article) + { + $authorsElements = $article->find('.c-list-item__authors a'); + + $authors = array_map(function ($author) { + return $author->plaintext; + }, $authorsElements); + + return join(', ', $authors); + } +} \ No newline at end of file diff --git a/bridges/BugzillaBridge.php b/bridges/BugzillaBridge.php index c2dc8d40..29112918 100644 --- a/bridges/BugzillaBridge.php +++ b/bridges/BugzillaBridge.php @@ -98,7 +98,7 @@ class BugzillaBridge extends BridgeAbstract // Array of comments is here if (!isset($json['bugs'][$this->bugid]['comments'])) { - returnClientError('Cannot find REST endpoint'); + throwClientException('Cannot find REST endpoint'); } foreach ($json['bugs'][$this->bugid]['comments'] as $comment) { @@ -131,7 +131,7 @@ class BugzillaBridge extends BridgeAbstract // Array of changesets which contain an array of changes if (!isset($json['bugs']['0']['history'])) { - returnClientError('Cannot find REST endpoint'); + throwClientException('Cannot find REST endpoint'); } foreach ($json['bugs']['0']['history'] as $changeset) { @@ -164,7 +164,7 @@ class BugzillaBridge extends BridgeAbstract } $cache = $this->loadCacheValue($this->instance . $user); - if (!is_null($cache)) { + if ($cache) { return $cache; } diff --git a/bridges/BukowskisBridge.php b/bridges/BukowskisBridge.php index 14889889..3573c206 100644 --- a/bridges/BukowskisBridge.php +++ b/bridges/BukowskisBridge.php @@ -206,7 +206,7 @@ class BukowskisBridge extends BridgeAbstract $this->items[] = [ 'title' => $title, 'uri' => $baseUrl . $relative_url, - 'uid' => $lot->getAttribute('data-lot-id'), + 'uid' => $relative_url, 'content' => count($images) > 0 ? "
    $title" : $title, 'enclosures' => array_slice($images, 1), ]; diff --git a/bridges/BundestagParteispendenBridge.php b/bridges/BundestagParteispendenBridge.php index cdf398e8..198cf534 100644 --- a/bridges/BundestagParteispendenBridge.php +++ b/bridges/BundestagParteispendenBridge.php @@ -26,21 +26,19 @@ TMPL; https://www.bundestag.de/ajax/filterlist/de/parlament/praesidium/parteienfinanzierung/fundstellen50000/462002-462002 URI; // Get the main page - $html = getSimpleHTMLDOMCached($ajaxUri, self::CACHE_TIMEOUT) - or returnServerError('Could not request AJAX list.'); + $html = getSimpleHTMLDOMCached($ajaxUri, self::CACHE_TIMEOUT); // Build the URL from the first anchor element. The list is sorted by year, descending, so the first element is the current year. $firstAnchor = $html->find('a', 0) - or returnServerError('Could not find the proper HTML element.'); + or throwServerException('Could not find the proper HTML element.'); - $url = 'https://www.bundestag.de' . $firstAnchor->href; + $url = $firstAnchor->href; // Get the actual page with the soft money donations - $html = getSimpleHTMLDOMCached($url, self::CACHE_TIMEOUT) - or returnServerError('Could not request ' . $url); + $html = getSimpleHTMLDOMCached($url, self::CACHE_TIMEOUT); $rows = $html->find('table.table > tbody > tr') - or returnServerError('Could not find the proper HTML elements.'); + or throwServerException('Could not find the proper HTML elements.'); foreach ($rows as $row) { $item = $this->generateItemFromRow($row); diff --git a/bridges/CNETBridge.php b/bridges/CNETBridge.php index 4a63c847..77339c7c 100644 --- a/bridges/CNETBridge.php +++ b/bridges/CNETBridge.php @@ -50,13 +50,13 @@ class CNETBridge extends SitemapBridge } if (empty($links)) { - returnClientError('Failed to retrieve article list'); + throwClientException('Failed to retrieve article list'); } foreach ($links as $article_uri) { $article_dom = convertLazyLoading(getSimpleHTMLDOMCached($article_uri)); $title = trim($article_dom->find('h1', 0)->plaintext); - $author = $article_dom->find('span.c-assetAuthor_name', 0)->plaintext; + $author = $article_dom->find('span.c-assetAuthor_name', 0); $headline = $article_dom->find('p.c-contentHeader_description', 0); $content = $article_dom->find('div.c-pageArticle_content, div.single-article__content, div.article-main-body', 0); $date = null; @@ -97,7 +97,11 @@ class CNETBridge extends SitemapBridge $item = []; $item['uri'] = $article_uri; $item['title'] = $title; - $item['author'] = $author; + + if ($author) { + $item['author'] = $author->plaintext; + } + $item['content'] = $content; if (!is_null($date)) { diff --git a/bridges/CVEDetailsBridge.php b/bridges/CVEDetailsBridge.php index b52d290e..776f675a 100644 --- a/bridges/CVEDetailsBridge.php +++ b/bridges/CVEDetailsBridge.php @@ -42,45 +42,23 @@ class CVEDetailsBridge extends BridgeAbstract $this->fetchContent(); } - foreach ($this->html->find('#searchresults > .row') as $i => $tr) { - // There are some optional vulnerability types, which will be - // added to the categories as well as the CWE number -- which is - // always given. - $categories = [$this->vendor]; - $enclosures = []; - - $detailLink = $tr->find('h3 > a', 0); - $detailHtml = getSimpleHTMLDOM($detailLink->href); - - // The CVE number itself + $var = $this->html->find('#searchresults > div > div.row'); + foreach ($var as $i => $tr) { + $uri = $tr->find('h3 > a', 0)->href ?? null; $title = $tr->find('h3 > a', 0)->innertext; - $content = $tr->find('.cvesummarylong', 0)->innertext; - $cweList = $detailHtml->find('h2', 2)->next_sibling(); - foreach ($cweList->find('li') as $li) { - $cweWithDescription = $li->find('a', 0)->innertext ?? ''; - - if (preg_match('/CWE-(\d+)/', $cweWithDescription, $cwe)) { - $categories[] = 'CWE-' . $cwe[1]; - $enclosures[] = 'https://cwe.mitre.org/data/definitions/' . $cwe[1] . '.html'; - } - } - - if ($this->product != '') { - $categories[] = $this->product; - } + $content = $tr->find('.cvesummarylong', 0)->innertext ?? ''; + $timestamp = $tr->find('[data-tsvfield="publishDate"]', 0)->innertext ?? 0; $this->items[] = [ - 'uri' => 'https://cvedetails.com/' . $detailHtml->find('h1 > a', 0)->href, + 'uri' => $uri, 'title' => $title, - 'timestamp' => $tr->find('[data-tsvfield="publishDate"]', 0)->innertext, + 'timestamp' => $timestamp, 'content' => $content, - 'categories' => $categories, - 'enclosures' => $enclosures, + 'categories' => [$this->vendor], + 'enclosures' => [], 'uid' => $title, ]; - - // We only want to fetch the latest 10 CVEs - if (count($this->items) >= 10) { + if (count($this->items) >= 30) { break; } } @@ -109,7 +87,7 @@ class CVEDetailsBridge extends BridgeAbstract $vendor = $html->find('#contentdiv h1 > a', 0); if ($vendor == null) { - returnServerError('Invalid Vendor ID ' . $this->getInput('vendor_id') . ' or Product ID ' . $this->getInput('product_id')); + throwServerException('Invalid Vendor ID ' . $this->getInput('vendor_id') . ' or Product ID ' . $this->getInput('product_id')); } $this->vendor = $vendor->innertext; diff --git a/bridges/CachetBridge.php b/bridges/CachetBridge.php index 355e7926..7ee48a5e 100644 --- a/bridges/CachetBridge.php +++ b/bridges/CachetBridge.php @@ -72,14 +72,14 @@ class CachetBridge extends BridgeAbstract { $ping = getContents(urljoin($this->getURI(), '/api/v1/ping')); if (!$this->validatePing($ping)) { - returnClientError('Provided URI is invalid!'); + throwClientException('Provided URI is invalid!'); } $url = urljoin($this->getURI(), '/api/v1/incidents?sort=id&order=desc'); $incidents = getContents($url); $incidents = json_decode($incidents); if ($incidents === null) { - returnClientError('/api/v1/incidents returned no valid json'); + throwClientException('/api/v1/incidents returned no valid json'); } usort($incidents->data, function ($a, $b) { diff --git a/bridges/CarThrottleBridge.php b/bridges/CarThrottleBridge.php index 70d7b54e..8475a414 100644 --- a/bridges/CarThrottleBridge.php +++ b/bridges/CarThrottleBridge.php @@ -6,46 +6,113 @@ class CarThrottleBridge extends BridgeAbstract const URI = 'https://www.carthrottle.com/'; const DESCRIPTION = 'Get the latest car-related news from Car Throttle.'; const MAINTAINER = 't0stiman'; + const DONATION_URI = 'https://ko-fi.com/tostiman'; + + const PARAMETERS = [ + 'Show articles from these categories:' => [ + 'news' => [ + 'name' => 'news', + 'type' => 'checkbox' + ], + 'reviews' => [ + 'name' => 'reviews', + 'type' => 'checkbox' + ], + 'features' => [ + 'name' => 'features', + 'type' => 'checkbox' + ], + 'videos' => [ + 'name' => 'videos', + 'type' => 'checkbox' + ], + 'gaming' => [ + 'name' => 'gaming', + 'type' => 'checkbox' + ] + ] + ]; public function collectData() { - $news = getSimpleHTMLDOMCached(self::URI . 'news'); + $this->items = []; - $this->items[] = []; + $this->handleCategory('news'); + $this->handleCategory('reviews'); + $this->handleCategory('features'); + $this->handleCategory2('videos', 'video'); + $this->handleCategory('gaming'); + } + + private function handleCategory($category) + { + if ($this->getInput($category)) { + $this->getArticles($category); + } + } + + private function handleCategory2($categoryParameter, $categoryURLname) + { + if ($this->getInput($categoryParameter)) { + $this->getArticles($categoryURLname); + } + } + + private function getArticles($category) + { + $categoryPage = getSimpleHTMLDOMCached(self::URI . $category); //for each post - foreach ($news->find('div.cmg-card') as $post) { + foreach ($categoryPage->find('div.cmg-card') as $post) { $item = []; - $titleElement = $post->find('div.title a.cmg-link')[0]; - $item['uri'] = self::URI . $titleElement->getAttribute('href'); + $titleElement = $post->find('a.title')[0]; + $post_uri = self::URI . $titleElement->getAttribute('href'); + + if (!isset($post_uri) || $post_uri == '') { + continue; + } + + $item['uri'] = $post_uri; $item['title'] = $titleElement->innertext; $articlePage = getSimpleHTMLDOMCached($item['uri']); - $authorDiv = $articlePage->find('div.author div'); - if ($authorDiv) { - $item['author'] = $authorDiv[1]->innertext; - } + $item['author'] = $this->parseAuthor($articlePage); + + $articleImage = $articlePage->find('figure')[0]; + $article = $articlePage->find('div.first-column div.body')[0]; - $dinges = $articlePage->find('div.main-body')[0] ?? null; //remove ads - if ($dinges) { - foreach ($dinges->find('aside') as $ad) { - $ad->outertext = ''; - $dinges->save(); - } + foreach ($article->find('aside') as $ad) { + $ad->outertext = ''; } - $var = $articlePage->find('div.summary')[0] ?? ''; - $var1 = $articlePage->find('figure.main-image')[0] ?? ''; - $dinges1 = $dinges ?? ''; + $summary = $articlePage->find('div.summary')[0]; - $item['content'] = $var . - $var1 . - $dinges1; + //these are supposed to be hidden + foreach ($article->find('.visually-hidden') as $found) { + $found->outertext = ''; + } + + $item['content'] = $summary . $articleImage . $article; array_push($this->items, $item); } } + + private function parseAuthor($articlePage) + { + $authorDivs = $articlePage->find('div address'); + if (!$authorDivs) { + return ''; + } + + $a = $authorDivs[0]->find('a')[0]; + if ($a) { + return $a->innertext; + } + + return $authorDivs[0]->innertext; + } } diff --git a/bridges/CaschyBridge.php b/bridges/CaschyBridge.php index 0e3a07bc..c25cdb08 100644 --- a/bridges/CaschyBridge.php +++ b/bridges/CaschyBridge.php @@ -54,7 +54,7 @@ class CaschyBridge extends FeedExpander { // remove unwanted stuff foreach ( - $article->find('div.video-container, div.aawp, p.aawp-disclaimer, iframe.wp-embedded-content, + $article->find('div.aawp, p.aawp-disclaimer, iframe.wp-embedded-content, div.wp-embed, p.wp-caption-text, script') as $element ) { $element->remove(); diff --git a/bridges/CastorusBridge.php b/bridges/CastorusBridge.php index a0a1454e..43ca5c37 100644 --- a/bridges/CastorusBridge.php +++ b/bridges/CastorusBridge.php @@ -36,7 +36,7 @@ class CastorusBridge extends BridgeAbstract $title = $activity->find('a', 0); if (!$title) { - returnServerError('Cannot find title!'); + throwServerException('Cannot find title!'); } return trim($title->plaintext); @@ -48,7 +48,7 @@ class CastorusBridge extends BridgeAbstract $url = $activity->find('a', 0); if (!$url) { - returnServerError('Cannot find url!'); + throwServerException('Cannot find url!'); } return self::URI . $url->href; @@ -62,7 +62,7 @@ class CastorusBridge extends BridgeAbstract $nodes = $activity->find('*'); if (!$nodes) { - returnServerError('Cannot find nodes!'); + throwServerException('Cannot find nodes!'); } foreach ($nodes as $node) { @@ -78,7 +78,7 @@ class CastorusBridge extends BridgeAbstract $price = $activity->find('span', 1); if (!$price) { - returnServerError('Cannot find price!'); + throwServerException('Cannot find price!'); } return $price->innertext; @@ -92,13 +92,13 @@ class CastorusBridge extends BridgeAbstract $html = getSimpleHTMLDOM(self::URI); if (!$html) { - returnServerError('Could not load data from ' . self::URI . '!'); + throwServerException('Could not load data from ' . self::URI . '!'); } $activities = $html->find('div#activite > li'); if (!$activities) { - returnServerError('Failed to find activities!'); + throwServerException('Failed to find activities!'); } foreach ($activities as $activity) { diff --git a/bridges/CentreFranceBridge.php b/bridges/CentreFranceBridge.php new file mode 100644 index 00000000..9f30b44a --- /dev/null +++ b/bridges/CentreFranceBridge.php @@ -0,0 +1,268 @@ + [ + 'newspaper' => [ + 'name' => 'Newspaper', + 'type' => 'list', + 'values' => [ + 'La Montagne' => 'lamontagne.fr', + 'Le Populaire du Centre' => 'lepopulaire.fr', + 'La République du Centre' => 'larep.fr', + 'Le Berry Républicain' => 'leberry.fr', + 'L\'Yonne Républicaine' => 'lyonne.fr', + 'L\'Écho Républicain' => 'lechorepublicain.fr', + 'Le Journal du Centre' => 'lejdc.fr', + 'L\'Éveil de la Haute-Loire' => 'leveil.fr', + 'Le Pays' => 'le-pays.fr' + ] + ], + 'remove-reserved-for-subscribers-articles' => [ + 'name' => 'Remove reserved for subscribers articles', + 'type' => 'checkbox', + 'title' => 'Filter out articles that are only available to subscribers' + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'title' => 'How many articles to fetch. 0 to disable.', + 'required' => true, + 'defaultValue' => 15 + ] + ], + 'Local news' => [ + 'locality-slug' => [ + 'name' => 'Locality slug', + 'type' => 'text', + 'required' => false, + 'title' => 'Fetch articles for a specific locality. If not set, headlines from the front page will be used instead.', + 'exampleValue' => 'moulins-03000' + ], + ] + ]; + + private static array $monthNumberByFrenchName = [ + 'janvier' => 1, 'février' => 2, 'mars' => 3, 'avril' => 4, 'mai' => 5, 'juin' => 6, 'juillet' => 7, + 'août' => 8, 'septembre' => 9, 'octobre' => 10, 'novembre' => 11, 'décembre' => 12 + ]; + + public function collectData() + { + $value = $this->getInput('limit'); + if (is_numeric($value) && (int)$value >= 0) { + $limit = $value; + } else { + $limit = static::PARAMETERS['global']['limit']['defaultValue']; + } + + if (empty($this->getInput('newspaper'))) { + return; + } + + $localitySlug = $this->getInput('locality-slug') ?? ''; + $alreadyFoundArticlesURIs = []; + + $newspaperUrl = 'https://www.' . $this->getInput('newspaper') . '/' . $localitySlug . '/'; + $html = getSimpleHTMLDOM($newspaperUrl); + + // Articles are detected through a standard tag + foreach ($html->find('article') as $articleDOMElement) { + $articleLinkDOMElement = $articleDOMElement->find('a', 0); + $articleURI = $articleLinkDOMElement->href; + + // If the URI has already been processed, ignore it + if (in_array($articleURI, $alreadyFoundArticlesURIs, true)) { + continue; + } + + // If news are filtered for a specific locality, filter out article for other localities + if ($localitySlug !== '' && !str_contains($articleURI, $localitySlug)) { + continue; + } + + $articleTitle = ''; + + // If article is reserved for subscribers + if ($articleLinkDOMElement->find('span.premium-icon', 0)) { + if ($this->getInput('remove-reserved-for-subscribers-articles') === true) { + continue; + } + + $articleTitle .= '🔒 '; + } + + if ($limit > 0 && count($this->items) === $limit) { + break; + } + + // Loop through each possible title class name + for ($i = 1; $i <= 3; $i++) { + $articleTitleDOMElement = $articleLinkDOMElement->find('.typo-card-title-' . $i, 0); + if (!$articleTitleDOMElement instanceof \simple_html_dom_node) { + continue; + } + + $articleTitle .= $articleTitleDOMElement->text(); + break; + } + + $articleFullURI = urljoin('https://www.' . $this->getInput('newspaper') . '/', $articleURI); + $item = [ + 'title' => $articleTitle, + 'uri' => $articleFullURI, + ...$this->collectArticleData($articleFullURI) + ]; + $this->items[] = $item; + + $alreadyFoundArticlesURIs[] = $articleURI; + } + } + + private function collectArticleData($uri): array + { + $html = getSimpleHTMLDOMCached($uri, 86400 * 90); // 90d + + $item = [ + 'enclosures' => [], + ]; + + $articleInformations = $html->find('#content hgroup > div.typo-p3 > *'); + if (is_array($articleInformations) && $articleInformations !== []) { + $publicationDateIndex = 0; + + // Article author + $probableAuthorName = strip_tags($articleInformations[0]->innertext); + if (str_starts_with($probableAuthorName, 'Par ')) { + $publicationDateIndex = 1; + $item['author'] = substr($probableAuthorName, 4); + } + + // Article publication date + preg_match('/Publié le (\d{2}) (.+) (\d{4})( à (\d{2})h(\d{2}))?/', strip_tags($articleInformations[$publicationDateIndex]->innertext), $articleDateParts); + if ($articleDateParts !== [] && array_key_exists($articleDateParts[2], self::$monthNumberByFrenchName)) { + $articleDate = new \DateTime('midnight'); + $articleDate->setDate($articleDateParts[3], self::$monthNumberByFrenchName[$articleDateParts[2]], $articleDateParts[1]); + + if (count($articleDateParts) === 7) { + $articleDate->setTime($articleDateParts[5], $articleDateParts[6]); + } + + $item['timestamp'] = $articleDate->getTimestamp(); + } + } + + $articleContent = $html->find('#content>div.flex+div.grid section>.z-10')[0] ?? null; + if ($articleContent instanceof \simple_html_dom_node) { + $articleHiddenParts = $articleContent->find('.ad-slot, #cf-digiteka-player'); + if (is_array($articleHiddenParts)) { + foreach ($articleHiddenParts as $articleHiddenPart) { + $articleContent->removeChild($articleHiddenPart); + } + } + + $item['content'] = $articleContent->innertext; + } + + $articleIllustration = $html->find('#content>div.flex+div.grid section>figure>img'); + if (is_array($articleIllustration) && count($articleIllustration) === 1) { + $item['enclosures'][] = $articleIllustration[0]->getAttribute('src'); + } + + $articleAudio = $html->find('audio[src^="https://api.octopus.saooti.com/"]'); + if (is_array($articleAudio) && count($articleAudio) === 1) { + $item['enclosures'][] = $articleAudio[0]->getAttribute('src'); + } + + $articleTags = $html->find('#content>div.flex+div.grid section>.bg-gray-light>a.border-gray-dark'); + if (is_array($articleTags)) { + $item['categories'] = array_map(static fn ($articleTag) => html_entity_decode($articleTag->innertext), $articleTags); + } + + $explode = explode('_', $uri); + $array_reverse = array_reverse($explode); + $string = $array_reverse[0]; + $uid = rtrim($string, '/'); + if (is_numeric($uid)) { + $item['uid'] = $uid; + } + + if (!isset($item['content'])) { + $item['content'] = ''; + } + + // If the article is a "grand format", we use another parsing strategy + if ($item['content'] === '' && $html->find('article') !== []) { + $articleContent = $html->find('article > section'); + foreach ($articleContent as $contentPart) { + if ($contentPart->find('#journo') !== []) { + $item['author'] = $contentPart->find('#journo')->innertext; + continue; + } + + $item['content'] .= $contentPart->innertext; + } + } + + $item['content'] = str_replace('premium', '🔒', $item['content']); + $item['content'] = trim($item['content']); + + return $item; + } + + public function getName() + { + if (empty($this->getInput('newspaper'))) { + return static::NAME; + } + + $newspaperNameByDomain = array_flip(self::PARAMETERS['global']['newspaper']['values']); + if (!isset($newspaperNameByDomain[$this->getInput('newspaper')])) { + return static::NAME; + } + + $completeTitle = $newspaperNameByDomain[$this->getInput('newspaper')]; + + if (!empty($this->getInput('locality-slug'))) { + $localityName = explode('-', $this->getInput('locality-slug')); + array_pop($localityName); + $completeTitle .= ' ' . ucfirst(implode('-', $localityName)); + } + + return $completeTitle; + } + + public function getIcon() + { + if (empty($this->getInput('newspaper'))) { + return static::URI . '/favicon.ico'; + } + + return 'https://www.' . $this->getInput('newspaper') . '/favicon.ico'; + } + + public function detectParameters($url) + { + $regex = '/^(https?:\/\/)?(www\.)?([a-z-]+\.fr)(\/)?([a-z-]+-[0-9]{5})?(\/)?$/'; + $url = strtolower($url); + + if (preg_match($regex, $url, $urlMatches) === 0) { + return null; + } + + if (!in_array($urlMatches[3], self::PARAMETERS['global']['newspaper']['values'], true)) { + return null; + } + + return [ + 'newspaper' => $urlMatches[3], + 'locality-slug' => empty($urlMatches[5]) ? null : $urlMatches[5] + ]; + } +} diff --git a/bridges/CeskaTelevizeBridge.php b/bridges/CeskaTelevizeBridge.php index be00d664..ab58eeee 100644 --- a/bridges/CeskaTelevizeBridge.php +++ b/bridges/CeskaTelevizeBridge.php @@ -18,32 +18,13 @@ class CeskaTelevizeBridge extends BridgeAbstract ] ]; - private function fixChars($text) - { - return html_entity_decode($text, ENT_QUOTES, 'UTF-8'); - } - - private function getUploadTimeFromString($string) - { - if (strpos($string, 'dnes') !== false) { - return strtotime('today'); - } elseif (strpos($string, 'včera') !== false) { - return strtotime('yesterday'); - } elseif (!preg_match('/(\d+).\s(\d+).(\s(\d+))?/', $string, $match)) { - returnServerError('Could not get date from Česká televize string'); - } - - $date = sprintf('%04d-%02d-%02d', $match[3] ?? date('Y'), $match[2], $match[1]); - return strtotime($date); - } - public function collectData() { $url = $this->getInput('url'); $validUrl = '/^(https:\/\/www\.ceskatelevize\.cz\/porady\/\d+-[a-z0-9-]+\/)(bonus\/)?$/'; if (!preg_match($validUrl, $url, $match)) { - returnServerError('Invalid url'); + throwServerException('Invalid url'); } $category = $match[4] ?? 'nove'; @@ -58,24 +39,42 @@ class CeskaTelevizeBridge extends BridgeAbstract } foreach ($html->find('#episodeListSection a[data-testid=card]') as $element) { - $itemTitle = $element->find('h3', 0); $itemContent = $element->find('p[class^=content-]', 0); - $itemDate = $element->find('div[class^=playTime-] span', 0); - $itemThumbnail = $element->find('img', 0); - $itemUri = self::URI . $element->getAttribute('href'); + $itemDate = $element->find('div[class^=playTime-] span, [data-testid=episode-item-broadcast] span', 0); + + // Remove special characters and whitespace + $cleanDate = preg_replace('/[^0-9.]/', '', $itemDate->plaintext); $item = [ - 'title' => $this->fixChars($itemTitle->plaintext), - 'uri' => $itemUri, - 'content' => '
    ' - . $this->fixChars($itemContent->plaintext), - 'timestamp' => $this->getUploadTimeFromString($itemDate->plaintext) + 'title' => $this->fixChars($element->find('h3', 0)->plaintext), + 'uri' => self::URI . $element->getAttribute('href'), + 'content' => '
    ' . $this->fixChars($itemContent->plaintext), + 'timestamp' => $this->getUploadTimeFromString($cleanDate), ]; $this->items[] = $item; } } + private function getUploadTimeFromString($string) + { + if (strpos($string, 'dnes') !== false) { + return strtotime('today'); + } elseif (strpos($string, 'včera') !== false) { + return strtotime('yesterday'); + } elseif (!preg_match('/(\d+).(\d+).((\d+))?/', $string, $match)) { + throwServerException('Could not get date from Česká televize string'); + } + + $date = sprintf('%04d-%02d-%02d', $match[3] ?? date('Y'), $match[2], $match[1]); + return strtotime($date); + } + + private function fixChars($text) + { + return html_entity_decode($text, ENT_QUOTES, 'UTF-8'); + } + public function getURI() { return $this->feedUri ?? parent::getURI(); diff --git a/bridges/ComickBridge.php b/bridges/ComickBridge.php new file mode 100644 index 00000000..8fd26cd1 --- /dev/null +++ b/bridges/ComickBridge.php @@ -0,0 +1,186 @@ + [ + 'name' => 'Manga Slug', + 'type' => 'text', + 'required' => true, + 'title' => 'The part of the URL after /comic/', + 'exampleValue' => '00-kusuriya-no-hitorigoto-maomao-no-koukyuu-nazotoki-techou' + ], + 'lang' => [ + 'name' => 'Language', + 'type' => 'list', + 'title' => 'Language for comic (list is # of comics, descending)', + 'values' => [ + 'English' => 'en', + 'Brazilian Portuguese' => 'pt-br', + 'Spanish Latin American' => 'es-la', + 'Russian' => 'ru', + 'Vietnamese' => 'vi', + 'French' => 'fr', + 'Polish' => 'pl', + 'Indonesian' => 'id', + 'Turkish' => 'tr', + 'Italian' => 'it', + 'Spanish; Castilian' => 'es', + 'Ukrainian' => 'uk', + 'Arabic' => 'ar', + 'Hong Kong (Traditional Chinese)' => 'zh-hk', + 'Hungarian' => 'hu', + 'Chinese' => 'zh', + 'German' => 'de', + 'Korean' => 'ko', + 'Thai' => 'th', + 'Catalan; Valencian' => 'ca', + 'Bulgarian' => 'bg', + 'Persian' => 'fa', + 'Romanian, Moldavian, Moldovan' => 'ro', + 'Czech' => 'cs', + 'Mongolian' => 'mn', + 'Portuguese' => 'pt', + 'Hebrew (modern)' => 'he', + 'Hindi' => 'hi', + 'Filipino/Tagalog' => 'tl', + 'Finnish' => 'fi', + 'Malay' => 'ms', + 'Basque' => 'eu', + 'Kazakh' => 'kk', + 'Serbian' => 'sr', + 'Burmese' => 'my', + 'Japanese' => 'ja', + 'Greek, Modern' => 'el', + 'Dutch' => 'nl', + 'Bengali' => 'bn', + 'Uzbek' => 'uz', + 'Esperanto' => 'eo', + 'Lithuanian' => 'lt', + 'Georgian' => 'ka', + 'Danish' => 'da', + 'Tamil' => 'ta', + 'Swedish' => 'sv', + 'Belarusian' => 'be', + 'Chuvash' => 'cv', + 'Croatian' => 'hr', + 'Latin' => 'la', + 'Nepali' => 'ne', + 'Urdu' => 'ur', + 'Galician' => 'gl', + 'Norwegian' => 'no', + 'Albanian' => 'sq', + 'Irish' => 'ga', + 'Javanese' => 'jv', + 'Telugu' => 'te', + 'Slovene' => 'sl', + 'Estonian' => 'et', + 'Azerbaijani' => 'az', + 'Slovak' => 'sk', + 'Afrikaans' => 'af', + 'Latvian' => 'lv', + ], + 'defaultValue' => 'en' + ], + 'fetch' => [ + 'name' => 'Fetch chapter page images', + 'type' => 'list', + 'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.', + 'defaultValue' => 'c', + 'values' => [ + 'None' => 'n', + 'Content' => 'c', + 'Enclosure' => 'e' + ] + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'title' => 'Maximum number of chapters to return', + 'defaultValue' => 10 + ] + ]]; + + private $title; + + private function getComick($url) + { + $API = 'https://api.comick.fun'; + + // Need a non-cURL UA, otherwise we get Cloudflare 403'd + $opts = [ + CURLOPT_USERAGENT => 'rss-bridge (https://github.com/RSS-Bridge/rss-bridge)' + ]; + $content = getContents("$API/$url", [], $opts); + return json_decode($content, true); + } + + public function collectData() + { + $slug = $this->getInput('slug'); + $lang = $this->getInput('lang'); + $limit = $this->getInput('limit'); + + $manga = $this->getComick("comic/$slug"); + $hid = $manga['comic']['hid']; + $this->title = $manga['comic']['title']; + $manga = $this->getComick("comic/$hid/chapters?lang=$lang&limit=$limit"); + + foreach ($manga['chapters'] as $chapter) { + $hid = $chapter['hid']; + $item['author'] = implode(', ', $chapter['group_name']); + $item['timestamp'] = strtotime($chapter['created_at']); + $item['uri'] = $this->getURI() . '/' . $hid; + + $item['title'] = ''; + if ($chapter['vol']) { + $item['title'] .= ' Vol. ' . $chapter['vol']; + } + if ($chapter['chap']) { + $item['title'] .= ' Ch. ' . $chapter['chap']; + } + if ($chapter['title']) { + $item['title'] .= ' - ' . $chapter['title']; + } + + + if ($this->getInput('fetch') != 'n') { + $chapter = $this->getComick("chapter/$hid"); + if (isset($chapter['chapter']['md_images'])) { + $item['content'] = ''; + foreach ($chapter['chapter']['md_images'] as $image) { + $img = 'https://meo.comick.pictures/' . $image['b2key']; + if ($this->getInput('fetch') == 'c') { + $item['content'] .= ''; + } + if ($this->getInput('fetch') == 'e') { + $item['enclosures'][] = $img; + } + } + } + } + + $this->items[] = $item; + } + } + + public function getName() + { + if ($this->title) { + return parent::getName() . ' - ' . $this->title; + } + return parent::getName(); + } + + public function getURI() + { + if ($this->getInput('slug')) { + return self::URI . 'comic/' . $this->getInput('slug'); + } + return parent::getURI(); + } +} diff --git a/bridges/ComicsKingdomBridge.php b/bridges/ComicsKingdomBridge.php index 8baf7511..227426c4 100644 --- a/bridges/ComicsKingdomBridge.php +++ b/bridges/ComicsKingdomBridge.php @@ -2,59 +2,65 @@ class ComicsKingdomBridge extends BridgeAbstract { - const MAINTAINER = 'stjohnjohnson'; + const MAINTAINER = 'TReKiE'; + // const MAINTAINER = 'stjohnjohnson'; const NAME = 'Comics Kingdom Unofficial RSS'; - const URI = 'https://comicskingdom.com/'; + const URI = 'https://wp.comicskingdom.com/wp-json/wp/v2/ck_comic'; const CACHE_TIMEOUT = 21600; // 6h const DESCRIPTION = 'Comics Kingdom Unofficial RSS'; const PARAMETERS = [ [ 'comicname' => [ - 'name' => 'comicname', + 'name' => 'Name of comic', 'type' => 'text', 'exampleValue' => 'mutts', 'title' => 'The name of the comic in the URL after https://comicskingdom.com/', 'required' => true + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'title' => 'The number of recent comics to get', + 'defaultValue' => 10 ] ]]; + protected $comicName; + public function collectData() { - $html = getSimpleHTMLDOM($this->getURI(), [], [], true, false); + $json = getContents($this->getURI()); + $data = json_decode($json, false); - // Get author from first page - $author = $html->find('div.author p', 0); - ; + if (isset($data[0]->_embedded->{'wp:term'}[0][0])) { + $this->comicName = $data[0]->_embedded->{'wp:term'}[0][0]->name; + } - // Get current date/link - $link = $html->find('meta[property=og:url]', -1)->content; - for ($i = 0; $i < 3; $i++) { + foreach ($data as $comicitem) { $item = []; - $page = getSimpleHTMLDOM($link); - - $imagelink = $page->find('meta[property=og:image]', 0)->content; - - $date = explode('/', $link); - - $item['id'] = $imagelink; - $item['uri'] = $link; - $item['author'] = $author; - $item['title'] = 'Comics Kingdom ' . $this->getInput('comicname'); - $item['timestamp'] = DateTime::createFromFormat('Y-m-d', $date[count($date) - 1])->getTimestamp(); - $item['content'] = ''; - + $item['id'] = $comicitem->id; + $item['uri'] = $comicitem->yoast_head_json->og_url; + $item['author'] = str_ireplace('By ', '', $comicitem->ck_comic_byline); + $item['title'] = $comicitem->yoast_head_json->title; + $item['timestamp'] = $comicitem->date; + $item['content'] = ''; $this->items[] = $item; - $link = $page->find('div.comic-viewer-inline a', 0)->href; - if (empty($link)) { - break; // allow bridge to continue if there's less than 3 comics - } } } public function getURI() { if (!is_null($this->getInput('comicname'))) { - return self::URI . urlencode($this->getInput('comicname')); + $params = [ + 'ck_feature' => $this->getInput('comicname'), + 'per_page' => $this->getInput('limit'), + 'date_inclusive' => 'true', + 'order' => 'desc', + 'page' => '1', + '_embed' => 'true' + ]; + + return self::URI . '?' . http_build_query($params); } return parent::getURI(); @@ -62,8 +68,8 @@ class ComicsKingdomBridge extends BridgeAbstract public function getName() { - if (!is_null($this->getInput('comicname'))) { - return $this->getInput('comicname') . ' - Comics Kingdom'; + if ($this->comicName) { + return $this->comicName . ' - Comics Kingdom'; } return parent::getName(); diff --git a/bridges/CrewbayBridge.php b/bridges/CrewbayBridge.php index 0ca017c2..0fb9def6 100644 --- a/bridges/CrewbayBridge.php +++ b/bridges/CrewbayBridge.php @@ -109,7 +109,7 @@ class CrewbayBridge extends BridgeAbstract public function collectData() { $url = $this->getURI(); - $html = getSimpleHTMLDOM($url) or returnClientError('No results for this query.'); + $html = getSimpleHTMLDOM($url); $annonces = $html->find('#SearchResults div.result'); $limit = 0; diff --git a/bridges/CssSelectorBridge.php b/bridges/CssSelectorBridge.php index ac3261bf..c7294aac 100644 --- a/bridges/CssSelectorBridge.php +++ b/bridges/CssSelectorBridge.php @@ -217,7 +217,7 @@ class CssSelectorBridge extends BridgeAbstract $links = $page->find($url_selector); if (empty($links)) { - returnClientError('No results for URL selector'); + throwClientException('No results for URL selector'); } $link_to_item = []; @@ -232,8 +232,10 @@ class CssSelectorBridge extends BridgeAbstract continue; } } - $item['uri'] = $link->href; - $item['title'] = $link->plaintext; + + $item['uri'] = html_entity_decode($link->href); + $item['title'] = html_entity_decode($link->plaintext); + if (isset($item['content'])) { $item['content'] = convertLazyLoading($item['content']); $item['content'] = defaultLinkTo($item['content'], $item['uri']); @@ -243,13 +245,13 @@ class CssSelectorBridge extends BridgeAbstract } if (empty($link_to_item)) { - returnClientError('The provided URL selector matches some elements, but they do not contain links.'); + throwClientException('The provided URL selector matches some elements, but they do not contain links.'); } $links = $this->filterUrlList(array_keys($link_to_item), $url_pattern, $limit); if (empty($links)) { - returnClientError('No results for URL pattern'); + throwClientException('No results for URL pattern'); } $items = []; @@ -272,7 +274,7 @@ class CssSelectorBridge extends BridgeAbstract protected function expandEntryWithSelector($entry_url, $content_selector, $content_cleanup = null, $title_cleanup = null, $title_default = null) { if (empty($content_selector)) { - returnClientError('Please specify a content selector'); + throwClientException('Please specify a content selector'); } $entry_html = getSimpleHTMLDOMCached($entry_url); diff --git a/bridges/CssSelectorComplexBridge.php b/bridges/CssSelectorComplexBridge.php index 632e6b6a..d5946b09 100644 --- a/bridges/CssSelectorComplexBridge.php +++ b/bridges/CssSelectorComplexBridge.php @@ -187,7 +187,7 @@ class CssSelectorComplexBridge extends BridgeAbstract // Fetch the elements from the article pages. if ($use_article_pages) { if (empty($article_page_content_selector)) { - returnClientError('`Article selector` is required when `Load article page` is enabled'); + throwClientException('`Article selector` is required when `Load article page` is enabled'); } foreach (array_keys($entry_elements) as $uri) { @@ -307,7 +307,7 @@ class CssSelectorComplexBridge extends BridgeAbstract $entryElements = $page->find($entry_selector); if (empty($entryElements)) { - returnClientError('No entry elements for entry selector'); + throwClientException('No entry elements for entry selector'); } // Extract URIs with the associated entry element @@ -327,7 +327,7 @@ class CssSelectorComplexBridge extends BridgeAbstract } if (empty($links_with_elements)) { - returnClientError('The provided URL selector matches some elements, but they do not + throwClientException('The provided URL selector matches some elements, but they do not contain links.'); } @@ -335,7 +335,7 @@ class CssSelectorComplexBridge extends BridgeAbstract $filtered_urls = $this->filterUrlList(array_keys($links_with_elements), $url_pattern, $limit); if (empty($filtered_urls)) { - returnClientError('No results for URL pattern'); + throwClientException('No results for URL pattern'); } $items = []; @@ -359,7 +359,7 @@ class CssSelectorComplexBridge extends BridgeAbstract $article_content = $entry_html->find($content_selector, 0); if (is_null($article_content)) { - returnClientError('Could not get article content at URL: ' . $entry_url); + throwClientException('Could not get article content at URL: ' . $entry_url); } $article_content = defaultLinkTo($article_content, $entry_url); @@ -370,7 +370,7 @@ class CssSelectorComplexBridge extends BridgeAbstract { $date = date_parse_from_format($format, $timeStr); if ($date['error_count'] != 0) { - returnClientError('Error while parsing time string'); + throwClientException('Error while parsing time string'); } $timestamp = mktime( @@ -383,7 +383,7 @@ class CssSelectorComplexBridge extends BridgeAbstract ); if ($timestamp == false) { - returnClientError('Error while creating timestamp'); + throwClientException('Error while creating timestamp'); } return $timestamp; @@ -442,7 +442,7 @@ class CssSelectorComplexBridge extends BridgeAbstract if (!is_null($time_selector) && $time_selector != '') { $time_element = $entry_html->find($time_selector, 0); $time = $time_element->getAttribute('datetime'); - if (is_null($time)) { + if (empty($time)) { $time = $time_element->innertext; } diff --git a/bridges/CubariBridge.php b/bridges/CubariBridge.php index a7b6d69d..72fadf6e 100644 --- a/bridges/CubariBridge.php +++ b/bridges/CubariBridge.php @@ -47,8 +47,10 @@ class CubariBridge extends BridgeAbstract */ public function collectData() { + // TODO: fix trivial SSRF $json = getContents($this->getInput('gist')); - $jsonFile = json_decode($json, true); + + $jsonFile = Json::decode($json); $this->mangaTitle = $jsonFile['title']; diff --git a/bridges/CubariProxyBridge.php b/bridges/CubariProxyBridge.php new file mode 100644 index 00000000..fbc7e365 --- /dev/null +++ b/bridges/CubariProxyBridge.php @@ -0,0 +1,129 @@ + [ + 'name' => 'Content service', + 'type' => 'list', + 'defaultValue' => 'mangadex', + 'values' => [ + 'MangAventure' => 'mangadventure', + 'MangaDex' => 'mangadex', + 'MangaKatana' => 'mangakatana', + 'WeebCentral' => 'weebcentral', + ] + ], + 'series' => [ + 'name' => 'Series ID/Name', + 'exampleValue' => '8c1d7d0c-e0b7-4170-941d-29f652c3c19d', # KnH + 'required' => true, + ], + 'fetch' => [ + 'name' => 'Fetch chapter page images', + 'type' => 'list', + 'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.', + 'defaultValue' => 'c', + 'values' => [ + 'None' => 'n', + 'Content' => 'c', + 'Enclosure' => 'e' + ] + ], + 'limit' => self::LIMIT + ]]; + + private $title; + + public function collectData() + { + $limit = $this->getInput('limit') ?? 10; + + $url = parent::getURI() . '/read/api/' . $this->getInput('service') . '/series/' . $this->getInput('series'); + $json = Json::decode(getContents($url)); + $this->title = $json['title']; + + $chapters = $json['chapters']; + krsort($chapters); + + $count = 0; + foreach ($chapters as $number => $element) { + $item = []; + $item['uri'] = $this->getURI() . '/' . $number; + + if ($element['title']) { + $item['title'] = $number . ' - ' . $element['title']; + } else { + $item['title'] = 'Volume ' . $element['volume'] . ' Chapter ' . $number; + } + + $group = '1'; + if (isset($element['release_date'])) { + $dates = $element['release_date']; + $date = max($dates); + $item['timestamp'] = $date; + $group = array_keys($dates, $date)[0]; + } + $page = $element['groups'][$group]; + $item['author'] = $json['groups'][$group]; + $api = parent::getURI() . $page; + $item['uid'] = $page; + $item['comments'] = $api; + + if ($this->getInput('fetch') != 'n') { + $pages = []; + try { + $jsonp = getContents($api); + $pages = Json::decode($jsonp); + } catch (HttpException $e) { + // allow error 500, as it's effectively a 429 + if ($e->getCode() != 500) { + throw $e; + } + } + if ($this->getInput('fetch') == 'e') { + $item['enclosures'] = $pages; + } + if ($this->getInput('fetch') == 'c') { + $item['content'] = ''; + foreach ($pages as $img) { + $item['content'] .= ''; + } + } + } + + if ($count++ == $limit) { + break; + } + + $this->items[] = $item; + } + } + + public function getName() + { + $name = parent::getName(); + if (isset($this->title)) { + $name .= ' - ' . $this->title; + } + return $name; + } + + public function getURI() + { + $uri = parent::getURI(); + if ($this->getInput('service')) { + $uri .= '/read/' . $this->getInput('service') . '/' . $this->getInput('series'); + } + return $uri; + } + + public function getIcon() + { + return parent::getURI() . '/static/favicon.png'; + } +} diff --git a/bridges/CuriousCatBridge.php b/bridges/CuriousCatBridge.php deleted file mode 100644 index 3d6e87d0..00000000 --- a/bridges/CuriousCatBridge.php +++ /dev/null @@ -1,113 +0,0 @@ - [ - 'name' => 'Username', - 'type' => 'text', - 'required' => true, - 'exampleValue' => 'koethekoethe', - ] - ]]; - - const CACHE_TIMEOUT = 3600; - - public function collectData() - { - $url = self::URI . '/api/v2/profile?username=' . urlencode($this->getInput('username')); - - $apiJson = getContents($url); - - $apiData = Json::decode($apiJson); - if (isset($apiData['error'])) { - throw new \Exception($apiData['error_code']); - } - - foreach ($apiData['posts'] as $post) { - $item = []; - - $item['author'] = 'Anonymous'; - - if ($post['senderData']['id'] !== false) { - $item['author'] = $post['senderData']['username']; - } - - $item['uri'] = $this->getURI() . '/post/' . $post['id']; - $item['title'] = $this->ellipsisTitle($post['comment']); - - $item['content'] = $this->processContent($post); - $item['timestamp'] = $post['timestamp']; - - $this->items[] = $item; - } - } - - public function getURI() - { - if (!is_null($this->getInput('username'))) { - return self::URI . '/' . $this->getInput('username'); - } - - return parent::getURI(); - } - - public function getName() - { - if (!is_null($this->getInput('username'))) { - return $this->getInput('username') . ' - Curious Cat'; - } - - return parent::getName(); - } - - private function processContent($post) - { - $author = 'Anonymous'; - - if ($post['senderData']['id'] !== false) { - $authorUrl = self::URI . '/' . $post['senderData']['username']; - - $author = <<{$post['senderData']['username']} -EOD; - } - - $question = $this->formatUrls($post['comment']); - $answer = $this->formatUrls($post['reply']); - - $content = <<{$author} asked:

    -
    {$question}

    -

    {$post['addresseeData']['username']} answered:

    -
    {$answer}
    -EOD; - - return $content; - } - - private function ellipsisTitle($text) - { - $length = 150; - - if (strlen($text) > $length) { - $text = explode('
    ', wordwrap($text, $length, '
    ')); - return $text[0] . '...'; - } - - return $text; - } - - private function formatUrls($content) - { - return preg_replace( - '/(http[s]{0,1}\:\/\/[a-zA-Z0-9.\/\?\&=\-_]{4,})/ims', - '$1 ', - $content - ); - } -} diff --git a/bridges/DRKBlutspendeBridge.php b/bridges/DRKBlutspendeBridge.php new file mode 100644 index 00000000..7c415d5d --- /dev/null +++ b/bridges/DRKBlutspendeBridge.php @@ -0,0 +1,267 @@ + [ + 'term' => [ + 'name' => 'PLZ / Ort', + 'required' => true, + 'exampleValue' => '12555', + ], + 'radius' => [ + 'name' => 'Umkreis in km', + 'type' => 'number', + 'exampleValue' => 10, + ], + 'limit_days' => [ + 'name' => 'Limit von Tagen', + 'title' => 'Nur Termine innerhalb der nächsten x Tagen', + 'type' => 'number', + 'exampleValue' => 28, + ], + 'limit_items' => [ + 'name' => 'Limit von Terminen', + 'title' => 'Nicht mehr als x Termine', + 'type' => 'number', + 'required' => true, + 'defaultValue' => 20, + ] + ] + ]; + + const OFFER_LOW_PRIORITIES = [ + 'Imbiss nach der Blutspende', + 'Registrierung als Stammzellspender', + 'Typisierung möglich!', + 'Allgemeine Informationen', + 'Krankenkassen belohnen Blutspender', + 'Wer benötigt eigentlich eine Blutspende?', + 'Win-Win-Situation für die Gesundheit!', + 'Terminreservierung', + 'Du möchtest das erste Mal Blut spenden?', + 'Spende-Check', + 'Sie haben Fragen vor Ihrer Blutspende?' + ]; + + const IMAGE_PRIORITIES = [ + 'DRK', + 'Imbiss', + 'Obst', + ]; + + public function collectData() + { + $limitItems = intval($this->getInput('limit_items')); + $this->collectExpandableDatas(self::buildAppointmentsURI(), $limitItems); + } + + protected function parseItem(array $item) + { + $html = getSimpleHTMLDOMCached($item['uri']); + + $detailsElement = $html->find('.details', 0); + + $dateLines = self::explodeLines($detailsElement->find('.datum', 0)->plaintext); + $addressLines = self::explodeLines($detailsElement->find('.adresse', 0)->plaintext); + + $infoElement = $detailsElement->find('.angebote > h4 + p', 0); + $info = $infoElement ? trim($infoElement->plaintext) : ''; + + $offers = self::parseOffers($detailsElement->find('.angebote .item')); + + $images = self::parseImages($detailsElement->find('.fotos', 0)); + usort($images, function ($imageA, $imageB): int { + list($titleA) = $imageA; + list($titleB) = $imageB; + $prioA = 0; + $prioB = 0; + foreach (self::IMAGE_PRIORITIES as $prioIndex => $prioTitleNeedle) { + if (stripos($titleA, $prioTitleNeedle) !== false) { + $prioA = $prioIndex + 1; + } + if (stripos($titleB, $prioTitleNeedle) !== false) { + $prioB = $prioIndex + 1; + } + } + return $prioA - $prioB; + }); + + $itemContent = << +

    + {$dateLines[0]} {$dateLines[1]}
    + {$addressLines[3]} +

    +

    + {$addressLines[0]}
    + {$addressLines[1]}
    + {$addressLines[2]} +

    + + HTML; + + if ($info) { + $itemContent .= << +

    Infos

    +

    {$info}

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

    {$offerTitle}

    +

    {$offerText}

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

    Fotos

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

    {$offerTitle}

    +

    {$offerText}

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

    '; + $content .= '
    '; + $content .= ''; + $content .= '
    ' . $figure_caption . '
    '; + $content .= '
    '; + $content .= $article_text; + + $this->items[] = [ + 'uri' => $url, + 'title' => $title, + 'author' => $author, + 'timestamp' => $published, + 'content' => trim($content), + ]; + } + } +} diff --git a/bridges/DagensNyheterDirektBridge.php b/bridges/DagensNyheterDirektBridge.php index 4d1629fb..fa219a09 100644 --- a/bridges/DagensNyheterDirektBridge.php +++ b/bridges/DagensNyheterDirektBridge.php @@ -18,8 +18,7 @@ class DagensNyheterDirektBridge extends BridgeAbstract { $NEWSURL = self::BASEURL . '/ajax/direkt/'; - $html = getSimpleHTMLDOM($NEWSURL) or - returnServerError('Could not request: ' . $NEWSURL); + $html = getSimpleHTMLDOM($NEWSURL); foreach ($html->find('article') as $element) { $link = $element->find('button', 0)->getAttribute('data-link'); @@ -27,11 +26,6 @@ class DagensNyheterDirektBridge extends BridgeAbstract $url = self::BASEURL . $link; $title = $element->find('h2', 0)->plaintext; $author = $element->find('div.ds-byline__titles', 0)->plaintext; - // Debug::log($link); - // Debug::log($datetime); - // Debug::log($title); - // Debug::log($url); - // Debug::log($author); $article_content = $element->find('div.direkt-post__content', 0); $article_html = ''; diff --git a/bridges/DailymotionBridge.php b/bridges/DailymotionBridge.php index 5d892954..513cbbe7 100644 --- a/bridges/DailymotionBridge.php +++ b/bridges/DailymotionBridge.php @@ -44,68 +44,32 @@ class DailymotionBridge extends BridgeAbstract public function getIcon() { - return 'https://static1-ssl.dmcdn.net/images/neon/favicons/android-icon-36x36.png.vf806ca4ed0deed812'; + return 'https://static1.dmcdn.net/neon-user-ssr/prod/favicons/apple-icon-60x60.831b96ed0a8eca7f6539.png'; } public function collectData() { - if ($this->queriedContext === 'By username' || $this->queriedContext === 'By playlist id') { - $apiJson = getContents($this->getApiUrl()); - - $apiData = json_decode($apiJson, true); + $apiJson = getContents($this->getApiUrl()); + $apiData = json_decode($apiJson, true); + if ($this->queriedContext === 'By playlist id') { $this->feedName = $this->getPlaylistTitle($this->getInput('p')); - - foreach ($apiData['list'] as $apiItem) { - $item = []; - - $item['uri'] = $apiItem['url']; - $item['uid'] = $apiItem['id']; - $item['title'] = $apiItem['title']; - $item['timestamp'] = $apiItem['created_time']; - $item['author'] = $apiItem['owner.screenname']; - $item['content'] = '

    -

    ' . $apiItem['description'] . '

    '; - $item['categories'] = $apiItem['tags']; - $item['enclosures'][] = $apiItem['thumbnail_url']; - - $this->items[] = $item; - } } - if ($this->queriedContext === 'From search results') { - $html = getSimpleHTMLDOM($this->getURI()); + foreach ($apiData['list'] as $apiItem) { + $item = []; - foreach ($html->find('div.media a.preview_link') as $element) { - $item = []; + $item['uri'] = $apiItem['url']; + $item['uid'] = $apiItem['id']; + $item['title'] = $apiItem['title']; + $item['timestamp'] = $apiItem['created_time']; + $item['author'] = $apiItem['owner.screenname']; + $item['content'] = '

    +

    ' . $apiItem['description'] . '

    '; + $item['categories'] = $apiItem['tags']; + $item['enclosures'][] = $apiItem['thumbnail_url']; - $item['id'] = str_replace('/video/', '', strtok($element->href, '_')); - $metadata = $this->getMetadata($item['id']); - - if (empty($metadata)) { - continue; - } - - $item['uri'] = $metadata['uri']; - $item['title'] = $metadata['title']; - $item['timestamp'] = $metadata['timestamp']; - - $item['content'] = '
    ' - . $item['title'] - . ''; - - $this->items[] = $item; - - if (count($this->items) >= 5) { - break; - } - } + $this->items[] = $item; } } @@ -136,6 +100,7 @@ class DailymotionBridge extends BridgeAbstract public function getURI() { $uri = self::URI; + switch ($this->queriedContext) { case 'By username': $uri .= 'user/' . urlencode($this->getInput('u')); @@ -162,35 +127,11 @@ class DailymotionBridge extends BridgeAbstract return $uri; } - private function getMetadata($id) - { - $metadata = []; - - $html = getSimpleHTMLDOM(self::URI . 'video/' . $id); - - if (!$html) { - return $metadata; - } - - $metadata['title'] = $html->find('meta[property=og:title]', 0)->getAttribute('content'); - $metadata['timestamp'] = strtotime( - $html->find('meta[property=video:release_date]', 0)->getAttribute('content') - ); - $metadata['thumbnailUri'] = $html->find('meta[property=og:image]', 0)->getAttribute('content'); - $metadata['uri'] = $html->find('meta[property=og:url]', 0)->getAttribute('content'); - return $metadata; - } - private function getPlaylistTitle($id) { - $title = ''; - - $url = self::URI . 'playlist/' . $id; - - $html = getSimpleHTMLDOM($url); - - $title = $html->find('meta[property=og:title]', 0)->getAttribute('content'); - return $title; + $apiJson = getContents($this->apiUrl . '/playlist/' . $this->getInput('p')); + $apiData = json_decode($apiJson, true); + return $apiData['name']; } private function getApiUrl() @@ -204,6 +145,9 @@ class DailymotionBridge extends BridgeAbstract return $this->apiUrl . '/playlist/' . $this->getInput('p') . '/videos?fields=' . urlencode($this->apiFields) . '&limit=5'; break; + case 'From search results': + return $this->apiUrl . '/videos?search=' . $this->getInput('s') . '&fields=' . urlencode($this->apiFields) . '&limit=5'; + break; } } } diff --git a/bridges/DailythanthiBridge.php b/bridges/DailythanthiBridge.php new file mode 100644 index 00000000..4e891e1c --- /dev/null +++ b/bridges/DailythanthiBridge.php @@ -0,0 +1,96 @@ + [ + 'name' => 'topic', + 'type' => 'list', + 'values' => [ + 'news' => [ + 'tamilnadu' => 'news/state', + 'india' => 'news/india', + 'world' => 'news/world', + 'sirappu-katturaigal' => 'news/sirappukatturaigal', + ], + 'cinema' => [ + 'news' => 'cinema/cinemanews', + ], + 'sports' => [ + 'sports' => 'sports', + 'cricket' => 'sports/cricket', + 'football' => 'sports/football', + 'tennis' => 'sports/tennis', + 'hockey' => 'sports/hockey', + 'other-sports' => 'sports/othersports', + ], + 'devotional' => [ + 'devotional' => 'others/devotional', + 'aalaya-varalaru' => 'aalaya-varalaru', + ], + ], + ], + ], + ]; + + public function getName() + { + $topic = $this->getKey('topic'); + return self::NAME . ($topic ? ' - ' . ucfirst($topic) : ''); + } + + public function collectData() + { + $dom = getSimpleHTMLDOM(self::URI . '/' . $this->getInput('topic')); + + foreach ($dom->find('div.ListingNewsWithMEDImage') as $element) { + $slug = $element->find('a', 1); + $title = $element->find('h3', 0); + if (!$slug || !$title) { + continue; + } + + $url = self::URI . $slug->href; + $date = $element->find('span', 1); + $date = $date ? $date->{'data-datestring'} : ''; + + $this->items[] = [ + 'content' => $this->constructContent($url), + 'timestamp' => $date ? $date . 'UTC' : '', + 'title' => $title->plaintext, + 'uid' => $slug->href, + 'uri' => $url, + ]; + } + } + + private function constructContent($url) + { + $dom = getSimpleHTMLDOMCached($url); + + $article = $dom->find('div.details-content-story', 0); + if (!$article) { + return 'Content Not Found'; + } + + // Remove ads + foreach ($article->find('div[id*="_ad"]') as $remove) { + $remove->outertext = ''; + } + + // Correct image tag in $article + foreach ($article->find('h-img') as $img) { + $img->parent->outertext = sprintf('

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

    ' . $image->outertext . '

    ' : ''; + + return $image . $article; + } +} diff --git a/bridges/DansTonChatBridge.php b/bridges/DansTonChatBridge.php deleted file mode 100644 index 9712ec9d..00000000 --- a/bridges/DansTonChatBridge.php +++ /dev/null @@ -1,28 +0,0 @@ -find('div.item') as $element) { - $item = []; - $item['uri'] = $element->find('a', 0)->href; - $titleContent = $element->find('h3 a', 0); - if ($titleContent) { - $item['title'] = 'DansTonChat ' . html_entity_decode($titleContent->plaintext, ENT_QUOTES); - } else { - $item['title'] = 'DansTonChat'; - } - $item['content'] = $element->find('div.item-content a', 0)->innertext; - $this->items[] = $item; - } - } -} diff --git a/bridges/DarkReadingBridge.php b/bridges/DarkReadingBridge.php index 4f1622e3..d8b88cb6 100644 --- a/bridges/DarkReadingBridge.php +++ b/bridges/DarkReadingBridge.php @@ -9,7 +9,7 @@ class DarkReadingBridge extends FeedExpander const PARAMETERS = [ [ 'feed' => [ - 'name' => 'Feed', + 'name' => 'Feed (NOT IN USE)', 'type' => 'list', 'values' => [ 'All Dark Reading Stories' => '000_AllArticles', @@ -41,17 +41,7 @@ class DarkReadingBridge extends FeedExpander public function collectData() { - $feed = $this->getInput('feed'); - $feed_splitted = explode('_', $feed); - $feed_id = $feed_splitted[0]; - $feed_name = $feed_splitted[1]; - if (empty($feed) || !ctype_digit($feed_id) || !preg_match('/[A-Za-z%20\/]/', $feed_name)) { - returnClientError('Invalid feed, please check the "feed" parameter.'); - } - $feed_url = $this->getURI() . 'rss_simple.asp'; - if ($feed_id != '000') { - $feed_url .= '?f_n=' . $feed_id . '&f_ln=' . $feed_name; - } + $feed_url = 'https://www.darkreading.com/rss.xml'; $limit = $this->getInput('limit') ?? 10; $this->collectExpandableDatas($feed_url, $limit); } @@ -71,7 +61,7 @@ class DarkReadingBridge extends FeedExpander private function extractArticleContent($article) { - $content = $article->find('div.article-content', 0)->innertext; + $content = $article->find('div.ContentModule-Wrapper', 0)->innertext; foreach ( [ diff --git a/bridges/DavesTrailerPageBridge.php b/bridges/DavesTrailerPageBridge.php deleted file mode 100644 index 965f7e59..00000000 --- a/bridges/DavesTrailerPageBridge.php +++ /dev/null @@ -1,40 +0,0 @@ -find('tr') as $tr) { - // If it's a date row, update the current date - if ($tr->align == 'center') { - $curr_date = $tr->plaintext; - continue; - } - - $item = []; - - // title - $item['title'] = $tr->find('td', 0)->find('b', 0)->plaintext; - - // content - $item['content'] = $tr->find('ul', 1); - - // uri - $item['uri'] = $tr->find('a', 3)->getAttribute('href'); - - // date: parsed by FeedItem using strtotime - $item['timestamp'] = $curr_date; - - $this->items[] = $item; - } - } -} diff --git a/bridges/DealabsBridge.php b/bridges/DealabsBridge.php index 4d39502c..7bad79b5 100644 --- a/bridges/DealabsBridge.php +++ b/bridges/DealabsBridge.php @@ -40,1837 +40,24 @@ class DealabsBridge extends PepperBridgeAbstract 'Deals par groupe' => [ 'group' => [ 'name' => 'Groupe', - 'type' => 'list', - 'title' => 'Groupe dont il faut afficher les deals', - 'values' => [ - 'Abattants WC' => 'abattants-wc', - 'Abonnement PlayStation Plus' => 'playstation-plus', - 'Abonnements cinéma' => 'abonnements-cinema', - 'Abonnements de train' => 'abonnements-de-train', - 'Abonnements internet' => 'abonnements-internet', - 'Abonnements presse' => 'abonnements-presse', - 'Accessoires aquarium' => 'accessoires-aquarium', - 'Accessoires auto' => 'auto', - 'Accessoires électroniques' => 'accessoires-gadgets', - 'Accessoires gamers PC' => 'accessoires-gamers-pc', - 'Accessoires gaming' => 'accessoires-gaming', - 'Accessoires iPhone' => 'accessoires-iphone', - 'Accessoires mode' => 'accessoires-mode', - 'Accessoires moto' => 'moto', - 'Accessoires Nintendo' => 'accessoires-nintendo', - 'Accessoires PC portables' => 'accessoires-pc-portables', - 'Accessoires photo' => 'accessoires-photo', - 'Accessoires PlayStation' => 'accessoires-playstation', - 'Accessoires pour barbecue' => 'accessoires-barbecue', - 'Accessoires studio photo' => 'accessoires-studio-photo', - 'Accessoires téléphonie' => 'accessoires-telephonie', - 'Accessoires TV' => 'accessoires-tv', - 'Accessoires vélo' => 'accessoires-velo', - 'Accessoires Xbox' => 'accessoires-xbox', - 'Acer' => 'acer', - 'Acer Predator' => 'acer-predator', - 'Achats / Ventes' => 'achats-ventes-echanges-estimations-dons', - 'Achats à l'étranger' => 'limport-sites-avis-questions-langues', - 'Adaptateurs' => 'adaptateurs', - 'Adhérents Fnac' => 'adherents-fnac', - 'Adhésions & Souscriptions' => 'adhesions-souscriptions-abonnements', - 'adidas' => 'adidas', - 'Adidas Gazelle' => 'adidas-gazelle', - 'adidas Stan Smith' => 'adidas-stan-smith', - 'adidas Superstar' => 'adidas-superstar', - 'adidas Ultraboost' => 'adidas-ultraboost', - 'adidas Yung-1' => 'adidas-yung-1', - 'adidas ZX Flux' => 'adidas-zx-flux', - 'Adoucissant' => 'adoucissant', - 'Agendas' => 'agendas', - 'Age of Empires' => 'age-of-empires', - 'Age of Empires: Definitive Edition' => 'age-of-empires-definitive-edition', - 'Alarmes' => 'alarmes', - 'Albums photo' => 'albums-photo', - 'Alcools' => 'alcools', - 'Alcools forts' => 'alcools-forts', - 'Alimentation' => 'epicerie', - 'Alimentation bébés' => 'alimentation-bebes', - 'Alimentation PC' => 'alimentation-pc', - 'Alimentation sportifs' => 'alimentation-sportifs', - 'Amazfit Bip' => 'xiaomi-amazfit-bip', - 'Amazon Echo' => 'amazon-echo', - 'Amazon Echo Dot' => 'amazon-echo-dot', - 'Amazon Echo Plus' => 'amazon-echo-plus', - 'Amazon Echo Show' => 'amazon-echo-show', - 'Amazon Echo Show 5' => 'amazon-echo-show-5', - 'Amazon Echo Spot' => 'amazon-echo-spot', - 'Amazon Fire TV' => 'amazon-fire-tv', - 'Amazon Kindle' => 'amazon-kindle', - 'Amazon Prime' => 'amazon-prime', - 'AMD Radeon' => 'amd-radeon', - 'AMD Ryzen' => 'amd-ryzen', - 'AMD Ryzen 5 5600X' => 'amd-ryzen-5-5600x', - 'AMD Ryzen 7 5800X' => 'amd-ryzen-7-5800x', - 'AMD Ryzen 9 5900X' => 'amd-ryzen-9-5900x', - 'AMD Ryzen 9 5950X' => 'amd-ryzen-9-5950x', - 'AMD Vega' => 'amd-vega', - 'amiibo' => 'amiibo', - 'Amplis (guitare/basse)' => 'amplis-guitare-basse', - 'Amplis audio' => 'amplis', - 'Ampoules' => 'ampoules', - 'Ampoules à LED' => 'ampoules-a-led', - 'Angleterre' => 'angleterre', - 'Animal Crossing' => 'animal-crossing', - 'Animal Crossing: New Horizons' => 'animal-crossing-new-horizons', - 'Animaux' => 'animaux', - 'Anker' => 'anker', - 'Anno 1800' => 'anno-1800', - 'Annonces officielles' => 'annonces-officielles', - 'Anthem' => 'anthem', - 'Anti-nuisibles' => 'anti-nuisibles', - 'Anti-puces' => 'anti-puces', - 'Antivirus' => 'antivirus', - 'Antivols' => 'antivols', - 'Apex Legends' => 'apex-legends', - 'Appareils à raclette' => 'appareils-raclette', - 'Appareils de musculation' => 'appareils-de-musculation', - 'Appareils photo' => 'appareils-photo', - 'Appareils photo Canon' => 'appareils-photo-canon', - 'Appareils photo compacts' => 'appareils-photo-compacts', - 'Appareils photo instantanés' => 'appareils-photo-instantanes', - 'Appareils photo Nikon' => 'appareils-photo-nikon', - 'Appareils photo Olympus' => 'appareils-photo-olympus', - 'Appareils photo Panasonic' => 'appareils-photo-panasonic', - 'Appareils photo Sony' => 'appareils-photo-sony', - 'Apple' => 'apple', - 'Apple AirPods' => 'apple-airpods', - 'Apple AirPods 2' => 'apple-airpods-2', - 'Apple AirPods Max' => 'apple-airpods-max', - 'Apple AirPods Pro' => 'apple-airpods-pro', - 'Apple HomePod' => 'apple-homepod', - 'Apple HomePod Mini' => 'apple-homepod-mini', - 'Apple TV' => 'apple-tv', - 'Apple TV+' => 'apple-tv-plus', - 'Apple Watch' => 'apple-watch', - 'Apple Watch 3' => 'apple-watch-3', - 'Apple Watch 4' => 'apple-watch-4', - 'Apple Watch 5' => 'apple-watch-5', - 'Apple Watch 6' => 'apple-watch-6', - 'Apple Watch SE' => 'apple-watch-se', - 'Applications' => 'applications', - 'Applications Android' => 'applications-android', - 'Applications iOS' => 'applications-ios', - 'Appliques murales' => 'appliques-murales', - 'Applis & logiciels' => 'applis-logiciels', - 'Après-shampooings' => 'apres-shampooings', - 'Aquariums' => 'aquariums', - 'Arbres à chat' => 'arbres-a-chat', - 'Arduino' => 'arduino', - 'Armoires & placards' => 'armoires-et-placards', - 'Articles de cuisine et d'entretien' => 'articles-de-cuisine', - 'Arts culinaires' => 'arts-culinaires', - 'Arts de la table' => 'arts-de-la-table', - 'ASICS' => 'asics', - 'Asmodée' => 'asmodee', - 'Aspirateurs' => 'aspirateurs', - 'Aspirateurs balais' => 'aspirateurs-balais', - 'Aspirateurs Dreame' => 'aspirateurs-xiaomi', - 'Aspirateurs Dyson' => 'aspirateurs-dyson', - 'Aspirateurs robot' => 'aspirateurs-robot', - 'Aspirateurs Rowenta' => 'apsirateurs-rowenta', - 'Aspirateurs sans sac' => 'aspirateurs-sans-sac', - 'Assassin's Creed' => 'assassin-s-creed', - 'Assassin's Creed: Unity' => 'assassins-creed-unity', - 'Assassin's Creed: Valhalla' => 'assassin-s-creed-valhalla', - 'Assassin's Creed Odyssey' => 'assassin-s-creed-odyssey', - 'Assassin's Creed Origins' => 'assassin-s-creed-origins', - 'Assurances' => 'assurances', - 'Astuces pour économiser' => 'vos-astuces-pour-faire-des-economies', - 'Asus' => 'asus', - 'Asus ROG' => 'asus-rog', - 'Asus ROG Phone' => 'asus-rog-phone', - 'Asus ROG Phone 2' => 'asus-rog-phone-2', - 'ASUS Transformer' => 'asus-transformer', - 'Asus VivoBook' => 'asus-vivobook', - 'Asus ZenBook' => 'asus-zenbook', - 'Asus ZenFone 2' => 'asus-zenfone-2', - 'Asus ZenFone 3' => 'asus-zenfone-3', - 'Asus ZenFone 4' => 'asus-zenfone-4', - 'Asus ZenFone 6' => 'asus-zenfone-6', - 'Asus ZenFone GO' => 'asus-zenfone-go', - 'Asus ZenFone Zoom' => 'asus-zenfone-zoom', - 'Audio & Hi-fi' => 'audio-et-hi-fi', - 'Aukey' => 'aukey', - 'Auto-Moto' => 'auto-moto', - 'Autoradios' => 'autoradios', - 'Azzaro Wanted' => 'azzaro-wanted', - 'Baby foot' => 'baby-foot', - 'BabyLiss' => 'babyliss', - 'Babyphones' => 'babyphones', - 'Badminton' => 'badminton', - 'Bagagerie' => 'bagagerie', - 'Baignoires pour bébé' => 'baignoires-pour-bebe', - 'Bains de bouche' => 'bains-de-bouche', - 'Balais & serpillères' => 'balais-et-serpilleres', - 'Balances connectées' => 'balances-connectees', - 'Balançoires' => 'balancoires', - 'Ballet & danse' => 'ballet-et-danse', - 'Ballons de football' => 'ballons-de-football', - 'Bandes dessinées' => 'bandes-dessinees', - 'Banques' => 'banques', - 'Barbecue' => 'barbecue', - 'Barbecue électrique' => 'barbecue-electrique', - 'Barbecue Weber' => 'barbecue-weber', - 'Barbie' => 'barbie', - 'Barres de son' => 'barres-de-son', - 'Barres de son Yamaha' => 'barres-de-son-yamaha', - 'Batman Arkham' => 'batman-arkham', - 'Batteries externes' => 'batteries-externes', - 'Batteries voiture' => 'batteries-voiture', - 'Batteurs' => 'batteurs-electriques', - 'Battlefield' => 'battlefield', - 'Battlefield 1' => 'battlefield-1', - 'Battlefield V' => 'battlefield-5', - 'Béaba' => 'beaba', - 'Beats by Dre' => 'beats-by-dre', - 'Beats Solo 3' => 'beats-solo-3', - 'Beats Studio 3' => 'beats-studio-3', - 'Beauté' => 'beaute', - 'Bébés' => 'bebes-nouveaux-nes', - 'BenQ' => 'benq', - 'Be quiet!' => 'be-quiet', - 'Beyerdynamic MMX 300' => 'beyerdynamic-mmx-300', - 'Biberons' => 'biberons', - 'Bien-être & santé' => 'bien-etre-et-massages', - 'Bières' => 'bieres', - 'Bijoux' => 'bijoux', - 'Bikinis' => 'bikinis', - 'Bilans de santé & dépistages' => 'bilans-de-sante-et-depistages', - 'Billets de bus' => 'billets-de-bus', - 'Billets de train' => 'billets-de-train', - 'BioShock' => 'bioshock', - 'BioShock Infinite' => 'bioshock-infinite', - 'Bitdefender' => 'bitdefender', - 'Blabla' => 'blabla-parlez-de-tout-et-de-rien', - 'Black & Decker' => 'black-decker', - 'Blackberry' => 'blackberry', - 'Black Desert Online' => 'black-desert-online', - 'Blédina' => 'bledina', - 'Blenders' => 'blenders', - 'Bleu de Chanel' => 'bleu-de-chanel', - 'Blousons de moto' => 'blousons-de-moto', - 'Blu-Ray' => 'blu-ray', - 'Bodys pour bébé' => 'bodys-pour-bebe', - 'Boissons' => 'boissons', - 'Boîtes à outils' => 'boites-a-outils', - 'Boîtiers PC' => 'boitiers-pc', - 'Boîtiers TV' => 'boitiers-tv', - 'Bonbons' => 'bonbons', - 'Bonnets' => 'bonnets', - 'Bonnets de bain' => 'bonnets-de-bain', - 'Borderlands' => 'borderlands', - 'Borderlands 3' => 'borderlands-3', - 'Bosch' => 'bosch', - 'Bose' => 'bose', - 'Bose Headphones 700' => 'bose-headphones-700', - 'Bose Home Speaker 500' => 'bose-home-speaker-500', - 'Bose QuietComfort' => 'bose-quietcomfort', - 'Bose QuietComfort 35 II' => 'bose-quietcomfort-35ii', - 'Bose SoundLink' => 'bose-soundlink', - 'Bose SoundTouch' => 'bose-soundtouch', - 'Bottes' => 'bottes', - 'Bottes de moto' => 'bottes-de-moto', - 'Bottes de neige' => 'bottes-neige', - 'Bottes de pluie' => 'bottes-pluie', - 'Bottes femme' => 'bottes-femme', - 'Bottes homme' => 'bottes-homme', - 'Bougies & bougeoirs' => 'bougies-et-bougeoirs', - 'Box beauté' => 'box-beaute', - 'Bracelet fitness' => 'bracelet-fitness', - 'Brandt' => 'brandt', - 'Braun Series 3' => 'braun-series-3', - 'Braun Series 5' => 'braun-series-5', - 'Braun Series 7' => 'braun-series-7', - 'Braun Series 9' => 'braun-series-9', - 'Braun Silk Épil' => 'braun-silk-epil', - 'Brita' => 'brita', - 'Brosses à dents' => 'brosses-a-dents', - 'Brosses à dents électriques' => 'brosses-a-dents-electriques', - 'Brosses à dents électriques Oral-B' => 'brosses-a-dents-electriques-oral-b', - 'Brosses pour animaux' => 'brosses-pour-animaux', - 'Cable management' => 'cable-management', - 'Câbles' => 'cables', - 'Câbles Ethernet' => 'cables-ethernet', - 'Câbles HDMI' => 'cables-hdmi', - 'Câbles Jack' => 'cables-jack', - 'Câbles USB' => 'cables-usb', - 'Cadeaux' => 'cadeaux', - 'Cadres' => 'cadres', - 'Cadres de vélo' => 'cadres-de-velo', - 'Café' => 'cafe', - 'Café en dosettes' => 'cafe-en-dosettes', - 'Café en grain' => 'cafe-en-grain', - 'Cafetières' => 'cafetieres', - 'Cafetières expresso' => 'cafetieres-expresso', - 'Cafetières filtre' => 'cafetieres-filtre', - 'Cafetières italiennes' => 'cafetieres-italiennes', - 'Cahiers' => 'cahiers', - 'Caissons de basses' => 'caissons-de-basses', - 'Calendrier de l'Avent Lego' => 'calendriers-avent-lego', - 'Calendriers' => 'calendriers', - 'Calendriers de l'Avent' => 'calendriers-avent', - 'Call of Duty' => 'call-of-duty', - 'Call of Duty: Black Ops Cold War' => 'call-of-duty-black-ops-cold-war', - 'Call of Duty: Black Ops III' => 'call-of-duty-black-ops-3', - 'Call of Duty: Black Ops IIII' => 'call-of-duty-black-ops-4', - 'Call of Duty: Infinite Warfare' => 'call-of-duty-infinite-warfare', - 'Call of Duty: Modern Warfare' => 'call-of-duty-modern-warfare', - 'Call of Duty: WW2' => 'call-of-duty-ww2', - 'Calor' => 'calor', - 'Caméras' => 'cameras', - 'Caméras IP' => 'cameras-ip', - 'Caméras sportives' => 'cameras-sportives', - 'Camping' => 'camping', - 'Canapés' => 'canape', - 'Canon' => 'canon', - 'Captain Toad: Treasure Tracker' => 'captain-toad-treasure-tracker', - 'Caravanes' => 'caravanes', - 'Carburant' => 'carburant', - 'Cartables' => 'cartables', - 'Cartes & programmes de fidélité' => 'cartes-et-programmes-de-fidelite', - 'Cartes bancaires' => 'cartes-bancaires', - 'Cartes de développement' => 'cartes-developpement', - 'Cartes graphiques' => 'cartes-graphiques', - 'Cartes mémoire' => 'cartes-memoire', - 'Cartes mères' => 'cartes-meres', - 'Cartes postales' => 'cartes-postales', - 'Cartes prépayées Playstation Store' => 'playstation-store', - 'Cartes SD' => 'cartes-sd', - 'Cartes son' => 'cartes-son', - 'Casio' => 'casio', - 'Casque sans fil Xbox' => 'casque-sans-fil-xbox', - 'Casques Apple' => 'casques-apple', - 'Casques à réduction de bruit' => 'casque-reduction-active-bruit', - 'Casques audio' => 'casques-audio', - 'Casques Bose' => 'casques-bose', - 'Casques de moto' => 'casques-de-moto', - 'Casques de vélo' => 'casques-de-velo', - 'Casques Jabra' => 'casques-jabra', - 'Casques Samsung' => 'casques-samsung', - 'Casques sans fil' => 'casques-sans-fil', - 'Casques Sennheiser' => 'casques-sennheiser', - 'Casques Sony' => 'casques-sony', - 'Casques VR' => 'vr', - 'Casquettes' => 'casquettes', - 'Casseroles' => 'casseroles', - 'Catit' => 'catit', - 'Caves à vin' => 'caves-a-vin', - 'CD & vinyles' => 'cd-vinyles', - 'CDAV' => 'cdav', - 'Ceintures' => 'ceintures', - 'Centrales vapeur' => 'centrales-vapeur', - 'Chaînes hi-fi' => 'chaines-hi-fi', - 'Chaises' => 'chaises', - 'Chaises hautes' => 'chaises-hautes', - 'Chambre' => 'chambre', - 'Champagne' => 'champagne', - 'Chapeaux' => 'chapeaux', - 'Chapeaux & casquettes' => 'chapeaux-casquettes', - 'Chargeurs' => 'chargeurs', - 'Chargeurs allume-cigare' => 'chargeurs-allume-cigare', - 'Chargeurs de piles' => 'chargeurs-de-piles', - 'Chargeurs sans fil' => 'chargeurs-sans-fil', - 'Chasse' => 'chasse', - 'Chatières' => 'chatieres', - 'Chats' => 'chats', - 'Chauffage' => 'chauffage', - 'Chaussettes & collants' => 'chaussettes-et-collants', - 'Chaussons' => 'chaussons', - 'Chaussures' => 'chaussures', - 'Chaussures adidas' => 'chaussures-adidas', - 'Chaussures de football' => 'chaussures-de-football', - 'Chaussures de randonnée' => 'chaussures-de-randonnee', - 'Chaussures de ski' => 'chaussures-de-ski', - 'Chaussures de ville' => 'chaussures-de-ville', - 'Chaussures New Balance' => 'chaussures-new-balance', - 'Chaussures Nike' => 'chaussures-nike', - 'Chaussures pour enfants' => 'chaussures-enfants', - 'Chaussures pour femme' => 'chaussures-femme', - 'Chaussures pour homme' => 'chaussures-homme', - 'Chaussures Puma' => 'chaussures-puma', - 'Chaussures Reebok' => 'chaussures-reebok', - 'Chaussures running' => 'chaussures-de-running', - 'Chelsea boots' => 'chelsea-boots', - 'Chemises' => 'chemises', - 'Chiens' => 'chiens', - 'Chocolat' => 'chocolat', - 'Chuck Taylor' => 'chuck-taylor', - 'Cinéma' => 'cinema', - 'Cire dépilatoire' => 'cire-depilatoire', - 'Cirque & arts de rue' => 'cirque-et-arts-de-rue', - 'Citytrips' => 'citytrips', - 'Civilization' => 'civilization', - 'Civilization VI' => 'civilization-vi', - 'CK One' => 'ck-one', - 'Clarks' => 'clarks', - 'Claviers' => 'claviers', - 'Claviers (musique)' => 'claviers-musique', - 'Claviers gamer' => 'claviers-gamer', - 'Claviers Logitech' => 'claviers-logitech', - 'Claviers mécaniques' => 'claviers-mecaniques', - 'Claviers sans fil' => 'claviers-sans-fil', - 'Clés USB' => 'cles-usb', - 'Climatisation' => 'climatisation', - 'Climatiseurs' => 'climatiseurs', - 'Cocottes' => 'cocottes', - 'Coffrets de livres' => 'coffrets-de-livres', - 'Coffrets DVD' => 'coffrets-dvd', - 'Coffrets maquillage' => 'coffrets-maquillage', - 'Colliers & laisses' => 'colliers-et-laisses', - 'Compléments alimentaires' => 'complements-alimentaires', - 'Composteurs' => 'composteurs', - 'Concerts' => 'concerts', - 'Concours' => 'concours', - 'Congélateurs' => 'congelateurs', - 'Connectiques' => 'connectiques', - 'Console Google Stadia' => 'google-stadia', - 'Console Nintendo Classic Mini' => 'nintendo-classic-mini', - 'Console Nintendo Classic Mini: SNES' => 'nintendo-classic-mini-snes', - 'Console Nintendo Switch' => 'nintendo-switch', - 'Console Nintendo Switch Lite' => 'nintendo-switch-lite', - 'Console PS4' => 'playstation-4', - 'Console PS4 Pro' => 'playstation-4-pro', - 'Console PS5' => 'playstation-5', - 'Consoles' => 'consoles', - 'Consoles & jeux vidéo' => 'consoles-jeux-video', - 'Console Sega Mega Drive Mini' => 'sega-mega-drive-mini', - 'Console Xbox One S' => 'xbox-one-s', - 'Console Xbox One X' => 'xbox-one-x', - 'Console Xbox Series S' => 'xbox-series-s', - 'Console Xbox Series X' => 'xbox-series-x', - 'Consommables imprimantes' => 'consommables-imprimantes', - 'Converse' => 'converse', - 'Coques iPhone' => 'coques-iphone', - 'Corsair Void PRO' => 'corsair-void-pro', - 'Costumes' => 'costumes', - 'Costumes & déguisements' => 'costumes-et-deguisements', - 'Couches' => 'couches', - 'Couettes' => 'couettes', - 'Coupes menstruelles' => 'coupes-menstruelles', - 'Cours & formations' => 'cours-et-formations', - 'Courses hippiques' => 'courses-hippiques', - 'Couteaux de cuisine' => 'couteaux-de-cuisine', - 'Couture' => 'couture', - 'Couverts' => 'couverts', - 'Couverts pour bébés' => 'couverts-pour-bebes', - 'Covoiturage' => 'covoiturage', - 'Crash Team Racing Nitro-Fueled' => 'crash-team-racing-nitro-fueled', - 'Cravates' => 'cravates', - 'Crédits' => 'credits', - 'Crèmes hydratantes' => 'cremes-hydratantes', - 'Crèmes solaires' => 'cremes-solaires', - 'Croisières' => 'croisieres', - 'Croquettes pour chat' => 'croquettes-pour-chat', - 'Croquettes pour chien' => 'croquettes-pour-chien', - 'Cuiseurs à riz' => 'cuiseur-riz', - 'Cuisinières' => 'cuisinieres', - 'Culottes menstruelles' => 'culottes-menstruelles', - 'Culture & divertissement' => 'culture-divertissement', - 'Cyberpunk 2077' => 'cyberpunk-2077', - 'Cyclisme' => 'cyclisme', - 'Cyclisme & sports urbains' => 'cyclisme-sports-urbains', - 'Darksiders' => 'darksiders', - 'Dashcams' => 'dashcams', - 'DDR3' => 'ddr3', - 'DDR4' => 'ddr4', - 'Dead Rising' => 'dead-rising', - 'Death Stranding' => 'death-stranding', - 'Décoration' => 'decoration', - 'Décorations de Noël' => 'decoration-noel', - 'Deebot' => 'ecovacs-deebot', - 'Deezer' => 'deezer', - 'Dell' => 'dell', - 'Dell XPS' => 'dell-xps', - 'Delsey' => 'delsey', - 'Demandes de deals' => 'les-demandes-de-deals', - 'Denon' => 'denon', - 'Dentifrices' => 'dentifrices', - 'Déodorants' => 'deodorants', - 'Désherbants' => 'desherbants', - 'Déshumidificateurs' => 'deshumidificateurs', - 'Désinfectant' => 'desinfectants', - 'Désodorisants & parfums d'intérieur' => 'desodorisants-et-parfums-d-interieur', - 'Destiny' => 'destiny', - 'Destiny 2' => 'destiny-2', - 'Détecteurs de fumée' => 'detecteurs-de-fumee', - 'Detroit: Become Human' => 'detroit-become-human', - 'Deus Ex' => 'deus-ex', - 'Deus Ex: Mankind Divided' => 'deus-ex-mankind-divided', - 'Devil May Cry 5' => 'devil-may-cry-5', - 'Dishonored' => 'dishonored', - 'Dishonored 2' => 'dishonored-2', - 'Disney+' => 'disney-plus', - 'Disneyland Paris' => 'disneyland-paris', - 'Disques durs (internes)' => 'hdd', - 'Disques durs externes' => 'disques-durs-externes', - 'Divers' => 'divers', - 'DJI' => 'dji', - 'DJI Mavic Air 2' => 'dji-mavic-air-2', - 'DJI Mavic Mini' => 'dji-mavic-mini', - 'Dolce Gusto' => 'dolce-gusto', - 'Domotique' => 'smart-home', - 'Doom Eternal' => 'doom-eternal', - 'Dosettes Dolce Gusto' => 'dosettes-dolce-guste', - 'Dosettes Nespresso' => 'dosettes-nespresso', - 'Dosettes Senseo' => 'dosettes-senseo', - 'Dosettes Tassimo' => 'dosettes-tassimo', - 'Dr. Martens' => 'dr-martens', - 'Dragon Age' => 'dragon-age', - 'Dragon Ball' => 'dragon-ball', - 'Dragon Ball FighterZ' => 'dragon-ball-fighterz', - 'Dragon Ball Z: Kakarot' => 'dragon-ball-z-kakarot', - 'Dragon Quest' => 'dragon-quest', - 'Dragon Quest Builders' => 'dragon-quest-builders', - 'Dragon Quest Builders 2' => 'dragon-quest-builders-2', - 'Draisiennes' => 'draisiennes', - 'Draps & housses' => 'draps-et-housses', - 'Dreame V10' => 'xiaomi-dreame-v10', - 'Dreame V11' => 'xiaomi-dreame-v11', - 'Drones' => 'drones', - 'Durex' => 'durex', - 'DVD' => 'dvd', - 'Dying Light' => 'dying-light', - 'Dying Light 2' => 'dying-light-2', - 'Dyson' => 'dyson', - 'Dyson V10' => 'dyson-v10', - 'Dyson V11' => 'dyson-v11', - 'Eastpak' => 'eastpak', - 'Ebooks' => 'ebooks', - 'Écharpes & foulards' => 'echarpes-et-foulards', - 'Éclairage intelligent' => 'smart-light', - 'Écouteurs' => 'ecouteurs', - 'Écouteurs sans fil' => 'ecouteurs-sans-fil', - 'Écouteurs sport' => 'ecouteurs-sport', - 'Ecovacs' => 'ecovacs', - 'Ecovacs Deebot 900' => 'ecovacs-deebot-900', - 'Ecovacs Deebot OZMO 930' => 'ecovacs-deebot-ozmo-930', - 'Écrans' => 'ecrans', - 'Écrans 4K / UHD' => 'ecrans-4k-uhd', - 'Écrans 21" et moins' => 'ecrans-21-pouces-et-moins', - 'Écrans 24"' => 'ecrans-24-pouces', - 'Écrans 27"' => 'ecrans-27-pouces', - 'Écrans 29" et plus' => 'ecrans-29-pouces-et-plus', - 'Écrans Acer' => 'ecrans-acer', - 'Écrans Asus' => 'ecrans-asus', - 'Écrans BenQ' => 'ecrans-benq', - 'Écrans Dell' => 'ecrans-dell', - 'Écrans de projection' => 'ecrans-de-projection', - 'Écrans FreeSync' => 'ecrans-freesync', - 'Écrans gaming' => 'ecrans-gamer', - 'Écrans incurvés' => 'ecrans-incurves', - 'Écrans Philips' => 'ecrans-philips', - 'Écrans Samsung' => 'ecrans-samsung', - 'Électricité (matériel)' => 'electricite', - 'Electrolux' => 'electrolux', - 'Électroménager' => 'electromenager', - 'Embauchoirs' => 'embauchoirs', - 'Enceintes' => 'enceintes', - 'Enceintes Bluetooth' => 'enceintes-bluetooth', - 'Enceintes connectées' => 'enceintes-connectees', - 'Enceintes portables sans fil' => 'enceintes-portables-sans-fil', - 'Énergie' => 'energie', - 'Engrais' => 'engrais', - 'Épicerie & courses' => 'epicerie-courses-supermarches', - 'Épilateurs à lumière pulsée' => 'epilateurs-a-lumiere-pulsee', - 'Épilateurs électriques' => 'epilateurs-electriques', - 'Épilation' => 'epilation', - 'Équipement motard' => 'equipement-motard', - 'Équipement running' => 'equipement-running', - 'Équipement sportif' => 'equipement-sportif', - 'Érotisme' => 'erotisme', - 'Escarpins' => 'escarpins', - 'Événements sportifs' => 'evenements-sportifs', - 'Expositions' => 'expositions', - 'Extracteurs de jus' => 'extracteurs-de-jus', - 'F1 2017' => 'f1-2017', - 'F1 2019' => 'f1-2019', - 'Facom' => 'facom', - 'Fallout' => 'fallout', - 'Fallout 4' => 'fallout-4', - 'Fallout 76' => 'fallout-76', - 'Famille & enfants' => 'famille-enfants', - 'Far Cry' => 'far-cry', - 'Far Cry New Dawn' => 'far-cry-new-dawn', - 'Fards à paupières' => 'fards-a-paupieres', - 'Fast-foods' => 'fast-foods', - 'Fauteuils' => 'fauteuils', - 'Fauteuils gamer' => 'fauteuils-gaming', - 'Fe' => 'fe', - 'Fers à lisser / à friser' => 'fers-a-lisser-a-friser', - 'Fers à repasser' => 'fers-a-repasser', - 'Fers à souder' => 'fers-a-souder', - 'Festivals' => 'festivals', - 'Feutres' => 'feutres', - 'FIFA' => 'fifa', - 'FIFA 17' => 'fifa-17', - 'FIFA 18' => 'fifa-18', - 'FIFA 19' => 'fifa-19', - 'FIFA 20' => 'fifa-20', - 'FIFA 21' => 'fifa-21', - 'Figurines' => 'figurines', - 'Films & Séries' => 'films', - 'Final Fantasy' => 'final-fantasy', - 'Final Fantasy XII' => 'final-fantasy-xii', - 'Finances & Assurances' => 'finances-assurances', - 'fitbit' => 'fitbit', - 'Fitness & yoga' => 'fitness-yoga', - 'Flash' => 'flash', - 'Fluval' => 'fluval', - 'Foires & salons' => 'foires-et-salons', - 'Fonds de teint' => 'fonds-de-teint', - 'Football' => 'football', - 'Forfaits de ski' => 'forfaits-ski', - 'Forfaits mobiles' => 'forfaits-mobiles', - 'Forfaits mobiles et internet' => 'telecommunications', - 'For Honor' => 'for-honor', - 'Formations premiers secours' => 'formations-premiers-secours', - 'Formule 1' => 'formule-1', - 'Fortnite' => 'fortnite', - 'Fortnite: Pack Feu Obscur' => 'fortnite-pack-feu-obscur', - 'Forza' => 'forza', - 'Forza Horizon' => 'forza-horizon', - 'Forza Horizon 3' => 'forza-horizon-3', - 'Forza Horizon 4' => 'forza-horizon-4', - 'Forza Motorsport' => 'forza-motosport', - 'Forza Motorsport 7' => 'forza-motorsport-7', - 'Fossil' => 'fossil', - 'Fournitures scolaires' => 'fournitures-scolaires', - 'Fours' => 'fours', - 'Fours à poser' => 'fours-a-poser', - 'Fours encastrables' => 'fours-encastrables', - 'Friandises pour chat' => 'friandises-pour-chat', - 'Friandises pour chien' => 'friandises-pour-chien', - 'Friskies' => 'friskies', - 'Friteuses' => 'friteuses', - 'Friteuses sans huile' => 'friteuses-sans-huile', - 'Fruits & légumes' => 'fruits-et-legumes', - 'Fujifilm' => 'fujifilm', - 'Funko Pop' => 'funko-pop', - 'FURminator' => 'furminator', - 'Futuroscope' => 'futuroscope', - 'Gamelles' => 'gamelles', - 'Game of Thrones' => 'game-of-thrones', - 'Gaming' => 'le-laboratoire-des-gamers', - 'Gants' => 'gants', - 'Gants moto' => 'gants-moto', - 'Garmin' => 'garmin', - 'Garmin Fenix' => 'garmin-fenix', - 'Garmin Forerunner' => 'garmin-forerunner', - 'Garmin Vivoactive' => 'garmin-vivoactive', - 'Garmin Vivomove' => 'garmin-vivomove', - 'Gâteaux & biscuits' => 'gateaux-et-biscuits', - 'Gears 5' => 'gears-5', - 'Gel hydroalcoolique' => 'gel-hydroalcoolique', - 'Gels douche' => 'gels-douche', - 'Geox' => 'geox', - 'Ghost of Tsushima' => 'ghost-of-tsushima', - 'Gigoteuses' => 'gigoteuses', - 'Gillette Fusion' => 'gillette-fusion', - 'Gillette Mach3' => 'gillette-mach3', - 'Glaces' => 'glaces', - 'Glacières' => 'glacieres', - 'Glisse urbaine' => 'glisse-urbaine', - 'God of War' => 'god-of-war', - 'Google Chromecast' => 'google-chromecast', - 'Google Home' => 'google-home', - 'Google Home Max' => 'google-home-max', - 'Google Home Mini' => 'google-home-mini', - 'Google Nest Hub' => 'google-nest-hub', - 'Google Nest Mini' => 'google-nest-mini', - 'Google Pixel' => 'google-pixel', - 'Google Pixel 2' => 'google-pixel-2', - 'Google Pixel 2 XL' => 'google-pixel-2-xl', - 'Google Pixel 3' => 'google-pixel-3', - 'Google Pixel 3 XL' => 'google-pixel-3-xl', - 'Google Pixel 3a' => 'google-pixel-3a', - 'Google Pixel 4' => 'google-pixel-4', - 'Google Pixel 4 XL' => 'google-pixel-4xl', - 'Google Pixel 4a' => 'google-pixel-4a', - 'Google Pixel 5' => 'google-pixel-5', - 'Google Pixel XL' => 'google-pixel-xl', - 'GoPro' => 'gopro-hero', - 'GoPro Hero 9' => 'gopro-hero-9', - 'Gran Turismo' => 'gran-turismo', - 'Grille-pain' => 'grille-pain', - 'Grossesse & maternité' => 'grossesse-maternite', - 'GTA' => 'gta', - 'GTA V' => 'gta-v', - 'GTX 1060' => 'nvidia-geforce-gtx-1060', - 'GTX 1070' => 'nvidia-geforce-gtx-1070', - 'GTX 1080' => 'nvidia-geforce-gtx-1080', - 'GTX 1080 Ti' => 'nvidia-geforce-gtx-1080-ti', - 'GTX 1650' => 'gtx-1650', - 'GTX 1660' => 'gtx-1660', - 'GTX 1660 Ti' => 'gtx-1660-ti', - 'Guerlain La Petite Robe Noire' => 'guerlain-petite-robe-noire', - 'Guirlandes lumineuses' => 'guirlandes-lumineuses', - 'Guitares' => 'guitares', - 'Gyropodes' => 'gyropodes', - 'Half Life' => 'half-life', - 'Half Life 2' => 'half-life-2', - 'Half Life Alyx' => 'half-life-alyx', - 'Halloween' => 'halloween', - 'Haltères & poids' => 'halteres-et-poids', - 'Hama' => 'hama', - 'Hamacs' => 'hamacs', - 'Hand spinners' => 'hand-spinners', - 'Harnais pour chien' => 'harnais-pour-chien', - 'Harry Potter' => 'harry-potter', - 'Havaianas' => 'havaianas', - 'High-Tech' => 'high-tech', - 'High-tech & informatique' => 'le-laboratoire-high-tech-informatique', - 'Hisense' => 'hisense', - 'Home Cinéma' => 'home-cinema', - 'Honor' => 'honor', - 'Honor 6X' => 'honor-6x', - 'Honor 8' => 'honor-8', - 'Honor 8 Pro' => 'honor-8-pro', - 'Honor 8X' => 'honor-8x', - 'Honor 8X Max' => 'honor-8x-max', - 'Honor 9' => 'honor-9', - 'Honor 10' => 'honor-10', - 'Honor 20' => 'honor-20', - 'Honor 20 Lite' => 'honor-20-lite', - 'Honor 20 Pro' => 'honor-20-pro', - 'Honor Band 5' => 'honor-band-5', - 'Honor MagicBook' => 'honor-magicbook', - 'Honor MagicWatch 2' => 'honor-magicwatch-2', - 'Honor View 20' => 'honor-view-20', - 'Horizon Zero Dawn' => 'horizon-zero-dawn', - 'Hôtels & Hébergements' => 'hotels', - 'Hoverboards' => 'hoverboards', - 'HTC 10' => 'htc-10', - 'HTC Desire' => 'htc-desire', - 'HTC One M9' => 'htc-one-m9', - 'HTC U11' => 'htc-u11', - 'HTC U Play' => 'htc-u-play', - 'HTC U Ultra' => 'htc-u-ultra', - 'HTC Vive' => 'htc-vive', - 'Huawei' => 'huawei', - 'Huawei FreeBuds 3' => 'huawei-freebuds-3', - 'Huawei Mate 9' => 'huawei-mate-9', - 'Huawei Mate 10' => 'huawei-mate-10', - 'Huawei Mate 10 Pro' => 'huawei-mate-10-pro', - 'Huawei Mate 20' => 'huawei-mate-20', - 'Huawei Mate 20 Lite' => 'huawei-mate-20-lite', - 'Huawei Mate 20 Pro' => 'huawei-mate-20-pro', - 'Huawei Mate 20 RS' => 'huawei-mate-20-rs', - 'Huawei Mate 30' => 'huawei-mate-30', - 'Huawei Mate 30 Lite' => 'huawei-mate-30-lite', - 'Huawei Mate 30 Pro' => 'huawei-mate-30-pro', - 'Huawei P8 Lite' => 'huawei-p8-lite', - 'Huawei P9 Lite' => 'huawei-p9-lite', - 'Huawei P10' => 'huawei-p10', - 'Huawei P10 Lite' => 'huawei-p10-lite', - 'Huawei P10 Plus' => 'huawei-p10-plus', - 'Huawei P20' => 'huawei-p20', - 'Huawei P20 Lite' => 'huawei-p20-lite', - 'Huawei P20 Pro' => 'huawei-p20-pro', - 'Huawei P30' => 'huawei-p30', - 'Huawei P30 Lite' => 'huawei-p30-lite', - 'Huawei P30 Pro' => 'huawei-p30-pro', - 'Huawei P40' => 'huawei-p40', - 'Huawei P40 Lite' => 'huawei-p40-lite', - 'Huawei P40 Pro' => 'huawei-p40-pro', - 'Huawei Watch' => 'huawei-watch', - 'Huawei Watch 2' => 'huawei-watch-2', - 'Hubs' => 'hubs', - 'Hugo Boss Bottled' => 'hugo-boss-bottled', - 'Huile moteur' => 'huile-moteur', - 'Hygiène & soins' => 'hygiene-soins', - 'Hygiène de la maison' => 'hygiene-de-la-maison', - 'Hygiène des bébés' => 'hygiene-des-bebes', - 'Hygiène intime' => 'hygiene-intime', - 'iMac' => 'mac-de-bureau', - 'iMac 2021' => 'imac-2021', - 'Image, son, photo' => 'le-laboratoire-audiovisuel', - 'Impressions photo' => 'impressions-photo', - 'Imprimantes' => 'imprimantes', - 'Imprimantes 3D' => 'imprimantes-3d', - 'Imprimantes Brother' => 'imprimantes-brother', - 'Imprimantes Canon' => 'imprimantes-canon', - 'Imprimantes Epson' => 'imprimantes-epson', - 'Imprimantes HP' => 'imprimantes-hp', - 'Imprimantes laser' => 'imprimantes-laser', - 'Imprimantes multifonctions' => 'imprimantes-multifonctions', - 'Informatique' => 'informatique', - 'Instax Mini' => 'instax-mini', - 'Instruments de musique' => 'instruments-de-musique', - 'Intel i5' => 'intel-i5', - 'Intel i7' => 'intel-i7', - 'Intel i9' => 'intel-i9', - 'iPad' => 'apple-ipad', - 'iPad 2019' => 'ipad-2019', - 'iPad 2020' => 'ipad-2020', - 'iPad Air' => 'ipad-air', - 'iPad Air 2019' => 'ipad-air-2019', - 'iPad Air 2020' => 'ipad-air-2020', - 'iPad Mini' => 'apple-ipad-mini', - 'iPad Pro' => 'apple-ipad-pro', - 'iPad Pro 11' => 'ipad-pro-11', - 'iPad Pro 12.9' => 'ipad-pro-12-9', - 'iPad Pro 2020' => 'ipad-pro-2020', - 'iPhone' => 'apple-iphone', - 'iPhone 6' => 'apple-iphone-6', - 'iPhone 7' => 'apple-iphone-7', - 'iPhone 7 Plus' => 'apple-iphone-7-plus', - 'iPhone 8' => 'apple-iphone-8', - 'iPhone 8 Plus' => 'apple-iphone-8-plus', - 'iPhone 11' => 'iphone-11', - 'iPhone 11 Pro' => 'iphone-11-pro', - 'iPhone 11 Pro Max' => 'iphone-11-pro-max', - 'iPhone 12' => 'iphone-12', - 'iPhone 12 Mini' => 'iphone-12-mini', - 'iPhone 12 Pro' => 'iphone-12-pro', - 'iPhone 12 Pro Max' => 'iphone-12-pro-max', - 'iPhone SE' => 'apple-iphone-se', - 'iPhone X' => 'apple-iphone-x', - 'iPhone XR' => 'apple-iphone-xr', - 'iPhone XS' => 'apple-iphone-xs', - 'iPhone XS Max' => 'apple-iphone-xs-max', - 'iRobot Roomba' => 'irobot-roomba', - 'Isolation' => 'isolation', - 'Jabra Elite 75t' => 'jabra-elite-75t', - 'Jabra Elite 85h' => 'jabra-elite-85h', - 'Jabra Elite 85t' => 'jabra-elite-85t', - 'Jabra Elite Active 65t' => 'jabra-elite-active-65t', - 'Jacuzzis' => 'jacuzzis', - 'Jardin' => 'jardin', - 'Jardin & bricolage' => 'jardin-bricolage', - 'Jardinage' => 'entretien-du-jardin', - 'JBL' => 'jbl', - 'JBL Charge 4' => 'jbl-charge-4', - 'JBL Flip' => 'jbl-flip', - 'JBL GO' => 'jbl-go', - 'JBL Xtreme 2' => 'jbl-xtreme-2', - 'Jeans' => 'jeans', - 'Jets dentaires' => 'jets-dentaires', - 'Jeux & jouets' => 'jeux-jouets', - 'Jeux & sports de café' => 'jeux-sports-cafe-bar', - 'Jeux d'adresse' => 'jeux-adresse', - 'Jeux d'apprentissage' => 'jeux-d-apprentissage', - 'Jeux d'eau' => 'jeux-jouets-eau', - 'Jeux d'extérieur' => 'jeux-d-exterieur', - 'Jeux d'imitation' => 'jeux-d-imitation', - 'Jeux de cartes et de plateau' => 'jeux-cartes-plateau-societe', - 'Jeux de construction' => 'jeux-de-construction', - 'Jeux de hasard & paris' => 'jeux-et-paris', - 'Jeux de société' => 'jeux-de-societe', - 'Jeux Nintendo 3DS' => 'jeux-3ds', - 'Jeux Nintendo Switch' => 'jeux-nintendo-switch', - 'Jeux PC' => 'jeux-pc', - 'Jeux PC dématérialisés' => 'jeux-pc-dematerialises', - 'Jeux pour bébés' => 'jeux-pour-bebes', - 'Jeux PS4' => 'jeux-playstation-4', - 'Jeux PS4 dématérialisés' => 'jeux-ps4-dematerialises', - 'Jeux PS5' => 'jeux-playstation-5', - 'Jeux PS5 dématérialisés' => 'jeux-playstation-5-dematerialises', - 'Jeux PS Plus' => 'jeux-ps-plus', - 'Jeux vidéo' => 'jeux-video', - 'Jeux VR' => 'jeux-vr', - 'Jeux Wii U' => 'jeux-wii-u', - 'Jeux Xbox One' => 'jeux-xbox-one', - 'Jeux Xbox One dématérialisés' => 'jeux-xbox-dematerialises', - 'Jeux Xbox Series X' => 'jeux-xbox-series-x', - 'Jeux Xbox with Gold' => 'jeux-xbox-with-gold', - 'Jouets' => 'jouets', - 'Jouets pour chat' => 'jouets-pour-chat', - 'Jouets pour chien' => 'jouets-pour-chien', - 'Journaux numériques' => 'journaux-numeriques', - 'Journaux papier' => 'journaux-papier', - 'Joy-Con' => 'manettes-nintendo-switch-joy-con', - 'Jungle Speed' => 'jungle-speed', - 'Just Cause' => 'just-cause', - 'Just Cause 3' => 'just-cause-3', - 'Just Cause 4' => 'just-cause-4', - 'Kärcher' => 'karcher', - 'Kaspersky' => 'kaspersky', - 'Kinder' => 'kinder', - 'Kindle Oasis' => 'kindle-oasis', - 'Kindle Paperwhite' => 'kindle-paperwhite', - 'Kindle Voyage' => 'kindle-voyage', - 'Kingdom Hearts' => 'kingdom-hearts', - 'Kingdom Hearts 3' => 'kingdom-hearts-3', - 'Kingston HyperX Cloud II' => 'kingston-hyperx-cloud-2', - 'Kits premiers secours' => 'premiers-secours', - 'Kobo' => 'kobo', - 'Kobo Aura 2' => 'kobo-aura-2', - 'Kobo Aura H2o' => 'kobo-aura-h2o', - 'Kobo Aura One' => 'kobo-aura-one', - 'L'annale du destin' => 'l-annale-du-destin', - 'L'ombre de la guerre' => 'l-ombre-de-la-guerre', - 'L'ombre du Mordor' => 'l-ombre-du-mordor', - 'Lacoste' => 'lacoste', - 'Lampadaires' => 'lampadaires', - 'Lampes' => 'lampes', - 'Lampes de table' => 'lampes-de-table', - 'Lampes solaires' => 'lampes-solaires', - 'Lancôme La Vie est Belle' => 'lancome-la-vie-est-belle', - 'Lapeyre' => 'lapeyre', - 'La Terre du Milieu' => 'la-terre-du-milieu', - 'Lavage auto' => 'lavage-auto', - 'Lavazza' => 'lavazza', - 'Lave-linge' => 'lave-linge', - 'Lave-linge frontal' => 'lave-linge-frontal', - 'Lave-linge séchant' => 'lave-linge-sechant', - 'Lave-linge top' => 'lave-linge-top', - 'Lave-vaisselle' => 'lave-vaisselle', - 'Lay-Z-Spa' => 'lay-z-spa', - 'Leasing voiture' => 'leasing-voiture', - 'Le bâton de la vérité' => 'le-baton-de-la-verite', - 'Lecteurs Blu-Ray' => 'lecteurs-blu-ray', - 'Lecteurs CD' => 'lecteurs-cd', - 'Lecteurs DVD' => 'lecteurs-dvd', - 'Lego' => 'lego', - 'Lego Architecture' => 'lego-architecture', - 'Lego Batman' => 'lego-batman', - 'Lego City' => 'lego-city', - 'Lego Creator' => 'lego-creator', - 'Lego Dimensions' => 'lego-dimensions', - 'Lego Duplo' => 'lego-duplo', - 'Lego Friends' => 'lego-friends', - 'Lego Harry Potter' => 'lego-harry-potter', - 'Lego Ideas' => 'lego-ideas', - 'Lego Marvel' => 'lego-marvel', - 'Lego Nexo Knights' => 'lego-nexo-knights', - 'Lego Ninjago' => 'lego-ninjago', - 'Lego Star Wars' => 'lego-star-wars', - 'Lego Technic' => 'lego-technic', - 'Lenovo' => 'lenovo', - 'Lenovo IdeaPad' => 'lenovo-ideapad', - 'Lenovo K6 Note' => 'lenovo-k6-note', - 'Lenovo P8' => 'lenovo-p8', - 'Lenovo Tab 3' => 'lenovo-tab-3', - 'Lenovo Tab 4' => 'lenovo-tab-4', - 'Lenovo ThinkPad' => 'lenovo-thinkpad', - 'Lenovo Yoga' => 'lenovo-yoga', - 'Lenovo Yoga Tab 3' => 'lenovo-yoga-tab-3', - 'Lentilles de contact' => 'lentilles-de-contact', - 'Le Seigneur des anneaux' => 'le-seigneur-des-anneaux', - 'Les Sims' => 'les-sims', - 'Les Sims 4' => 'les-sims-4', - 'Lessive' => 'lessive', - 'Levi's' => 'levi-s', - 'LG' => 'lg', - 'LG G4' => 'lg-g4', - 'LG G5' => 'lg-g5', - 'LG G6' => 'lg-g6', - 'LG OLED TV' => 'lg-oled-tv', - 'LG Q6' => 'lg-q6', - 'LG Q8' => 'lg-q8', - 'Life is Strange' => 'life-is-strange', - 'Linge de maison' => 'linge-de-maison', - 'Lingerie' => 'lingerie', - 'Lingettes désinfectantes' => 'lingettes-desinfectantes', - 'Lingettes pour bébés' => 'lingettes-pour-bebes', - 'Liseuses' => 'liseuses', - 'Litière pour chat' => 'litiere-pour-chat', - 'Lits' => 'lits', - 'Lits pour bébé' => 'lits-pour-bebe', - 'Lits pour enfants' => 'lits-pour-enfants', - 'Little Nightmares' => 'little-nightmares', - 'Livraison de repas' => 'service-de-livraison-de-repas', - 'Livres & littérature' => 'livres-litterature', - 'Livres & Magazines' => 'livres', - 'Livres audio' => 'livres-audio', - 'Livres photo' => 'livres-photo', - 'Location de voiture' => 'location-de-voiture', - 'Logiciels' => 'logiciels', - 'Logiciels de sécurité' => 'logiciels-de-securite', - 'Logiciels Microsoft' => 'logiciels-microsoft', - 'Logitech' => 'logitech', - 'Logitech G502' => 'logitech-g502', - 'Logitech G703' => 'logitech-g703', - 'Logitech G Pro X' => 'logitech-g-pro-x', - 'Logitech Harmony' => 'logitech-harmony', - 'Logitech MX Master' => 'logitech-mx-master', - 'Logitech MX Master 2S' => 'logitech-mx-master-2s', - 'Loisirs créatifs' => 'loisirs-creatifs', - 'Lolita Lempicka' => 'lolita-lempicka-premier-parfum', - 'Loup-Garou' => 'loup-garou', - 'Lubrifiants' => 'lubrifiants', - 'Luges' => 'luges', - 'Luigi's Mansion 3' => 'luigi-mansion-3', - 'Luminaires' => 'luminaires', - 'Lunettes de natation' => 'lunettes-de-natation', - 'Lunettes de soleil' => 'lunettes-de-soleil', - 'M&M's' => 'metm-s', - 'MacBook' => 'macbook', - 'MacBook Air' => 'apple-macbook-air', - 'MacBook Pro' => 'apple-macbook-pro', - 'MacBook Pro 13' => 'macbook-pro-13', - 'MacBook Pro 15' => 'macbook-pro-15', - 'MacBook Pro 16' => 'macbook-pro-16', - 'Machines à café à dosettes' => 'machines-a-cafe-a-dosettes', - 'Machines à café en grain' => 'machines-a-cafe-en-grain', - 'Machines à coudre' => 'machines-a-coudre', - 'Machines à pain' => 'machines-a-pain', - 'Machines de sport' => 'machines-sport', - 'Machines Dolce Gusto' => 'machines-dolce-gusto', - 'Machines Nespresso' => 'machines-nespresso', - 'Machines Senseo' => 'machines-senseo', - 'Machines Tassimo' => 'machines-tassimo', - 'Mac mini' => 'mac-mini', - 'Madden NFL 20' => 'madden-nfl-20', - 'Magasins d'usine' => 'magasins-usine', - 'Magazines' => 'magazines', - 'Maillots de bain' => 'maillots-de-bain', - 'Maillots de football' => 'maillots-de-football', - 'Maison & Habitat' => 'maison-habitat', - 'Maisons de poupées' => 'maisons-poupees', - 'Makita' => 'makita', - 'Manettes' => 'manettes-accessoires-consoles', - 'Manettes DualSense' => 'manettes-playstation-5', - 'Manettes Nintendo Switch' => 'manettes-nintendo-switch', - 'Manettes Nintendo Switch Pro' => 'manettes-nintendo-switch-pro', - 'Manettes PlayStation 4' => 'manettes-playstation-4', - 'Manettes Xbox' => 'manettes-xbox', - 'Manettes Xbox One' => 'manettes-xbox-one', - 'Manettes Xbox One Elite' => 'manettes-xbox-one-elite', - 'Manettes Xbox Series X' => 'manettes-xbox-series-x', - 'Manix' => 'manix', - 'Manteaux' => 'manteaux', - 'Maquillage' => 'maquillage', - 'Marchands et leurs offres' => 'vos-avisdemandes-sur-les-marchands-et-leurs-offres', - 'Mario & Sonic aux Jeux Olympiques de Tokyo 2020' => 'mario-sonic-jeux-olympiques-tokyo-2020', - 'Mario Kart' => 'mario-kart', - 'Marques' => 'marques', - 'Marteaux & maillets' => 'marteaux-et-maillets', - 'Marvel's Avengers' => 'marvels-avengers', - 'Mascara' => 'mascara', - 'Masques cheveux' => 'masques-cheveux', - 'Masques de protection' => 'masques-de-protection-respiratoire', - 'Masques de ski' => 'masques-de-ski', - 'Mass Effect' => 'mass-effect', - 'Mass Effect: Andromeda' => 'mass-effect-andromeda', - 'Matchs de football' => 'matchs-de-football', - 'Matelas' => 'matelas', - 'Matelas gonflables' => 'matelas-gonflables', - 'Matériaux de construction' => 'materiaux-de-construction', - 'Matériel de ski' => 'materiel-de-ski', - 'Medion' => 'medion', - 'Metro' => 'metro', - 'Metro 2033' => 'metro-2033', - 'Metro Exodus' => 'metro-exodus', - 'Meubles pour aquarium' => 'meubles-pour-aquarium', - 'Meubles pour chat' => 'meubles-pour-chat', - 'Meubles salle de bain' => 'salle-de-bain', - 'Micro-casques gaming' => 'micro-casques-gaming', - 'Micro-ondes' => 'micro-ondes', - 'Microphones' => 'microphones', - 'Micro SD' => 'micro-sd', - 'Microsoft Flight Simulator' => 'microsoft-flight-simulator', - 'Microsoft Office' => 'microsoft-office', - 'Microsoft Surface Book' => 'microsoft-surface-book', - 'Microsoft Surface Pro 6' => 'microsoft-surface-pro-6', - 'Microsoft Surface Pro 7' => 'microsoft-surface-pro-7', - 'Miele' => 'miele', - 'Minecraft' => 'minecraft', - 'Mini PC' => 'mini-pc', - 'Mini réfrigérateurs' => 'mini-refrigerateurs', - 'Miroirs' => 'miroirs', - 'Mixeurs & Blenders' => 'mixeurs-blenders', - 'Mixeurs plongeants' => 'mixeur-plongeant', - 'Mobilier' => 'mobilier', - 'Mobilier de bureau' => 'fournitures-de-bureau', - 'Mobilier de jardin' => 'mobilier-jardin', - 'Mobilier de salon' => 'mobilier-salon', - 'Mobvoi Ticwatch' => 'mobvoi-ticwatch', - 'Mode' => 'mode', - 'Mode & accessoires' => 'mode-accessoires', - 'Mode & beauté' => 'le-laboratoire-de-la-mode-beaute', - 'Mode enfants' => 'mode-enfants', - 'Mode femme' => 'mode-femme', - 'Mode homme' => 'mode-homme', - 'Modélisme' => 'modelisme', - 'Monopoly' => 'monopoly', - 'Montage PC' => 'montage-pc', - 'Montre connectée Amazfit' => 'montres-connectees-amazfit', - 'Montre connectée Garmin' => 'montres-connectees-garmin', - 'Montre connectée Honor' => 'montres-connectees-honor', - 'Montre connectée Samsung' => 'smartwatch-samsung', - 'Montres' => 'montres', - 'Montres connectées' => 'smartwatch', - 'Mortal Kombat' => 'mortal-kombat', - 'Mortal Kombat 11' => 'mortal-kombat-11', - 'Moto C Plus' => 'moto-c-plus', - 'Moto E4' => 'moto-e4', - 'Moto G5' => 'moto-g5', - 'Moto G5 Plus' => 'moto-g5-plus', - 'Moto G5S' => 'moto-g5s', - 'Moto G5S Plus' => 'moto-g5s-plus', - 'Moto G6' => 'moto-g6', - 'Moto G6 Play' => 'moto-g6-play', - 'Moto G6 Plus' => 'moto-g6-plus', - 'Moto G7 Play' => 'moto-g7-play', - 'Moto G7 Plus' => 'moto-g7-plus', - 'Moto G7 Power' => 'moto-g7-power', - 'Moto M' => 'moto-m', - 'Motorola' => 'motorola', - 'Moto Z2' => 'moto-z2', - 'Moto Z2 Force' => 'moto-z2-force', - 'Moto Z2 Play' => 'moto-z2-play', - 'Moto Z3' => 'moto-z3', - 'Moto Z3 Play' => 'moto-z3-play', - 'Moulinex' => 'moulinex', - 'Mousses à raser' => 'mousses-a-raser', - 'MSI' => 'msi', - 'Musées' => 'musees', - 'Musique' => 'musique', - 'NAS' => 'nas', - 'Natation' => 'natation', - 'Nature & sports d'hiver' => 'nature-sports-hiver', - 'Navigation' => 'navigation', - 'NBA 2K' => 'nba-2k', - 'NBA 2K20' => 'nba-2k20', - 'NERF' => 'nerf', - 'Nescafé' => 'nescafe', - 'Nespresso' => 'nespresso', - 'Nest Learning Thermostat' => 'nest-learning-thermostat', - 'Nest Protect' => 'nest-protect', - 'Netflix' => 'netflix', - 'Nettoyeurs haute-pression' => 'nettoyeurs-haute-pression', - 'Nettoyeurs haute pression Karcher' => 'nettoyeurs-haute-pression-karcher', - 'Nettoyeurs vapeur' => 'nettoyeurs-vapeur', - 'New Balance' => 'new-balance', - 'New Balance 574' => 'new-balance-574', - 'NHL 20' => 'nhl-20', - 'Nike' => 'nike', - 'Nike Air Force' => 'nike-air-force', - 'Nike Air Jordan' => 'nike-air-jordan', - 'Nike Air Max' => 'nike-air-max', - 'Nike Air Max 90' => 'nike-air-max-90', - 'Nike Air Max 200' => 'nike-air-max-200', - 'Nike Air Max 270' => 'nike-air-max-270', - 'Nike Air Max 720' => 'nike-air-max-720', - 'Nike Free' => 'nike-free', - 'Nike Huarache' => 'nike-huarache', - 'Nike Roshe Run' => 'nike-roshe-run', - 'Nikon' => 'nikon', - 'Nikon D3500' => 'nikon-d3500', - 'Ni no Kuni' => 'ni-no-kuni', - 'Ni No Kuni: Wrath of the White Witch' => 'ni-no-kuni-wrath-white-witch', - 'Ni No Kuni II: Revenant Kingdom' => 'ni-no-kuni-2-revenant-kingdom', - 'Nintendo' => 'nintendo', - 'Nioh' => 'nioh', - 'Nivea' => 'nivea', - 'Nocciolata' => 'nocciolata', - 'Nokia' => 'nokia', - 'Nokia 5' => 'nokia-5', - 'Nokia 6' => 'nokia-6', - 'Nokia 8' => 'nokia-8', - 'Nokia 9 PureView' => 'nokia-9-pureview', - 'Nougats' => 'nougats', - 'Nourriture pour chat' => 'nourriture-pour-chat', - 'Nourriture pour chien' => 'nourriture-pour-chien', - 'Nourriture pour poissons' => 'nourriture-pour-poissons', - 'Nutella' => 'nutella', - 'Nvidia' => 'nvidia', - 'Nvidia GeForce' => 'nvidia-geforce', - 'Nvidia Shield' => 'nvidia-shield', - 'Objectifs' => 'objectifs', - 'Objets connectés' => 'objets-connectes', - 'Oculus Go' => 'oculus-go', - 'Oculus Rift' => 'oculus-rift', - 'Oiseaux' => 'oiseaux', - 'One Piece: Pirate Warriors' => 'one-piece-pirate-warriors', - 'OnePlus 5' => 'oneplus-5', - 'OnePlus 5T' => 'oneplus-5t', - 'OnePlus 6' => 'oneplus-6', - 'OnePlus 6T' => 'oneplus-6t', - 'OnePlus 7' => 'oneplus-7', - 'OnePlus 7 Pro' => 'oneplus-7-pro', - 'OnePlus 7T' => 'oneplus-7t', - 'OnePlus 7T Pro' => 'oneplus-7t-pro', - 'OnePlus 8' => 'oneplus-8', - 'OnePlus 8 Pro' => 'oneplus-8-pro', - 'OnePlus 8T' => 'oneplus-8t', - 'OnePlus 9' => 'oneplus-9', - 'OnePlus 9 Pro' => 'oneplus-9-pro', - 'OnePlus Nord' => 'oneplus-nord', - 'Onkyo' => 'onkyo', - 'Oppo Find X2 Lite' => 'oppo-find-x2-lite', - 'Oppo Find X2 Neo' => 'oppo-find-x2-neo', - 'Oppo Find X2 Pro' => 'oppo-find-x2-pro', - 'Oppo Reno' => 'oppo-reno', - 'Optique' => 'optique', - 'Oral-B' => 'oral-b', - 'Ordinateurs de bureau' => 'ordinateurs-de-bureau', - 'Ordinateurs tout-en-un' => 'pc-de-bureau-complets', - 'Oreillers' => 'oreillers', - 'Osram Smart+' => 'osram-smart-plus', - 'Outillage' => 'outillage', - 'Outils à main' => 'outils-main', - 'Outils de jardinage' => 'outils-de-jardinage', - 'Outils électriques' => 'outils-electriques', - 'Overwatch' => 'overwatch', - 'Packs clavier-souris' => 'packs-clavier-souris', - 'Packs consoles' => 'packs-consoles', - 'Paco Rabanne Invictus' => 'paco-rabanne-invictus', - 'Paco Rabanne Lady Million' => 'paco-rabanne-lady-million', - 'Paco Rabanne One Million' => 'paco-rabanne-one-million', - 'Pain & pâtisseries' => 'pain-patisseries', - 'Pampers' => 'pampers', - 'Panasonic' => 'panasonic', - 'Panasonic Lumix' => 'panasonic-lumix', - 'Panier Plus' => 'panier-plus', - 'Pantalons' => 'pantalons', - 'Papeterie' => 'papeterie', - 'Papeterie et bureautique' => 'papeterie-bureautique', - 'Papier bureautique' => 'papier-bureautique', - 'Papier peint' => 'papier-peint', - 'Papier toilette' => 'papier-toilette', - 'Parapharmacie' => 'parapharmacie', - 'Parasols' => 'parasols', - 'Parc Astérix' => 'parc-asterix', - 'Parcs d'attraction' => 'parcs-d-attraction', - 'Parfums' => 'parfums', - 'Parfums femme' => 'parfums-femme', - 'Parfums homme' => 'parfums-homme', - 'Parkas' => 'parkas', - 'Parrot' => 'parrot', - 'Partitions' => 'partitions', - 'Pâtée pour chat' => 'patee-pour-chat', - 'Pâtée pour chien' => 'patee-pour-chien', - 'Pâtes à tartiner' => 'pates-tartiner', - 'Pâtisserie' => 'patisserie', - 'PC Barebones' => 'pc-barebones', - 'PC gamer fixe' => 'pc-gamer-complets', - 'PC gaming' => 'pc-gaming', - 'PC hybrides' => 'hybrides', - 'PC Microsoft Surface' => 'pc-microsoft-surface', - 'PC portables' => 'pc-portables', - 'PC portables Acer' => 'pc-portables-acer', - 'PC portables ASUS' => 'pc-portables-asus', - 'PC portables Dell' => 'pc-portables-dell', - 'PC portables gaming' => 'portables-gamer', - 'PC portables Honor' => 'pc-portables-honor', - 'PC portables HP' => 'pc-portables-hp', - 'PC portables Lenovo' => 'pc-portables-lenovo', - 'PC portables Lenovo Legion' => 'lenovo-legion', - 'PC portables Xiaomi' => 'pc-portables-xiaomi', - 'Pêche' => 'peche', - 'Peignes & brosses à cheveux' => 'peignes-et-brosses-a-cheveux', - 'Peignoirs' => 'peignoirs', - 'Peintures' => 'peintures', - 'Peluches' => 'peluches', - 'Perceuses' => 'perceuses', - 'Périphériques PC' => 'peripheriques-pc', - 'Persona 5' => 'persona-5', - 'Persona 5 Royal' => 'persona-5-royal', - 'PES' => 'pro-evolution-soccer', - 'Pèse-personnes' => 'pese-personnes', - 'Petites voitures' => 'petites-voitures', - 'Pharmacie & parapharmacie' => 'pharmacie-parapharmacie', - 'Philips' => 'philips', - 'Philips Hue' => 'philips-hue', - 'Philips Hue E14' => 'philips-hue-e14', - 'Philips Hue E27' => 'philips-hue-e27', - 'Philips Hue Go' => 'philips-hue-go', - 'Philips Hue GU10' => 'philips-hue-gu10', - 'Philips Hue LightStrip' => 'philips-hue-lightstrip', - 'Philips Hue Play HDMI Sync Box' => 'philips-hue-play-hdmi-sync-box', - 'Philips Lumea' => 'philips-lumea', - 'Philips OneBlade' => 'philips-one-blade', - 'Philips Sonicare' => 'philips-sonicare', - 'Photo' => 'photo', - 'Pièces auto' => 'pieces-auto', - 'Pièces moto' => 'pieces-moto', - 'Pièces vélo' => 'pieces-velo', - 'Piles' => 'piles', - 'Piles rechargeables' => 'piles-rechargeables', - 'Pinceaux maquillage' => 'pinceaux-maquillage', - 'Pinces' => 'pinces', - 'Ping-pong' => 'ping-pong', - 'Pioneer' => 'pioneer', - 'Piscines' => 'piscines', - 'Pizza' => 'pizza', - 'Places de cinéma' => 'places-de-cinema', - 'Plafonniers' => 'plafonniers', - 'Plancha' => 'planchas', - 'Plantes & semis' => 'plantes', - 'Plaques de cuisson' => 'plaques-de-cuisson', - 'Platines vinyle' => 'platines-vinyle', - 'Plats & moules' => 'plats-et-moules', - 'PlayerUnknown's Battlegrounds' => 'playerunknown-s-battleground', - 'Playmobil' => 'playmobil', - 'PlayStation' => 'playstation', - 'Pneus' => 'pneus', - 'PocketBook' => 'pocketbook', - 'PocketBook Touch Lux 3' => 'pocketbook-touch-lux-3', - 'POCO F2 Pro' => 'poco-f2-pro', - 'POCO F3' => 'poco-f3', - 'POCO M3' => 'poco-m3', - 'POCO X3' => 'poco-x3', - 'POCO X3 Pro' => 'poco-x3-pro', - 'Poêles' => 'poeles', - 'Pokémon' => 'pokemon', - 'Pokémon: Let's Go' => 'pokemon-letsgo', - 'Pokémon Épée et Bouclier' => 'pokemon-epee-bouclier', - 'Pokémon Tournament' => 'pokemon-tournament', - 'Pokémon Ultra Sun / Moon' => 'pokemon-ultra-sun-moon', - 'Polaroid' => 'polaroid', - 'Polos' => 'polos', - 'Pompes à vélo' => 'pompes-velo', - 'Porte-bébé' => 'porte-bebe', - 'Portefeuilles' => 'portefeuilles', - 'Posters' => 'posters', - 'Potager' => 'potager', - 'Pots & cache-pots' => 'pots-et-cache-pots', - 'Poubelles' => 'poubelles', - 'Poulaillers' => 'poulaillers', - 'Poupées' => 'poupees', - 'Poussettes' => 'poussettes-bebe', - 'Présentez-vous !' => 'mieux-se-connaitre-presentez-vous', - 'Préservatifs' => 'preservatifs', - 'Princesse Tam-Tam' => 'princesse-tam-tam', - 'Prises connectées' => 'prises-connectees', - 'Processeurs' => 'processeurs', - 'Produit pour lentilles' => 'produit-pour-lentilles', - 'Produits de massage' => 'produits-de-massage', - 'Produits frais' => 'produits-frais', - 'Produits reconditionnés' => 'reconditionne', - 'Produits vétérinaires' => 'produits-veterinaires', - 'Programme d'Entraînement Cérébral du Dr. Kawashima' => 'dr-kawashima-brain-training', - 'Project Cars 2' => 'project-cars-2', - 'Protection de la maison' => 'protection-de-la-maison', - 'Protections intimes' => 'protections-intimes', - 'Protection solaire' => 'protection-solaire', - 'Puériculture' => 'puericulture', - 'Pulls' => 'pulls', - 'Puma' => 'puma', - 'Purificateurs d'air' => 'purificateurs-d-air', - 'Purina' => 'purina', - 'Puzzles' => 'puzzles', - 'Pyjamas' => 'pyjamas', - 'Pyjamas & chemises de nuit' => 'pyjamas-chemises-de-nuit', - 'Pyjamas pour bébés' => 'pyjamas-pour-bebes', - 'Qobuz' => 'qobuz', - 'Quiksilver' => 'quiksilver', - 'Radiateurs' => 'radiateurs', - 'Ralph Lauren' => 'ralph-lauren', - 'RAM' => 'ram', - 'Randonnée' => 'randonnee', - 'Raquettes de ping-pong' => 'raquettes-de-ping-pong', - 'Raquettes de tennis' => 'raquettes-de-tennis', - 'Rasage et épilation' => 'rasage-epilation', - 'Rasoirs Braun' => 'rasoirs-braun', - 'Rasoirs électriques' => 'rasoirs-electriques', - 'Rasoirs Gillette' => 'gillette', - 'Rasoirs manuels' => 'rasoirs-manuels', - 'Rasoirs Philips' => 'rasoirs-philips', - 'Rasoirs Wilkinson' => 'rasoirs-wilkinson-sword', - 'Raspberry Pi' => 'raspberry-pi', - 'Ray-Ban' => 'ray-ban', - 'Razer' => 'razer', - 'Razer DeathAdder' => 'razer-deathadder', - 'Realme 5 Pro' => 'realme-5-pro', - 'Realme X2 Pro' => 'realme-x2-pro', - 'Red Dead Redemption' => 'red-dead-redemption', - 'Red Dead Redemption 2' => 'red-dead-redemption-2', - 'Réductions étudiants & jeunes' => 'reductions-etudiants-et-jeunes', - 'Reebok' => 'reebok', - 'Reebok Club C' => 'reebok-club-c', - 'Réfrigérateurs' => 'refrigerateurs', - 'Réfrigérateurs américains' => 'refrigerateurs-americains', - 'Refroidissement PC' => 'refroidissement-pc', - 'Réhausseurs' => 'rehausseurs', - 'Remington' => 'remington', - 'Repas de fête' => 'repas-fete-reveillon', - 'Repassage' => 'repassage', - 'Répéteurs' => 'repeteurs', - 'Réseau' => 'reseau', - 'Resident Evil' => 'resident-evil', - 'Resident Evil 3' => 'resident-evil-3', - 'Resident Evil 7' => 'resident-evil-7', - 'Restaurants' => 'restaurants', - 'Revêtements de sols' => 'revetements-de-sols', - 'Revêtements muraux' => 'revetements-muraux', - 'Rhum' => 'rhum', - 'Richelieus' => 'richelieus', - 'Ring Fit Adventure' => 'ring-fit-adventure', - 'Risk' => 'risk', - 'Robes & jupes' => 'robes-et-jupes', - 'Roborock' => 'roborock', - 'Roborock S5 MAX' => 'roborock-s5-max', - 'Roborock S6' => 'roborock-s6', - 'Robots cuiseurs' => 'robots-cuiseurs', - 'Robots ménagers' => 'robots-menagers', - 'Robot tondeuse' => 'robot-tondeuse', - 'ROCCAT' => 'roccat', - 'Rollers' => 'rollers', - 'Rouges à lèvres' => 'rouges-a-levres', - 'Routeurs' => 'routeurs', - 'Rowenta' => 'rowenta', - 'Royal Canin' => 'royal-canin', - 'RTX 2060' => 'rtx-2060', - 'RTX 2070' => 'rtx-2070', - 'RTX 2080' => 'rtx-2080', - 'RTX 2080 Ti' => 'rtx-2080-ti', - 'RTX 3070' => 'rtx-3070', - 'RTX 3080' => 'rtx-3080', - 'RTX 3090' => 'rtx-3090', - 'RX 480' => 'rx-480', - 'RX 580' => 'rx-580', - 'RX 590' => 'radeon-rx-590', - 'RX Vega 56' => 'rx-vega-56', - 'RX Vega 64' => 'rx-vega-64', - 'Sacs à déjections' => 'sacs-a-dejections', - 'Sacs à dos' => 'sacs-a-dos', - 'Sacs à langer' => 'sacs-a-langer', - 'Sacs à main' => 'sacs-a-main', - 'Sacs bandoulière' => 'sacs-bandouliere', - 'Sacs de couchage' => 'sacs-de-couchage', - 'Sacs de randonnée' => 'sacs-de-randonnee', - 'Sacs de sport' => 'sacs-de-sport', - 'Sacs de voyage' => 'sacs-de-voyage', - 'Salle à manger' => 'salle-manger', - 'Samsonite' => 'samsonite', - 'Samsung' => 'samsung', - 'Samsung Galaxy A5' => 'samsung-galaxy-a5', - 'Samsung Galaxy A50' => 'samsung-galaxy-a50', - 'Samsung Galaxy A51' => 'samsung-galaxy-a51', - 'Samsung Galaxy A51 5G' => 'samsung-galaxy-a51-5g', - 'Samsung Galaxy A70' => 'samsung-galaxy-a70', - 'Samsung Galaxy A80' => 'samsung-galaxy-a80', - 'Samsung Galaxy Buds' => 'samsung-galaxy-buds', - 'Samsung Galaxy Buds+' => 'samsung-galaxy-buds-plus', - 'Samsung Galaxy Buds Live' => 'samsung-galaxy-buds-live', - 'Samsung Galaxy Buds Pro' => 'samsung-galaxy-buds-pro', - 'Samsung Galaxy Fold' => 'samsung-galaxy-fold', - 'Samsung Galaxy Note 8' => 'samsung-galaxy-note-8', - 'Samsung Galaxy Note 9' => 'samsung-galaxy-note-9', - 'Samsung Galaxy Note 10' => 'samsung-galaxy-note-10', - 'Samsung Galaxy Note 10 Lite' => 'samsung-galaxy-note-10-lite', - 'Samsung Galaxy Note 10 Plus' => 'samsung-galaxy-note-10-plus', - 'Samsung Galaxy Note20' => 'samsung-galaxy-note-20', - 'Samsung Galaxy Note20 Ultra' => 'samsung-galaxy-note-20-ultra', - 'Samsung Galaxy S7' => 'samsung-galaxy-s7', - 'Samsung Galaxy S7 Edge' => 'samsung-galaxy-s7-edge', - 'Samsung Galaxy S8' => 'samsung-galaxy-s8', - 'Samsung Galaxy S8+' => 'samsung-galaxy-s8plus', - 'Samsung Galaxy S9' => 'samsung-galaxy-s9', - 'Samsung Galaxy S9 Plus' => 'samsung-galaxy-s9-plus', - 'Samsung Galaxy S10' => 'samsung-galaxy-s10', - 'Samsung Galaxy S10 Lite' => 'samsung-galaxy-s10-lite', - 'Samsung Galaxy S10+' => 'samsung-galaxy-s10-plus', - 'Samsung Galaxy S10e' => 'samsung-galaxy-s10e', - 'Samsung Galaxy S20' => 'samsung-galaxy-s20', - 'Samsung Galaxy S20 FE' => 'samsung-galaxy-s20-fe', - 'Samsung Galaxy S20 Ultra' => 'samsung-galaxy-s20-ultra', - 'Samsung Galaxy S20+' => 'samsung-galaxy-s20-plus', - 'Samsung Galaxy S21 5G' => 'samsung-galaxy-s21-5g', - 'Samsung Galaxy S21 Ultra 5G' => 'samsung-galaxy-s21-ultra-5g', - 'Samsung Galaxy S21+ 5G' => 'samsung-galaxy-s21-plus-5g', - 'Samsung Galaxy Tab A' => 'samsung-galaxy-tab-a', - 'Samsung Galaxy Tab S2' => 'samsung-galaxy-tab-s2', - 'Samsung Galaxy Tab S3' => 'samsung-galaxy-tab-s3', - 'Samsung Galaxy Tab S4' => 'samsung-galaxy-tab-s4', - 'Samsung Galaxy Tab S5e' => 'samsung-galaxy-tab-s5e', - 'Samsung Galaxy Tab S6' => 'samsung-galaxy-tab-s6', - 'Samsung Galaxy Tab S7' => 'samsung-galaxy-tab-s7', - 'Samsung Galaxy Watch' => 'samsung-galaxy-watch', - 'Samsung Galaxy Watch3' => 'samsung-galaxy-watch-3', - 'Samsung Galaxy Watch Active 2' => 'samsung-galaxy-watch-active2', - 'Samsung Galaxy Z Flip' => 'galaxy-z-flip', - 'Samsung Gear' => 'samsung-gear', - 'Samsung Gear S3' => 'samsung-gear-s3', - 'Samsung Gear VR' => 'samsung-gear-vr', - 'Sandales' => 'sandales', - 'SanDisk' => 'sandisk', - 'Sanitaires et robinetterie' => 'sanitaires-robinetterie', - 'Santé & Cosmétiques' => 'sante-et-cosmetiques', - 'Sapins de Noël' => 'sapins-noel', - 'Savons' => 'savons', - 'Scanners' => 'scanners', - 'Scanners A3' => 'scanners-a3', - 'Scanners A4' => 'scanners-a4', - 'Scies' => 'scies', - 'Scooters' => 'scooters', - 'Seagate' => 'seagate', - 'Sécateurs' => 'secateurs', - 'Sèche-cheveux' => 'seche-cheveux', - 'Sèche-linge' => 'seche-linge', - 'Seiko' => 'seiko', - 'Séjours' => 'sejours', - 'Sekiro: Shadows Die Twice' => 'sekiro', - 'Semis & graines' => 'semis-et-graines', - 'Sennheiser' => 'sennheiser', - 'Senseo' => 'senseo', - 'Séries TV' => 'series-tv', - 'Service & réparation auto-moto' => 'service-reparation-auto-moto', - 'Services' => 'services-divers', - 'Services auto' => 'services-auto', - 'Services de livraison' => 'services-livraisons', - 'Services moto' => 'services-moto', - 'Services photo' => 'services-photo', - 'Serviettes' => 'serviettes', - 'Serviettes hygiéniques' => 'serviettes-hygieniques', - 'Sextoys' => 'sextoys', - 'Shadow of the Colossus' => 'shadow-of-the-colossus', - 'Shadow of the Tomb Raider' => 'shadow-tomb-raider', - 'Shalimar' => 'shalimar', - 'Shampooings & soins' => 'shampooings-et-soins', - 'Shenmue' => 'shenmue', - 'Shenmue I & II' => 'shenmue-i-ii', - 'Shenmue III' => 'shenmue-iii', - 'Shorts' => 'shorts', - 'Shorts de bain' => 'shorts-de-bain', - 'Sièges auto' => 'sieges-auto', - 'Siemens' => 'siemens', - 'Skates & longboards' => 'skates-et-longboards', - 'Skechers' => 'sketchers', - 'Ski' => 'ski', - 'Skyrim' => 'skyrim', - 'Slips & boxers' => 'slips-et-boxers', - 'Smartphones' => 'smartphones', - 'Smartphones à moins de 100€' => 'smartphones-moins-de-100', - 'Smartphones à moins de 200€' => 'smartphones-moins-de-200', - 'Smartphones Android' => 'smartphones-android', - 'Smartphones Asus' => 'smartphones-asus', - 'Smartphones Google' => 'smartphones-google', - 'Smartphones Honor' => 'smartphones-honor', - 'Smartphones HTC' => 'smartphones-htc', - 'Smartphones Huawei' => 'smartphones-huawei', - 'Smartphones Lenovo Motorola' => 'smartphones-lenovo-motorola', - 'Smartphones LG' => 'smartphones-lg', - 'Smartphones Nokia' => 'smartphones-nokia', - 'Smartphones OnePlus' => 'smartphones-oneplus', - 'Smartphones Oppo' => 'smartphones-oppo', - 'Smartphones Realme' => 'smartphones-realme', - 'Smartphones Samsung' => 'smartphones-samsung', - 'Smartphones Sony' => 'smartphones-sony', - 'Smartphones Xiaomi' => 'smartphones-xiaomi', - 'Smartphones ZTE' => 'smartphones-zte', - 'Smart TV' => 'smart-tv', - 'Sneakers' => 'sneakers', - 'SodaStream' => 'sodastream', - 'Sofas gonflable' => 'sofas-gonflable', - 'Soin barbe et rasage' => 'soin-barbe-rasage', - 'Soin de la peau' => 'soin-peau', - 'Soin des cheveux' => 'soin-des-cheveux', - 'Soin des ongles' => 'soin-ongles', - 'Soins dentaires' => 'soins-dentaires', - 'Sonos' => 'sonos', - 'Sonos Beam' => 'sonos-beam', - 'Sonos Move' => 'sonos-move', - 'Sonos One' => 'sonos-one', - 'Sonos PLAY:1' => 'sonos-play-1', - 'Sonos PLAY:3' => 'sonos-play-3', - 'Sonos PLAY:5' => 'sonos-play-5', - 'Sonos PLAYBAR' => 'sonos-playbar', - 'Sony' => 'sony', - 'Sony PlayStation VR' => 'sony-playstation-vr', - 'Sony Pulse 3D sans fil' => 'casque-audio-sony-pulse-3d', - 'Sony WF-1000XM3' => 'sony-wf-1000xm3', - 'Sony WH-1000XM3' => 'sony-wh-1000xm3', - 'Sony WH-1000XM4' => 'sony-wh-1000xm4', - 'Sony Xperia XA1' => 'sony-xperia-xa1', - 'Sony Xperia X Compact' => 'sony-xperia-x-compact', - 'Sony Xperia XZ1' => 'sony-xperia-xz1', - 'Sony Xperia XZ1 Compact' => 'sony-xperia-xz1-compact', - 'Sony Xperia XZ Premium' => 'sony-xperia-xz-premium', - 'Sony Xperia Z3' => 'sony-xperia-z3', - 'Soulcalibur' => 'soulcalibur', - 'Souris' => 'souris', - 'Souris gamer' => 'souris-gamer', - 'Souris Logitech' => 'souris-logitech', - 'Souris sans fil' => 'souris-sans-fil', - 'Sous-vêtements' => 'sous-vetements', - 'Sous-vêtements de sport' => 'sous-vetements-de-sport', - 'South Park' => 'south-park', - 'Soutiens-gorge' => 'soutiens-gorge', - 'Spas' => 'spa', - 'Spectacles' => 'spectacles', - 'Spectacles & Billetterie' => 'sorties', - 'Spectacles comiques' => 'spectacles-comiques', - 'Spectacles pour enfants' => 'spectacles-pour-enfants', - 'Sports & plein air' => 'sports-plein-air', - 'Sports collectifs' => 'sports-collectifs', - 'Sports nautiques' => 'sports-nautiques', - 'Sportswear' => 'sportswear', - 'Spotify' => 'spotify', - 'SSD' => 'ssd', - 'Star Wars: Jedi Fallen Order' => 'star-wars-jedi-fallen-order', - 'Star Wars: Squadrons' => 'star-wars-squadrons', - 'Star Wars Battlefront' => 'star-wars-battlefront', - 'Stations météo' => 'stations-meteo', - 'Stickers muraux' => 'stickers-muraux', - 'Stihl' => 'stihl', - 'Stockage externe' => 'stockage', - 'Streaming' => 'streaming', - 'Streaming musical' => 'streaming-musical', - 'Streaming vidéo' => 'streaming-video', - 'Stylos' => 'stylos', - 'Sucettes' => 'sucettes', - 'Super Mario' => 'super-mario', - 'Super Mario 3D All-Stars' => 'super-mario-3d-all-stars', - 'Super Mario Maker 2' => 'super-mario-maker-2', - 'Super Mario Party' => 'super-mario-party', - 'Super Smash Bros. Ultimate' => 'super-smash-bros-ultimate', - 'Support GPS & smartphone' => 'support-gps-et-smartphone', - 'Supports TV' => 'supports-tv', - 'Surface Pro 4' => 'surface-pro-4', - 'Surgelés' => 'surgeles', - 'Surveillance' => 'surveillance', - 'Suspensions' => 'suspensions', - 'Swatch' => 'swatch', - 'Switch réseau' => 'switch-reseau', - 'Systèmes d'exploitation' => 'systemes-d-exploitation', - 'Systèmes multiroom' => 'systemes-multiroom', - 'T-shirts' => 't-shirts', - 'Tables' => 'tables', - 'Tables à langer' => 'tables-a-langer', - 'Tables à repasser' => 'tables-a-repasser', - 'Tables basses' => 'tables-basses', - 'Tables de camping' => 'tables-de-camping', - 'Tables de mixage' => 'tables-de-mixage', - 'Tables de ping-pong' => 'tables-ping-pong', - 'Tablettes' => 'tablettes', - 'Tablettes graphiques' => 'tablettes-graphiques', - 'Tablettes graphiques Huion' => 'huion', - 'Tablettes graphiques Wacom' => 'wacom', - 'Tablettes Huawei' => 'tablettes-huawei', - 'Tablettes Lenovo' => 'tablettes-lenovo', - 'Tablettes Microsoft Surface' => 'tablettes-microsoft-surface', - 'Tablettes Samsung' => 'tablettes-samsung', - 'Tablettes Xiaomi' => 'tablettes-xiaomi', - 'Tampons' => 'tampons', - 'Tapis' => 'tapis', - 'Tapis de souris' => 'tapis-de-souris', - 'Tassimo' => 'tassimo', - 'Taxis' => 'taxis', - 'Tefal' => 'tefal', - 'Tekken' => 'tekken', - 'Tekken 7' => 'tekken-7', - 'Télécommandes' => 'telecommandes', - 'Téléphones fixes' => 'telephones-fixes', - 'Téléphonie' => 'telephonie', - 'Téléviseurs' => 'televiseurs', - 'Tentes' => 'tentes', - 'Tentes Quechua' => 'tentes-quechua', - 'Têtes de brosse à dents de rechange' => 'tetes-de-brosse-a-dents-de-rechange', - 'Théâtre' => 'theatre', - 'The Last of Us' => 'the-last-of-us', - 'The Last of Us Part II' => 'the-last-of-us-part-2', - 'The Legend of Zelda' => 'the-legend-of-zelda', - 'The Legend of Zelda: Breath of the Wild' => 'zelda-breath-of-the-wild', - 'The Legend of Zelda: Link's Awakening' => 'legend-of-zelda-link-s-awakening', - 'The Legend of Zelda: Skyward Sword HD' => 'the-legend-of-zelda-skyward-sword-hd', - 'Thermomètres' => 'thermometres', - 'Thermomix' => 'thermomix', - 'Thermostats connectés' => 'thermostat-connecte', - 'Thés' => 'thes', - 'Thés glacés' => 'thes-glaces', - 'The Walking dead' => 'the-walking-dead', - 'The Witcher' => 'the-witcher', - 'The Witcher 3' => 'the-witcher-3', - 'Time's Up!' => 'time-s-up', - 'Tokyo Laundry' => 'tokyo-laundry', - 'Tomb Raider' => 'tomb-raider', - 'Tom Clancy's' => 'tom-clancy-s', - 'Tom Clancy's Ghost Recon: Wildlands' => 'tom-clancy-s-ghost-recon-wildlands', - 'Tom Clancy's Ghost Recon Breakpoint' => 'tom-clancy-s-ghost-recon-breakpoint', - 'Tom Clancy's The Division' => 'tom-clancy-s-the-division', - 'TomTom' => 'tomtom', - 'Tondeuses' => 'tondeuses', - 'Tondeuses à gazon' => 'tondeuses-a-gazon', - 'Toner' => 'toner', - 'Tongs' => 'tongs', - 'Torchons' => 'torchons', - 'Toshiba' => 'toshiba', - 'Total War' => 'total-war', - 'Total War: Warhammer' => 'total-war-warhammer', - 'Total War: Warhammer II' => 'total-war-warhammer-ii', - 'Tournevis' => 'tournevis-et-visseuses', - 'TP-Link' => 'tp-link', - 'Trains & Bus' => 'trains-bus', - 'Trampolines' => 'trampolines', - 'Transats & cosys' => 'transats-et-cosys', - 'Transport bébé' => 'poussettes', - 'Transport d'animaux' => 'transport-d-animaux', - 'Transports en commun' => 'transports-en-commun', - 'Transports urbains' => 'transports-urbains', - 'Travaux & matériaux' => 'travaux-materiaux', - 'Trépieds' => 'trepieds', - 'Trixie' => 'trixie', - 'Tronçonneuses' => 'tronconneuses', - 'Tropico' => 'tropico', - 'Tropico 6' => 'tropico-6', - 'Trottinettes' => 'trottinettes', - 'Trottinettes électriques' => 'trottinettes-electriques', - 'Trottinettes électriques en libre-service' => 'location-trottinettes-electriques', - 'Trottinettes Xiaomi' => 'trottinettes-xiaomi', - 'TV & Vidéo' => 'tv-video', - 'TV 4K' => 'tv-4k', - 'TV 40'' à 64''' => 'tv-40-pouces-a-64-pouces', - 'TV 65'' et plus' => 'tv-65-pouces-et-plus', - 'TV Hisense' => 'tv-hisense', - 'TV LG' => 'tv-lg', - 'TV OLED' => 'tv-oled', - 'TV Panasonic' => 'tv-panasonic', - 'TV Philips' => 'tv-philips', - 'TV Samsung' => 'tv-samsung', - 'TV Samsung QLED' => 'tv-samsung-qled', - 'TV Samsung The Frame' => 'tv-samsung-the-frame', - 'TV Sony' => 'tv-sony', - 'TV TCL' => 'tv-tcl', - 'TV Toshiba' => 'tv-toshiba', - 'TV Xiaomi' => 'tv-xiaomi', - 'UE Boom 2' => 'ue-boom-2', - 'UE Boom 3' => 'ue-boom-3', - 'UE Megaboom' => 'ue-megaboom', - 'UE Megaboom 3' => 'ue-megaboom-3', - 'UE Wonderboom' => 'ue-wonderboom', - 'Ultraportables' => 'ultraportables', - 'Uncharted' => 'uncharted', - 'Uncharted 4' => 'uncharted-4', - 'Uncharted: The Lost Legacy' => 'uncharted-the-lost-legacy', - 'Under Armour' => 'under-armour', - 'Until Dawn' => 'until-dawn', - 'Ustensiles de cuisine' => 'ustensiles-de-cuisine', - 'Ustensiles de cuisson' => 'ustensiles-de-cuisson', - 'Vacances et séjours' => 'vacances-sejours', - 'Vaisselle' => 'vaisselle', - 'Valises' => 'valises', - 'Valises cabine' => 'valises-cabine', - 'Valises rigides' => 'valises-rigides', - 'Vans Old Skool' => 'vans-old-skool', - 'Variétés & revues' => 'varietes-et-revues', - 'Vases' => 'vases', - 'Veet' => 'veet', - 'Veilleuses' => 'veilleuses', - 'Vélos' => 'velos', - 'Vélos d'appartement' => 'velos-d-appartement', - 'Vélos électriques' => 'velos-electriques', - 'Ventilateurs' => 'ventilateurs', - 'Ventirad' => 'ventirad', - 'Vernis à ongles' => 'vernis-a-ongles', - 'Verres' => 'verres', - 'Vestes' => 'vestes', - 'Vestes polaires' => 'vestes-polaires', - 'Vêtements d'été' => 'vetements-d-ete', - 'Vêtements d'hiver' => 'vetements-d-hiver', - 'Vêtements de grossesse' => 'vetements-de-grossesse', - 'Vêtements de montagne' => 'vetements-techniques', - 'Vêtements de running' => 'vetements-de-running', - 'Vêtements de ski' => 'vetements-de-ski', - 'Vêtements de sport' => 'vetements-de-sport', - 'Vêtements pour bébé' => 'vetements-pour-bebe', - 'Vidéoprojecteurs' => 'projecteurs', - 'Vidéoprojecteurs 3D' => 'videoprojecteurs-3d', - 'Vidéoprojecteurs Acer' => 'videoprojecteurs-acer', - 'Vidéoprojecteurs BenQ' => 'videoprojecteurs-benq', - 'Vidéoprojecteurs Epson' => 'videoprojecteurs-epson', - 'Vidéoprojecteurs HD' => 'videoprojecteurs-hd', - 'Vidéoprojecteurs LG' => 'videoprojecteurs-lg', - 'Vidéoprojecteurs Optoma' => 'videoprojecteurs-optoma', - 'Vins' => 'vins', - 'Visites & patrimoine' => 'visites-et-patrimoine', - 'Visseuses' => 'visseuses', - 'VOD' => 'vod', - 'Voitures & motos' => 'voitures-motos', - 'Voitures télécommandées' => 'voitures-telecommandees', - 'Volants' => 'volants-de-course', - 'Vols' => 'billets-d-avion', - 'Voyages' => 'voyages', - 'Voyages & loisirs' => 'le-laboratoire-des-voyages-loisirs', - 'VPN' => 'vpn', - 'VTC' => 'vtc', - 'VTT' => 'vtt', - 'Wacom Cintiq' => 'cintiq', - 'Watch Dogs' => 'watch-dogs', - 'Watch Dogs 2' => 'watch-dogs-2', - 'Watch Dogs: Legion' => 'watch-dogs-legion', - 'Watercooling' => 'watercooling', - 'WD (Western Digital)' => 'western-digital', - 'Wearables' => 'wearables', - 'Webcams' => 'webcams', - 'Whey' => 'whey', - 'Whirlpool' => 'whirlpool', - 'Whiskas' => 'whiskas', - 'Whisky' => 'whisky', - 'Wiko' => 'wiko', - 'Wilkinson Sword Hydro 5' => 'wilkinson-sword-hydro-5', - 'Windows' => 'windows', - 'WindScribe' => 'windscribe', - 'Wolfenstein' => 'wolfenstein', - 'Wolfenstein II: The New Colossus' => 'wolfenstein-ii-the-new-colossus', - 'Xbox' => 'xbox', - 'Xbox Game Pass' => 'xbox-game-pass', - 'Xbox Live' => 'xbox-live', - 'XCOM' => 'xcom', - 'XCOM 2' => 'xcom-2', - 'Xiaomi' => 'xiaomi', - 'Xiaomi AirDots' => 'xiaomi-airdots', - 'Xiaomi Black Shark' => 'xiaomi-black-shark', - 'Xiaomi Black Shark 2' => 'xiaomi-black-shark-2', - 'Xiaomi Mi6' => 'xiaomi-mi6', - 'Xiaomi Mi8' => 'xiaomi-mi8', - 'Xiaomi Mi8 Lite' => 'xiaomi-mi8-lite', - 'Xiaomi Mi8 Pro' => 'xiaomi-mi8-pro', - 'Xiaomi Mi8 SE' => 'xoaimi-mi8-se', - 'Xiaomi Mi9' => 'xiaomi-mi9', - 'Xiaomi Mi 9 Lite' => 'xiaomi-mi-9-lite', - 'Xiaomi Mi 9 Pro' => 'xiaomi-mi-9-pro', - 'Xiaomi Mi 9 SE' => 'xiaomi-mi-9-se', - 'Xiaomi Mi 9T' => 'xiaomi-mi-9t', - 'Xiaomi Mi 9T Pro' => 'xiaomi-mi-9t-pro', - 'Xiaomi Mi 10' => 'xiaomi-mi-10', - 'Xiaomi Mi 10 Lite' => 'xiaomi-mi-10-lite', - 'Xiaomi Mi 10 Pro' => 'xiaomi-mi-10-pro', - 'Xiaomi Mi 10T' => 'xiaomi-mi-10t', - 'Xiaomi Mi 10T Lite' => 'xiaomi-mi-10t-lite', - 'Xiaomi Mi 10T Pro' => 'xiaomi-mi-10t-pro', - 'Xiaomi Mi 11' => 'xiaomi-mi-11', - 'Xiaomi Mi 11 Lite' => 'xiaomi-mi-11-lite', - 'Xiaomi Mi A1' => 'xiaomi-mi-a1', - 'Xiaomi Mi A2' => 'xiaomi-mi-a2', - 'Xiaomi Mi A2 Lite' => 'xiaomi-mi-a2-lite', - 'Xiaomi Mi Airdots Pro' => 'xiaomi-mi-airdots-pro', - 'Xiaomi Mi Band' => 'xiaomi-mi-band', - 'Xiaomi Mi Band 4' => 'xiaomi-mi-band-4', - 'Xiaomi Mi Band 5' => 'xiaomi-mi-band-5', - 'Xiaomi Mi Band 6' => 'xiaomi-mi-band-6', - 'Xiaomi Mi Box' => 'xiaomi-mi-box', - 'Xiaomi Mi Electric Scooter M365' => 'xiaomi-mi-electric-scooter-m365', - 'Xiaomi Mi Max' => 'xiaomi-mi-max', - 'Xiaomi Mi Mix' => 'xiaomi-mi-mix', - 'Xiaomi Mi Mix 2' => 'xiaomi-mi-mix-2', - 'Xiaomi Mi Note 10' => 'xiaomi-mi-note-10', - 'Xiaomi Mi Note 10 Pro' => 'xiaomi-mi-note-10-pro', - 'Xiaomi Mi Pad 3' => 'xiaomi-mi-pad-3', - 'Xiaomi Mi Watch' => 'xiaomi-mi-watch', - 'Xiaomi Pocophone F1' => 'xiaomi-pocophone-f1', - 'Xiaomi Redmi 4A' => 'xiaomi-redmi-4a', - 'Xiaomi Redmi 4X' => 'xiaomi-redmi-4x', - 'Xiaomi Redmi 7' => 'xiaomi-redmi-7', - 'Xiaomi Redmi 9' => 'xiaomi-redmi-9', - 'Xiaomi Redmi AirDots' => 'xiaomi-redmi-airdots', - 'Xiaomi Redmi Note 4' => 'xiaomi-redmi-note-4', - 'Xiaomi Redmi Note 5' => 'xiaomi-redmi-note-5', - 'Xiaomi Redmi Note 6' => 'xiaomi-redmi-note-6', - 'Xiaomi Redmi Note 7' => 'xiaomi-redmi-note-7', - 'Xiaomi Redmi Note 8' => 'xiaomi-redmi-note-8', - 'Xiaomi Redmi Note 8 Pro' => 'xiaomi-redmi-note-8-pro', - 'Xiaomi Redmi Note 9' => 'xiaomi-redmi-note-9', - 'Xiaomi Redmi Note 9 Pro' => 'xiaomi-redmi-note-9-pro', - 'Xiaomi Redmi Note 9S' => 'xiaomi-redmi-note-9s', - 'Xiaomi Redmi Note 10' => 'xiaomi-redmi-note-10', - 'Xiaomi Redmi Note 10 Pro' => 'xiaomi-redmi-10-pro', - 'Xiaomi Smart Home' => 'xiaomi-smart-home', - 'Yamaha' => 'yamaha', - 'Yeelight' => 'xiaomi-yeelight', - 'Yoshi's Crafted World' => 'yoshi-crafted-world', - 'Zoos' => 'zoos', - ] - ], + 'type' => 'text', + 'exampleValue' => 'abonnements-internet', + 'title' => 'Nom du groupe dans l\'URL : Il faut entrer le nom du groupe qui est présent après "https://www.dealabs.com/groupe/" et avant tout éventuel "?" +Exemple : Si l\'URL du groupe affichées dans le navigateur est : +https://www.dealabs.com/groupe/abonnements-internet?sortBy=lowest_price +Il faut alors saisir : +abonnements-internet', + ], + 'subgroups' => [ + 'name' => 'Catégorie', + 'type' => 'text', + 'exampleValue' => '1071', + 'title' => 'Numéro du ou des catégories dans l\'URL : Il faut entrer le ou les numéros de catégories qui sont présent après "groups=" et avant tout éventuel "&" +Exemple : Si l\'URL du groupe affichées dans le navigateur est : +https://www.dealabs.com/groupe/telecommunications?groups=1071%2C1070&sortBy=new +Il faut alors saisir : +1071%2C1070', + ], 'order' => [ 'name' => 'Trier par', 'type' => 'list', @@ -1911,12 +98,10 @@ class DealabsBridge extends PepperBridgeAbstract 'uri-group' => 'groupe/', 'uri-deal' => 'bons-plans/', 'uri-merchant' => 'search/bons-plans?merchant-id=', + 'image-host' => 'https://static-pepper.dealabs.com/', 'request-error' => 'Impossible de joindre Dealabs', 'thread-error' => 'Impossible de déterminer l\'ID de la discussion. Vérifiez l\'URL que vous avez entré', - 'no-results' => 'Il n'y a rien à afficher pour le moment :(', - 'relative-date-indicator' => [ - 'il y a', - ], + 'currency' => '€', 'price' => 'Prix', 'shipping' => 'Livraison', 'origin' => 'Origine', @@ -1924,42 +109,9 @@ class DealabsBridge extends PepperBridgeAbstract 'title-keyword' => 'Recherche', 'title-group' => 'Groupe', 'title-talk' => 'Surveillance Discussion', - 'local-months' => [ - 'janvier', - 'février', - 'mars', - 'avril', - 'mai', - 'juin', - 'juillet', - 'août', - 'septembre', - 'octobre', - 'novembre', - 'décembre' - ], - 'local-time-relative' => [ - 'il y a ', - 'min', - 'h', - 'jour', - 'jours', - 'mois', - 'ans', - 'et ' - ], - 'date-prefixes' => [ - 'Actualisé ', - ], - 'relative-date-alt-prefixes' => [ - 'Actualisé ', - ], - 'relative-date-ignore-suffix' => [ - ], - - 'localdeal' => [ - 'Local', - 'Pays d\'expédition' - ], + 'deal-type' => 'Type de deal', + 'localdeal' => 'Deal Local', + 'context-hot' => '-hot', + 'context-new' => '-nouveaux', ]; } diff --git a/bridges/DemosBerlinBridge.php b/bridges/DemosBerlinBridge.php index 05fd2335..cc44a7cf 100644 --- a/bridges/DemosBerlinBridge.php +++ b/bridges/DemosBerlinBridge.php @@ -24,7 +24,8 @@ class DemosBerlinBridge extends BridgeAbstract public function collectData() { - $json = getContents('https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/index.php/index/all.json'); + $url = 'https://www.berlin.de/polizei/service/versammlungsbehoerde/versammlungen-aufzuege/index.php/index/all.json'; + $json = getContents($url); $jsonFile = json_decode($json, true); $daysInterval = DateInterval::createFromDateString($this->getInput('days') . ' day'); diff --git a/bridges/DerpibooruBridge.php b/bridges/DerpibooruBridge.php index e06e0eff..2d650d57 100644 --- a/bridges/DerpibooruBridge.php +++ b/bridges/DerpibooruBridge.php @@ -78,13 +78,9 @@ class DerpibooruBridge extends BridgeAbstract public function collectData() { - $queryJson = json_decode(getContents( - self::URI - . 'api/v1/json/search/images?filter_id=' - . urlencode($this->getInput('f')) - . '&q=' - . urlencode($this->getInput('q')) - )); + $url = self::URI . 'api/v1/json/search/images?filter_id=' . urlencode($this->getInput('f')) . '&q=' . urlencode($this->getInput('q')); + + $queryJson = json_decode(getContents($url)); foreach ($queryJson->images as $post) { $item = []; diff --git a/bridges/DeutscheWelleBridge.php b/bridges/DeutscheWelleBridge.php index 29b478b9..51214320 100644 --- a/bridges/DeutscheWelleBridge.php +++ b/bridges/DeutscheWelleBridge.php @@ -73,12 +73,12 @@ class DeutscheWelleBridge extends FeedExpander protected function parseItem(array $item) { - $parsedUrl = parse_url($item['uri']); - unset($parsedUrl['query']); - $url = $this->unparseUrl($parsedUrl); + $parsedUri = parse_url($item['uri']); + unset($parsedUri['query']); + $item['uri'] = $this->unparseUrl($parsedUri); - $page = getSimpleHTMLDOM($url); - $page = defaultLinkTo($page, $url); + $page = getSimpleHTMLDOM($item['uri']); + $page = defaultLinkTo($page, $item['uri']); $article = $page->find('article', 0); @@ -112,6 +112,13 @@ class DeutscheWelleBridge extends FeedExpander $img->height = null; } + // remove bad img src's added by defaultLinkTo() above + // these images should have src="" and will then use + // the srcset attribute to load the best image for the displayed size + foreach ($article->find('figure > picture > img') as $img) { + $img->src = ''; + } + // replace lazy-loaded images foreach ($article->find('figure.placeholder-image') as $figure) { $img = $figure->find('img', 0); diff --git a/bridges/DevToBridge.php b/bridges/DevToBridge.php index d1e27d79..86638f4c 100644 --- a/bridges/DevToBridge.php +++ b/bridges/DevToBridge.php @@ -75,7 +75,7 @@ apple-icon-5c6fa9f2bce280428589c6195b7f1924206a53b782b371cfe2d02da932c8c173.png' $html = defaultLinkTo($html, static::URI); $articles = $html->find('div.crayons-story') - or returnServerError('Could not find articles!'); + or throwServerException('Could not find articles!'); foreach ($articles as $article) { $item = []; diff --git a/bridges/DiarioDoAlentejoBridge.php b/bridges/DiarioDoAlentejoBridge.php index 9b82b49f..0bd0f1d4 100644 --- a/bridges/DiarioDoAlentejoBridge.php +++ b/bridges/DiarioDoAlentejoBridge.php @@ -47,7 +47,7 @@ class DiarioDoAlentejoBridge extends BridgeAbstract }, self::PT_MONTH_NAMES), array_map(function ($num) { return sprintf('-%02d-', $num); - }, range(1, sizeof(self::PT_MONTH_NAMES))), + }, range(1, count(self::PT_MONTH_NAMES))), $element->find('span.date', 0)->innertext ); diff --git a/bridges/DonnonsBridge.php b/bridges/DonnonsBridge.php index a33a1013..1afdc4f2 100644 --- a/bridges/DonnonsBridge.php +++ b/bridges/DonnonsBridge.php @@ -1,5 +1,7 @@ getPageURI($page); - $html = getSimpleHTMLDOM($uri); + $dom = getSimpleHTMLDOM($uri); - $searchDiv = $html->find('div[id=search]', 0); + $searchDiv = $dom->find('div[id=search]', 0); - if (!is_null($searchDiv)) { - $elements = $searchDiv->find('a.lst-annonce'); - foreach ($elements as $element) { - $item = []; + if (! $searchDiv) { + return; + } - // Lien vers le don - $item['uri'] = self::URI . $element->href; - // Id de l'objet - $item['uid'] = $element->getAttribute('data-id'); + $elements = $searchDiv->find('a.lst-annonce'); + foreach ($elements as $element) { + $item = []; - // Grab info from json - $jsonString = $element->find('script', 0)->innertext; - $json = json_decode($jsonString, true); + // Lien vers le don + $item['uri'] = self::URI . $element->href; + // Id de l'objet + $item['uid'] = $element->getAttribute('data-id'); - $name = $json['name']; - $category = $json['category']; - $date = $json['availabilityStarts']; - $description = $json['description']; - $city = $json['availableAtOrFrom']['address']['addressLocality']; - $region = $json['availableAtOrFrom']['address']['addressRegion']; + // Grab info from json + $jsonString = $element->find('script', 0)->innertext; + $json = json_decode($jsonString, true); - // Grab info from HTML - $imageSrc = $element->find('img.ima-center', 0)->getAttribute('src'); - // Use large image instead of small one - $imageSrc = str_replace('/xs/', '/lg/', $imageSrc); - $image = self::URI . $imageSrc; - $author = $element->find('div.avatar-holder', 0)->plaintext; + $name = $json['name']; + $category = $json['category']; + $date = $json['availabilityStarts']; + $description = $json['description']; + $city = $json['availableAtOrFrom']['address']['addressLocality']; + $region = $json['availableAtOrFrom']['address']['addressRegion']; - $content = ' - -
    -

    ' . $name . '

    -

    ' . $description . '

    -

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

    -

    Par : ' . $author . '

    -

    Date : ' . $date . '

    -
    - '; + // Grab info from HTML + $imageSrc = $element->find('img.ima-center', 0)->getAttribute('src'); + // Use large image instead of small one + $imageSrc = str_replace('/xs/', '/lg/', $imageSrc); + $image = self::URI . $imageSrc; + $author = $element->find('div.avatar-holder', 0)->plaintext; - // Titre du don - $item['title'] = '[' . $category . '] ' . $name; - $item['timestamp'] = $date; - $item['author'] = $author; - $item['content'] = $content; - $item['enclosures'] = [$image]; + $content = ' + +
    +

    ' . $name . '

    +

    ' . $description . '

    +

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

    +

    Par : ' . $author . '

    +

    Date : ' . $date . '

    +
    + '; - $this->items[] = $item; - } + // Titre du don + $item['title'] = '[' . $category . '] ' . $name; + $item['timestamp'] = $date; + $item['author'] = $author; + $item['content'] = $content; + $item['enclosures'] = [$image]; + + $this->items[] = $item; } } diff --git a/bridges/DribbbleBridge.php b/bridges/DribbbleBridge.php index 3957c9de..539127b3 100644 --- a/bridges/DribbbleBridge.php +++ b/bridges/DribbbleBridge.php @@ -18,12 +18,12 @@ favicon-63b2904a073c89b52b19aa08cebc16a154bcf83fee8ecc6439968b1e6db569c7.ico'; { $html = getSimpleHTMLDOM(self::URI); - $json = $this->loadEmbeddedJsonData($html); + $data = $this->fetchData($html); foreach ($html->find('li[id^="screenshot-"]') as $shot) { $item = []; - $additional_data = $this->findJsonForShot($shot, $json); + $additional_data = $this->findJsonForShot($shot, $data); if ($additional_data === null) { $item['uri'] = self::URI . $shot->find('a', 0)->href; $item['title'] = $shot->find('.shot-title', 0)->plaintext; @@ -46,9 +46,8 @@ favicon-63b2904a073c89b52b19aa08cebc16a154bcf83fee8ecc6439968b1e6db569c7.ico'; } } - private function loadEmbeddedJsonData($html) + private function fetchData($html) { - $json = []; $scripts = $html->find('script'); foreach ($scripts as $script) { @@ -69,12 +68,17 @@ favicon-63b2904a073c89b52b19aa08cebc16a154bcf83fee8ecc6439968b1e6db569c7.ico'; $end = strpos($script->innertext, '];') + 1; // convert JSON to PHP array - $json = json_decode(substr($script->innertext, $start, $end - $start), true); - break; + $json = substr($script->innertext, $start, $end - $start); + + try { + // TODO: fix broken json + return Json::decode($json); + } catch (\JsonException $e) { + return []; + } } } - - return $json; + return []; } private function findJsonForShot($shot, $json) diff --git a/bridges/Drive2ruBridge.php b/bridges/Drive2ruBridge.php index b3bd73c3..a8db31d2 100644 --- a/bridges/Drive2ruBridge.php +++ b/bridges/Drive2ruBridge.php @@ -204,13 +204,13 @@ class Drive2ruBridge extends BridgeAbstract break; case 'Бортжурналы (По модели или марке)': if (!preg_match('/^https:\/\/www.drive2.ru\/experience/', $this->getInput('url'))) { - returnServerError('Invalid url'); + throwServerException('Invalid url'); } $this->getLogbooksContent($this->getInput('url')); break; case 'Личные блоги': if (!preg_match('/^[a-zA-Z0-9-]{3,16}$/', $this->getInput('username'))) { - returnServerError('Invalid username'); + throwServerException('Invalid username'); } $this->getUserContent('https://www.drive2.ru/users/' . $this->getInput('username')); break; diff --git a/bridges/DuvarOrgBridge.php b/bridges/DuvarOrgBridge.php new file mode 100644 index 00000000..f5f01063 --- /dev/null +++ b/bridges/DuvarOrgBridge.php @@ -0,0 +1,86 @@ + [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => true, + 'title' => 'Maximum number of items to return', + 'defaultValue' => 20, + ], + 'urlsuffix' => [ + 'name' => 'URL Suffix', + 'type' => 'list', + 'title' => 'Suffix for the URL to scrape a specific section', + 'defaultValue' => 'Main', + 'values' => [ + 'Main' => '', + 'Balanced' => '/uyumlu', + 'Protest' => '/muhalif', + 'Center' => '/merkez', + 'Alternative' => '/alternatif', + 'Global' => '/global', + ], + ], + ]]; + + public function collectData() + { + $postCount = $this->getInput('postcount'); + $urlSuffix = $this->getInput('urlsuffix'); + $url = self::URI . $urlSuffix; + $html = getSimpleHTMLDOM($url); + + foreach ($html->find('article.news-item') as $data) { + if ($data === null) { + continue; + } + + try { + $item = []; + $linkElement = $data->find('h2.news-title a', 0); + $titleElement = $data->find('h2.news-title a', 0); + $timestampElement = $data->find('time.meta-tag.date-tag', 0); + $contentElement = $data->find('div.news-description', 0); + + if ($linkElement) { + $item['uri'] = $linkElement->getAttribute('href'); + } else { + continue; + } + if ($titleElement) { + $item['title'] = trim($titleElement->plaintext); + } else { + continue; + } + if ($timestampElement) { + $item['timestamp'] = strtotime($timestampElement->plaintext); + } else { + $item['timestamp'] = time(); + } + if ($contentElement) { + $item['content'] = trim($contentElement->plaintext); + } else { + $item['content'] = ''; + } + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; + + if (count($this->items) >= $postCount) { + break; + } + } catch (Exception $e) { + continue; + } + } + } +} \ No newline at end of file diff --git a/bridges/EASeedBridge.php b/bridges/EASeedBridge.php new file mode 100644 index 00000000..bb5fa41d --- /dev/null +++ b/bridges/EASeedBridge.php @@ -0,0 +1,42 @@ +find('ea-grid', 0); + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('ea-tile') as $article) { + $a = $article->find('a', 0); + $date = $article->find('div', 1)->plaintext; + $title = $article->find('h3', 0)->plaintext; + $author = $article->find('div', 0)->plaintext; + + $entry = getSimpleHTMLDOMCached($a->href, static::CACHE_TIMEOUT * 7 * 4); + + $content = $entry->find('main', 0); + + // remove header and links to other posts + $content->find('ea-header', 0)->outertext = ''; + $content->find('ea-section', -1)->outertext = ''; + + $this->items[] = [ + 'title' => $title, + 'author' => $author, + 'uri' => $a->href, + 'content' => $content, + 'timestamp' => strtotime($date), + ]; + } + } +} diff --git a/bridges/EBayBridge.php b/bridges/EBayBridge.php index 507930ea..463f73d6 100644 --- a/bridges/EBayBridge.php +++ b/bridges/EBayBridge.php @@ -5,15 +5,21 @@ class EBayBridge extends BridgeAbstract const NAME = 'eBay'; const DESCRIPTION = 'Returns the search results from the eBay auctioning platforms'; const URI = 'https://www.eBay.com'; - const MAINTAINER = 'wrobelda'; + const MAINTAINER = 'NotsoanoNimus, wrobelda'; const PARAMETERS = [[ 'url' => [ 'name' => 'Search URL', 'title' => 'Copy the URL from your browser\'s address bar after searching for your items and paste it here', - 'pattern' => '^(https:\/\/)?(www.)?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).*$', + 'pattern' => '^(https:\/\/)?(www\.)?(befr\.|benl\.)?ebay\.(com|com\.au|at|be|ca|ch|cn|es|fr|de|com\.hk|ie|it|com\.my|nl|ph|pl|com\.sg|co\.uk)\/.*$', 'exampleValue' => 'https://www.ebay.com/sch/i.html?_nkw=atom+rss', 'required' => true, - ] + ], + 'includesSearchLink' => [ + 'name' => 'Include Original Search Link', + 'title' => 'Whether or not each feed item should include the original search query link to eBay which was used to find the given listing.', + 'type' => 'checkbox', + 'defaultValue' => false, + ], ]]; public function getURI() @@ -23,6 +29,10 @@ class EBayBridge extends BridgeAbstract $uri = trim(preg_replace('/([?&])_sop=[^&]+(&|$)/', '$1', $this->getInput('url')), '?&/'); $uri .= (parse_url($uri, PHP_URL_QUERY) ? '&' : '?') . '_sop=10'; + // Ensure the List View is used instead of the Gallery View. + $uri = trim(preg_replace('/[?&]_dmd=[^&]+(&|$)/i', '$1', $uri), '?&/'); + $uri .= '&_dmd=1'; + return $uri; } else { return parent::getURI(); @@ -46,7 +56,7 @@ class EBayBridge extends BridgeAbstract }); if ($searchQuery) { - return $searchQuery[0]; + return 'eBay - ' . $searchQuery[0]; } return parent::getName(); @@ -61,44 +71,90 @@ class EBayBridge extends BridgeAbstract $inexactMatches->remove(); } + // Remove "NEW LISTING" labels: we sort by the newest, so this is redundant. + foreach ($html->find('.LIGHT_HIGHLIGHT') as $new_listing_label) { + $new_listing_label->remove(); + } + $results = $html->find('ul.srp-results > li.s-item'); foreach ($results as $listing) { $item = []; - // Remove "NEW LISTING" label, we sort by the newest, so this is redundant - foreach ($listing->find('.LIGHT_HIGHLIGHT') as $new_listing_label) { - $new_listing_label->remove(); + // Define a closure to shorten the ugliness of querying the current listing. + $find = function ($query, $altText = '') use ($listing) { + return $listing->find($query, 0)->plaintext ?? $altText; + }; + + $item['title'] = $find('.s-item__title'); + if (!$item['title']) { + // Skip entries where the title cannot be found (for w/e reason). + continue; } - $listingTitle = $listing->find('.s-item__title', 0); - if ($listingTitle) { - $item['title'] = $listingTitle->plaintext; - } - - $subtitle = implode('', $listing->find('.s-item__subtitle')); - - $listingUrl = $listing->find('.s-item__link', 0); - if ($listingUrl) { - $item['uri'] = $listingUrl->href; + // It appears there may be more than a single 'subtitle' subclass in the listing. Collate them. + $subtitles = $listing->find('.s-item__subtitle'); + if (is_array($subtitles)) { + $subtitle = trim(implode(' ', array_column($subtitles, 'plaintext'))); } else { - $item['uri'] = null; + $subtitle = trim($subtitles->plaintext ?? ''); } + // Get the listing's link and uid. + $itemUri = $listing->find('.s-item__link', 0); + if ($itemUri) { + $item['uri'] = $itemUri->href; + } if (preg_match('/.*\/itm\/(\d+).*/i', $item['uri'], $matches)) { $item['uid'] = $matches[1]; } - $priceDom = $listing->find('.s-item__details > .s-item__detail > .s-item__price', 0); - $price = $priceDom->plaintext ?? 'N/A'; + // Price should be fetched on its own so we can provide the alt text without complication. + $price = $find('.s-item__price', '[NO PRICE]'); - $shippingFree = $listing->find('.s-item__details > .s-item__detail > .s-item__freeXDays', 0)->plaintext ?? ''; - $localDelivery = $listing->find('.s-item__details > .s-item__detail > .s-item__localDelivery', 0)->plaintext ?? ''; - $logisticsCost = $listing->find('.s-item__details > .s-item__detail > .s-item__logisticsCost', 0)->plaintext ?? ''; + // Map a list of dynamic variable names to their subclasses within the listing. + // This is just a bit of sugar to make this cleaner and more maintainable. + $propertyMappings = [ + 'additionalPrice' => '.s-item__additional-price', + 'discount' => '.s-item__discount', + 'shippingFree' => '.s-item__freeXDays', + 'localDelivery' => '.s-item__localDelivery', + 'logisticsCost' => '.s-item__logisticsCost', + 'location' => '.s-item__location', + 'obo' => '.s-item__formatBestOfferEnabled', + 'sellerInfo' => '.s-item__seller-info-text', + 'bids' => '.s-item__bidCount', + 'timeLeft' => '.s-item__time-left', + 'timeEnd' => '.s-item__time-end', + ]; - $location = $listing->find('.s-item__details > .s-item__detail > .s-item__location', 0)->plaintext ?? ''; + foreach ($propertyMappings as $k => $v) { + $$k = $find($v); + } - $sellerInfo = $listing->find('.s-item__seller-info-text', 0)->plaintext ?? ''; + // When an additional price detail or discount is defined, create the 'discountLine'. + if ($additionalPrice || $discount) { + $discountLine = '
    (' + . trim($additionalPrice ?? '') + . '; ' . trim($discount ?? '') + . ')'; + } else { + $discountLine = ''; + } + // Prepend the time-left info with a comma if the right details were found. + $timeInfo = trim($timeLeft . ' ' . $timeEnd); + if ($timeInfo) { + $timeInfo = ', ' . $timeInfo; + } + + // Set the listing type. + if ($bids) { + $listingTypeDetails = "Auction: {$bids}{$timeInfo}"; + } else { + $listingTypeDetails = 'Buy It Now'; + } + + // Acquire the listing's primary image and atach it. $image = $listing->find('.s-item__image-wrapper > img', 0); if ($image) { // Not quite sure why append fragment here @@ -106,11 +162,23 @@ class EBayBridge extends BridgeAbstract $item['enclosures'] = [$imageUrl]; } + // Include the original search link, if specified. + if ($this->getInput('includesSearchLink')) { + $searchLink = '

    View Search

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

    -

    $price $shippingFree $localDelivery $logisticsCost

    -

    $subtitle

    +

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

    +

    {$subtitle}

    +$searchLink CONTENT; + $this->items[] = $item; } } diff --git a/bridges/EZTVBridge.php b/bridges/EZTVBridge.php index 25a88124..556bd39e 100644 --- a/bridges/EZTVBridge.php +++ b/bridges/EZTVBridge.php @@ -50,7 +50,9 @@ class EZTVBridge extends BridgeAbstract $eztv_uri = $this->getEztvUri(); $ids = explode(',', trim($this->getInput('ids'))); foreach ($ids as $id) { - $data = json_decode(getContents(sprintf('%s/api/get-torrents?imdb_id=%s', $eztv_uri, $id))); + $url = sprintf('%s/api/get-torrents?imdb_id=%s', $eztv_uri, $id); + $json = getContents($url); + $data = json_decode($json); if (!isset($data->torrents)) { // No results continue; diff --git a/bridges/EconomistBridge.php b/bridges/EconomistBridge.php index aad72275..eaa50ba1 100644 --- a/bridges/EconomistBridge.php +++ b/bridges/EconomistBridge.php @@ -8,6 +8,12 @@ class EconomistBridge extends FeedExpander const CACHE_TIMEOUT = 3600; //1hour const DESCRIPTION = 'Returns the latest articles for the selected category'; + const CONFIGURATION = [ + 'cookie' => [ + 'required' => false, + ] + ]; + const PARAMETERS = [ 'global' => [ 'limit' => [ @@ -99,7 +105,24 @@ class EconomistBridge extends FeedExpander protected function parseItem(array $item) { - $dom = getSimpleHTMLDOM($item['uri']); + $headers = []; + if ($this->getOption('cookie')) { + $headers = [ + 'Authority: www.economist.com', + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-language: en-US,en;q=0.9', + 'Cache-control: max-age=0', + 'Cookie: ' . $this->getOption('cookie'), + 'Upgrade-insecure-requests: 1', + 'User-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36' + ]; + } + try { + $dom = getSimpleHTMLDOM($item['uri'], $headers); + } catch (Exception $e) { + $item['content'] = $e->getMessage(); + return $item; + } $article = $dom->find('#new-article-template', 0); if ($article == null) { @@ -204,6 +227,15 @@ class EconomistBridge extends FeedExpander foreach ($elem->find('a.ds-link-with-arrow-icon') as $a) { $a->parent->removeChild($a); } + // Sections like "Leaders on day X" + foreach ($elem->find('div[data-tracking-id=content-well-chapter-list]') as $div) { + $div->parent->removeChild($div); + } + // "Explore more" section + foreach ($elem->find('h3[id=article-tags]') as $h3) { + $div = $h3->parent; + $div->parent->removeChild($div); + } // The Economist puts infographics into iframes, which doesn't // work in any of my readers. So this replaces iframes with diff --git a/bridges/EconomistWorldInBriefBridge.php b/bridges/EconomistWorldInBriefBridge.php index 47782a51..3b717d81 100644 --- a/bridges/EconomistWorldInBriefBridge.php +++ b/bridges/EconomistWorldInBriefBridge.php @@ -9,6 +9,12 @@ class EconomistWorldInBriefBridge extends BridgeAbstract const CACHE_TIMEOUT = 3600; // 1 hour const DESCRIPTION = 'Returns stories from the World in Brief section'; + const CONFIGURATION = [ + 'cookie' => [ + 'required' => false, + ] + ]; + const PARAMETERS = [ '' => [ 'splitGobbets' => [ @@ -35,27 +41,51 @@ class EconomistWorldInBriefBridge extends BridgeAbstract 'quote' => [ 'name' => 'Include the quote of the day', 'type' => 'checkbox' + ], + 'mergeEverything' => [ + 'name' => 'Merge everything into one entry', + 'type' => 'checkbox', + 'defaultValue' => false, + 'title' => 'Whether to merge all the stories into one entry' ] ] ]; public function collectData() { - $html = getSimpleHTMLDOM(self::URI); - $gobbets = $html->find('._gobbets', 0); - if ($this->getInput('splitGobbets') == 1) { + $headers = []; + if ($this->getOption('cookie')) { + $headers = [ + 'Authority: www.economist.com', + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-language: en-US,en;q=0.9', + 'Cache-control: max-age=0', + 'Cookie: ' . $this->getOption('cookie'), + 'Upgrade-insecure-requests: 1', + 'User-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36' + ]; + } + $html = getSimpleHTMLDOM(self::URI, $headers); + $gobbets = $html->find('p[data-component="the-world-in-brief-paragraph"]'); + if ($this->getInput('splitGobbets') == 1 && !$this->getInput('mergeEverything')) { $this->splitGobbets($gobbets); } else { $this->mergeGobbets($gobbets); }; if ($this->getInput('agenda') == 1) { - $articles = $html->find('._articles', 0); - $this->collectArticles($articles); + $articles = $html->find('div[data-test-id="chunks"] > div > div', 0); + + if ($articles != null) { + $this->collectArticles($articles); + } } if ($this->getInput('quote') == 1) { - $quote = $html->find('._quote-container', 0); + $quote = $html->find('blockquote[data-test-id="inspirational-quote"]', 0); $this->addQuote($quote); } + if ($this->getInput('mergeEverything') == 1) { + $this->mergeEverything(); + } } private function splitGobbets($gobbets) @@ -63,7 +93,7 @@ class EconomistWorldInBriefBridge extends BridgeAbstract $today = new Datetime(); $today->setTime(0, 0, 0, 0); $limit = $this->getInput('limit'); - foreach ($gobbets->find('._gobbet') as $gobbet) { + foreach ($gobbets as $gobbet) { $title = $gobbet->plaintext; $match = preg_match('/[\.,]/', $title, $matches, PREG_OFFSET_CAPTURE); if ($match > 0) { @@ -89,7 +119,7 @@ class EconomistWorldInBriefBridge extends BridgeAbstract $today = new Datetime(); $today->setTime(0, 0, 0, 0); $contents = ''; - foreach ($gobbets->find('._gobbet') as $gobbet) { + foreach ($gobbets as $gobbet) { $contents .= "

    {$gobbet->innertext}"; } $this->items[] = [ @@ -106,10 +136,17 @@ class EconomistWorldInBriefBridge extends BridgeAbstract $i = 0; $today = new Datetime(); $today->setTime(0, 0, 0, 0); - foreach ($articles->find('._article') as $article) { - $title = $article->find('._headline', 0)->plaintext; - $image = $article->find('._main-image', 0); - $content = $article->find('._content', 0); + foreach ($articles->children() as $element) { + if ($element->tag != 'div') { + continue; + } + if ($element->find('._newsletterContentPromo', 0) != null) { + continue; + } + $image = $element->find('figure', 0); + $title = $element->find('h3', 0)->plaintext; + $content = $element->find('h3', 0)->parent(); + $content->find('h3', 0)->outertext = ''; $res_content = ''; if ($image != null && $this->getInput('agendaPictures') == 1) { @@ -140,4 +177,35 @@ class EconomistWorldInBriefBridge extends BridgeAbstract 'uid' => 'quote-' . $today->format('U') ]; } + + private function mergeEverything() + { + $today = new Datetime(); + $today->setTime(0, 0, 0, 0); + $contents = ''; + + foreach ($this->items as $item) { + $header = null; + if (str_contains($item['uid'], 'story-')) { + $header = $item['title']; + } elseif (str_contains($item['uid'], 'quote-')) { + $header = 'Quote of the day'; + } elseif (str_contains($item['uid'], 'world-in-brief-')) { + $header = 'World in brief'; + } + if ($header != null) { + $contents .= "

    {$header}

    "; + } + $contents .= $item['content']; + } + + $item = [ + 'uri' => self::URI, + 'title' => 'The Economist World in Brief ' . $today->format('d.m.Y'), + 'content' => $contents, + 'timestamp' => $today->format('U'), + 'uid' => 'world-in-brief-merged' . $today->format('U') + ]; + $this->items = [$item]; + } } diff --git a/bridges/EdfPricesBridge.php b/bridges/EdfPricesBridge.php index f67ed30b..06cd30b7 100644 --- a/bridges/EdfPricesBridge.php +++ b/bridges/EdfPricesBridge.php @@ -12,8 +12,28 @@ class EdfPricesBridge extends BridgeAbstract 'contract' => [ 'name' => 'Choisir un contrat', 'type' => 'list', - // we can add later HCHP, EJP, base - 'values' => ['Tempo' => '/energie/edf/tarifs/tempo'], + // we can add later more option prices + 'values' => [ + 'Base' => '/energie/edf/tarifs/tarif-bleu#base', + 'HPHC' => '/energie/edf/tarifs/tarif-bleu#hphc', + 'EJP' => '/energie/edf/tarifs/tarif-bleu#ejp', + 'Tempo' => '/energie/edf/tarifs/tempo' + ], + ], + 'power' => [ + 'name' => 'Choisir une puissance', + 'type' => 'list', + 'values' => [ + '3 kVA' => 3, + '6 kVA' => 6, + '9 kVA' => 9, + '12 kVA' => 12, + '15 kVA' => 15, + '18 kVA' => 18, + '24 kVA' => 24, + '30 kVA' => 30, + '36 kVA' => 36 + ] ] ] ]; @@ -24,36 +44,20 @@ class EdfPricesBridge extends BridgeAbstract * @param string $contractUri * @return void */ - private function tempo(simple_html_dom $html, string $contractUri): void + private function tempo(simple_html_dom $html, string $contractUri, int $power): void { - // current color and next - $daysDom = $html->find('#calendrier', 0)->nextSibling()->find('.card--ejp'); - if ($daysDom && count($daysDom) === 2) { - foreach ($daysDom as $dayDom) { - $day = trim($dayDom->find('.card__title', 0)->innertext) . '/' . (new \DateTime('now'))->format(('Y')); - $dayColor = $dayDom->find('.card-ejp__icon span', 0)->innertext; - - $text = $day . ' - ' . $dayColor; - $item['uri'] = self::URI . $contractUri; - $item['title'] = $text; - $item['author'] = self::MAINTAINER; - $item['content'] = $text; - $item['uid'] = hash('sha256', $item['title']); - - $this->items[] = $item; - } - } - // colors - $ulDom = $html->find('#tarif-de-l-offre-edf-tempo-current-date-html-year', 0)->nextSibling()->nextSibling()->nextSibling(); + $ulDom = $html->find('#tarif-de-l-offre-tempo-edf-template-date-now-y', 0)->nextSibling()->nextSibling()->nextSibling(); $elementsDom = $ulDom->find('li'); if ($elementsDom && count($elementsDom) === 3) { + // price per kWh is same for all powers foreach ($elementsDom as $elementDom) { $item = []; $matches = []; - preg_match_all('/Jour (.*) : Heures (.*) : (.*) € \/ Heures (.*) : (.*) €/um', $elementDom->innertext, $matches, PREG_SET_ORDER, 0); + preg_match_all('/Jour (.*) : Heures (.*) : (.*) € \/ Heures (.*) : (.*) €/um', $elementDom->innertext, $matches, PREG_SET_ORDER, 0); + // for tempo contract we have 2x3 colors if ($matches && count($matches[0]) === 6) { for ($i = 0; $i < 2; $i++) { $text = 'Jour ' . $matches[0][1] . ' - Heures ' . $matches[0][2 + 2 * $i] . ' : ' . $matches[0][3 + 2 * $i] . '€'; @@ -69,26 +73,166 @@ class EdfPricesBridge extends BridgeAbstract } } - // powers - $ulPowerContract = $ulDom->nextSibling()->nextSibling(); - $elementsPowerContractDom = $ulPowerContract->find('li'); - if ($elementsPowerContractDom && count($elementsPowerContractDom) === 4) { - foreach ($elementsPowerContractDom as $elementPowerContractDom) { + // add subscription power info + $tablePrices = $ulDom->nextSibling()->nextSibling()->nextSibling()->find('.table--responsive', 0); + $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 7); + } + + /** + * @param simple_html_dom $html + * @param string $contractUri + * @return void + */ + private function base(simple_html_dom $html, string $contractUri, int $power): void + { + $tablePrices = $html + ->find('#grille-tarifaire-et-prix-du-kwh-du-tarif-reglemente-edf-en-option-base', 0) + ->nextSibling() + ->nextSibling() + ->nextSibling(); + + $prices = $tablePrices->find('.table--stripped tbody tr'); + // last element is useless because part of another table + array_pop($prices); + + // price per kWh is same for all powers + if ($prices && count($prices) === 9) { + $item = []; + + $text = 'Base : ' . $prices[0]->children(2); + $item['uri'] = self::URI . $contractUri; + $item['title'] = $text; + $item['author'] = self::MAINTAINER; + $item['content'] = $text; + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; + } + + $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 9); + } + + /** + * @param simple_html_dom $html + * @param string $contractUri + * @return void + */ + private function hphc(simple_html_dom $html, string $contractUri, int $power): void + { + $tablePrices = $html + ->find('#grille-tarifaire-et-prix-du-kwh-du-tarif-reglemente-edf-en-option-heures-pleines-heures-creuses', 0) + ->nextSibling() + ->nextSibling() + ->nextSibling(); + + $prices = $tablePrices->find('.table--stripped tbody tr'); + // last element is useless because part of another table + array_pop($prices); + + // price per kWh is same for all powers + if ($prices && count($prices) === 8) { + $values = ['HC', 'HP']; + foreach ($values as $key => $value) { + $i++; $item = []; - $matches = []; - preg_match_all('/(.*) kVA : (.*) €/um', $elementPowerContractDom->innertext, $matches, PREG_SET_ORDER, 0); + $text = $values[$key] . ' : ' . $prices[0]->children($key + 2); + $item['uri'] = self::URI . $contractUri; + $item['title'] = $text; + $item['author'] = self::MAINTAINER; + $item['content'] = $text; + $item['uid'] = hash('sha256', $item['title']); - if ($matches && count($matches[0]) === 3) { - $text = $matches[0][1] . ' kVA : ' . $matches[0][2] . '€'; - $item['uri'] = self::URI . $contractUri; - $item['title'] = $text; - $item['author'] = self::MAINTAINER; - $item['content'] = $text; - $item['uid'] = hash('sha256', $item['title']); + $this->items[] = $item; + } + } - $this->items[] = $item; + $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 8); + } + + /** + * @param simple_html_dom $html + * @param string $contractUri + * @return void + */ + private function ejp(simple_html_dom $html, string $contractUri, int $power): void + { + $tablePrices = $html + ->find('#grille-tarifaire-et-prix-du-kwh-du-tarif-reglemente-edf-en-option-ejp', 0) + ->nextSibling() + ->nextSibling() + ->nextSibling(); + + $prices = $tablePrices->find('.table--stripped tbody tr'); + // last element is useless because part of another table + array_pop($prices); + + // price per kWh is same for all powers + if ($prices && count($prices) === 5) { + $values = ['Non EJP', 'EJP']; + foreach ($values as $key => $value) { + $i++; + $item = []; + + $text = $values[$key] . ' : ' . $prices[0]->children($key + 2); + $item['uri'] = self::URI . $contractUri; + $item['title'] = $text; + $item['author'] = self::MAINTAINER; + $item['content'] = $text; + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; + } + } + + $this->addSubscriptionPowerInfo($tablePrices, $contractUri, $power, 5); + } + + private function addSubscriptionPowerInfo(simple_html_dom_node $tablePrices, string $contractUri, int $power, int $numberOfPrices): void + { + $prices = $tablePrices->find('.table--stripped tbody tr'); + // last element is useless because part of another table + array_pop($prices); + + // 7 contracts for tempo: 6, 9, 12, 15, 18, 30 and 36 kVA + // 9 contracts for base: 3, 6, 9, 12, 15, 18, 24, 30 and 36 kVA + // 7 contracts for HPHC: 6, 9, 12, 15, 18, 24, 30 and 36 kVA + // 5 contracts for EJP: 9, 12, 15, 18 and 36 kVA + if ($prices && count($prices) === $numberOfPrices) { + $powerFound = false; + foreach ($prices as $price) { + $powerText = $price->firstChild()->firstChild()->innertext; + $powerValue = (int)substr($powerText, 0, strpos($powerText, ' kVA')); + + if ($powerValue !== $power) { + continue; } + + $item = []; + + $text = $powerText . ' : ' . $price->children(1) . '/an'; + $item['uri'] = self::URI . $contractUri; + $item['title'] = $text; + $item['author'] = self::MAINTAINER; + $item['content'] = $text; + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; + $powerFound = true; + break; + } + + if (!$powerFound) { + $item = []; + + $text = 'Pas de tarif abonnement pour cette puissance et ce contrat'; + $item['uri'] = self::URI . $contractUri; + $item['title'] = $text; + $item['author'] = self::MAINTAINER; + $item['content'] = $text; + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; } } } @@ -97,10 +241,23 @@ class EdfPricesBridge extends BridgeAbstract { $contract = $this->getKey('contract'); $contractUri = $this->getInput('contract'); + $power = $this->getInput('power'); $html = getSimpleHTMLDOM(self::URI . $contractUri); if ($contract === 'Tempo') { - $this->tempo($html, $contractUri); + $this->tempo($html, $contractUri, $power); + } + + if ($contract === 'Base') { + $this->base($html, $contractUri, $power); + } + + if ($contract === 'HPHC') { + $this->hphc($html, $contractUri, $power); + } + + if ($contract === 'EJP') { + $this->ejp($html, $contractUri, $power); } } } diff --git a/bridges/ElektroARGOSBridge.php b/bridges/ElektroARGOSBridge.php new file mode 100644 index 00000000..35a885c6 --- /dev/null +++ b/bridges/ElektroARGOSBridge.php @@ -0,0 +1,656 @@ + [], + 'Events' => [], + 'Topics and Promos' => [] + ]; + + /** + * Fetches and processes data based on the selected context. + * + * This function retrieves the HTML content for the specified context's URI, + * resolves relative links within the content, and then delegates the data + * extraction to the appropriate method (currently only `collectNews` for the 'Articles' context). + */ + public function collectData() + { + $html = getSimpleHTMLDOMCached($this->getURI(), self::CACHE_TIMEOUT); + + defaultLinkTo($html, static::URI); + + // Router + switch ($this->queriedContext) { + case 'News and articles': + $this->collectNews($html); + break; + case 'Events': + $this->collectEvents($html); + break; + case 'Topics and Promos': + $this->collectTopic($html); + break; + } + } + + /** + * Returns the icon for the bridge. + * + * @return string The icon URL. + */ + public function getURI() + { + $uri = static::URI; + + // URI Router + switch ($this->queriedContext) { + case 'News and articles': + $uri .= 'akce/nabidka/'; + break; + case 'Events': + $uri .= 'pobocka-praha-hostivar/akce/udalosti/'; + break; + case 'Topics and Promos': + $uri .= 'pobocka-praha-hostivar/akce/temata/'; + break; + } + + return $uri; + } + + /** + * Returns the keyword URL map for the bridge. + * + * @return string The Name. + */ + public function getKeywordUrlMap() + { + // Get the keyword URL map from the class constant + $keywordUrlMap = static::KEYWORDURLMAP; + + // returns the keyword URL map + return $keywordUrlMap; + } + + /** + * Returns the name for the bridge. + * + * @return string The Name. + */ + public function getName() + { + $name = static::NAME; + + $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : ''; + + switch ($this->queriedContext) { + case 'News and articles': + break; + case 'Events': + break; + case 'Topics and Promos': + break; + } + + return $name; + } + + /** + * Parse most used date formats + * + * Basically strtotime doesn't convert dates correctly due to formats + * being hard to interpret. So we use the DateTime object, manually + * fixing dates and times (set to 00:00:00.000). + * + * We don't know the timezone, so just assume +00:00 (or whatever + * DateTime chooses) + */ + private function fixDate($date) + { + $df = $this->parseDateTimeFromString($date); + + return date_format($df, 'U'); + } + + /** + * Extracts the images from the article. + * + * @param object $article The article object. + * @return array An array of image URLs. + */ + private function extractImages($article) + { + // Notice: We can have zero or more images (though it should mostly be 1) + $elements = $article->find('img'); + + $images = []; + + foreach ($elements as $img) { + $images[] = $img->src; + } + + return $images; + } + + // region Weekly offer + + /** + * Collects uri, timestamp, title, content and images in the product offers from the HTML and transforms to rss. + * + * @param object $html The HTML object. + * @return void + */ + private function collectNews($html) + { + // Check if page contains articles and split by class + $articles = $html->find('.com-news-feature-prerex') or + throwServerException('No articles found! Layout might have changed!'); + + // Articles loop + foreach ($articles as $article) { + $item = []; + + // Add URI + $item['uri'] = $this->extractNewsUri($article); +// echo $item['uri'] . '
    '; + // Add title + $item['title'] = $this->extractNewsTitle($article); +// echo $item['title'] . '
    '; + $item['enclosures'] = $this->extractImages($article); + + // Add to rss query + $this->items[] = $item; + } + } + + /** + * Collects uri, timestamp, title, content and images in the promotional letter from the HTML and transforms to rss. + * + * @param object $html The HTML object. + * @return void + */ + private function collectEvents($html) + { + // Check if page contains articles and split by class + $articles = $html->find('.com-news-common-prerex') or + throwServerException('No articles found! Layout might have changed!'); + + // Articles loop + foreach ($articles as $article) { + $item = []; + + // Add URI + $item['uri'] = $this->extractEventUri($article); + // Add title + $item['title'] = $this->extractEventTitle($article); + // Add content + $item['content'] = $this->extractEventDescription($article); + // Parse time + $newsDate = $this->extractDate($article); + // Remove prefix + $newsDate = str_replace('zveřejněno: ', '', $newsDate); + // Fix date + $item['timestamp'] = $this->fixDate($newsDate); + // Add images + $item['enclosures'] = $this->extractImages($article); + + // Add to rss query + $this->items[] = $item; + } + } + + /** + * Collects uri, timestamp, title, content and images in the promotional letter from the HTML and transforms to rss. + * + * @param object $html The HTML object. + * @return void + */ + private function collectTopic($html) + { + // Check if page contains articles and split by class + $articles = $html->find('.com-news-common-prerex') or + throwServerException('No articles found! Layout might have changed!'); + + // Articles loop + foreach ($articles as $article) { + $item = []; + + // Add URI + $item['uri'] = $this->extractEventUri($article); + // Add title + $item['title'] = $this->extractEventTitle($article); + // Add content + $item['content'] = $this->extractEventDescription($article); + // Parse time + $newsDate = $this->extractDate($article); + // Remove prefix + $newsDate = str_replace('zveřejněno: ', '', $newsDate); + // Fix date + $item['timestamp'] = $this->fixDate($newsDate); + // Add images + $item['enclosures'] = $this->extractImages($article); + + // Add to rss query + $this->items[] = $item; + } + } + + /** + * Extracts the URI of the news article. + * + * @param object $article The article object. + * @return string The URI of the news article. + */ + private function extractEventUri($article) + { + return $article->href; + } + + + /** + * Extracts the URI of the news article. + * + * @param object $article The article object. + * @return string The URI of the news article. + */ + private function extractNewsUri($article) + { + // Return URI of the article + $element = $article->find('a', 0) or + throwServerException('Anchor not found!'); + + return $element->href; + } + + /** + * Extracts the URI of the news article. + * + * @param object $article The article object. + * @return string The URI of the news article. + */ + private function extractLetterUri($article) + { + // Return URI of the article + $element = $article->find('a.ws-btn', 0); + + // Element empty check + if ($element == null) { + return ''; + } + + return $element->href; + } + + /** + * Extracts the date of the news article. + * + * @param object $article The article object. + * @return string The date of the news article. + */ + private function extractDate($article) + { + // Check if date is set + $element = $article->find('div.com-news-common-prerex__date', 0) or + throwServerException('Date not found!'); + + return $element->plaintext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription($article) + { + // Extract description + $element = $article->find('ul.ws-product-information__piece-description', 0)->find('li', 0) or + throwServerException('Description not found!'); + + return $element->innertext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription1($article) + { + // Extract description + $element = $article->find('div.ws-product-price-validity', 0)->find('div', 0) or + throwServerException('Description not found!'); + + return $element->innertext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription2($article) + { + // Extract description + $element = $article->find('div.ws-product-price-validity', 0)->find('div', 1) or + throwServerException('Description not found!'); + + return $element->innertext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription3($article) + { + // Extract description + $element = $article->find('div.ws-product-badge-text', 0); + + // Check if element is not null + // If it is null, return empty string + // If it is not null, return the inner text + // This is to avoid errors when the element is not found + // and to ensure that the function always returns a string + if ($element != null) { + return $element->innertext; + } else { + return ''; + } + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription4($article) + { + // Extract description + $element = $article->find('div.ws-product-price-type__value', 0); + + return $element->innertext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription5($article) + { + // Extract description + $element = $article->find('div.ws-product-price-type__label', 0); + + return $element->innertext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription6($article) + { + // Extract description + $element = $article->find('div.ws-product-price', 0)->find('div.ws-product-price-type', 1); + + // Element empty check + if ($element == null) { + return ''; + } + + // Not null, so we can safely access the element + $element = $element->find('div.ws-product-price-type__value', 0); + + return $element->innertext; + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractEventDescription($article) + { + // Extract description + $element = $article->find('.com-news-common-prerex__text', 0); + + return $element->innertext; + } + + /** + * Extracts the title of the news article. + * + * @param object $article The article object. + * @return string The title of the news article. + */ + private function extractNewsTitle($article) + { + // Extract title + $element = $article->find('img', 0) or + throwServerException('Title not found!'); + + return $element->alt; + } + + /** + * Extracts the title of the news article. + * + * @param object $article The article object. + * @return string The title of the news article. + */ + private function extractEventTitle($article) + { + // Extract title + $element = $article->find('div.com-news-common-prerex__right-box', 0)->find('h3', 0) + or throwServerException('Title not found!'); + + return $element->plaintext; + } + + /** + * Extracts the description of the letter article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractLetterDescription($article) + { + // Extract description + $element = $article->find('a', 0); + + return $element; + } + + /** + * It attempts to recognize the date/time format in a string and create a DateTime object. + * + * It goes through the list of defined formats and tries to apply them to the input string. + * Returns the first successfully parsed DateTime object that matches the entire string. + * + * @param string $dateString A string potentially containing a date and/or time. + * @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null. + */ + private function parseDateTimeFromString(string $dateString): ?DateTime + { + // List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs! + // Order may matter if the formats are ambiguous. + // It is recommended to give more specific formats (with time, full year) before more general ones. + $possibleFormats = [ + // Czech formats (day.month.year) + 'd.m.Y H:i:s', // 10.04.2025 10:57:47 + 'j.n.Y H:i:s', // 10.4.2025 10:57:47 + 'd. m. Y H:i:s', // 10. 04. 2025 10:57:47 + 'j. n. Y H:i:s', // 10. 4. 2025 10:57:47 + 'd.m.Y H:i', // 10.04.2025 10:57 + 'j.n.Y H:i', // 10.4.2025 10:57 + 'd. m. Y H:i', // 10. 04. 2025 10:57 + 'j. n. Y H:i', // 10. 4. 2025 10:57 + 'd.m.Y', // 10.04.2025 + 'j.n.Y', // 10.4.2025 + 'd. m. Y', // 10. 04. 2025 + 'j. n. Y', // 10. 4. 2025 + // ISO 8601 and international formats (year-month-day) + 'Y-m-d H:i:s', // 2025-04-10 10:57:47 + 'Y-m-d H:i', // 2025-04-10 10:57 + 'Y-m-d', // 2025-04-10 + 'YmdHis', // 20250410105747 + 'Ymd', // 20250410 + // American formats (month/day/year) - beware of ambiguity! + 'm/d/Y H:i:s', // 04/10/2025 10:57:47 + 'n/j/Y H:i:s', // 4/10/2025 10:57:47 + 'm/d/Y H:i', // 04/10/2025 10:57 + 'n/j/Y H:i', // 4/10/2025 10:57 + 'm/d/Y', // 04/10/2025 + 'n/j/Y', // 4/10/2025 + // Standard formats (including time zone) + DateTime::ATOM, // example. 2025-04-10T10:57:47+02:00 + DateTime::RFC3339, // example. 2025-04-10T10:57:47+02:00 + DateTime::RFC3339_EXTENDED, // example. 2025-04-10T10:57:47.123+02:00 + DateTime::RFC2822, // example. Thu, 10 Apr 2025 10:57:47 +0200 + DateTime::ISO8601, // example. 2025-04-10T105747+0200 + 'Y-m-d\TH:i:sP', // ISO 8601 s 'T' oddělovačem + 'Y-m-d\TH:i:s.uP', // ISO 8601 s mikrosekundami + // You can add more formats as needed... + // e.g. 'd-M-Y' (10-Apr-2025) - requires English locale + // e.g. 'j. F Y' (10. abren 2025) - requires Czech locale + ]; + + // Set locale for parsing month/day names (if using F, M, l, D) + // E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8'); + + foreach ($possibleFormats as $format) { + // We will try to create a DateTime object from the given format + $dateTime = DateTime::createFromFormat($format, $dateString); + + // We check that the parsing was successful AND ALSO + // that there were no errors or warnings during the parsing. + // This is important to ensure that the format matches the ENTIRE string. + if ($dateTime !== false) { + $errors = DateTime::getLastErrors(); + if (!($errors)) { + // Success! We found a valid format for the entire string. + return $dateTime; + } + } + } + + // If no format matches or parsing failed + return null; + } + + /** + * Finds values from an associative array whose keys are substrings of a given text. + * + * The function iterates through the `$map` associative array. For each key, + * it checks if that key exists as a substring within the input `$text`. + * If found, the corresponding value from the map is added to the result array. + * The search is case-sensitive and treats special characters literally. + * + * @param string $text The input text string to search within. + * @param array $map An associative array (key => value). Keys from this array will be searched for in `$text`. + * @return array An array of values whose corresponding keys were found as substrings in `$text`. Returns an empty array if no keys are found. + */ + private function findValuesByKeySubstring(string $text, array $map): array + { + $foundValues = []; // Initialize array for found values + + // Iterate through each key => value pair in the map + foreach ($map as $key => $value) { + // Use strpos(), which finds the position of the first occurrence of a substring. + // Returns the position (including 0) or `false` if the substring is not found. + // We use `!== false` to correctly handle the case where the key starts at position 0. + // Cast key to string for robustness (though array keys are usually strings or ints). + // `strpos` treats special characters in the key and text literally. + + // echo "Key: $key, Text: $text
    \n"; + if (strpos($text, $key) !== false) { + // If the key was found in the text, add its corresponding value to the result array + $foundValues[] = $value; + } + } + + // Return the array of found values + return $foundValues; + } + + /** + * Removes Czech diacritics from a given string. + * + * This function replaces Czech characters with their ASCII equivalents. + * For example, 'á' becomes 'a', 'č' becomes 'c', etc. + * + * @param string $text The input string with Czech diacritics. + * @return string The string with Czech diacritics removed. + */ + private function removeCzechDiacritics(string $text): string + { + $czech = [ + 'á', 'č', 'ď', 'é', 'ě', 'í', 'ň', 'ó', 'ř', 'š', 'ť', 'ú', 'ů', 'ý', 'ž', + 'Á', 'Č', 'Ď', 'É', 'Ě', 'Í', 'Ň', 'Ó', 'Ř', 'Š', 'Ť', 'Ú', 'Ů', 'Ý', 'Ž' + ]; + $ascii = [ + 'a', 'c', 'd', 'e', 'e', 'i', 'n', 'o', 'r', 's', 't', 'u', 'u', 'y', 'z', + 'A', 'C', 'D', 'E', 'E', 'I', 'N', 'O', 'R', 'S', 'T', 'U', 'U', 'Y', 'Z' + ]; + + return str_replace($czech, $ascii, $text); + } + + // endregion + + /** + * Creates title by clean URI by removing unwanted characters and leaves last part of the URI. + * + * @param string $text The input string with Czech diacritics. + * @return string The string with Czech diacritics removed. + */ + private function formatTitleFromURI(string $uri): string + { + // get last part of the URI + $title = basename($uri); + + // Pattern: /[^\p{L}\p{N}]+/u + // [^...] - Match any character NOT in the set + // \p{L} - Any Unicode letter (including 'é', 'ü', 'ñ', etc.) + // \p{N} - Any Unicode number (0-9 and other numeric characters) + // + - Match one or more occurrences of the preceding pattern consecutively + // /u - Unicode modifier, essential for \p{} constructs + $pattern = '/[^\p{L}\p{N}]+/u'; + $replacement = ' '; // Replace with a single space + + // lets replace + $title = preg_replace($pattern, $replacement, $title); + + // first letter to uppercase + $title = ucfirst($title); + + return trim((string)$title); + } +} diff --git a/bridges/ElloBridge.php b/bridges/ElloBridge.php index 42c88a06..a9e69cfe 100644 --- a/bridges/ElloBridge.php +++ b/bridges/ElloBridge.php @@ -34,11 +34,9 @@ class ElloBridge extends BridgeAbstract ]; if (!empty($this->getInput('u'))) { - $postData = getContents(self::URI . 'api/v2/users/~' . urlencode($this->getInput('u')) . '/posts', $header) or - returnServerError('Unable to query Ello API.'); + $postData = getContents(self::URI . 'api/v2/users/~' . urlencode($this->getInput('u')) . '/posts', $header); } else { - $postData = getContents(self::URI . 'api/v2/posts?terms=' . urlencode($this->getInput('s')), $header) or - returnServerError('Unable to query Ello API.'); + $postData = getContents(self::URI . 'api/v2/posts?terms=' . urlencode($this->getInput('s')), $header); } $postData = json_decode($postData); @@ -117,7 +115,7 @@ class ElloBridge extends BridgeAbstract $apiKey = $this->cache->get($cacheKey); if (!$apiKey) { - $keyInfo = getContents(self::URI . 'api/webapp-token') or returnServerError('Unable to get token.'); + $keyInfo = getContents(self::URI . 'api/webapp-token'); $apiKey = json_decode($keyInfo)->token->access_token; $ttl = 60 * 60 * 20; $this->cache->set($cacheKey, $apiKey, $ttl); diff --git a/bridges/EpicGamesFreeBridge.php b/bridges/EpicGamesFreeBridge.php new file mode 100644 index 00000000..6944ae2e --- /dev/null +++ b/bridges/EpicGamesFreeBridge.php @@ -0,0 +1,86 @@ + [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'English' => 'en-US', + 'العربية' => 'ar', + 'Deutsch' => 'de', + 'Español (Spain)' => 'es-ES', + 'Español (LA)' => 'es-MX', + 'Français' => 'fr', + 'Italiano' => 'it', + '日本語' => 'ja', + '한국어' => 'ko', + 'Polski' => 'pl', + 'Português (Brasil)' => 'pt-BR', + 'Русский' => 'ru', + 'ไทย' => 'th', + 'Türkçe' => 'tr', + '简体中文' => 'zh-CN', + '繁體中文' => 'zh-Hant', + ], + 'title' => 'Language for game information', + 'defaultValue' => 'en-US', + ], + 'country' => [ + 'name' => 'Country', + 'title' => 'Country store to check for deals', + 'defaultValue' => 'US', + ] + ]]; + + public function collectData() + { + $url = 'https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?'; + $params = [ + 'locale' => $this->getInput('locale'), + 'country' => $this->getInput('country'), + 'allowCountries' => $this->getInput('country'), + ]; + $url .= http_build_query($params); + $json = Json::decode(getContents($url)); + + $data = $json['data']['Catalog']['searchStore']['elements']; + foreach ($data as $element) { + $promo = $element['promotions']['promotionalOffers'][0]['promotionalOffers'][0] ?? false; + if ( + !$promo || + $promo['discountSetting']['discountType'] !== 'PERCENTAGE' || + $promo['discountSetting']['discountPercentage'] !== 0 + ) { + continue; + } + $slug = $element['catalogNs']['mappings'][0]['pageSlug'] ?? null; + if ($slug !== null) { + $uri = parent::getURI() . $this->getInput('locale') . '/p/' . $slug; + } else { + // slug not found, show the root promos page + $uri = parent::getURI() . $this->getInput('locale') . '/free-games'; + } + $item = [ + 'author' => $element['seller']['name'], + 'content' => $element['description'], + 'enclosures' => array_map(fn($item) => $item['url'], $element['keyImages']), + 'timestamp' => strtotime($promo['startDate']), + 'title' => $element['title'], + 'uri' => $uri, + ]; + $this->items[] = $item; + } + } + + public function getURI() + { + $uri = parent::getURI() . $this->getInput('locale') . '/free-games'; + return $uri; + } +} diff --git a/bridges/ExplosmBridge.php b/bridges/ExplosmBridge.php index 0312a4c4..1572fb56 100644 --- a/bridges/ExplosmBridge.php +++ b/bridges/ExplosmBridge.php @@ -36,6 +36,9 @@ class ExplosmBridge extends BridgeAbstract $html = getSimpleHTMLDOM($url); $element = $html->find('[class*=ComicImage]', 0); + if (!$element) { + break; // skip, if element was not found + } $date = $element->find('[class^=Author__Right] p', 0)->plaintext; $author = str_replace('by ', '', $element->find('[class^=Author__Right] p', 1)->plaintext); $image = $element->find('img', 0)->src; diff --git a/bridges/FB2Bridge.php b/bridges/FB2Bridge.php index 141ea59b..47ae6b3f 100644 --- a/bridges/FB2Bridge.php +++ b/bridges/FB2Bridge.php @@ -85,13 +85,13 @@ class FB2Bridge extends BridgeAbstract $pageInfo = $this->getPageInfos($page, $cookies); if ($pageInfo['userId'] === null) { - returnClientError( + throwClientException( << [ - 'name' => 'Widget selection', - 'type' => 'list', - 'values' => [ - 'Latest added apps' => 'added', - 'Latest updated apps' => 'updated' - ] - ] - ]]; - - public function getIcon() - { - return self::URI . 'assets/favicon.ico'; - } - - private function getTimestamp($url) - { - $curlOptions = [ - CURLOPT_CUSTOMREQUEST => 'HEAD', - CURLOPT_NOBODY => true, - ]; - $reponse = getContents($url, [], $curlOptions, true); - $lastModified = $reponse['headers']['last-modified'][0] ?? null; - $timestamp = strtotime($lastModified ?? 'today'); - return $timestamp; - } - - public function collectData() - { - $url = self::URI; - $html = getSimpleHTMLDOM($url); - - // targetting the corresponding widget based on user selection - // "updated" is the 5th widget on the page, "added" is the 6th - - switch ($this->getInput('u')) { - case 'updated': - $html_widget = $html->find('div.sidebar-widget', 5); - break; - default: - $html_widget = $html->find('div.sidebar-widget', 6); - break; - } - - // and now extracting app info from the selected widget (and yeah turns out icons are of heterogeneous sizes) - - foreach ($html_widget->find('a') as $element) { - $item = []; - $item['uri'] = self::URI . $element->href; - $item['title'] = $element->find('h4', 0)->plaintext; - $item['icon'] = $element->find('img', 0)->src; - $item['timestamp'] = $this->getTimestamp($item['icon']); - $item['summary'] = $element->find('span.package-summary', 0)->plaintext; - $item['content'] = ' - - -
    ' . $item['summary']; - $this->items[] = $item; - } - } -} diff --git a/bridges/FDroidRepoBridge.php b/bridges/FDroidRepoBridge.php index 7ce41baf..844f6abb 100644 --- a/bridges/FDroidRepoBridge.php +++ b/bridges/FDroidRepoBridge.php @@ -14,7 +14,7 @@ class FDroidRepoBridge extends BridgeAbstract 'name' => 'Repository URL', 'title' => 'Usually ends with /repo/', 'required' => true, - 'exampleValue' => 'https://srv.tt-rss.org/fdroid/repo' + 'exampleValue' => 'https://molly.im/fdroid/foss/fdroid/repo' ] ], 'Latest Updates' => [ @@ -35,7 +35,7 @@ class FDroidRepoBridge extends BridgeAbstract 'package' => [ 'name' => 'Package Identifier', 'required' => true, - 'exampleValue' => 'org.fox.ttrss' + 'exampleValue' => 'im.molly.app' ] ] ]; @@ -45,11 +45,7 @@ class FDroidRepoBridge extends BridgeAbstract public function collectData() { - if (!extension_loaded('zip')) { - throw new \Exception('FDroidRepoBridge requires the php-zip extension'); - } - - $this->repo = $this->getRepo(); + $this->repo = $this->fetchData(); switch ($this->queriedContext) { case 'Latest Updates': $this->getAllUpdates(); @@ -58,63 +54,15 @@ class FDroidRepoBridge extends BridgeAbstract $this->getPackage($this->getInput('package')); break; default: - returnServerError('Unimplemented Context (collectData)'); + throw new \Exception('Unimplemented Context (collectData)'); } } - public function getURI() - { - if (empty($this->queriedContext)) { - return parent::getURI(); - } - - $url = rtrim($this->GetInput('url'), '/'); - return strstr($url, '?', true) ?: $url; - } - - public function getName() - { - if (empty($this->queriedContext)) { - return parent::getName(); - } - - $name = $this->repo['repo']['name']; - switch ($this->queriedContext) { - case 'Latest Updates': - return $name; - case 'Follow Package': - return $this->getInput('package') . ' - ' . $name; - default: - returnServerError('Unimplemented Context (getName)'); - } - } - - private function getRepo() + private function fetchData() { $url = $this->getURI(); - - // Get repo information (only available as JAR) - $jar = getContents($url . '/index-v1.jar'); - $jar_loc = tempnam(sys_get_temp_dir(), ''); - file_put_contents($jar_loc, $jar); - - // JAR files are specially formatted ZIP files - $jar = new \ZipArchive(); - if ($jar->open($jar_loc) !== true) { - unlink($jar_loc); - throw new \Exception('Failed to extract archive'); - } - - // Get file pointer to the relevant JSON inside - $fp = $jar->getStream('index-v1.json'); - if (!$fp) { - returnServerError('Failed to get file pointer'); - } - - $data = json_decode(stream_get_contents($fp), true); - fclose($fp); - $jar->close(); - unlink($jar_loc); + $json = getContents($url . '/index-v1.json'); + $data = Json::decode($json); return $data; } @@ -158,9 +106,9 @@ class FDroidRepoBridge extends BridgeAbstract $summary = $lang['summary'] ?? $app['summary'] ?? ''; $description = markdownToHtml(trim($lang['description'] ?? $app['description'] ?? 'None')); $whatsNew = markdownToHtml(trim($lang['whatsNew'] ?? 'None')); - $website = $this->link($lang['webSite'] ?? $app['webSite'] ?? $app['authorWebSite'] ?? null); - $source = $this->link($app['sourceCode'] ?? null); - $issueTracker = $this->link($app['issueTracker'] ?? null); + $website = $this->createAnchor($lang['webSite'] ?? $app['webSite'] ?? $app['authorWebSite'] ?? null); + $source = $this->createAnchor($app['sourceCode'] ?? null); + $issueTracker = $this->createAnchor($app['issueTracker'] ?? null); $license = $app['license'] ?? 'None'; $item['content'] = <<repo['packages'][$package])) { - returnClientError('Invalid Package Name'); + throw new \Exception('Invalid Package Name'); } $package = $this->repo['packages'][$package]; @@ -192,7 +140,7 @@ EOD; $item['uri'] = $this->getURI() . '/' . $version['apkName']; $item['title'] = $version['versionName']; $item['timestamp'] = date(DateTime::ISO8601, (int) ($version['added'] / 1000)); - $item['uid'] = $version['versionCode']; + $item['uid'] = (string) $version['versionCode']; $size = round($version['size'] / 1048576, 1); // Bytes -> MB $sdk_link = 'https://developer.android.com/studio/releases/platforms'; $item['content'] = <<queriedContext)) { + return parent::getURI(); + } + + $url = rtrim($this->getInput('url'), '/'); + if (strstr($url, '?', true)) { + return strstr($url, '?', true); + } else { + return $url; + } + } + + public function getName() + { + if (empty($this->queriedContext)) { + return parent::getName(); + } + + $name = $this->repo['repo']['name']; + switch ($this->queriedContext) { + case 'Latest Updates': + return $name; + case 'Follow Package': + return $this->getInput('package') . ' - ' . $name; + default: + throw new \Exception('Unimplemented Context (getName)'); + } + } + + private function createAnchor($url) { if (empty($url)) { return null; } - return '' . $url . ''; + return sprintf('%s', $url, $url); } } diff --git a/bridges/FabBridge.php b/bridges/FabBridge.php new file mode 100644 index 00000000..6596f869 --- /dev/null +++ b/bridges/FabBridge.php @@ -0,0 +1,43 @@ +results as $item) { + $thumbnail = $item->thumbnails[0]->mediaUrl; + $itemurl = static::URI . '/listings/' . $item->uid; + + $itemapiurl = static::URI . '/i/listings/' . $item->uid; + $itemjson = getContents($itemapiurl, $header); + $itemjson = json_decode($itemjson); + + $this->items[] = [ + 'title' => $item->title, + 'author' => $item->user->sellerName, + 'uri' => $itemurl, + 'timestamp' => strtotime($item->lastUpdatedAt), + 'content' => '' . $itemjson->description, + ]; + } + } +} diff --git a/bridges/FacebookBridge.php b/bridges/FacebookBridge.php index 3e693a38..797752f9 100644 --- a/bridges/FacebookBridge.php +++ b/bridges/FacebookBridge.php @@ -155,7 +155,7 @@ class FacebookBridge extends BridgeAbstract break; default: - returnClientError('Unknown context: "' . $this->queriedContext . '"!'); + throwClientException('Unknown context: "' . $this->queriedContext . '"!'); } $limit = $this->getInput('limit') ?: -1; @@ -184,7 +184,7 @@ class FacebookBridge extends BridgeAbstract $html = getSimpleHTMLDOM($touchURI, $header); if (!$this->isPublicGroup($html)) { - returnClientError('This group is not public! RSS-Bridge only supports public groups!'); + throwClientException('This group is not public! RSS-Bridge only supports public groups!'); } defaultLinkTo($html, substr(self::URI, 0, strlen(self::URI) - 1)); @@ -192,7 +192,7 @@ class FacebookBridge extends BridgeAbstract $this->groupName = $this->extractGroupName($html); $posts = $html->find('div.story_body_container') - or returnServerError('Failed finding posts!'); + or throwServerException('Failed finding posts!'); foreach ($posts as $post) { $item = []; @@ -224,7 +224,7 @@ class FacebookBridge extends BridgeAbstract return explode('/', $urlparts['path'])[2]; } elseif (strpos($group, '/') !== false) { - returnClientError('The group you provided is invalid: ' . $group); + throwClientException('The group you provided is invalid: ' . $group); } else { return $group; } @@ -246,7 +246,7 @@ class FacebookBridge extends BridgeAbstract $provided_host !== $facebook_host && 'www.' . $provided_host !== $facebook_host ) { - returnClientError('The host you provided is invalid! Received "' + throwClientException('The host you provided is invalid! Received "' . $provided_host . '", expected "' . $facebook_host @@ -268,7 +268,7 @@ class FacebookBridge extends BridgeAbstract private function extractGroupName($html) { $ogtitle = $html->find('._de1', 0) - or returnServerError('Unable to find group title!'); + or throwServerException('Unable to find group title!'); return html_entity_decode($ogtitle->plaintext, ENT_QUOTES); } @@ -276,7 +276,7 @@ class FacebookBridge extends BridgeAbstract private function extractGroupPostURI($post) { $elements = $post->find('a') - or returnServerError('Unable to find URI!'); + or throwServerException('Unable to find URI!'); foreach ($elements as $anchor) { // Find the one that is a permalink @@ -292,7 +292,7 @@ class FacebookBridge extends BridgeAbstract private function extractGroupPostContent($post) { $content = $post->find('div._5rgt', 0) - or returnServerError('Unable to find user content!'); + or throwServerException('Unable to find user content!'); $context_text = $content->innertext; if ($content->next_sibling() !== null) { @@ -304,7 +304,7 @@ class FacebookBridge extends BridgeAbstract private function extractGroupPostAuthor($post) { $element = $post->find('h3 a', 0) - or returnServerError('Unable to find author information!'); + or throwServerException('Unable to find author information!'); return $element->plaintext; } @@ -334,7 +334,7 @@ class FacebookBridge extends BridgeAbstract private function extractGroupPostTitle($post) { $element = $post->find('h3', 0) - or returnServerError('Unable to find title!'); + or throwServerException('Unable to find title!'); if (strpos($element->plaintext, 'shared') === false) { $content = strip_tags($this->extractGroupPostContent($post)); @@ -370,14 +370,14 @@ class FacebookBridge extends BridgeAbstract !array_key_exists('path', $urlparts) || $urlparts['path'] === '/' ) { - returnClientError('The URL you provided doesn\'t contain the user name!'); + throwClientException('The URL you provided doesn\'t contain the user name!'); } return explode('/', $urlparts['path'])[1]; } else { // First character cannot be a forward slash if (strpos($user, '/') === 0) { - returnClientError('Remove leading slash "/" from the username!'); + throwClientException('Remove leading slash "/" from the username!'); } return $user; @@ -572,7 +572,7 @@ EOD; $loginForm = $html->find('._585r', 0); if ($loginForm != null) { - returnServerError('You must be logged in to view this page. This is not supported by RSS-Bridge.'); + throwServerException('You must be logged in to view this page. This is not supported by RSS-Bridge.'); } $mainColumn = $html->find('#pagelet_timeline_main_column'); diff --git a/bridges/FallGuysBridge.php b/bridges/FallGuysBridge.php index 1e527484..46411f33 100644 --- a/bridges/FallGuysBridge.php +++ b/bridges/FallGuysBridge.php @@ -37,13 +37,14 @@ class FallGuysBridge extends BridgeAbstract public function collectData() { - $html = getSimpleHTMLDOM(self::getURI()); + $newsData = self::requestJsonData(self::getURI(), false); - $data = json_decode($html->find('#__NEXT_DATA__', 0)->innertext); + foreach ($newsData->props->pageProps->newsList as $newsItem) { + $newsItemUrl = self::getURI() . '/' . $newsItem->slug; + $newsItemTitle = $newsItem->header->title; - foreach ($data->props->pageProps->newsList as $newsItem) { $headerDescription = property_exists($newsItem->header, 'description') ? $newsItem->header->description : ''; - $headerImage = $newsItem->header->image->src; + $headerImage = $newsItem->newsLandingConfig->options[0]->image->src->url; $contentImages = [$headerImage]; @@ -52,67 +53,79 @@ class FallGuysBridge extends BridgeAbstract

    HTML; - foreach ($newsItem->content->items as $contentItem) { - if (property_exists($contentItem, 'articleCopy')) { - if (property_exists($contentItem->articleCopy, 'title')) { - $title = $contentItem->articleCopy->title; + try { + $newsItemData = self::requestJsonData($newsItemUrl, true); + } catch (\Exception $e) { + $this->logger->error(sprintf('Failed to request data for news item "%s" (%s)', $newsItemTitle, $newsItemUrl), ['e' => $e]); + $newsItemData = null; + } + if (!$newsItemData) { + $this->logger->error(sprintf('Failed to parse json data for news item "%s" (%s)', $newsItemTitle, $newsItemUrl)); + } else { + foreach ($newsItemData->props->pageProps->pageData->content->items as $contentItem) { + if (property_exists($contentItem, 'articleCopy')) { + if (property_exists($contentItem->articleCopy, 'title')) { + $title = $contentItem->articleCopy->title; + + $content .= <<{$title} + HTML; + } + + $text = $contentItem->articleCopy->copy; $content .= <<{$title} +

    {$text}

    HTML; - } + } elseif (property_exists($contentItem, 'articleImage')) { + $image = $contentItem->articleImage->imageSrc; - $text = $contentItem->articleCopy->copy; + if ($image != $headerImage) { + $contentImages[] = $image; - $content .= <<{$text}

    - HTML; - } elseif (property_exists($contentItem, 'articleImage')) { - $image = $contentItem->articleImage->imageSrc; + $content .= <<

    + HTML; + } + } elseif (property_exists($contentItem, 'embeddedVideo')) { + $mediaOptions = $contentItem->embeddedVideo->mediaOptions; + $mainContentOptions = $contentItem->embeddedVideo->mainContentOptions; - if ($image != $headerImage) { - $contentImages[] = $image; + if (count($mediaOptions) == count($mainContentOptions)) { + for ($i = 0; $i < count($mediaOptions); $i++) { + if (property_exists($mediaOptions[$i], 'youtubeVideo')) { + $videoUrl = 'https://youtu.be/' . $mediaOptions[$i]->youtubeVideo->contentId; + $image = $mainContentOptions[$i]->image->src ?? ''; - $content .= <<

    - HTML; - } - } elseif (property_exists($contentItem, 'embeddedVideo')) { - $mediaOptions = $contentItem->embeddedVideo->mediaOptions; - $mainContentOptions = $contentItem->embeddedVideo->mainContentOptions; + $content .= '

    '; - if (count($mediaOptions) == count($mainContentOptions)) { - for ($i = 0; $i < count($mediaOptions); $i++) { - if (property_exists($mediaOptions[$i], 'youtubeVideo')) { - $videoUrl = 'https://youtu.be/' . $mediaOptions[$i]->youtubeVideo->contentId; - $image = $mainContentOptions[$i]->image->src ?? ''; + if ($image != $headerImage) { + $contentImages[] = $image; - $content .= '

    '; - - if ($image != $headerImage) { - $contentImages[] = $image; + $content .= <<
    + HTML; + } $content .= <<
    + (Video: {$videoUrl}) HTML; + + $content .= '

    '; } - - $content .= <<(Video: {$videoUrl}) - HTML; - - $content .= '

    '; } } + } else { + $this->logger->warning(sprintf('Unsupported content item in news item "%s" (%s)', $newsItemTitle, $newsItemUrl)); } } } $item = [ - 'uid' => $newsItem->_id, - 'uri' => self::getURI() . '/' . $newsItem->_slug, - 'title' => $newsItem->_title, - 'timestamp' => $newsItem->lastModified, + 'uid' => $newsItem->id, + 'uri' => $newsItemUrl, + 'title' => $newsItemTitle, + 'timestamp' => $newsItem->activeDate, 'content' => $content, 'enclosures' => $contentImages, ]; @@ -131,4 +144,12 @@ class FallGuysBridge extends BridgeAbstract { return self::BASE_URI . '/favicon.ico'; } + + private function requestJsonData(string $url, bool $useCache) + { + $html = $useCache ? getSimpleHTMLDOMCached($url) : getSimpleHTMLDOM($url); + $jsonElement = $html->find('#__NEXT_DATA__', 0); + $json = $jsonElement ? $jsonElement->innertext : null; + return json_decode($json); + } } diff --git a/bridges/FanaticalBridge.php b/bridges/FanaticalBridge.php new file mode 100644 index 00000000..3eeeae1b --- /dev/null +++ b/bridges/FanaticalBridge.php @@ -0,0 +1,95 @@ + [ + 'name' => 'Bundle type', + 'type' => 'list', + 'defaultValue' => 'all', + 'values' => [ + 'All' => 'all', + 'Books' => 'book-', + 'ELearning' => 'elearning-', + 'Games' => '', + 'Software' => 'software-', + ] + ] + ]]; + + + const IMGURL = 'https://fanatical.imgix.net/product/original/'; + public function collectData() + { + $api = 'https://www.fanatical.com/api/all/en'; + $json = json_decode(getContents($api), true)['pickandmix']; + $type = $this->getInput('type'); + + foreach ($json as $element) { + if ($type != 'all') { + if ($element['type'] != $type . 'bundle') { + continue; + } + } + + $item = [ + 'categories' => [$element['type']], + 'content' => '
      ', + 'enclosures' => [self::IMGURL . $element['cover_image']], + 'timestamp' => $element['valid_from'], + 'title' => $element['name'], + 'uri' => parent::getURI() . 'pick-and-mix/' . $element['slug'], + ]; + + $slugs = []; + foreach ($element['products'] as $product) { + $slug = $product['slug']; + if (in_array($slug, $slugs)) { + continue; + } + $slugs[] = $slug; + $uri = parent::getURI() . 'game/' . $slug; + $item['content'] .= '
    • ' . $product['name'] . '
    • '; + $item['enclosures'][] = self::IMGURL . $product['cover']; + } + foreach ($element['tiers'] as $tier) { + $count = $tier['quantity']; + $price = round($tier['price']['USD'] / 100, 2); + $per = round($price / $count, 2); + $item['categories'][] = "$count at $per for $price total"; + } + + $item['content'] .= '
    '; + $this->items[] = $item; + } + } + + public function getName() + { + $name = parent::getName(); + $name .= $this->getKey('type') ? ' - ' . $this->getKey('type') : ''; + return $name; + } + + public function getURI() + { + $uri = parent::getURI(); + $type = $this->getKey('type'); + if ($type) { + $uri .= 'bundle/'; + if ($type != 'All') { + $uri .= strtolower($type); + } + } + return $uri; + } + + public function getIcon() + { + return 'https://cdn.fanatical.com/production/icons/fanatical-icon-android-chrome-192x192.png'; + } +} diff --git a/bridges/FeedExpanderExampleBridge.php b/bridges/FeedExpanderExampleBridge.php index f0af64f4..d4f41473 100644 --- a/bridges/FeedExpanderExampleBridge.php +++ b/bridges/FeedExpanderExampleBridge.php @@ -40,7 +40,7 @@ class FeedExpanderExampleBridge extends FeedExpander parent::collectExpandableDatas('http://segfault.linuxmint.com/feed/atom/'); break; default: - returnClientError('Unknown version ' . $this->getInput('version') . '!'); + throwClientException('Unknown version ' . $this->getInput('version') . '!'); } } } diff --git a/bridges/FeedMergeBridge.php b/bridges/FeedMergeBridge.php index f2c1d9d5..4fe42013 100644 --- a/bridges/FeedMergeBridge.php +++ b/bridges/FeedMergeBridge.php @@ -6,8 +6,10 @@ class FeedMergeBridge extends FeedExpander const NAME = 'FeedMerge'; const URI = 'https://github.com/RSS-Bridge/rss-bridge'; const DESCRIPTION = <<<'TEXT' -This bridge merges two or more feeds into a single feed. Max 10 items are fetched from each feed. -TEXT; + This bridge merges two or more feeds into a single feed.
    + Max 10 latest items are fetched from each individual feed.
    + Items with identical url or title are considered duplicates (and are removed).
    + TEXT; const PARAMETERS = [ [ @@ -36,11 +38,11 @@ TEXT; ]; /** - * todo: Consider a strategy which produces a shorter feed url + * TODO: Consider a strategy which produces a shorter feed url */ public function collectData() { - $limit = (int)($this->getInput('limit') ?: 10); + $limit = (int)($this->getInput('limit') ?: 99); $feeds = [ $this->getInput('feed_1'), $this->getInput('feed_2'), @@ -61,9 +63,10 @@ TEXT; if (count($feeds) > 1) { // Allow one or more feeds to fail try { - $this->collectExpandableDatas($feed); + $this->collectExpandableDatas($feed, 10); } catch (HttpException $e) { $this->logger->warning(sprintf('Exception in FeedMergeBridge: %s', create_sane_exception_message($e))); + // This feed item might be spammy. Considering dropping it. $this->items[] = [ 'title' => 'RSS-Bridge: ' . $e->getMessage(), // Give current time so it sorts to the top @@ -71,7 +74,7 @@ TEXT; ]; continue; } catch (\Exception $e) { - if (str_starts_with($e->getMessage(), 'Unable to parse xml')) { + if (str_starts_with($e->getMessage(), 'Failed to parse xml')) { // Allow this particular exception from FeedExpander $this->logger->warning(sprintf('Exception in FeedMergeBridge: %s', create_sane_exception_message($e))); continue; @@ -79,29 +82,48 @@ TEXT; throw $e; } } else { - $this->collectExpandableDatas($feed); + $this->collectExpandableDatas($feed, 10); } } - // Sort by timestamp descending + // If $this->items is empty we should consider throw exception here + + // Sort by timestamp, uri, title in descending order usort($this->items, function ($a, $b) { $t1 = $a['timestamp'] ?? $a['uri'] ?? $a['title']; $t2 = $b['timestamp'] ?? $b['uri'] ?? $b['title']; return $t2 <=> $t1; }); - // Remove duplicates by using url as unique key + // Remove duplicates by url $items = []; foreach ($this->items as $item) { - $index = $item['uri'] ?? null; - if ($index) { - // Overwrite duplicates - $items[$index] = $item; + $uri = $item['uri'] ?? null; + if ($uri) { + // Insert or override the existing duplicate + $items[$uri] = $item; } else { + // The item doesn't have a uri! $items[] = $item; } } - $this->items = array_slice(array_values($items), 0, $limit); + $this->items = array_values($items); + + // Remove duplicates by title + $items = []; + foreach ($this->items as $item) { + $title = $item['title'] ?? null; + if ($title) { + // Insert or override the existing duplicate + $items[$title] = $item; + } else { + // The item doesn't have a title! + $items[] = $item; + } + } + $this->items = array_values($items); + + $this->items = array_slice($this->items, 0, $limit); } public function getIcon() diff --git a/bridges/FicbookBridge.php b/bridges/FicbookBridge.php index d11015ad..3aa7d998 100644 --- a/bridges/FicbookBridge.php +++ b/bridges/FicbookBridge.php @@ -187,7 +187,6 @@ class FicbookBridge extends BridgeAbstract $fixed_date = str_replace(' г.', '', $fixed_date); if ($fixed_date === $date) { - Debug::log('Unable to fix date: ' . $date); return null; } diff --git a/bridges/FilterBridge.php b/bridges/FilterBridge.php index 1add47f4..a1066fb5 100644 --- a/bridges/FilterBridge.php +++ b/bridges/FilterBridge.php @@ -15,6 +15,12 @@ class FilterBridge extends FeedExpander 'exampleValue' => 'https://lorem-rss.herokuapp.com/feed?unit=day', 'required' => true, ], + 'name' => [ + 'name' => 'Feed name (optional)', + 'type' => 'text', + 'exampleValue' => 'My feed', + 'required' => false, + ], 'filter' => [ 'name' => 'Filter (regular expression!!!)', 'required' => false, @@ -77,7 +83,7 @@ class FilterBridge extends FeedExpander { $url = $this->getInput('url'); if (!Url::validate($url)) { - returnClientError('The url parameter must either refer to http or https protocol.'); + throw new \Exception('The url parameter must either refer to http or https protocol.'); } $this->collectExpandableDatas($this->getURI()); } @@ -158,11 +164,18 @@ class FilterBridge extends FeedExpander public function getURI() { $url = $this->getInput('url'); - - if (empty($url)) { - $url = parent::getURI(); + if ($url) { + return $url; } + return parent::getURI(); + } - return $url; + public function getName() + { + $name = $this->getInput('name'); + if ($name) { + return $name; + } + return parent::getName(); } } diff --git a/bridges/FinanzflussBridge.php b/bridges/FinanzflussBridge.php index f255fb59..b0f5cc13 100644 --- a/bridges/FinanzflussBridge.php +++ b/bridges/FinanzflussBridge.php @@ -22,19 +22,9 @@ class FinanzflussBridge extends BridgeAbstract $domarticle = getSimpleHTMLDOM($url); $content = $domarticle->find('div.content', 0); - //get header-image and set absolute src + //get header-image $headerimage = $domarticle->find('div.article-header-image', 0); $headerimageimg = $headerimage->find('img[src]', 0); - $src = $headerimageimg->src; - $headerimageimg->src = $baseurl . $src; - $headerimageimg->srcset = $baseurl . $src; - - //set absolute src for all img - foreach ($content->find('img[src]') as $img) { - $src = $img->src; - $img->src = $baseurl . $src; - $img->srcset = $baseurl . $src; - } //remove unwanted stuff foreach ($content->find('div.newsletter-signup') as $element) { diff --git a/bridges/FindACrewBridge.php b/bridges/FindACrewBridge.php index 9119535b..0bc181f3 100644 --- a/bridges/FindACrewBridge.php +++ b/bridges/FindACrewBridge.php @@ -60,7 +60,7 @@ class FindACrewBridge extends BridgeAbstract CURLOPT_POSTFIELDS => http_build_query($data) . "\n" ]; - $html = getSimpleHTMLDOM($url, $header, $opts) or returnClientError('No results for this query.'); + $html = getSimpleHTMLDOM($url, $header, $opts); $annonces = $html->find('.css_SrhRst'); $limit = $this->getInput('limit') ?? 10; diff --git a/bridges/FirefoxAddonsBridge.php b/bridges/FirefoxAddonsBridge.php index fcf2ca02..7314aa8c 100644 --- a/bridges/FirefoxAddonsBridge.php +++ b/bridges/FirefoxAddonsBridge.php @@ -58,13 +58,13 @@ class FirefoxAddonsBridge extends BridgeAbstract } $item['content'] = <<Release Notes +

    Release Notes

    {$releaseNotes}

    -Compatibility +

    Compatibility

    {$compatibility}

    -License +

    License

    {$license}

    -Download +

    Download

    {$xpiFilename} ($size)

    EOD; diff --git a/bridges/FirstLookMediaTechBridge.php b/bridges/FirstLookMediaTechBridge.php deleted file mode 100644 index f9963c6f..00000000 --- a/bridges/FirstLookMediaTechBridge.php +++ /dev/null @@ -1,52 +0,0 @@ - [ - 'type' => 'checkbox', - 'name' => 'Include Projects?', - ] - ] - ]; - - public function collectData() - { - $html = getSimpleHTMLDOM(self::URI); - - if ($this->getInput('projects')) { - $top_projects = $html->find('.PromoList-ul', 0); - foreach ($top_projects->find('li.PromoList-item') as $element) { - $item = []; - - $item_uri = $element->find('a', 0); - $item['uri'] = $item_uri->href; - $item['title'] = strip_tags($item_uri->innertext); - $item['content'] = $element->find('div > div', 0); - - $this->items[] = $item; - } - } - - $top_articles = $html->find('.PromoList-ul', 1); - foreach ($top_articles->find('li.PromoList-item') as $element) { - $item = []; - - $item_left = $element->find('div > div', 0); - $item_date = $element->find('.PromoList-date', 0); - $item['timestamp'] = strtotime($item_date->innertext); - $item_date->outertext = ''; /* Remove */ - $item['author'] = $item_left->innertext; - $item_uri = $element->find('a', 0); - $item['uri'] = self::URI . $item_uri->href; - $item['title'] = strip_tags($item_uri); - - $this->items[] = $item; - } - } -} diff --git a/bridges/FlickrBridge.php b/bridges/FlickrBridge.php index 522c7284..1707bc84 100644 --- a/bridges/FlickrBridge.php +++ b/bridges/FlickrBridge.php @@ -112,7 +112,7 @@ class FlickrBridge extends BridgeAbstract break; default: - returnClientError('Invalid context: ' . $this->queriedContext); + throwClientException('Invalid context: ' . $this->queriedContext); } $model_json = $this->extractJsonModel($html); diff --git a/bridges/FolhaDeSaoPauloBridge.php b/bridges/FolhaDeSaoPauloBridge.php index dba86c52..27713a41 100644 --- a/bridges/FolhaDeSaoPauloBridge.php +++ b/bridges/FolhaDeSaoPauloBridge.php @@ -44,8 +44,6 @@ class FolhaDeSaoPauloBridge extends FeedExpander $item['content'] = $text; $item['uri'] = explode('*', $item['uri'])[1]; } - } else { - Debug::log('???: ' . $item['uri']); } } else { $item['uri'] = explode('*', $item['uri'])[1]; @@ -58,13 +56,11 @@ class FolhaDeSaoPauloBridge extends FeedExpander { $feed_input = $this->getInput('feed'); if (substr($feed_input, 0, strlen(self::URI)) === self::URI) { - Debug::log('Input:: ' . $feed_input); $feed_url = $feed_input; } else { /* TODO: prepend `/` if missing */ $feed_url = self::URI . '/' . $this->getInput('feed'); } - Debug::log('URL: ' . $feed_url); $limit = $this->getInput('amount'); $this->collectExpandableDatas($feed_url, $limit); } diff --git a/bridges/ForensicArchitectureBridge.php b/bridges/ForensicArchitectureBridge.php new file mode 100644 index 00000000..5f024f3a --- /dev/null +++ b/bridges/ForensicArchitectureBridge.php @@ -0,0 +1,25 @@ +investigations as $investigation) { + $this->items[] = [ + 'content' => $investigation->abstract, + 'timestamp' => $investigation->publication_date, + 'title' => $investigation->title, + 'uid' => $investigation->id, + 'uri' => self::URI . 'investigation/' . $investigation->slug, + ]; + } + } +} diff --git a/bridges/Formula1Bridge.php b/bridges/Formula1Bridge.php index f84b1ca7..28b5f6e9 100644 --- a/bridges/Formula1Bridge.php +++ b/bridges/Formula1Bridge.php @@ -5,13 +5,13 @@ class Formula1Bridge extends BridgeAbstract const NAME = 'Formula1 Bridge'; const URI = 'https://formula1.com/'; const DESCRIPTION = 'Returns latest official Formula 1 news'; - const MAINTAINER = 'AxorPL'; + const MAINTAINER = 'axor-mst'; - const API_KEY = 'qPgPPRJyGCIPxFT3el4MF7thXHyJCzAP'; + const API_KEY = 'xZ7AOODSjiQadLsIYWefQrpCSQVDbHGC'; const API_URL = 'https://api.formula1.com/v1/editorial/articles?limit=%u'; const ARTICLE_AUTHOR = 'Formula 1'; - const ARTICLE_URL = 'https://formula1.com/en/latest/article.%s.%s.html'; + const ARTICLE_URL = 'https://formula1.com/en/latest/article/%s.%s'; const LIMIT_MIN = 1; const LIMIT_DEFAULT = 10; @@ -36,9 +36,13 @@ class Formula1Bridge extends BridgeAbstract $limit = min(self::LIMIT_MAX, max(self::LIMIT_MIN, $limit)); $url = sprintf(self::API_URL, $limit); - $json = json_decode(getContents($url, ['apikey: ' . self::API_KEY])); + $json = json_decode(getContents($url, [ + 'Accept: application/json', + 'apikey: ' . self::API_KEY, + 'locale: en' + ])); if (property_exists($json, 'error')) { - returnServerError($json->message); + throwServerException($json->message); } $list = $json->items; diff --git a/bridges/FreeTelechargerBridge.php b/bridges/FreeTelechargerBridge.php index f0e5d35a..69111d73 100644 --- a/bridges/FreeTelechargerBridge.php +++ b/bridges/FreeTelechargerBridge.php @@ -3,7 +3,8 @@ class FreeTelechargerBridge extends BridgeAbstract { const NAME = 'Free-Telecharger'; - const URI = 'https://www.free-telecharger.art/'; + const URI = 'https://www.free-telecharger.fun/'; + const ALTERNATEURI = 'https://www.free-telecharger.com/'; const DESCRIPTION = 'Suivi de série sur Free-Telecharger'; const MAINTAINER = 'sysadminstory'; const PARAMETERS = [ @@ -12,19 +13,19 @@ class FreeTelechargerBridge extends BridgeAbstract 'name' => 'URL de la série', 'type' => 'text', 'required' => true, - 'title' => 'URL d\'une série sans le https://www.free-telecharger.art/', + 'title' => 'URL d\'une série sans le https://www.free-telecharger.fun/', 'pattern' => 'series.*\.html', 'exampleValue' => 'series-vf-hd/151432-wolf-saison-1-complete-web-dl-720p.html' ], ] ]; const CACHE_TIMEOUT = 3600; - private string $showTitle; - private string $showTechDetails; + private string $showTitle = ''; + private string $showTechDetails = ''; public function collectData() { - $html = getSimpleHTMLDOM(self::URI . $this->getInput('url')); + $html = getSimpleHTMLDOM(self::ALTERNATEURI . $this->getInput('url')); // Find all block content of the page $blocks = $html->find('div[class=block1]'); diff --git a/bridges/FunkBridge.php b/bridges/FunkBridge.php index df499035..9aea6335 100644 --- a/bridges/FunkBridge.php +++ b/bridges/FunkBridge.php @@ -32,7 +32,7 @@ class FunkBridge extends BridgeAbstract $url .= '?size=' . $this->getInput('max'); } - $jsonString = getContents($url) or returnServerError('No contents received!'); + $jsonString = getContents($url); $json = json_decode($jsonString, true); foreach ($json['list'] as $element) { @@ -40,7 +40,7 @@ class FunkBridge extends BridgeAbstract } break; default: - returnServerError('Unknown context!'); + throwServerException('Unknown context!'); } } diff --git a/bridges/FurAffinityBridge.php b/bridges/FurAffinityBridge.php index 087c3ded..635df01b 100644 --- a/bridges/FurAffinityBridge.php +++ b/bridges/FurAffinityBridge.php @@ -676,7 +676,7 @@ class FurAffinityBridge extends BridgeAbstract $name = parent::getName(); if ($this->getOption('aCookie') !== null) { $username = $this->loadCacheValue('username'); - if ($username !== null) { + if ($username) { $name = $username . '\'s ' . parent::getName(); } } @@ -920,7 +920,9 @@ class FurAffinityBridge extends BridgeAbstract break; } - $item = []; + $item = [ + 'categories' => [], + ]; $submissionURL = $figure->find('b u a', 0)->href; $imgURL = $figure->find('b u a img', 0)->src; @@ -936,8 +938,7 @@ class FurAffinityBridge extends BridgeAbstract if ($this->getInput('full') === true) { $submissionHTML = $this->getFASimpleHTMLDOM($submissionURL, $cache); if (!$this->isHiddenSubmission($submissionHTML)) { - $stats = $submissionHTML->find('.stats-container', 0); - $popupDate = $stats->find('.popup_date', 0); + $popupDate = $submissionHTML->find('section .popup_date', 0); if ($popupDate) { $item['timestamp'] = strtotime($popupDate->title); } @@ -947,9 +948,10 @@ class FurAffinityBridge extends BridgeAbstract $item['enclosures'] = [$var->href]; } - foreach ($stats->find('#keywords a') as $keyword) { + foreach ($submissionHTML->find('.tags-row .tags a') as $keyword) { $item['categories'][] = $keyword->plaintext; } + $item['categories'] = array_filter($item['categories']); $previewSrc = $submissionHTML->find('#submissionImg', 0); if ($previewSrc) { diff --git a/bridges/FurAffinityUserBridge.php b/bridges/FurAffinityUserBridge.php index fa10d7ae..1866308d 100644 --- a/bridges/FurAffinityUserBridge.php +++ b/bridges/FurAffinityUserBridge.php @@ -34,8 +34,7 @@ class FurAffinityUserBridge extends BridgeAbstract $url = self::URI . '/gallery/' . $this->getInput('searchUsername'); - $html = getSimpleHTMLDOM($url, [], $opt) - or returnServerError('Could not load the user\'s gallery page.'); + $html = getSimpleHTMLDOM($url, [], $opt); $submissions = $html->find('section[id=gallery-gallery]', 0)->find('figure'); foreach ($submissions as $submission) { diff --git a/bridges/GBAtempBridge.php b/bridges/GBAtempBridge.php index 4aa04799..361e3f1d 100644 --- a/bridges/GBAtempBridge.php +++ b/bridges/GBAtempBridge.php @@ -31,7 +31,7 @@ class GBAtempBridge extends BridgeAbstract $img = $this->findItemImage($newsItem, 'a.news_image'); $time = $this->findItemDate($newsItem); $author = $newsItem->find('a.username', 0)->plaintext; - $title = $this->decodeHtmlEntities($newsItem->find('h3.news_title', 0)->plaintext); + $title = $this->decodeHtmlEntities($newsItem->find('h2.news_title', 0)->plaintext); $content = $this->fetchPostContent($url, self::URI); $this->items[] = $this->buildItem($url, $title, $author, $time, $img, $content); unset($newsItem); // Some items are heavy, freeing the item proactively helps saving memory @@ -41,7 +41,7 @@ class GBAtempBridge extends BridgeAbstract foreach ($html->find('li.portal_review') as $reviewItem) { $url = urljoin(self::URI, $reviewItem->find('a.review_boxart', 0)->href); $img = $this->findItemImage($reviewItem, 'a.review_boxart'); - $title = $this->decodeHtmlEntities($reviewItem->find('h2.review_title', 0)->plaintext); + $title = $this->decodeHtmlEntities($reviewItem->find('div.review_title', 0)->find('h2', 0)->plaintext); $content = getSimpleHTMLDOMCached($url); $author = $content->find('span.author--name', 0)->plaintext; $time = $this->findItemDate($content); diff --git a/bridges/GOGBridge.php b/bridges/GOGBridge.php index eacff97f..ff713a96 100644 --- a/bridges/GOGBridge.php +++ b/bridges/GOGBridge.php @@ -9,20 +9,19 @@ class GOGBridge extends BridgeAbstract public function collectData() { - $values = getContents('https://www.gog.com/games/ajax/filtered?limit=25&sort=new'); + $values = getContents('https://catalog.gog.com/v1/catalog?limit=48&order=desc%3AstoreReleaseDate'); $decodedValues = json_decode($values); $limit = 0; foreach ($decodedValues->products as $game) { $item = []; - $item['author'] = $game->developer . ' / ' . $game->publisher; + $item['author'] = implode(', ', $game->developers) . ' / ' . implode(', ', $game->publishers); $item['title'] = $game->title; $item['id'] = $game->id; - $item['uri'] = self::URI . $game->url; + $item['uri'] = $game->storeLink; $item['content'] = $this->buildGameContentPage($game); - $item['timestamp'] = $game->globalReleaseDate; - foreach ($game->gallery as $image) { + foreach ($game->screenshots as $image) { $item['enclosures'][] = $image . '.jpg'; } @@ -42,18 +41,10 @@ class GOGBridge extends BridgeAbstract $gameDescriptionValue = json_decode($gameDescriptionText); $content = 'Genres: '; - $content .= implode(', ', $game->genres); + $content .= implode(', ', array_column($game->genres, 'name')); $content .= '
    Supported Platforms: '; - if ($game->worksOn->Windows) { - $content .= 'Windows '; - } - if ($game->worksOn->Mac) { - $content .= 'Mac '; - } - if ($game->worksOn->Linux) { - $content .= 'Linux '; - } + $content .= implode(', ', $game->operatingSystems); $content .= '
    ' . $gameDescriptionValue->description->full; diff --git a/bridges/GULPProjekteBridge.php b/bridges/GULPProjekteBridge.php new file mode 100644 index 00000000..05689bc9 --- /dev/null +++ b/bridges/GULPProjekteBridge.php @@ -0,0 +1,164 @@ +addArguments(['--accept-lang=de']); + return $chromeOptions; + } + + /** + * @throws Facebook\WebDriver\Exception\NoSuchElementException + * @throws Facebook\WebDriver\Exception\TimeoutException + */ + protected function clickAwayCookieBanner() + { + $this->getDriver()->wait()->until(WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::id('onetrust-reject-all-handler'))); + $buttonRejectCookies = $this->getDriver()->findElement(WebDriverBy::id('onetrust-reject-all-handler')); + $buttonRejectCookies->click(); + $this->getDriver()->wait()->until(WebDriverExpectedCondition::invisibilityOfElementLocated(WebDriverBy::id('onetrust-reject-all-handler'))); + } + + /** + * @throws Facebook\WebDriver\Exception\NoSuchElementException + * @throws Facebook\WebDriver\Exception\TimeoutException + */ + protected function clickNextPage() + { + $nextPage = $this->getDriver()->findElement(WebDriverBy::xpath('//app-linkable-paginator//li[@id="next-page"]/a')); + $href = $nextPage->getAttribute('href'); + $nextPage->click(); + $this->getDriver()->wait()->until(WebDriverExpectedCondition::not( + WebDriverExpectedCondition::presenceOfElementLocated( + WebDriverBy::xpath('//app-linkable-paginator//li[@id="next-page"]/a[@href="' . $href . '"]') + ) + )); + } + + /** + * Returns the uri of the 'Projektanbieter' logo or false if there is + * no logo present in the item. + * + * @return string | false + */ + protected function getLogo(RemoteWebElement $item) + { + try { + $logo = $item->findElement(WebDriverBy::tagName('img'))->getAttribute('src'); + if (str_starts_with($logo, 'http')) { + // different domain + return $logo; + } else { + // relative path + $remove = substr(self::URI, strrpos(self::URI, '/') + 1); + return substr(self::URI, 0, -strlen($remove)) . $logo; + } + } catch (NoSuchElementException $e) { + return false; + } + } + + /** + * Converts a string like "vor einigen Minuten" into a reasonable timestamp. + * Long and complicated, but we don't want to be more specific than + * the information we have available. + * + * @throws Exception If the DateInterval can't be parsed. + */ + protected function getTimestamp(string $timeAgo): int + { + $dateTime = new DateTime(); + $dateArray = explode(' ', $dateTime->format('Y m d H i s')); + $quantityStr = explode(' ', $timeAgo)[1]; + // convert possible word into a number + if (in_array($quantityStr, ['einem', 'einer', 'einigen'])) { + $quantity = 1; + } else { + $quantity = intval($quantityStr); + } + // subtract time ago + inferior units for lower precision + if (str_contains($timeAgo, 'Sekunde')) { + $interval = new DateInterval('PT' . $quantity . 'S'); + } elseif (str_contains($timeAgo, 'Minute')) { + $interval = new DateInterval('PT' . $quantity . 'M' . $dateArray[5] . 'S'); + } elseif (str_contains($timeAgo, 'Stunde')) { + $interval = new DateInterval('PT' . $quantity . 'H' . $dateArray[4] . 'M' . $dateArray[5] . 'S'); + } elseif (str_contains($timeAgo, 'Tag')) { + $interval = new DateInterval('P' . $quantity . 'DT' . $dateArray[3] . 'H' . $dateArray[4] . 'M' . $dateArray[5] . 'S'); + } else { + throw new UnexpectedValueException($timeAgo); + } + $dateTime = $dateTime->sub($interval); + return $dateTime->getTimestamp(); + } + + /** + * The main loop which clicks through search result pages and puts + * the content into the $items array. + * + * @throws Facebook\WebDriver\Exception\NoSuchElementException + * @throws Facebook\WebDriver\Exception\TimeoutException + */ + public function collectData() + { + parent::collectData(); + + try { + $this->clickAwayCookieBanner(); + $this->setIcon($this->getDriver()->findElement(WebDriverBy::xpath('//link[@rel="shortcut icon"]'))->getAttribute('href')); + + while (true) { + $items = $this->getDriver()->findElements(WebDriverBy::tagName('app-project-view')); + foreach ($items as $item) { + $feedItem = []; + + $heading = $item->findElement(WebDriverBy::xpath('.//app-heading-tag/h1/a')); + $feedItem['title'] = $heading->getText(); + $feedItem['uri'] = 'https://www.gulp.de' . $heading->getAttribute('href'); + $info = $item->findElement(WebDriverBy::tagName('app-icon-info-list')); + if ($logo = $this->getLogo($item)) { + $feedItem['enclosures'] = [$logo]; + } + if (str_contains($info->getText(), 'Projektanbieter:')) { + $feedItem['author'] = $info->findElement(WebDriverBy::xpath('.//li/span[2]/span'))->getText(); + } else { + // mostly "Direkt vom Auftraggeber" or "GULP Agentur" + $feedItem['author'] = $item->findElement(WebDriverBy::tagName('b'))->getText(); + } + $feedItem['content'] = $item->findElement(WebDriverBy::xpath('.//p[@class="description"]'))->getText(); + $timeAgo = $item->findElement(WebDriverBy::xpath('.//small[contains(@class, "time-ago")]'))->getText(); + $feedItem['timestamp'] = $this->getTimestamp($timeAgo); + + $this->items[] = $feedItem; + } + + if (count($this->items) < self::MAXITEMS) { + $this->clickNextPage(); + } else { + break; + } + } + } finally { + $this->cleanUp(); + } + } +} diff --git a/bridges/GameBananaBridge.php b/bridges/GameBananaBridge.php index 591ac0e9..0f04f56b 100644 --- a/bridges/GameBananaBridge.php +++ b/bridges/GameBananaBridge.php @@ -28,6 +28,8 @@ class GameBananaBridge extends BridgeAbstract return 'https://images.gamebanana.com/static/img/favicon/favicon.ico'; } + private $title; + public function collectData() { $url = 'https://api.gamebanana.com/Core/List/New?itemtype=Mod&page=1&gameid=' . $this->getInput('gid'); @@ -38,7 +40,7 @@ class GameBananaBridge extends BridgeAbstract $json_list = json_decode($api_response, true); // Get first page mod list $url = 'https://api.gamebanana.com/Core/Item/Data?itemtype[]=Game&fields[]=name&itemid[]=' . $this->getInput('gid'); - $fields = 'name,Owner().name,text,screenshots,Files().aFiles(),date,Url().sProfileUrl(),udate'; + $fields = 'name,Owner().name,text,screenshots,Files().aFiles(),date,Url().sProfileUrl(),udate,Updates().aLatestUpdates(),Category().name,RootCategory().name'; foreach ($json_list as $element) { // Build api request to minimize API calls $mid = $element[1]; $url .= '&itemtype[]=Mod&fields[]=' . $fields . '&itemid[]=' . $mid; @@ -50,11 +52,18 @@ class GameBananaBridge extends BridgeAbstract array_shift($json_list); // Take title from API request and remove from json foreach ($json_list as $element) { + // Trashed mod IDs are still picked up and return null; skip + if ($element[0] == null) { + continue; + } + $item = []; $item['uri'] = $element[6]; $item['comments'] = $item['uri'] . '#PostsListModule'; $item['title'] = $element[0]; $item['author'] = $element[1]; + $item['categories'][] = $element[9]; + $item['categories'][] = $element[10]; $item['timestamp'] = $element[5]; if ($this->getInput('updates')) { @@ -72,6 +81,22 @@ class GameBananaBridge extends BridgeAbstract foreach ($img_list as $img_element) { $item['content'] .= ''; } + + // Get updates from element[8], if applicable + if ($this->getInput('updates') && count($element[8]) > 0) { + $update = $element[8][0]; + $item['content'] .= '
    Update: ' . $update['_sTitle']; + if ($update['_sText'] != '') { + $item['content'] .= '
    ' . $update['_sText']; + } + foreach ($update['_aChangeLog'] as $change) { + if ($change['cat'] == '') { + $change['cat'] = 'Change'; + } + $item['content'] .= '
    ' . $change['cat'] . ': ' . $change['text']; + } + $item['content'] .= '

    '; + } $item['content'] .= '
    ' . $element[2]; $item['uid'] = $item['uri'] . $item['title'] . $item['timestamp']; diff --git a/bridges/GatesNotesBridge.php b/bridges/GatesNotesBridge.php index 3381e096..b46b3ce6 100644 --- a/bridges/GatesNotesBridge.php +++ b/bridges/GatesNotesBridge.php @@ -21,6 +21,10 @@ class GatesNotesBridge extends BridgeAbstract $rawContent = getContents($apiUrl); $cleanedContent = trim($rawContent, '"'); + $cleanedContent = str_replace([ + '', + '' + ], '', $cleanedContent); $cleanedContent = str_replace('\r\n', "\n", $cleanedContent); $cleanedContent = stripslashes($cleanedContent); diff --git a/bridges/GelbooruBridge.php b/bridges/GelbooruBridge.php index 5fc6b33c..96d16bf9 100644 --- a/bridges/GelbooruBridge.php +++ b/bridges/GelbooruBridge.php @@ -33,7 +33,7 @@ class GelbooruBridge extends BridgeAbstract return $this->getURI() . 'index.php?&page=dapi&s=post&q=index&json=1&pid=' . $this->getInput('p') . '&limit=' . $this->getInput('l') - . '&tags=' . urlencode($this->getInput('t')); + . '&tags=' . urlencode($this->getInput('t') ?? ''); } /* @@ -76,18 +76,16 @@ class GelbooruBridge extends BridgeAbstract public function collectData() { - $content = getContents($this->getFullURI()); - // $content is empty string + $url = $this->getFullURI(); + $content = getContents($url); - // Most other Gelbooru-based boorus put their content in the root of - // the JSON. This check is here for Bridges that inherit from this one - $posts = json_decode($content); - if (isset($posts->post)) { - $posts = $posts->post; + if ($content === '') { + return; } - if (is_null($posts)) { - returnServerError('No posts found.'); + $posts = Json::decode($content, false); + if (isset($posts->post)) { + $posts = $posts->post; } foreach ($posts as $post) { diff --git a/bridges/GenshinImpactBridge.php b/bridges/GenshinImpactBridge.php index 24bc39d8..924155d9 100644 --- a/bridges/GenshinImpactBridge.php +++ b/bridges/GenshinImpactBridge.php @@ -2,11 +2,11 @@ class GenshinImpactBridge extends BridgeAbstract { - const MAINTAINER = 'corenting'; const NAME = 'Genshin Impact'; - const URI = 'https://genshin.mihoyo.com/en/news'; - const CACHE_TIMEOUT = 7200; // 2h - const DESCRIPTION = 'News from the Genshin Impact website'; + const URI = 'https://genshin.hoyoverse.com/en/news'; + const CACHE_TIMEOUT = 18000; // 5h + const DESCRIPTION = 'Latest news from the Genshin Impact website'; + const MAINTAINER = 'Miicat_47'; const PARAMETERS = [ [ 'category' => [ @@ -25,37 +25,31 @@ class GenshinImpactBridge extends BridgeAbstract public function collectData() { - $category = $this->getInput('category'); - - $url = 'https://genshin.mihoyo.com/content/yuanshen/getContentList'; - $url = $url . '?pageSize=5&pageNum=1&channelId=' . $category; + $url = 'https://api-os-takumi-static.hoyoverse.com/content_v2_user/app/a1b1f9d3315447cc/getContentList?iAppId=32&iChanId=395&iPageSize=5&iPage=1&sLangKey=en-us'; $api_response = getContents($url); - $json_list = json_decode($api_response, true); + $json_list = Json::decode($api_response); foreach ($json_list['data']['list'] as $json_item) { - $article_url = 'https://genshin.mihoyo.com/content/yuanshen/getContent'; - $article_url = $article_url . '?contentId=' . $json_item['contentId']; - $article_res = getContents($article_url); - $article_json = json_decode($article_res, true); - $article_time = $article_json['data']['start_time']; - $timezone = 'Asia/Shanghai'; - $article_timestamp = new DateTime($article_time, new DateTimeZone($timezone)); + $article_html = str_get_html($json_item['sContent']); + // Check if article contains a embed YouTube video + $exp_youtube = '#https://[w\.]+youtube\.com/embed/([\w]+)#m'; + if (preg_match($exp_youtube, $article_html, $matches)) { + // Replace the YouTube embed with a YouTube link + $yt_embed = $article_html->find('div[class="ttr-video-frame"]', 0); + $yt_link = sprintf('https://youtube.com/watch?v=%1$s', $matches[1]); + $article_html = str_replace($yt_embed, $yt_link, $article_html); + } $item = []; - - $item['title'] = $article_json['data']['title']; - $item['timestamp'] = $article_timestamp->format('U'); - $item['content'] = $article_json['data']['content']; - $item['uri'] = $this->getArticleUri($json_item); - $item['id'] = $json_item['contentId']; + $item['title'] = $json_item['sTitle']; + $item['timestamp'] = $json_item['dtStartTime']; + $item['content'] = $article_html; + $item['uri'] = 'https://genshin.hoyoverse.com/en/news/detail/' . $json_item['iInfoId']; + $item['id'] = $json_item['iInfoId']; // Picture - foreach ($article_json['data']['ext'] as $ext) { - if ($ext['arrtName'] == 'banner' && count($ext['value']) == 1) { - $item['enclosures'] = [$ext['value'][0]['url']]; - break; - } - } + $json_ext = Json::decode($json_item['sExt']); + $item['enclosures'] = [$json_ext['banner'][0]['url']]; $this->items[] = $item; } @@ -63,11 +57,6 @@ class GenshinImpactBridge extends BridgeAbstract public function getIcon() { - return 'https://genshin.mihoyo.com/favicon.ico'; - } - - private function getArticleUri($json_item) - { - return 'https://genshin.mihoyo.com/en/news/detail/' . $json_item['contentId']; + return 'https://genshin.hoyoverse.com/favicon.ico'; } } diff --git a/bridges/GitHubGistBridge.php b/bridges/GitHubGistBridge.php index 969ee3be..4c72ac24 100644 --- a/bridges/GitHubGistBridge.php +++ b/bridges/GitHubGistBridge.php @@ -56,7 +56,7 @@ class GitHubGistBridge extends BridgeAbstract $html = defaultLinkTo($html, $this->getURI()); $fileinfo = $html->find('[class~="file-info"]', 0) - or returnServerError('Could not find file info!'); + or throwServerException('Could not find file info!'); $this->filename = $fileinfo->plaintext; @@ -68,18 +68,18 @@ class GitHubGistBridge extends BridgeAbstract foreach ($comments as $comment) { $uri = $comment->find('a[href*=#gistcomment]', 0) - or returnServerError('Could not find comment anchor!'); + or throwServerException('Could not find comment anchor!'); $title = $comment->find('h3', 0); $datetime = $comment->find('[datetime]', 0) - or returnServerError('Could not find comment datetime!'); + or throwServerException('Could not find comment datetime!'); $author = $comment->find('a.author', 0) - or returnServerError('Could not find author name!'); + or throwServerException('Could not find author name!'); $message = $comment->find('[class~="comment-body"]', 0) - or returnServerError('Could not find comment body!'); + or throwServerException('Could not find comment body!'); $item = []; diff --git a/bridges/GiteaBridge.php b/bridges/GiteaBridge.php index f7f426e9..901312e2 100644 --- a/bridges/GiteaBridge.php +++ b/bridges/GiteaBridge.php @@ -155,8 +155,7 @@ class GiteaBridge extends BridgeAbstract public function collectData() { - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request ' . $this->getURI()); + $html = getSimpleHTMLDOM($this->getURI()); $html = defaultLinkTo($html, $this->getURI()); $this->title = $html->find('[property="og:title"]', 0)->content; @@ -189,7 +188,7 @@ class GiteaBridge extends BridgeAbstract protected function collectReleasesData($html) { $releases = $html->find('#release-list > li') - or returnServerError('Unable to find releases'); + or throwServerException('Unable to find releases'); foreach ($releases as $release) { $this->items[] = [ @@ -204,7 +203,7 @@ class GiteaBridge extends BridgeAbstract protected function collectTagsData($html) { $tags = $html->find('table#tags-table > tbody > tr') - or returnServerError('Unable to find tags'); + or throwServerException('Unable to find tags'); foreach ($tags as $tag) { $this->items[] = [ @@ -217,7 +216,7 @@ class GiteaBridge extends BridgeAbstract protected function collectCommitsData($html) { $commits = $html->find('#commits-table tbody tr') - or returnServerError('Unable to find commits'); + or throwServerException('Unable to find commits'); foreach ($commits as $commit) { $this->items[] = [ @@ -233,7 +232,7 @@ class GiteaBridge extends BridgeAbstract protected function collectIssuesData($html) { $issues = $html->find('.issue.list li') - or returnServerError('Unable to find issues'); + or throwServerException('Unable to find issues'); foreach ($issues as $issue) { $uri = $issue->find('a', 0)->href; @@ -246,8 +245,7 @@ class GiteaBridge extends BridgeAbstract ]; if ($this->getInput('include_description')) { - $issue_html = getSimpleHTMLDOMCached($uri, 3600) - or returnServerError('Unable to load issue description'); + $issue_html = getSimpleHTMLDOMCached($uri, 3600); $issue_html = defaultLinkTo($issue_html, $uri); @@ -261,7 +259,7 @@ class GiteaBridge extends BridgeAbstract protected function collectSingleIssueOrPrData($html) { $comments = $html->find('.comment') - or returnServerError('Unable to find comments'); + or throwServerException('Unable to find comments'); foreach ($comments as $comment) { if ( @@ -295,7 +293,7 @@ class GiteaBridge extends BridgeAbstract protected function collectPullRequestsData($html) { $issues = $html->find('.issue.list li') - or returnServerError('Unable to find pull requests'); + or throwServerException('Unable to find pull requests'); foreach ($issues as $issue) { $uri = $issue->find('a', 0)->href; @@ -308,8 +306,7 @@ class GiteaBridge extends BridgeAbstract ]; if ($this->getInput('include_description')) { - $issue_html = getSimpleHTMLDOMCached($uri, 3600) - or returnServerError('Unable to load issue description'); + $issue_html = getSimpleHTMLDOMCached($uri, 3600); $issue_html = defaultLinkTo($issue_html, $uri); diff --git a/bridges/GithubIssueBridge.php b/bridges/GithubIssueBridge.php index 7f56abbd..345d1148 100644 --- a/bridges/GithubIssueBridge.php +++ b/bridges/GithubIssueBridge.php @@ -192,16 +192,22 @@ class GithubIssueBridge extends BridgeAbstract public function collectData() { - $html = getSimpleHTMLDOM($this->getURI()); + $url = $this->getURI(); + $html = getSimpleHTMLDOM($url); switch ($this->queriedContext) { case static::BRIDGE_OPTIONS[1]: // Issue comments $this->items = $this->extractIssueComments($html); break; case static::BRIDGE_OPTIONS[0]: // Project Issues - foreach ($html->find('.js-active-navigation-container .js-navigation-item') as $issue) { - $info = $issue->find('.opened-by', 0); + // PRs + $issues = $html->find('.js-active-navigation-container .js-navigation-item'); + if (!$issues) { + // Issues + $issues = $html->find('.IssueRow-module__row--XmR1f'); + } + foreach ($issues as $issue) { preg_match('/\/([0-9]+)$/', $issue->find('a', 0)->href, $match); $issueNbr = $match[1]; @@ -211,6 +217,7 @@ class GithubIssueBridge extends BridgeAbstract if ($this->getInput('c')) { $uri = static::URI . $this->getInput('u') . '/' . $this->getInput('p') . '/' . static::URL_PATH . '/' . $issueNbr; + $issue = getSimpleHTMLDOMCached($uri, static::CACHE_TIMEOUT); if ($issue) { $this->items = array_merge( @@ -222,24 +229,34 @@ class GithubIssueBridge extends BridgeAbstract $item['content'] = 'Can not extract comments from ' . $uri; } - $item['author'] = $info->find('a', 0)->plaintext; - $item['timestamp'] = strtotime( - $info->find('relative-time', 0)->getAttribute('datetime') - ); - $item['title'] = html_entity_decode( - $issue->find('.js-navigation-open', 0)->plaintext, - ENT_QUOTES, - 'UTF-8' - ); + $item['author'] = $issue->find('a', 1)->plaintext; - $comment_count = 0; - if ($span = $issue->find('a[aria-label*="comment"] span', 0)) { - $comment_count = $span->plaintext; + $time = $issue->find('relative-time', 0); + $datetime = $time->getAttribute('datetime'); + if ($datetime) { + $item['timestamp'] = strtotime($datetime); } - $item['content'] .= "\n" . 'Comments: ' . $comment_count; + $item['title'] = ''; + + # Works for PRs + $title = $issue->find('a.Link--primary', 0); + if ($title) { + $item['title'] = html_entity_decode($title->plaintext, ENT_QUOTES, 'UTF-8'); + } + + $title2 = $issue->find('h3 a', 0); + if ($title2) { + $item['title'] = html_entity_decode($title2->plaintext, ENT_QUOTES, 'UTF-8'); + } + //$comment_count = 0; + //if ($span = $issue->find('a[aria-label*="comment"] span', 0)) { + // $comment_count = $span->plaintext; + //} + + //$item['content'] .= "\n" . 'Comments: ' . $comment_count; $item['uri'] = self::URI - . trim($issue->find('.js-navigation-open', 0)->getAttribute('href'), '/'); + . trim($issue->find('a', 0)->getAttribute('href'), '/'); $this->items[] = $item; } break; diff --git a/bridges/GithubTrendingBridge.php b/bridges/GithubTrendingBridge.php index 0f8e5e96..2ce47270 100644 --- a/bridges/GithubTrendingBridge.php +++ b/bridges/GithubTrendingBridge.php @@ -586,16 +586,18 @@ class GithubTrendingBridge extends BridgeAbstract 'Monthly' => 'monthly', ], 'defaultValue' => 'today' + ], + 'spokenLanguage' => [ + 'name' => 'Spoken Language Code', + 'type' => 'text', + 'exampleValue' => 'en', ] ] - ]; public function collectData() { - $params = ['since' => urlencode($this->getInput('date_range'))]; - $url = self::URI . '/' . $this->getInput('language') . '?' . http_build_query($params); - + $url = $this->constructUrl(); $html = getSimpleHTMLDOM($url); $this->items = []; @@ -630,4 +632,32 @@ class GithubTrendingBridge extends BridgeAbstract return parent::getName(); } + + private function constructUrl() + { + $url = self::URI; + $language = $this->getInput('language'); + $dateRange = $this->getInput('date_range'); + $spokenLanguage = $this->getInput('spokenLanguage'); + + if (!empty($language)) { + $url .= '/' . $language; + } + + $queryParams = []; + + if (!empty($dateRange)) { + $queryParams['since'] = $dateRange; + } + + if (!empty($spokenLanguage)) { + $queryParams['spoken_language_code'] = trim($spokenLanguage); + } + + if (!empty($queryParams)) { + $url .= '?' . http_build_query($queryParams); + } + + return $url; + } } diff --git a/bridges/GlassdoorBridge.php b/bridges/GlassdoorBridge.php index 8c53cfa9..19946d43 100644 --- a/bridges/GlassdoorBridge.php +++ b/bridges/GlassdoorBridge.php @@ -98,7 +98,7 @@ class GlassdoorBridge extends BridgeAbstract private function collectBlogData($html, $limit) { $posts = $html->find('div.post') - or returnServerError('Unable to find blog posts!'); + or throwServerException('Unable to find blog posts!'); foreach ($posts as $post) { $item = []; @@ -121,7 +121,7 @@ class GlassdoorBridge extends BridgeAbstract private function collectReviewData($html, $limit) { $reviews = $html->find('#ReviewsFeed li[id^="empReview]') - or returnServerError('Unable to find reviews!'); + or throwServerException('Unable to find reviews!'); foreach ($reviews as $review) { $item = []; @@ -163,7 +163,7 @@ class GlassdoorBridge extends BridgeAbstract FILTER_FLAG_PATH_REQUIRED ) ) { - returnClientError('The specified URL is invalid!'); + throwClientException('The specified URL is invalid!'); } $uri = filter_var($uri, FILTER_SANITIZE_URL); @@ -189,7 +189,7 @@ class GlassdoorBridge extends BridgeAbstract ]; if (!in_array($parts[1], $allowed_strings)) { - returnClientError('Please specify a URL pointing to the companies review page!'); + throwClientException('Please specify a URL pointing to the companies review page!'); } return $uri; diff --git a/bridges/GlowficBridge.php b/bridges/GlowficBridge.php index b51ead8d..7a58a08f 100644 --- a/bridges/GlowficBridge.php +++ b/bridges/GlowficBridge.php @@ -28,7 +28,7 @@ class GlowficBridge extends BridgeAbstract public function collectData() { $url = $this->getAPIURI(); - $metadata = get_headers($url . '/replies', true) or returnClientError('Post did not return reply headers.'); + $metadata = get_headers($url . '/replies', true); $metadata['Last-Page'] = ceil($metadata['Total'] / $metadata['Per-Page']); if ( !is_null($this->getInput('start_page')) && @@ -41,8 +41,7 @@ class GlowficBridge extends BridgeAbstract $first_page = 1; } for ($page_offset = $first_page; $page_offset <= $metadata['Last-Page']; $page_offset++) { - $jsonContents = getContents($url . '/replies?page=' . $page_offset) or - returnClientError('Could not retrieve replies for page ' . $page_offset . '.'); + $jsonContents = getContents($url . '/replies?page=' . $page_offset); $replies = json_decode($jsonContents); foreach ($replies as $reply) { $item = []; @@ -75,8 +74,9 @@ class GlowficBridge extends BridgeAbstract private function getPost() { $url = $this->getAPIURI(); - $jsonPost = getContents($url) or returnClientError('Could not retrieve post metadata.'); + $jsonPost = getContents($url); $post = json_decode($jsonPost); + return $post; } diff --git a/bridges/GoComicsBridge.php b/bridges/GoComicsBridge.php index 586e2a0d..eff5467d 100644 --- a/bridges/GoComicsBridge.php +++ b/bridges/GoComicsBridge.php @@ -2,7 +2,8 @@ class GoComicsBridge extends BridgeAbstract { - const MAINTAINER = 'sky'; + const MAINTAINER = 'TReKiE'; + //const MAINTAINER = 'sky'; const NAME = 'GoComics Unofficial RSS'; const URI = 'https://www.gocomics.com/'; const CACHE_TIMEOUT = 21600; // 6h @@ -13,32 +14,61 @@ class GoComicsBridge extends BridgeAbstract 'type' => 'text', 'exampleValue' => 'heartofthecity', 'required' => true + ], + 'date-in-title' => [ + 'name' => 'Add date and full name to each day\'s title', + 'type' => 'checkbox', + 'title' => 'Adds the date and the full name into the title of each day\'s comic', + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'title' => 'The number of recent comics to get', + 'defaultValue' => 5 ] ]]; public function collectData() { - $html = getSimpleHTMLDOM($this->getURI()); + $link = $this->getURI(); + $landingpage = getSimpleHTMLDOM($link); + $element = $landingpage->find('div[data-post-url]', 0); + if ($element) { + $link = $element->getAttribute('data-post-url'); + } else { // fallback for comics without data-post-url (assumes daily comic) + $nextcomiclink = $landingpage->find('a[class*="ComicNavigation_controls__button_previous__"]', 0)->href; + preg_match('/(\d{4}\/\d{2}\/\d{2})/', $nextcomiclink, $nclmatches); + if (!empty($nclmatches[1])) { + $nextdate = new DateTime($nclmatches[1]); + $nextdate = $nextdate->modify('+1 day')->format('Y/m/d'); + $link = $link . '/' . $nextdate; + } else { + throw new \Exception('Could not find the first comic URL. Please create a new GitHub issue.'); + } + } - //Get info from first page - $author = preg_replace('/By /', '', $html->find('.media-subheading', 0)->plaintext); + for ($i = 0; $i < $this->getInput('limit'); $i++) { + $html = getSimpleHTMLDOMCached($link, 86400); + + $imagelink = $html->find('meta[property="og:image"]', 0)->content; + $parts = explode('/', $link); + $date = DateTime::createFromFormat('Y/m/d', implode('/', array_slice($parts, -3))); + $title = $html->find('meta[property="og:title"]', 0)->content; + preg_match('/by (.*?) for/', $title, $authormatches); + $author = $authormatches[1] ?? 'GoComics'; - $link = self::URI . $html->find('.gc-deck--cta-0', 0)->find('a', 0)->href; - for ($i = 0; $i < 5; $i++) { $item = []; - - $page = getSimpleHTMLDOM($link); - $imagelink = $page->find('.comic.container', 0)->getAttribute('data-image'); - $date = explode('/', $link); - $item['id'] = $imagelink; $item['uri'] = $link; $item['author'] = $author; $item['title'] = 'GoComics ' . $this->getInput('comicname'); - $item['timestamp'] = DateTime::createFromFormat('Ymd', $date[5] . $date[6] . $date[7])->getTimestamp(); + if ($this->getInput('date-in-title') === true) { + $item['title'] = $title; + } + $item['timestamp'] = $date->setTime(0, 0, 0)->getTimestamp(); $item['content'] = ''; - $link = self::URI . $page->find('.js-previous-comic', 0)->href; + $link = rtrim(self::URI, '/') . $html->find('a[class*="ComicNavigation_controls__button_previous__"]', 0)->href; $this->items[] = $item; } } diff --git a/bridges/GogsBridge.php b/bridges/GogsBridge.php index 685e5ba2..ceb8a502 100644 --- a/bridges/GogsBridge.php +++ b/bridges/GogsBridge.php @@ -141,7 +141,7 @@ class GogsBridge extends BridgeAbstract protected function collectCommitsData($html) { $commits = $html->find('#commits-table tbody tr') - or returnServerError('Unable to find commits'); + or throwServerException('Unable to find commits'); foreach ($commits as $commit) { $this->items[] = [ @@ -157,7 +157,7 @@ class GogsBridge extends BridgeAbstract protected function collectIssuesData($html) { $issues = $html->find('.issue.list li') - or returnServerError('Unable to find issues'); + or throwServerException('Unable to find issues'); foreach ($issues as $issue) { $uri = $issue->find('a', 0)->href; @@ -171,8 +171,7 @@ class GogsBridge extends BridgeAbstract ]; if ($this->getInput('include_description')) { - $issue_html = getSimpleHTMLDOMCached($uri, 3600) - or returnServerError('Unable to load issue description'); + $issue_html = getSimpleHTMLDOMCached($uri, 3600); $issue_html = defaultLinkTo($issue_html, $uri); @@ -186,7 +185,7 @@ class GogsBridge extends BridgeAbstract protected function collectSingleIssueData($html) { $comments = $html->find('.comments .comment') - or returnServerError('Unable to find comments'); + or throwServerException('Unable to find comments'); foreach ($comments as $comment) { $this->items[] = [ @@ -204,7 +203,7 @@ class GogsBridge extends BridgeAbstract protected function collectReleasesData($html) { $releases = $html->find('#release-list li') - or returnServerError('Unable to find releases'); + or throwServerException('Unable to find releases'); foreach ($releases as $release) { $this->items[] = [ diff --git a/bridges/GolemBridge.php b/bridges/GolemBridge.php index 599d713a..c908d5fe 100644 --- a/bridges/GolemBridge.php +++ b/bridges/GolemBridge.php @@ -53,7 +53,7 @@ class GolemBridge extends FeedExpander ] ]]; const LIMIT = 5; - const HEADERS = ['Cookie: golem_consent20=simple|220101;']; + const HEADERS = ['Cookie: golem_consent20=simple|250101;']; public function collectData() { @@ -106,10 +106,33 @@ class GolemBridge extends FeedExpander $article = $page->find('article', 0); + //built youtube iframes + foreach ($article->find('.embedcontent') as &$embedcontent) { + $ytscript = $embedcontent->find('script', 0); + if (preg_match('/(www.youtube.com.*?)\"/', $ytscript->innertext, $link)) { + $link = 'https://' . str_replace('\\', '', $link[1]); + $embedcontent->innertext .= <<'; + EOT; + } + } + + //built golem videos + foreach ($article->find('.gvideofig') as &$embedcontent) { + if (preg_match('/gvideo_(.*)/', $embedcontent->id, $videoid)) { + $embedcontent->innertext .= << + EOT; + } + } + // delete known bad elements foreach ( $article->find('div[id*="adtile"], #job-market, #seminars, iframe, - div.gbox_affiliate, div.toc, .embedcontent, script') as $bad + div.gbox_affiliate, div.toc') as $bad ) { $bad->remove(); } @@ -129,7 +152,7 @@ class GolemBridge extends FeedExpander $img->src = $img->getAttribute('data-src-full'); } - foreach ($content->find('p, h1, h2, h3, img[src*="."]') as $element) { + foreach ($content->find('p, h1, h2, h3, pre, img[src*="."], div[class*="golem_tablediv"], iframe, video') as $element) { $item .= $element; } diff --git a/bridges/GoogleScholarBridge.php b/bridges/GoogleScholarBridge.php index 11dc123b..3004180f 100644 --- a/bridges/GoogleScholarBridge.php +++ b/bridges/GoogleScholarBridge.php @@ -109,7 +109,7 @@ class GoogleScholarBridge extends BridgeAbstract case 'user': $userId = $this->getInput('userId'); $uri = self::URI . '/citations?hl=en&view_op=list_works&sortby=pubdate&user=' . $userId; - $html = getSimpleHTMLDOM($uri) or returnServerError('Could not fetch Google Scholar data.'); + $html = getSimpleHTMLDOM($uri); $publications = $html->find('tr[class="gsc_a_tr"]'); @@ -184,7 +184,7 @@ class GoogleScholarBridge extends BridgeAbstract $uri .= $sortBy ? '&scisbd=1' : ''; $uri .= $numResults ? '&num=' . $numResults : ''; - $html = getSimpleHTMLDOM($uri) or returnServerError('Could not fetch Google Scholar data.'); + $html = getSimpleHTMLDOM($uri); $publications = $html->find('div[class="gs_r gs_or gs_scl"]'); diff --git a/bridges/GoogleSearchBridge.php b/bridges/GoogleSearchBridge.php index 9b0713ac..08d05964 100644 --- a/bridges/GoogleSearchBridge.php +++ b/bridges/GoogleSearchBridge.php @@ -26,7 +26,7 @@ class GoogleSearchBridge extends BridgeAbstract // todo: wrap this in try..catch because 429 too many requests happens a lot $dom = getSimpleHTMLDOM($this->getURI(), ['Accept-language: en-US']); if (!$dom) { - returnServerError('No results for this query.'); + throwServerException('No results for this query.'); } $result = $dom->find('div[id=res]', 0); diff --git a/bridges/GovTrackBridge.php b/bridges/GovTrackBridge.php new file mode 100644 index 00000000..a2c18d9f --- /dev/null +++ b/bridges/GovTrackBridge.php @@ -0,0 +1,111 @@ + [ + 'name' => 'Feed to track', + 'type' => 'list', + 'defaultValue' => 'posts', + 'values' => [ + 'All Legislative Activity' => 'bill-activity', + 'Bill Summaries' => 'bill-summaries', + 'Legislation Coming Up' => 'coming-up', + 'Major Legislative Activity' => 'major-bill-activity', + 'New Bills and Resolutions' => 'introduced-bills', + 'New Laws' => 'enacted-bills', + 'News from Us' => 'posts' + ] + ], + 'limit' => self::LIMIT + ]]; + + public function collectData() + { + $limit = $this->getInput('limit') ?? 15; + if ($this->getInput('feed') == 'posts') { + $this->collectExpandableDatas($this->getURI() . '.rss', $limit); + } else { + $this->collectEvent($this->getURI(), $limit); + } + } + + protected function parseItem(array $item) + { + $html = getSimpleHTMLDOMCached($item['uri']); + $html = defaultLinkTo($html, parent::getURI()); + + $item['categories'] = [$html->find('.breadcrumb-item', 1)->plaintext]; + $content = $html->find('#content .col-md', 1); + $item['author'] = explode(' by ', $content->firstChild()->plaintext)[1]; + $content->removeChild($content->firstChild()); + $item['content'] = $content->innertext; + + return $item; + } + + private function collectEvent($uri, $limit) + { + $html = getSimpleHTMLDOMCached($uri); + preg_match('/"csrfmiddlewaretoken" value="(.*)"/', $html, $preg); + $header = [ + "cookie: csrftoken=$preg[1]", + "x-csrftoken: $preg[1]", + 'referer: ' . parent::getURI(), + ]; + preg_match('/var selected_feed = "(.*)";/', $html, $preg); + $opt = [ CURLOPT_POSTFIELDS => [ + 'count' => $limit, + 'feed' => $preg[1] + ]]; + + $html = getContents(parent::getURI() . 'events/_load_events', $header, $opt); + $html = defaultLinkTo(str_get_html($html), parent::getURI()); + + foreach ($html->find('.tracked_event') as $event) { + $bill = $event->find('.event_title a, .event_body a', 0); + $date = explode(' ', $event->find('.event_date', 0)->plaintext); + preg_match('/Sponsor:(.*)\n/', $event->plaintext, $preg); + + $item = [ + 'author' => $preg[1] ?? '', + 'content' => $event->find('td', 1)->innertext, + 'enclosures' => [$event->find('img', 0)->src], + 'timestamp' => strtotime(implode(' ', array_slice($date, 2))), + 'title' => explode(': ', $bill->innertext)[0], + 'uri' => $bill->href, + ]; + + foreach ($event->find('.event_title, .event_type span') as $tag) { + if (!$tag->find('a', 0)) { + $item['categories'][] = $tag->plaintext; + } + } + + $this->items[] = $item; + } + } + + public function getName() + { + $name = parent::getName(); + if ($this->getInput('feed') != null) { + $name .= ' - ' . $this->getKey('feed'); + } + return $name; + } + + public function getURI() + { + if ($this->getInput('feed') == 'posts') { + $url = parent::getURI() . $this->getInput('feed'); + } else { + $url = parent::getURI() . 'events/' . $this->getInput('feed'); + } + return $url; + } +} diff --git a/bridges/HackerNewsUserThreadsBridge.php b/bridges/HackerNewsUserThreadsBridge.php index fee96b61..0ab7445d 100644 --- a/bridges/HackerNewsUserThreadsBridge.php +++ b/bridges/HackerNewsUserThreadsBridge.php @@ -21,8 +21,6 @@ class HackerNewsUserThreadsBridge extends BridgeAbstract { $url = 'https://news.ycombinator.com/threads?id=' . $this->getInput('user'); $html = getSimpleHTMLDOM($url); - Debug::log('queried ' . $url); - Debug::log('found ' . $html); $item = []; $articles = $html->find('tr[class*="comtr"]'); diff --git a/bridges/HardwareInfoBridge.php b/bridges/HardwareInfoBridge.php deleted file mode 100644 index 5970ecd0..00000000 --- a/bridges/HardwareInfoBridge.php +++ /dev/null @@ -1,65 +0,0 @@ -collectExpandableDatas('https://nl.hardware.info/updates/all.rss', 10); - } - - protected function parseItem(array $item) - { - $itemUrl = $item['uri']; - $articlePage = getSimpleHTMLDOMCached($itemUrl); - - $article = $articlePage->find('div.article__content', 0); - - //everything under the social bar is not part of the article, remove it - $reachedEndOfArticle = false; - - foreach ($article->find('*') as $child) { - if ( - !$reachedEndOfArticle && isset($child->attr['class']) - && $child->attr['class'] == 'article__content__social-bar' - ) { - $reachedEndOfArticle = true; - } - - if ($reachedEndOfArticle) { - $child->outertext = ''; - } - } - - //get rid of some more elements we don't need - $to_remove_selectors = [ - 'script', - 'div.incontent', - 'div.article__content__social-bar', - 'div#revealNewsTip', - 'div.article__previous_next' - ]; - - foreach ($to_remove_selectors as $selector) { - foreach ($article->find($selector) as $found) { - $found->outertext = ''; - } - } - - // convert iframes to links. meant for embedded YouTube videos. - foreach ($article->find('iframe') as $found) { - $iframeUrl = $found->getAttribute('src'); - - if ($iframeUrl) { - $found->outertext = '' . $iframeUrl . ''; - } - } - - $item['content'] = $article; - return $item; - } -} diff --git a/bridges/HarvardBusinessReviewBridge.php b/bridges/HarvardBusinessReviewBridge.php new file mode 100644 index 00000000..cd99a1ba --- /dev/null +++ b/bridges/HarvardBusinessReviewBridge.php @@ -0,0 +1,88 @@ + [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => true, + 'title' => 'Maximum number of items to return', + 'defaultValue' => 6, //More requires clicking button "Load more" + ], + ]]; + + public function collectData() + { + $url = self::URI . '/the-latest'; + $html = getSimpleHTMLDOM($url); + + foreach ($html->find('li.stream-entry') as $data) { + // Skip if $data is null + if ($data === null) { + continue; + } + + try { + // Skip entries containing the text 'stream-ad-container' + if ($data->innertext !== null && strpos($data->innertext, 'stream-ad-container') !== false) { + continue; + } + + // Skip entries with class 'sponsored' + if ($data->hasClass('sponsored')) { + continue; + } + + $item = []; + $linkElement = $data->find('a', 0); + $titleElement = $data->find('h3.hed a', 0); + $authorElement = $data->find('ul.byline-list li', 0); + $timestampElement = $data->find('li.pubdate time', 0); + $contentElement = $data->find('div.dek', 0); + + if ($linkElement) { + $item['uri'] = self::URI . $linkElement->getAttribute('href'); + } else { + continue; // Skip this entry if no link is found + } + if ($titleElement) { + $item['title'] = trim($titleElement->plaintext); + } else { + continue; // Skip this entry if no title is found + } + if ($authorElement) { + $item['author'] = trim($authorElement->plaintext); + } else { + $item['author'] = 'Unknown'; // Default value if author is missing + } + if ($timestampElement) { + $item['timestamp'] = strtotime($timestampElement->plaintext); + } else { + $item['timestamp'] = time(); // Default to current time if timestamp is missing + } + if ($contentElement) { + $item['content'] = trim($contentElement->plaintext); + } else { + $item['content'] = ''; // Default to empty string if content is missing + } + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; + + if (count($this->items) >= $this->getInput('postcount')) { + break; + } + } catch (Exception $e) { + // Log the error if necessary + continue; // Skip to the next iteration on error + } + } + } +} \ No newline at end of file diff --git a/bridges/HarvardHealthBlogBridge.php b/bridges/HarvardHealthBlogBridge.php new file mode 100644 index 00000000..bb6a5ede --- /dev/null +++ b/bridges/HarvardHealthBlogBridge.php @@ -0,0 +1,71 @@ + [ + 'name' => 'Article Image', + 'type' => 'checkbox', + 'defaultValue' => 'checked', + ], + ], + ]; + + public function collectData() + { + $dom = getSimpleHTMLDOM(self::URI); + $count = 0; + + foreach ($dom->find('div[class="mb-16 md:flex"]') as $element) { + if ($count >= self::MAX_ARTICLES) { + break; + } + + $data = $element->find('a[class="hover:text-red transition-colors duration-200"]', 0); + if (!$data) { + continue; + } + + $url = $data->href; + + $this->items[] = [ + 'content' => $this->constructContent($url), + 'timestamp' => $element->find('time', 0)->datetime, + 'title' => $data->plaintext, + 'uid' => $url, + 'uri' => $url, + ]; + + $count++; + } + } + + private function constructContent($url) + { + $dom = getSimpleHTMLDOMCached($url); + + $article = $dom->find('div[class*="content-repository-content"]', 0); + if (!$article) { + return 'Content Not Found'; + } + + // remove article image + if (!$this->getInput('image')) { + $image = $article->find('p', 0); + $image->remove(); + } + + // remove ads + foreach ($article->find('.inline-ad') as $ad) { + $ad->outertext = ''; + } + + return $article->innertext; + } +} diff --git a/bridges/HaveIBeenPwnedBridge.php b/bridges/HaveIBeenPwnedBridge.php index e8a5e4f9..daf040dd 100644 --- a/bridges/HaveIBeenPwnedBridge.php +++ b/bridges/HaveIBeenPwnedBridge.php @@ -48,7 +48,7 @@ class HaveIBeenPwnedBridge extends BridgeAbstract . $pwnCount . ' breached accounts'; $item['dateAdded'] = $breach['AddedDate']; $item['breachDate'] = $breach['BreachDate']; - $item['uri'] = self::URI . '/PwnedWebsites#' . $breach['Name']; + $item['uri'] = self::URI . '/breach/' . $breach['Name']; $item['content'] = '

    ' . $breach['Description'] . '

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

    ' . $this->breachType($breach) . '

    '; diff --git a/bridges/HeiseBridge.php b/bridges/HeiseBridge.php index f89594ee..35f18d57 100644 --- a/bridges/HeiseBridge.php +++ b/bridges/HeiseBridge.php @@ -138,6 +138,7 @@ class HeiseBridge extends FeedExpander } // abort on heise+ articles if ($sessioncookie == '' && str_starts_with($item['title'], 'heise+ |')) { + $item['uri'] = 'https://archive.is/' . $item['uri']; return $item; } @@ -160,9 +161,17 @@ class HeiseBridge extends FeedExpander $article = defaultLinkTo($article, $item['uri']); // remove unwanted stuff - foreach ($article->find('figure.branding, a-ad, div.ho-text, a-img, .opt-in__content-container, .a-toc__list, a-collapse') as $element) { + foreach ( + $article->find('figure.branding, figure.a-inline-image, a-ad, div.ho-text, a-img, + .a-toc__list, a-collapse, .opt-in__description, .opt-in__footnote, .notice-banner__text, .notice-banner__link, .ad, .ad--inread') as $element + ) { $element->remove(); } + foreach ($article->find('img') as $element) { + if (str_contains($element->alt, 'l+f')) { + $element->remove(); + } + } // reload html, as remove() is buggy $article = str_get_html($article->outertext); @@ -179,7 +188,31 @@ class HeiseBridge extends FeedExpander } } - $categories = $article->find('.article-footer__topics ul.topics li.topics__item'); + //fix for embbedded youtube-videos + $oldlink = ''; + foreach ($article->find('div.video__yt-container') as &$ytvideo) { + if (preg_match('/www.youtube.*?\"/', $ytvideo->innertext, $link) && $link[0] != $oldlink) { + //save link to prevent duplicates + $oldlink = $link[0]; + $ytiframe = << + EOT; + //check if video is in header or article for correct possitioning + if (strpos($header->innertext, $link[0])) { + $item['content'] .= $ytiframe; + } else { + $ytvideo->innertext .= $ytiframe; + $reloadneeded = 1; + } + } + } + if (isset($reloadneeded)) { + $article = str_get_html($article->outertext); + } + + $categories = $article->find('.article-footer__topics ul.topics li.topics__item a-topic a'); foreach ($categories as $category) { $item['categories'][] = trim($category->plaintext); } @@ -187,7 +220,7 @@ class HeiseBridge extends FeedExpander $content = $article->find('.article-content', 0); if ($content) { $contentElements = $content->find( - 'p, h3, ul, table, pre, noscript img, a-bilderstrecke h2, a-bilderstrecke figure, a-bilderstrecke figcaption' + 'p, h3, ul, ol, table, pre, noscript img, a-bilderstrecke h2, a-bilderstrecke figure, a-bilderstrecke figcaption, noscript iframe' ); $item['content'] .= implode('', $contentElements); } diff --git a/bridges/HinduTamilBridge.php b/bridges/HinduTamilBridge.php new file mode 100644 index 00000000..50b9b8e6 --- /dev/null +++ b/bridges/HinduTamilBridge.php @@ -0,0 +1,97 @@ + [ + 'name' => 'topic', + 'type' => 'list', + 'defaultValue' => 'crime', + 'values' => [ + 'Astrology' => 'astrology', + 'Blogs' => 'blogs', + 'Business' => 'business', + 'Cartoon' => 'cartoon', + 'Cinema' => 'cinema', + 'Crime' => 'crime', + 'Discussion' => 'discussion', + 'Education' => 'education', + 'Environment' => 'environment', + 'India' => 'india', + 'Lifestyle' => 'life-style', + 'Literature' => 'literature', + 'Opinion' => 'opinion', + 'Reporters' => 'reporters-page', + 'Socialmedia' => 'social-media', + 'Spirituals' => 'spirituals', + 'Sports' => 'sports', + 'Supplements' => 'supplements', + 'Tamilnadu' => 'tamilnadu', + 'Technology' => 'technology', + 'Tourism' => 'tourism', + 'World' => 'world', + ], + ], + 'limit' => [ + 'name' => 'limit (max 100)', + 'type' => 'number', + 'defaultValue' => 10, + ], + ], + ]; + + public function getName() + { + $topic = $this->getKey('topic'); + return self::NAME . ($topic ? ' - ' . $topic : ''); + } + + public function collectData() + { + $limit = min(100, $this->getInput('limit')); + $url = self::FEED_BASE_URL . $this->getInput('topic'); + $this->collectExpandableDatas($url, $limit); + } + + protected function parseItem($item) + { + $dom = getSimpleHTMLDOMCached($item['uri']); + $content = $dom->find('#pgContentPrint', 0); + + if ($content === null) { + return $item; + } + + $item['timestamp'] = $this->getTimestamp($dom) ?? $item['timestamp']; + $item['content'] = $this->getImage($dom) . $this->cleanContent($content); + + return $item; + } + + private function cleanContent($content): string + { + foreach ($content->find('div[align="center"], script, .adsplacement') as $remove) { + $remove->outertext = ''; + } + + return $content->innertext; + } + + private function getTimestamp($dom): ?string + { + $date = $dom->find('meta[property="article:published_time"]', 0); + return $date ? $date->getAttribute('content') : null; + } + + private function getImage($dom): string + { + $image = $dom->find('meta[property="og:image"]', 0); + return $image ? sprintf('

    ', $image->getAttribute('content')) : ''; + } +} diff --git a/bridges/HotUKDealsBridge.php b/bridges/HotUKDealsBridge.php index a7e62250..7450c6f0 100644 --- a/bridges/HotUKDealsBridge.php +++ b/bridges/HotUKDealsBridge.php @@ -40,3202 +40,23 @@ class HotUKDealsBridge extends PepperBridgeAbstract 'Deals per group' => [ 'group' => [ 'name' => 'Group', - 'type' => 'list', - 'title' => 'Group whose deals must be displayed', - 'values' => [ - '3D Blu-ray' => '3d-bluray', - '3D Printer' => '3d-printer', - '3D TV' => '3d-tv', - '4K Blu-ray' => '4k-bluray', - '4K Monitor' => '4k-monitor', - '4K TV' => '4k-tv', - '5G Phones' => '5g-phones', - '7 Up' => '7up', - '8K TV' => '8k-tv', - '32 inch TV' => '32-inch-tv', - '40 inch TV' => '40-inch-tv', - '55 inch TV' => '55-inch-tv', - '65 inch TV' => '65-inch-tv', - '75 inch TV' => '75-inch-tv', - '144Hz Monitor' => '144hz', - 'A4 Paper' => 'a4-paper', - 'AAA Battery' => 'aaa', - 'AA Battery' => 'aa', - 'Abercrombie' => 'abercrombie', - 'Aberlour' => 'aberlour', - 'Accommodation' => 'accomodation', - 'Accurist' => 'accurist', - 'Ace Combat 7: Skies Unknown' => 'ace-combat-7', - 'Acer' => 'acer', - 'Acer Aspire' => 'acer-aspire', - 'Acer Laptop' => 'acer-laptop', - 'Acer PC Monitor' => 'acer-pc-monitor', - 'Acer Predator' => 'acer-predator', - 'Action Camera' => 'action-camera', - 'Action Figure & Playsets' => 'playsets', - 'Activewear' => 'sports-clothes', - 'Activia' => 'activia', - 'adidas' => 'adidas', - 'adidas Continental' => 'continental', - 'Adidas Gazelle' => 'gazelle', - 'Adidas Originals' => 'adidas-originals', - 'Adidas Samba' => 'samba', - 'Adidas Stan Smith' => 'stan-smith', - 'Adidas Superstar' => 'adidas-superstar', - 'Adidas Trainers' => 'adidas-shoes', - 'Adidas Ultraboost' => 'adidas-ultraboost', - 'Adidas ZX Flux' => 'adidas-zx-flux', - 'Adobe' => 'adobe', - 'Adobe Lightroom' => 'lightroom', - 'Adobe Photoshop' => 'photoshop', - 'Adult Products' => 'adult', - 'Advent Calendar' => 'advent-calendar', - 'Adventure Time' => 'adventure-time', - 'AEG' => 'aeg', - 'Aftershave' => 'aftershave', - 'Age Of Empires' => 'age-of-empires', - 'Air Bed' => 'air-bed', - 'Air Conditioner' => 'air-con', - 'Airer' => 'airer', - 'Airfix' => 'airfix', - 'Air Fryer' => 'air-fryer', - 'Airline' => 'airline', - 'Airport' => 'airport', - 'Airport Parking' => 'airport-parking', - 'Air Purifier' => 'air-purifier', - 'AirTag' => 'airtag', - 'Air Treatment' => 'air-treatment', - 'AKG' => 'akg', - 'Alarm Clock' => 'alarm-clock', - 'Alarm System' => 'alarm-system', - 'Alcatel' => 'alcatel', - 'Alcohol' => 'alcohol', - 'Alesis' => 'alesis', - 'Alien: Isolation' => 'alien-isolation', - 'Alienware' => 'alienware', - 'All-in-One PC' => 'all-in-one-pc', - 'All-in-One Printer' => 'all-in-one-printer', - 'Alloy Wheel' => 'alloy-wheels', - 'All Saints' => 'all-saints', - 'Almonds' => 'almonds', - 'Alpro' => 'alpro', - 'Alton Towers' => 'alton-towers', - 'Amazfit' => 'xiaomi-amazfit', - 'Amazfit Bip' => 'xiaomi-amazfit-bip', - 'Amazfit GTS' => 'amazfit-gts', - 'Amazfit Verge' => 'amazfit-verge', - 'Amazfit Verge Lite' => 'amazfit-verge-lite', - 'Amazfit Watch' => 'amazfit-watch', - 'Amazon Add On Item' => 'add-on-item', - 'Amazon Business' => 'amazon-business', - 'Amazon Echo' => 'amazon-echo', - 'Amazon Echo Dot' => 'amazon-echo-dot', - 'Amazon Echo Plus' => 'amazon-echo-plus', - 'Amazon Echo Show' => 'amazon-echo-show', - 'Amazon Echo Show 5' => 'echo-show-5', - 'Amazon Echo Show 8' => 'amazon-echo-show-8', - 'Amazon Echo Spot' => 'amazon-echo-spot', - 'Amazon Fire 7' => 'amazon-fire-7', - 'Amazon Fire HD 8' => 'amazon-fire-hd-7', - 'Amazon Fire HD 10 Tablet' => 'amazon-fire-hd-10', - 'Amazon Fire Tablet' => 'amazon-tablet', - 'Amazon Fire TV Cube' => 'fire-tv-cube', - 'Amazon Fire TV Stick' => 'amazon-fire-stick', - 'Amazon Pantry' => 'amazon-pantry', - 'Amazon Prime' => 'amazon-prime', - 'Amazon Prime Video' => 'amazon-video', - 'Amazon Warehouse' => 'amazon-warehouse', - 'AMD' => 'amd', - 'AMD Radeon' => 'radeon', - 'AMD Ryzen' => 'amd-ryzen', - 'AMD Ryzen 5 5600X' => 'amd-ryzen-5-5600x', - 'AMD Ryzen 7 5800X' => 'amd-ryzen-7-5800x', - 'AMD Ryzen 9 5900X' => 'amd-ryzen-9-5900x', - 'AMD Ryzen 9 5950X' => 'amd-ryzen-9-5950x', - 'Amex' => 'amex', - 'Amiibo' => 'amiibo', - 'Amplifier' => 'amplifier', - 'Anchor Butter' => 'anchor-butter', - 'Andrex' => 'andrex', - 'Android Apps' => 'android-app', - 'Android Smartphone' => 'android-smartphone', - 'Android Tablet' => 'android-tablet', - 'Angelcare' => 'angelcare', - 'Angle Grinder' => 'grinder', - 'Anglepoise' => 'anglepoise', - 'Angry Birds' => 'angry-birds', - 'Animal Crossing' => 'animal-crossing', - 'Anime' => 'anime', - 'Anker' => 'anker', - 'Ankle Boots' => 'ankle-boots', - 'Anno 1800' => 'anno-1800', - 'Anthem' => 'anthem', - 'Antibacterial Hand Gel' => 'hand-gel', - 'Antibacterial Wipes' => 'cleaning-wipes', - 'Antivirus' => 'antivirus', - 'Antler' => 'antler', - 'AOC' => 'aoc', - 'Apex Legends' => 'apex-legends', - 'A Plague Tale: Innocence' => 'a-plague-tale-innocence', - 'App' => 'app', - 'Apple' => 'apple', - 'Apple AirPods' => 'apple-airpods', - 'Apple Airpods 2' => 'airpods-2', - 'Apple Airpods Max' => 'airpods-max', - 'Apple Airpods Pro' => 'airpods-pro', - 'Apple EarPods' => 'earpods', - 'Apple Headphones' => 'apple-headphones', - 'Apple HomePod' => 'apple-homepod', - 'Apple HomePod mini' => 'apple-homepod-mini', - 'Apple Keyboard' => 'apple-keyboard', - 'Apple Pencil' => 'apple-pencil', - 'Apple TV' => 'apple-tv', - 'Apple TV 4K' => 'apple-tv-4k', - 'Apple Watch' => 'apple-watch', - 'Apple Watch 3' => 'apple-watch-3', - 'Apple Watch 4' => 'apple-watch-4', - 'Apple Watch 5' => 'apple-watch-5', - 'Apple Watch 6' => 'apple-watch-6', - 'Apple Watch SE' => 'apple-watch-se', - 'Apron' => 'apron', - 'Aquadoodle' => 'aquadoodle', - 'Aqua Optima' => 'aqua-optima', - 'Aquarium' => 'aquarium', - 'Aramis' => 'aramis', - 'Argan Oil' => 'argan-oil', - 'Ariel' => 'ariel', - 'Ark' => 'ark', - 'Armani' => 'armani', - 'Armchair' => 'armchair', - 'Armed Forces Discount' => 'armed-forces', - 'Arsenal F. C.' => 'arsenal', - 'Arts and Crafts' => 'craft', - 'Asics' => 'asics', - 'Ask' => 'ask', - 'ASRock' => 'asrock', - 'Assassin's Creed' => 'assassins-creed', - 'Assassin's Creed: Odyssey' => 'assassins-creed-odyssey', - 'Assassin's Creed: Origins' => 'assassins-creed-origins', - 'Assassin's Creed: Unity' => 'assassins-creed-unity', - 'Assassin's Creed: Valhalla' => 'assasins-creed-valhalla', - 'Astral Chain' => 'astral-chain', - 'ASTRO Gaming' => 'astro-gaming', - 'Astro Gaming A40' => 'astro-gaming-a40', - 'Astro Gaming A50' => 'astro-gaming-a50', - 'Asus' => 'asus', - 'ASUS Laptop' => 'asus-laptop', - 'ASUS Monitor' => 'asus-monitor', - 'ASUS ROG' => 'asus-rog', - 'Asus ROG Phone' => 'asus-rog-phone', - 'Asus ROG Phone 2' => 'asus-rog-phone-2', - 'ASUS Router' => 'asus-router', - 'Asus Smartphone' => 'asus-smartphone', - 'ASUS Vivobook' => 'asus-vivobook', - 'ASUS Zenbook' => 'zenbook', - 'Asus ZenFone 6' => 'asus-zenfone-6', - 'Atari' => 'atari', - 'Audi' => 'audi', - 'Audio & Hi-Fi' => 'audio', - 'Audio Accessories' => 'audio-accessories', - 'Audiobook' => 'audiobook', - 'Audio Technica' => 'audio-technica', - 'Aukey' => 'aukey', - 'Aussie' => 'aussie', - 'Autoglym' => 'autoglym', - 'Aveeno' => 'aveeno', - 'Avengers' => 'avengers', - 'AVG' => 'avg', - 'Aviva' => 'aviva', - 'Avon' => 'avon', - 'AV Receiver' => 'av-receiver', - 'Axe' => 'axe', - 'Baby Annabell' => 'baby-annabell', - 'Baby Bath' => 'baby-bath', - 'Baby Born' => 'baby-born', - 'Baby Bottle' => 'baby-bottles', - 'Baby Bouncer' => 'bouncer', - 'Baby Carrier' => 'baby-carrier', - 'Baby Clothes' => 'baby-clothes', - 'Baby Food' => 'baby-food', - 'Baby Gym' => 'baby-gym', - 'Baby Jogger' => 'baby-jogger', - 'Babyliss' => 'babyliss', - 'Baby Monitor' => 'baby-monitor', - 'Baby Shoes' => 'baby-shoes', - 'Baby Swing' => 'baby-swing', - 'Baby Walker' => 'baby-walker', - 'Baby Wipes' => 'wipes', - 'Bacardi' => 'bacardi', - 'Backpack' => 'backpack', - 'Back to the Future' => 'back-to-the-future', - 'Bacon' => 'bacon', - 'Badminton' => 'badminton', - 'Bag' => 'bag', - 'Bagless Vacuum Cleaner' => 'bagless-vacuum-cleaner', - 'Bahco' => 'bahco', - 'Baileys' => 'baileys', - 'Baked Beans' => 'baked-beans', - 'Bakery Products' => 'bakery-products', - 'Baking' => 'baking', - 'Ball Pit' => 'ball-pit', - 'Ballpoint Pen' => 'pen', - 'Band of Brothers' => 'band-of-brothers', - 'Bang & Olufsen' => 'bang-olufsen', - 'Bank' => 'bank', - 'Bank Account' => 'bank-account', - 'Banks & Credit Cards' => 'bank-credit-card', - 'Barbell' => 'barbell', - 'Barbie' => 'barbie', - 'Barbour' => 'barbour', - 'Barclaycard' => 'barclaycard', - 'Barclays' => 'barclays', - 'Barebones PC' => 'barebones', - 'bareMinerals' => 'bareminerals', - 'Barry M' => 'barry-m', - 'Bar Stools' => 'bar-stools', - 'Base Layer' => 'base-layer', - 'Basket' => 'basket', - 'Basketball' => 'basketball', - 'Basmati Rice' => 'basmati-rice', - 'Bath Mat' => 'bath-mat', - 'Bathroom Accessories' => 'bathroom', - 'Bathroom Cabinet' => 'bathroom-cabinet', - 'Bathroom Scale' => 'bathroom-scales', - 'Bathroom Tap' => 'tap', - 'Batman' => 'batman', - 'Battery' => 'battery', - 'Battleborn' => 'battleborn', - 'Battlefield' => 'battlefield', - 'Battlefield 1' => 'battlefield-1', - 'Battlefield 4' => 'battlefield-4', - 'Battlefield 5' => 'battlefield-5', - 'Battlestar Galactica' => 'battlestar-galactica', - 'Baylis & Harding' => 'baylis-and-harding', - 'Bayonetta' => 'bayonetta', - 'Bayonetta 2' => 'bayonetta-2', - 'Baywatch' => 'baywatch', - 'BB-8' => 'bb-8', - 'BBC' => 'bbc', - 'BBQ Food' => 'bbq', - 'BBQs and Grills' => 'grill', - 'Bean Bag' => 'bean-bag', - 'Beanie Hat' => 'beanie-hat', - 'Bean to Cup Machine' => 'bean-to-cup', - 'Beard Trimmer' => 'beard-trimmer', - 'Beats by Dre' => 'beats-by-dre', - 'Beats Solo 3' => 'beats-solo-3', - 'Beats Studio 3' => 'beats-studio-3', - 'Beauty' => 'beauty-care', - 'Beauty and the Beast' => 'beauty-and-the-beast', - 'Becks' => 'becks', - 'Bed' => 'bed', - 'Bedding' => 'bedding', - 'Bedding & Linens' => 'bedding-linens', - 'Bed Frame' => 'bed-frame', - 'Bedroom' => 'bedroom-furniture', - 'Beef' => 'beef', - 'Beer' => 'beer', - 'Beer Advent Calendar' => 'beer-advent-calendar', - 'Beko' => 'beko', - 'Belkin' => 'belkin', - 'Belstaff' => 'belstaff', - 'Belt' => 'belt', - 'BelVita' => 'belvita', - 'Ben & Jerry's' => 'ben-jerrys', - 'Benefit Cosmetics' => 'benefit-cosmetics', - 'BenQ' => 'benq', - 'BenQ Monitor' => 'benq-monitor', - 'Ben Sherman' => 'ben-sherman', - 'BeoPlay Headphones' => 'beoplay-headphones', - 'Beoplay Speakers' => 'beoplay', - 'Berghaus' => 'berghaus', - 'Bestway' => 'bestway', - 'Betting' => 'betting', - 'Beyerdynamic' => 'beyerdynamic', - 'Bic' => 'bic', - 'Bike' => 'bike', - 'Bike Accessories' => 'bike-accessories', - 'Bike Brake' => 'brakes', - 'Bike Computer' => 'bike-computer', - 'Bike Helmet' => 'bicycle-helmet', - 'Bike Inner Tube' => 'inner-tube', - 'Bike Lights' => 'bike-lights', - 'Bike Lock' => 'bike-lock', - 'Bike Parts' => 'bike-parts', - 'Bike Pump' => 'bike-pump', - 'Biker Equipment' => 'biker-equipment', - 'Bike Saddle' => 'saddle', - 'Biking & Urban Sports' => 'biking-urban-sports', - 'Bikini' => 'bikini', - 'Billabong' => 'billabong', - 'Bin' => 'bin', - 'Binatone' => 'binatone', - 'Bingo' => 'bingo', - 'Binoculars' => 'binoculars', - 'Bio Oil' => 'bio-oil', - 'Bioshock' => 'bioshock', - 'Birds Eye' => 'birds-eye', - 'Birkenstock' => 'birkenstock', - 'Biscuits' => 'biscuits', - 'Bissell' => 'bissell', - 'Bistro Set' => 'bistro-set', - 'Bitdefender' => 'bitdefender', - 'Black & Decker' => 'black-decker', - 'Blackberry Smartphone' => 'blackberry', - 'Blanket' => 'blanket', - 'Blaupunkt' => 'blaupunkt', - 'Blazer' => 'blazer', - 'Bleach' => 'bleach', - 'Blended Malt' => 'malt', - 'Blender' => 'blender', - 'Blinds' => 'blinds', - 'Blink XT2 Smart Security Camera' => 'blink-xt2', - 'Blizzard' => 'blizzard', - 'Blood & Truth' => 'blood-and-truth', - 'Bloodborne' => 'bloodborne', - 'Blood Pressure Monitor' => 'blood-pressure', - 'Blu-ray' => 'blu-ray', - 'Blu-ray Player' => 'blu-ray-player', - 'Bluetooth Headphones' => 'bluetooth-headphones', - 'Bluetooth Speaker' => 'bluetooth-speaker', - 'BMW' => 'bmw', - 'BMW Mini Cooper' => 'mini-cooper', - 'BMX' => 'bmx', - 'Board Game' => 'board-game', - 'Boardman' => 'boardman', - 'Boat Shoes' => 'boat-shoes', - 'Bodum' => 'bodum', - 'Bogof' => 'bogof', - 'Boiler' => 'boiler', - 'Bold' => 'bold', - 'Bombay Sapphire' => 'bombay-sapphire', - 'Bomber Jacket' => 'bomber-jacket', - 'Bonne Maman' => 'bonne-maman', - 'Bonsai' => 'bonsai', - 'Book' => 'book', - 'Bookcase' => 'bookcase', - 'Books & Magazines' => 'books-magazines', - 'Booster Seat' => 'booster-seat', - 'Boots' => 'boots', - 'Borderlands' => 'borderlands', - 'Borderlands 3' => 'borderlands-3', - 'Bosch' => 'bosch', - 'Bosch Dishwasher' => 'bosch-dishwasher', - 'Bosch Drill' => 'bosch-drill', - 'Bosch Fridge' => 'bosch-fridge', - 'Bosch Rotak' => 'rotak', - 'Bosch Washing Machine' => 'bosch-washing-machine', - 'Bose' => 'bose', - 'Bose Headphones' => 'bose-headphones', - 'Bose Noise Cancelling Headphones 700' => 'bose-headphones-700', - 'Bose QuietComfort' => 'bose-quietcomfort', - 'Bose QuietComfort 35 II' => 'bose-quietcomfort-35-ii', - 'Bose SoundLink' => 'bose-soundlink', - 'Bose SoundLink Around-Ear II' => 'bose-soundlink-2', - 'Bose SoundTouch' => 'bose-soundtouch', - 'BOSS' => 'hugo-boss', - 'Boss Bottled' => 'boss-bottled', - 'Bouncy Castle' => 'bouncy-castle', - 'Bourbon' => 'bourbon', - 'Bourjois' => 'bourjois', - 'Bowers & Wilkins' => 'bowers-wilkins', - 'Bowling' => 'bowling', - 'Bowmore' => 'bowmore', - 'Boxers' => 'boxers', - 'Boxing' => 'boxing', - 'Boxing Gloves' => 'boxing-gloves', - 'Boy's Clothes' => 'clothes-for-boys', - 'Bra' => 'bra', - 'Brabantia' => 'brabantia', - 'Bracelet' => 'bracelet', - 'Brands' => 'brand', - 'Brandy' => 'brandy', - 'Branston' => 'branston', - 'Branston Beans' => 'branston-beans', - 'Braun' => 'braun', - 'Braun Series 3' => 'braun-series-3', - 'Braun Series 5' => 'braun-series-5', - 'Braun Series 7' => 'braun-series-7', - 'Braun Series 9' => 'braun-series-9', - 'Braun Shaver' => 'braun-shaver', - 'Bread' => 'bread', - 'Breadmaker' => 'breadmaker', - 'Breakdown Cover' => 'breakdown', - 'Breaking Bad' => 'breaking-bad', - 'Breast Pump' => 'breast-pump', - 'Breville' => 'breville', - 'Breville Blend Active' => 'blendactive', - 'Brewdog' => 'brewdog', - 'Bridge Camera' => 'bridge-camera', - 'Briefcase' => 'briefcase', - 'Brita' => 'brita', - 'Britax' => 'britax', - 'British Airways' => 'british-airways', - 'Broadband' => 'broadband', - 'Broadband & Phone Contracts' => 'broadband-phone-service', - 'Brogues' => 'brogues', - 'Brother' => 'brother', - 'Brother Printer' => 'brother-printer', - 'Brownie' => 'brownie', - 'BT' => 'bt', - 'BT Sport' => 'bt-sport', - 'Budweiser' => 'budweiser', - 'Buffalo' => 'buffalo', - 'Bugaboo' => 'bugaboo', - 'Buggy' => 'buggy', - 'Build-A-Bear' => 'build-a-bear', - 'Bulb' => 'bulbs', - 'Bulletstorm' => 'bulletstorm', - 'Bulmers' => 'bulmers', - 'Bulova' => 'bulova', - 'Burberry' => 'burberry', - 'Burger' => 'burger', - 'Burnout Paradise' => 'burnout-paradise', - 'Burt's Bees' => 'burts-bees', - 'Bus and Coach Ticket' => 'bus', - 'Bush' => 'bush', - 'Bushmills' => 'bushmills', - 'Butter' => 'butter', - 'Buying From Abroad' => 'buying-from-abroad', - 'Bvlgari' => 'bvlgari', - 'Cabin Case' => 'cabin-case', - 'Cabinet' => 'cabinet', - 'Cable Reel' => 'cable-reel', - 'Cables' => 'cables', - 'Cadbury's' => 'cadbury', - 'Café Rouge' => 'cafe-rouge', - 'Cafetière' => 'cafetiere', - 'Caffè Nero' => 'cafe-nero', - 'Cake' => 'cake', - 'Calculator' => 'calculator', - 'Calendar' => 'calendar', - 'Call of Duty' => 'call-of-duty', - 'Call of Duty: Black Ops' => 'black-ops', - 'Call of Duty: Black Ops 3' => 'black-ops-3', - 'Call of Duty: Black Ops 4' => 'black-ops-4', - 'Call of Duty: Black Ops Cold War' => 'call-of-duty-black-ops-cold-war', - 'Call of Duty: Infinite Warfare' => 'call-of-duty-infinite-warfare', - 'Call of Duty: Modern Warfare' => 'modern-warfare', - 'Call of Duty: WW2' => 'call-of-duty-ww2', - 'Calpol' => 'calpol', - 'Calvin Klein' => 'calvin-klein', - 'Camcorder' => 'camcorder', - 'Camelbak' => 'camelbak', - 'Camera' => 'camera', - 'Camera Accessories' => 'camera-accessories', - 'Camera Bag' => 'camera-bag', - 'Camera Lens' => 'lens', - 'Camping' => 'camping', - 'Campingaz' => 'campingaz', - 'Candle' => 'candle', - 'Cannondale' => 'cannondale', - 'Canon' => 'canon', - 'Canon Camera' => 'canon-camera', - 'Canon EOS' => 'canon-eos', - 'Canon Lens' => 'canon-lens', - 'Canon Pixma' => 'canon-pixma', - 'Canon PowerShot' => 'canon-powershot', - 'Canon PowerShot SX430 IS' => 'canon-powershot-sx430-is', - 'Canon Printer' => 'canon-printer', - 'Canterbury' => 'canterbury', - 'Canton' => 'canton', - 'Canvas Print' => 'canvas-print', - 'Cap' => 'cap', - 'Capsule Machine' => 'capsule-machine', - 'Captain America' => 'captain-america', - 'Captain Morgan' => 'captain-morgan', - 'Captain Toad: Treasure Tracker' => 'captain-toad-treasure-tracker', - 'Car' => 'car', - 'Car & Motorcycle' => 'car-motorcycle', - 'Car Accessories' => 'car-accessories', - 'Caravan' => 'caravan', - 'Car Battery' => 'car-battery', - 'Carbon Monoxide Detector' => 'carbon-monoxide', - 'Car Care' => 'car-care', - 'Car Charger' => 'car-charger', - 'Cardhu' => 'cardhu', - 'Cardigan' => 'cardigan', - 'Card Reader' => 'card-reader', - 'Carex' => 'carex', - 'Carhartt' => 'carhartt', - 'Car Hire' => 'car-hire', - 'Car Insurance' => 'car-insurance', - 'Car Leasing' => 'car-lease', - 'Carling' => 'carling', - 'Car Lock' => 'lock', - 'Carlsberg' => 'carlsberg', - 'Car Mats' => 'car-mats', - 'Carolina Herrera' => 'carolina-herrera', - 'Car Parts' => 'car-parts', - 'Carpet' => 'carpet', - 'Carpet Cleaner' => 'carpet-cleaner', - 'CarPlan' => 'carplan', - 'Car Polish' => 'car-polish', - 'Carrera Bikes' => 'carrera', - 'Car Seat' => 'car-seat', - 'Car Service' => 'car-service', - 'Car Stereo' => 'car-stereo', - 'Car Wash' => 'car-wash', - 'Car Wax' => 'car-wax', - 'Casio' => 'casio', - 'Casio Eco-Drive' => 'eco-drive', - 'Casio Edifice' => 'edifice', - 'Casio G-Shock' => 'g-shock', - 'Casserole' => 'casserole', - 'Cast Iron Pots and Pans' => 'cast-iron', - 'Castrol' => 'castrol', - 'Caterpillar' => 'caterpillar', - 'Cat Flap' => 'cat-flap', - 'Cat Food' => 'cat-food', - 'Cath Kidston' => 'cath-kidston', - 'Cat Supplies' => 'cat-supplies', - 'CCTV' => 'cctv', - 'CD' => 'cd', - 'CD Player' => 'cd-player', - 'Ceiling Light' => 'ceiling-light', - 'Celebrations' => 'celebrations', - 'Cereal' => 'cereal', - 'Cetirizine' => 'cetirizine', - 'Chad Valley' => 'chad-valley', - 'Chainsaw' => 'chainsaw', - 'Champagne' => 'champagne', - 'Champneys' => 'champneys', - 'Chanel' => 'chanel', - 'Chanel Coco Mademoiselle' => 'coco-mademoiselle', - 'Changing Bag' => 'changing-bag', - 'Channel 4' => 'channel-4', - 'Charger' => 'charger', - 'Cheese' => 'cheese', - 'Chelsea Boots' => 'chelsea-boots', - 'Chelsea F. C.' => 'chelsea', - 'Chess' => 'chess', - 'Chessington' => 'chessington', - 'Chest Freezer' => 'chest-freezer', - 'Chest of Drawers' => 'chest-of-drawers', - 'Chicco' => 'chicco', - 'Chicken' => 'chicken', - 'Childcare' => 'baby', - 'Children's Books' => 'childrens-books', - 'Chino' => 'chino', - 'Chisel' => 'chisel', - 'Chloe' => 'chloe', - 'Chocolate' => 'chocolate', - 'Chocolate Advent Calendar' => 'chocolate-advent-calendar', - 'Chopper' => 'chopper', - 'Chopping Board' => 'chopping-board', - 'Christmas Card' => 'christmas-card', - 'Christmas Decoration' => 'christmas-decorations', - 'Christmas Gift' => 'christmas-gifts', - 'Christmas Jumper' => 'christmas-jumper', - 'Christmas Lights' => 'christmas-lights', - 'Christmas Stocking Fillers' => 'christmas-stocking-fillers', - 'Christmas Toys' => 'christmas-toys', - 'Christmas Tree' => 'christmas-tree', - 'Chromebook' => 'chromebook', - 'Chromecast' => 'chromecast', - 'Chromecast Ultra' => 'chromecast-ultra', - 'Chromecast with Google TV' => 'chromecast-google-tv', - 'Chronograph' => 'chronograph', - 'Chupa Chups' => 'chupa-chups', - 'Chuwi' => 'chuwi', - 'Cider' => 'cider', - 'Cinema' => 'cinema', - 'Cineworld' => 'cineworld', - 'Circular Saw' => 'circular-saw', - 'Circulon' => 'circulon', - 'Ciroc' => 'ciroc', - 'Cities Skylines' => 'cities-skylines', - 'Citizen' => 'citizen', - 'Citroen' => 'citroen', - 'City Break' => 'city-breaks', - 'Civilization' => 'civilization', - 'Clarins' => 'clarins', - 'Clarks' => 'clarks', - 'Clearance' => 'clearance', - 'Climbing' => 'climbing', - 'Climbing Frame' => 'climbing-frame', - 'Clinique' => 'clinique', - 'Clothes' => 'clothes', - 'Cloud Service' => 'cloud', - 'Clutch Bag' => 'clutch', - 'Coat' => 'coat', - 'Coca Cola' => 'coke', - 'Cocktail' => 'cocktail', - 'Coconut Oil' => 'coconut', - 'Coffee' => 'coffee', - 'Coffee Beans' => 'coffee-beans', - 'Coffee Machine' => 'coffee-machine', - 'Coffee Pods' => 'coffee-pods', - 'Coffee Table' => 'coffee-table', - 'Cognac' => 'cognac', - 'Cola' => 'cola', - 'Coleman' => 'coleman', - 'Colgate' => 'colgate', - 'Combi Drill' => 'combi', - 'Comfort' => 'comfort', - 'Comic' => 'comic', - 'Command & Conquer' => 'command-and-conquer', - 'Compact Camera' => 'compact-camera', - 'Compact Flash' => 'compact-flash', - 'Competitions' => 'competitions', - 'Compost' => 'compost', - 'Compressor' => 'compressor', - 'Computer Accessories' => 'computer-accessories', - 'Computers & Tablets' => 'computers', - 'Concert' => 'concert', - 'Condé Nast' => 'conde-nast', - 'Conditioner' => 'conditioner', - 'Condom' => 'condom', - 'Connectors' => 'connectors', - 'Contact Lenses' => 'contact-lenses', - 'Contents Insurance' => 'contents-insurance', - 'Controller' => 'controller', - 'Converse' => 'converse', - 'Converse Chuck Taylor' => 'chuck-taylor', - 'Cooker' => 'cooker', - 'Cooking Oil' => 'cooking-oil', - 'Cookware' => 'cooking', - 'Cookware Set' => 'cookware-set', - 'Cookworks' => 'cookworks', - 'Cool Box' => 'cool-box', - 'Coors Light' => 'coors-light', - 'Cordless Drill' => 'cordless-drill', - 'Cordless Phone' => 'cordless-phone', - 'Cornetto' => 'cornetto', - 'Corona Beer' => 'corona', - 'Corsair' => 'corsair', - 'Cosatto' => 'cosatto', - 'Costa Coffee' => 'costa-coffee', - 'Costume' => 'costume', - 'Cot' => 'cot', - 'Counter Strike' => 'counter-strike', - 'Courses and Training' => 'education', - 'Cow & Gate' => 'cow-and-gate', - 'Cozy Coupe' => 'cozy-coupe', - 'CPU' => 'cpu', - 'CPU Cooler' => 'cpu-cooler', - 'Craghoppers' => 'craghoppers', - 'Crash Bandicoot' => 'crash-bandicoot', - 'Crash Team Racing Nitro-Fueled' => 'crash-team-racing-nitro-fueled', - 'Crayola' => 'crayola', - 'Creatine' => 'creatine', - 'Credit Card' => 'credit-card', - 'Creme Egg' => 'creme-egg', - 'Cricket' => 'cricket', - 'Crisps' => 'crisps', - 'Crocs' => 'crocs', - 'Cross Trainer' => 'cross-trainer', - 'Crown Paint' => 'crown', - 'Crucial' => 'crucial', - 'Cruelty Free Makeup' => 'cruelty-free-makeup', - 'Cruises' => 'cruise', - 'Cube Bikes' => 'cube', - 'Cubot' => 'cubot', - 'Cufflinks' => 'cufflinks', - 'Culture & Leisure' => 'entertainment', - 'Cuphead' => 'cuphead', - 'Cuprinol' => 'cuprinol', - 'Curling Wand' => 'curling-wand', - 'Curtain' => 'curtain', - 'Cushelle' => 'cushelle', - 'Cushion' => 'cushion', - 'Cutlery' => 'cutlery', - 'CyberLink' => 'cyberlink', - 'Cyberpunk 2077' => 'cyberpunk-2077', - 'Cybex' => 'cybex', - 'Cycling' => 'cycling', - 'Cycling Jacket' => 'cycling-jacket', - 'D-Link' => 'd-link', - 'DAB Radio' => 'dab-radio', - 'Dacia' => 'dacia', - 'Daily Mail' => 'daily-mail', - 'Dairy Milk' => 'dairy-milk', - 'Darksiders' => 'darksiders', - 'Dark Souls' => 'dark-souls', - 'Dark Souls 3' => 'dark-souls-3', - 'Dartboard' => 'dartboard', - 'Darts' => 'darts', - 'Dash Cam' => 'dash-cam', - 'Data Storage' => 'storage', - 'Davidoff' => 'davidoff', - 'Days Gone' => 'days-gone', - 'Days Out' => 'days-out', - 'Daz' => 'daz', - 'DC Comic' => 'dc', - 'DDR3' => 'ddr3', - 'DDR4' => 'ddr4', - 'Dead Island' => 'dead-island', - 'Dead or Alive 6' => 'dead-or-alive-6', - 'Deadpool' => 'deadpool', - 'Dead Rising' => 'dead-rising', - 'Death Stranding' => 'death-stranding', - 'Deezer' => 'deezer', - 'Dehumidifier' => 'dehumidifier', - 'Dell' => 'dell', - 'Dell Laptop' => 'dell-laptop', - 'Dell Monitor' => 'dell-monitor', - 'Dell XPS' => 'xps', - 'Delonghi' => 'delonghi', - 'Demon's Souls' => 'demon-souls', - 'Denby' => 'denby', - 'Denon' => 'denon', - 'Deodorant' => 'deodorant', - 'Desk' => 'desk', - 'Desperados Beer' => 'desperados', - 'Despicable Me' => 'despicable-me', - 'Destiny' => 'destiny', - 'Destiny 2' => 'destiny-2', - 'Detergent' => 'detergent', - 'Detroit: Become Human' => 'detroit-become-human', - 'Dettol' => 'dettol', - 'Deus Ex' => 'deus-ex', - 'Deus Ex: Mankind Divided' => 'deus-ex-mankind-divided', - 'Development Boards' => 'development-boards', - 'Devil May Cry 5' => 'devil-may-cry-5', - 'DeWalt' => 'dewalt', - 'DFDS' => 'dfds', - 'Diablo 3' => 'diablo-3', - 'Diary' => 'diary', - 'Dickies' => 'dickies', - 'Diesel' => 'diesel', - 'Diet' => 'diet', - 'Diggerland' => 'diggerland', - 'Digihome' => 'digihome', - 'Digimon' => 'digimon', - 'Digital Camera' => 'digital-camera', - 'Digital Watch' => 'digital-watch', - 'Dildo' => 'dildo', - 'Dimplex' => 'dimplex', - 'Dining Room' => 'dining-room', - 'Dining Room Chair' => 'chair', - 'Dining Set' => 'dining-set', - 'Dining Table' => 'dining-table', - 'Dinner Plate' => 'plates', - 'Dinner Set' => 'dinner-set', - 'Dinosaur' => 'dinosaur', - 'Dior' => 'dior', - 'Dior Sauvage' => 'dior-sauvage', - 'Dirt' => 'dirt', - 'Dirt 4' => 'dirt-4', - 'DIRT 5' => 'dirt-5', - 'Dirt Rally 2.0' => 'dirt-rally-2', - 'Disaronno' => 'disaronno', - 'Discord Nitro' => 'discord-nitro', - 'Disgaea' => 'disgaea', - 'Dishonored' => 'dishonored', - 'Dishonored 2' => 'dishonored-2', - 'Dishwasher' => 'dishwasher', - 'Dishwasher Tablets' => 'dishwasher-tablets', - 'Disinfectants' => 'disinfectants', - 'Disney' => 'disney', - 'Disney's Cars' => 'disney-cars', - 'Disney's Frozen' => 'disney-frozen', - 'Disney+' => 'disney-plus', - 'Disney Infinity' => 'disney-infinity', - 'Disneyland' => 'disneyland', - 'Disney Princess' => 'disney-princess', - 'Disney Tsum Tsum' => 'tsum-tsum', - 'Disney World' => 'disney-world', - 'Divan' => 'divan', - 'DIY' => 'diy', - 'DJ Equipment' => 'dj', - 'DJI Phantom' => 'dji-phantom', - 'DKNY' => 'dkny', - 'Doctor Who' => 'doctor-who', - 'Dog Bed' => 'dog-bed', - 'Dog Food' => 'dog-food', - 'Dog Supplies' => 'dog', - 'Dolce & Gabbana' => 'dolce', - 'Dolce Gusto' => 'dolce-gusto', - 'Dolce Gusto Coffee Machine' => 'dolce-gusto-coffee-machine', - 'Doll' => 'doll', - 'Dolls House' => 'dolls-house', - 'Domain Service' => 'domain', - 'Doogee' => 'doogee', - 'Doom' => 'doom', - 'Door' => 'door', - 'Doorbell' => 'doorbell', - 'Door Handles' => 'door-handles', - 'Doormat' => 'doormat', - 'Doritos' => 'doritos', - 'Dove' => 'dove', - 'Down Jacket' => 'down-jacket', - 'Downton Abbey' => 'downton-abbey', - 'Dr. Martens' => 'dr-martens', - 'Dragon Age' => 'dragon-age', - 'Dragon Ball' => 'dragon-ball', - 'Dragon Ball: FighterZ' => 'dragon-ball-fighterz', - 'Dragon Quest' => 'dragon-quest', - 'Dragon Quest Builders' => 'dragon-quest-builders', - 'Dragon Quest Builders 2' => 'dragon-quest-builders-2', - 'Dragon Quest XI: Echoes of an Elusive Age' => 'dragon-quest-xi', - 'Draper' => 'draper', - 'Drayton Manor' => 'drayton-manor', - 'Dreame T20' => 'dreame-t20', - 'Dreame V9' => 'dreame-v9', - 'Dreame V9P' => 'dreame-v9p', - 'Dreame V10' => 'dreame-v10', - 'Dreame V11' => 'dreame-v11', - 'Dreame Vacuum Cleaner' => 'xiaomi-vacuum-cleaner', - 'Dremel' => 'dremel', - 'Dress' => 'dress', - 'Dressing Gown' => 'dressing-gown', - 'Drill' => 'drill', - 'Drill Driver' => 'driver', - 'Drinks' => 'drinks', - 'Driveclub' => 'driveclub', - 'Driving Lessons' => 'driving-lessons', - 'Drone' => 'drone', - 'Dryer' => 'dryer', - 'DSLR Camera' => 'dslr', - 'Dual Fuel Cooker' => 'dual-fuel', - 'Dualit' => 'dualit', - 'Dual Sim' => 'sim', - 'Dulux' => 'dulux', - 'Duracell' => 'duracell', - 'Durex' => 'durex', - 'Duvet' => 'duvet', - 'DVD' => 'dvd', - 'DVD Player' => 'dvd-player', - 'Dying Light' => 'dying-light', - 'Dymo' => 'dymo', - 'Dyson' => 'dyson', - 'Dyson Supersonic' => 'dyson-supersonic', - 'Dyson V6' => 'dyson-v6', - 'Dyson V7' => 'dyson-v7', - 'Dyson V8' => 'dyson-v8', - 'Dyson V10' => 'dyson-v10', - 'Dyson V11' => 'dyson-v11', - 'Dyson Vacuum Cleaner' => 'dyson-vacuum-cleaner', - 'e-Reader' => 'ereader', - 'EA' => 'ea', - 'EA Access' => 'ea-access', - 'Earphones' => 'earphones', - 'Earrings' => 'earrings', - 'EA Sports' => 'ea-sports', - 'EA Sports UFC' => 'ufc', - 'Easter Eggs' => 'egg', - 'Eastpak' => 'eastpak', - 'eBook' => 'ebook', - 'Ecovacs' => 'ecovacs', - 'Ecover' => 'ecover', - 'Educational Toys' => 'educational-toys', - 'EE' => 'ee', - 'eFootball PES 2021' => 'pes-2021', - 'ELC Happyland' => 'happyland', - 'Electrical Accessories' => 'electrical-accessories', - 'Electric Bike' => 'electric-bike', - 'Electric Blanket' => 'electric-blanket', - 'Electric Cooker' => 'electric-cooker', - 'Electric Fires' => 'electric-fire', - 'Electric Scooter' => 'electric-scooter', - 'Electric Shower' => 'electric-shower', - 'Electric Toothbrush' => 'electric-toothbrush', - 'Electronic Accessories' => 'electronics-accessories', - 'Electronics' => 'electronics', - 'Elemis' => 'elemis', - 'Elephone' => 'elephone', - 'Elgato' => 'elgato', - 'Elite Dangerous' => 'elite-dangerous', - 'Elizabeth Arden' => 'elizabeth-arden', - 'Emirates' => 'emirates', - 'Endura' => 'endura', - 'Eneloop' => 'eneloop', - 'Energizer' => 'energizer', - 'Energy' => 'energy', - 'Energy, Heating & Gas' => 'energy-heating-gas', - 'Energy Drinks' => 'energy-drinks', - 'Engine Oil' => 'engine-oil', - 'Epilator' => 'epilator', - 'Epson' => 'epson', - 'Epson Printer' => 'epson-printer', - 'Espresso' => 'espresso', - 'Espresso Machine' => 'espresso-machine', - 'Esprit' => 'esprit', - 'Estée Lauder' => 'estee-lauder', - 'Ethernet' => 'ethernet', - 'Etnies' => 'etnies', - 'Eurostar Ticket' => 'eurostar', - 'Eurotunnel' => 'eurotunnel', - 'Everton F. C.' => 'everton', - 'EVGA' => 'evga', - 'Evian' => 'evian', - 'Exercise Equipment' => 'exercise-equipment', - 'Exercise Weights' => 'weight', - 'Extension Lead' => 'extension-lead', - 'External Hard Drive' => 'external-hard-drive', - 'F1' => 'formula-one', - 'F1 2017' => 'f1-2017', - 'F1 2018' => 'f1-2018', - 'F1 2019' => 'f1-2019', - 'F1 2020' => 'f1-2020', - 'Fabric Conditioner' => 'fabric-conditioner', - 'Face Cream' => 'face-cream', - 'Face Mask' => 'face-mask', - 'Fairy' => 'fairy', - 'Fairy Light' => 'fairy-light', - 'Fallout' => 'fallout', - 'Fallout 4' => 'fallout-4', - 'Fallout 76' => 'fallout-76', - 'Family & Kids' => 'kids', - 'Family Break' => 'family-break', - 'Family Guy' => 'family-guy', - 'Famous Grouse' => 'famous-grouse', - 'Fancy Dress' => 'fancy-dress', - 'Fans' => 'fan', - 'Fanta' => 'fanta', - 'Far Cry' => 'far-cry', - 'Far Cry 4' => 'far-cry-4', - 'Far Cry 5' => 'far-cry-5', - 'Far Cry New Dawn' => 'far-cry-new-dawn', - 'Far Cry Primal' => 'far-cry-primal', - 'Farming Simulator' => 'farming-simulator', - 'Fashion & Accessories' => 'fashion', - 'Fashion Accessories' => 'fashion-accessories', - 'Fashion for Men' => 'mens-clothing', - 'Fashion for Women' => 'womens-clothes', - 'Fast and Furious' => 'fast-and-furious', - 'Father's Day' => 'fathers-day', - 'FatMax' => 'fatmax', - 'FC Barcelona' => 'fc-barcelona', - 'Felix' => 'felix', - 'Fence' => 'fence', - 'Fender Guitar' => 'fender', - 'Ferrero Rocher' => 'ferrero-rocher', - 'Ferry' => 'ferry', - 'Festival' => 'festival', - 'Fever Thermometer' => 'thermometer', - 'Fiat' => 'fiat', - 'Fidget Spinner' => 'spinner', - 'FIFA' => 'fifa', - 'FIFA 17' => 'fifa-17', - 'FIFA 18' => 'fifa-18', - 'FIFA 19' => 'fifa-19', - 'FIFA 20' => 'fifa-20', - 'FIFA 21' => 'fifa-21', - 'FightStick' => 'fightstick', - 'Figures' => 'figures', - 'Fila Trainers' => 'fila-trainers', - 'Filing Cabinet' => 'filing-cabinet', - 'Final Fantasy' => 'final-fantasy', - 'Final Fantasy 15' => 'final-fantasy-15', - 'Finance & Insurance' => 'personal-finance', - 'Finish' => 'finish', - 'Finlux' => 'finlux', - 'Fiorelli' => 'fiorelli', - 'Fire Emblem' => 'fire-emblem', - 'Fire Pit' => 'fire-pit', - 'Fireplace' => 'fireplace', - 'Firewall: Zero Hour' => 'firewall-zero-hour', - 'First Aid' => 'first-aid', - 'Fish & Seafood' => 'fish-and-seafood', - 'Fish and Aquatic Pet Supplies' => 'fish', - 'Fisher Price' => 'fisher-price', - 'Fisher Price Imaginext' => 'imaginext', - 'Fisher Price Jumperoo' => 'jumperoo', - 'Fisher Price Little People' => 'little-people', - 'Fishing' => 'fishing', - 'Fiskars' => 'fiskars', - 'Fitbit' => 'fitbit', - 'Fitbit Alta' => 'fitbit-alta', - 'Fitbit Blaze' => 'fitbit-blaze', - 'Fitbit Charge 2' => 'fitbit-charge-2', - 'Fitbit Inspire' => 'fitbit-inspire', - 'Fitbit Versa' => 'fitbit-versa', - 'Fitness & Running' => 'fitness', - 'Fitness App' => 'fitness-app', - 'Fitness Tracker' => 'fitness-tracker', - 'Flamingo Land' => 'flamingo-land', - 'Flea Treatment' => 'flea', - 'Fleece Clothing' => 'fleece', - 'Flights' => 'flight', - 'Flip Flops' => 'flip-flops', - 'Floodlight' => 'floodlight', - 'Flooring' => 'flooring', - 'Flowers' => 'flowers', - 'Flymo' => 'flymo', - 'FM Transmitter' => 'fm-transmitter', - 'Food' => 'food', - 'Food Containers' => 'food-containers', - 'Food Processor' => 'food-processor', - 'Food Server' => 'food-server', - 'Football' => 'football', - 'Football Boots' => 'football-boots', - 'Football Manager' => 'football-manager', - 'Football Matches' => 'football-matches', - 'Football Shirt' => 'football-shirt', - 'Foot Pump' => 'foot-pump', - 'Ford' => 'ford', - 'For Honor' => 'for-honor', - 'Fortnite' => 'fortnite', - 'Fortnite: Darkfire' => 'fortnite-darkfire', - 'Forza' => 'forza', - 'Forza 7' => 'forza-7', - 'Forza Horizon' => 'forza-horizon', - 'Forza Horizon 3' => 'forza-horizon-3', - 'Forza Horizon 4' => 'forza-horizon-4', - 'Forza Motorsport' => 'forza-motorsport', - 'Foscam' => 'foscam', - 'Fossil' => 'fossil', - 'Foster's' => 'fosters', - 'Foundation' => 'foundation', - 'Fountain Pen' => 'fountain-pen', - 'Fred Perry' => 'fred-perry', - 'Freesat' => 'freesat', - 'Freeview' => 'freeview', - 'Freezer' => 'freezer', - 'Fridge' => 'fridge', - 'Fridge Freezer' => 'fridge-freezer', - 'Frontline' => 'frontline', - 'Frozen Food' => 'frozen', - 'Fruit' => 'fruit', - 'Fruit and Vegetables' => 'fruit-and-vegetable', - 'Fruit of the Loom' => 'fruit-of-the-loom', - 'Fryer' => 'fryer', - 'Frying Pan' => 'frying-pan', - 'Fujifilm' => 'fuji', - 'Fujitsu' => 'fujitsu', - 'Funko Pop' => 'funko-pop', - 'Furby' => 'furby', - 'Furniture' => 'furniture', - 'G-Star' => 'g-star', - 'G-Sync Monitor' => 'g-sync', - 'Gaggia' => 'gaggia', - 'Gambling' => 'gambling', - 'Game App' => 'game-app', - 'Game of Thrones' => 'game-of-thrones', - 'Games & Board Games' => 'board-games', - 'Games Consoles' => 'console', - 'Gaming' => 'gaming', - 'Gaming Accessories' => 'gaming-accessories', - 'Gaming Chair' => 'gaming-chair', - 'Gaming Headset' => 'gaming-headset', - 'Gaming Keyboard' => 'gaming-keyboard', - 'Gaming Laptop' => 'gaming-laptop', - 'Gaming Monitor' => 'gaming-monitor', - 'Gaming Mouse' => 'gaming-mouse', - 'Gaming PC' => 'gaming-pc', - 'Gant' => 'gant', - 'Garage' => 'garage', - 'Garage & Service' => 'garage-service', - 'Garden' => 'garden', - 'Garden & Do It Yourself' => 'garden-diy', - 'Garden Furniture' => 'garden-furniture', - 'Gardening' => 'gardening', - 'Garden Storage' => 'garden-storage', - 'Garden Table' => 'table', - 'Garmin' => 'garmin', - 'Garmin Fenix' => 'garmin-fenix', - 'Garmin Fenix 6' => 'garmin-fenix-6', - 'Garmin Fenix 6 Pro' => 'garmin-fenix-6-pro', - 'Garmin Forerunner' => 'garmin-forerunner', - 'Garmin Vivoactive' => 'garmin-vivoactive', - 'Garmin Watch' => 'garmin-watch', - 'Garnier' => 'garnier', - 'Gas' => 'gas', - 'Gas Canister' => 'butane', - 'Gas Cooker' => 'gas-cooker', - 'Gatwick' => 'gatwick', - 'Gazebo' => 'gazebo', - 'GBK' => 'gbk', - 'Gears 5' => 'gears-5', - 'Gears of War' => 'gears-of-war', - 'Gears of War 4' => 'gears-of-war-4', - 'George Foreman' => 'george-foreman', - 'Geox' => 'geox', - 'GHD' => 'ghd', - 'Ghostbusters' => 'ghostbusters', - 'Ghostbusters: The Video Game Remastered' => 'ghostbusters-the-video-game', - 'Ghost of Tsushima' => 'ghost-of-tsushima', - 'Gibson Guitar' => 'gibson', - 'giffgaff' => 'giffgaff', - 'Gift Card' => 'gift-card', - 'Gift Hamper' => 'hamper', - 'Gifts' => 'gifts', - 'Gift Set' => 'gift-set', - 'GIGABYTE' => 'gigabyte', - 'Gigaset' => 'gigaset', - 'Gilet' => 'gilet', - 'Gillette Fusion' => 'fusion', - 'Gillette Mach3' => 'mach-3', - 'Gillette Razor' => 'gillette', - 'Gimbal' => 'gimbal', - 'Gin' => 'gin', - 'Girl's Clothes' => 'girls-clothes', - 'Glasses' => 'glasses', - 'Glassware' => 'glassware', - 'Glenfiddich' => 'glenfiddich', - 'Glenlivet' => 'glenlivet', - 'Glenmorangie' => 'glenmorangie', - 'Gloves' => 'gloves', - 'Glue' => 'glue', - 'Glue Gun' => 'glue-gun', - 'Gluten-Free' => 'gluten-free', - 'God of War' => 'god-of-war', - 'Go Kart' => 'go-kart', - 'Golf' => 'golf', - 'Golf Balls' => 'golf-balls', - 'Golf Clubs' => 'golf-clubs', - 'Goodfellas' => 'goodfellas', - 'Goodmans' => 'goodmans', - 'Goodyear' => 'goodyear', - 'Google' => 'google', - 'Google Home' => 'google-home', - 'Google Home Max' => 'google-home-max', - 'Google Home Mini' => 'google-home-mini', - 'Google Nest' => 'nest', - 'Google Nest Audio' => 'google-nest-audio', - 'Google Nest Hub' => 'google-home-hub', - 'Google Nest Mini' => 'nest-mini', - 'Google Nest Protect' => 'google-nest-protect', - 'Google Nexus' => 'nexus', - 'Google Pixel' => 'google-pixel', - 'Google Pixel 2' => 'google-pixel-2', - 'Google Pixel 2 XL' => 'google-pixel-2-xl', - 'Google Pixel 3' => 'google-pixel-3', - 'Google Pixel 3 XL' => 'google-pixel-3-xl', - 'Google Pixel 3a' => 'google-pixel-3a', - 'Google Pixel 3a XL' => 'google-pixel-3a-xl', - 'Google Pixel 4' => 'google-pixel-4', - 'Google Pixel 4 XL' => 'google-pixel-4-xl', - 'Google Pixel 4a' => 'google-pixel-4a', - 'Google Pixel 4a 5G' => 'google-pixel-4a-5g', - 'Google Pixel 5' => 'google-pixel-5', - 'Google Pixelbook' => 'google-pixelbook', - 'Google Pixel XL' => 'google-pixel-xl', - 'Google Smartphone' => 'google-smartphone', - 'Google Stadia' => 'google-stadia', - 'GoPro' => 'gopro', - 'GoPro HERO 6' => 'gopro-hero-6', - 'GoPro HERO 7' => 'gopro-hero-7', - 'GoPro HERO 8' => 'gopro-hero-8', - 'GoPro HERO 9' => 'gopro-hero-9', - 'Gore-Tex Clothing and Shoes' => 'gore-tex', - 'Graco' => 'graco', - 'Grand National' => 'grand-national', - 'Gran Turismo' => 'gran-turismo', - 'Gran Turismo Sport' => 'gran-turismo-sport', - 'Graphics Card' => 'graphics-card', - 'Gravity Rush' => 'gravity-rush', - 'Graze' => 'graze', - 'GreedFall' => 'greedfall', - 'Greenhouse' => 'greenhouse', - 'Greeting Cards and Wrapping Paper' => 'wrapping-paper-and-cards', - 'Greggs' => 'greggs', - 'Grey Goose' => 'grey-goose', - 'Griffin Technology' => 'griffin', - 'GroBag' => 'grobag', - 'Groceries' => 'groceries', - 'Gruffalo' => 'gruffalo', - 'Grundig' => 'grundig', - 'GTA' => 'gta', - 'GTA V' => 'gta-v', - 'GTX 970' => 'gtx-970', - 'GTX 980' => 'gtx-980', - 'GTX 1060' => 'gtx-1060', - 'GTX 1070' => 'gtx-1070', - 'GTX 1080' => 'gtx-1080', - 'GTX 1080 Ti' => 'gtx-1080-ti', - 'GTX 1660' => 'gtx-1660', - 'GTX 1660 Ti' => 'gtx-1660-ti', - 'Guardians of the Galaxy' => 'guardians-of-the-galaxy', - 'Gucci' => 'gucci', - 'Guinness' => 'guinness', - 'Guitar' => 'guitar', - 'Guitar Amp' => 'guitar-amp', - 'Guitar Hero' => 'guitar-hero', - 'Gulliver's' => 'gullivers', - 'Gym' => 'gym', - 'Gym Membership' => 'gym-membership', - 'H1Z1' => 'h1z1', - 'Häagen Dazs' => 'haagen-dazs', - 'Habitat' => 'habitat', - 'Hacksaw' => 'hacksaw', - 'Hair Brush' => 'hair-brush', - 'Hair Care' => 'hair', - 'Hair Clipper' => 'hair-clipper', - 'Hair Colour' => 'hair-colour', - 'Haircut' => 'haircut', - 'Hair Dryer' => 'hair-dryer', - 'Hair Dye' => 'hair-dye', - 'Hair Removal Devices' => 'hair-removal-devices', - 'Halifax' => 'halifax', - 'Hall' => 'hall', - 'Halloween' => 'halloween', - 'Halo' => 'halo', - 'Halo 5' => 'halo-5', - 'Ham' => 'ham', - 'Hammer' => 'hammer', - 'Hammer Drill' => 'hammer-drill', - 'Hammock' => 'hammock', - 'Handbag' => 'handbag', - 'Hand Blender' => 'hand-blender', - 'Hand Cream' => 'hand-cream', - 'Hand Mixer' => 'hand-mixer', - 'Hand Tools' => 'hand-tools', - 'Handwash' => 'handwash', - 'Hard Drive' => 'hard-drive', - 'Haribo' => 'haribo', - 'Harman Kardon' => 'harman-kardon', - 'Harry Potter' => 'harry-potter', - 'Hasbro' => 'hasbro', - 'Hat' => 'hat', - 'Hatchimals' => 'hatchimals', - 'Hats & Caps' => 'hats-caps', - 'Hauck' => 'hauck', - 'Hayfever Remedies' => 'hayfever', - 'Headboard' => 'headboard', - 'Headphones' => 'headphones', - 'Headset' => 'headset', - 'Health & Beauty' => 'beauty', - 'Healthcare' => 'health-care', - 'Heart Rate Monitor' => 'heart-rate-monitor', - 'Heater' => 'heater', - 'Heating' => 'heating', - 'Heating Appliances' => 'heating-appliances', - 'Hedge Trimmer' => 'hedge-trimmer', - 'Heineken' => 'heineken', - 'Heinz' => 'heinz', - 'Heinz Beanz' => 'heinz-baked-beans', - 'Hello Kitty' => 'hello-kitty', - 'Hello Neighbour' => 'hello-neighbour', - 'Helly Hansen' => 'helly-hansen', - 'Henry Hoover' => 'henry-hoover', - 'Hermes' => 'hermes', - 'High5' => 'high-5', - 'Highchair' => 'highchair', - 'Hiking' => 'hiking', - 'Hilton' => 'hilton', - 'Hisense' => 'hisense', - 'Hisense TVs' => 'hisense-tv', - 'Hitachi' => 'hitachi', - 'Hitman' => 'hitman', - 'Hive' => 'hive', - 'Hive Active Heating' => 'hive-active-heating', - 'Hob' => 'hob', - 'Hobbit' => 'hobbit', - 'Hockey' => 'hockey', - 'Holiday Inn' => 'holiday-inn', - 'Holiday Park' => 'holiday-parks', - 'Holidays and Trips' => 'holidays-and-trips', - 'Hollow Knight' => 'hollow-knight', - 'Home & Living' => 'home', - 'Home Accessories' => 'home-accessories', - 'Home Appliances' => 'home-appliances', - 'Home Care' => 'home-care', - 'Home Cinema' => 'home-cinema', - 'HoMedics' => 'homedics', - 'Homefront' => 'homefront', - 'Home Networking' => 'network', - 'Homeplug' => 'homeplug', - 'Home Security' => 'home-security', - 'Homeware' => 'homeware', - 'Honda' => 'honda', - 'Honey' => 'honey', - 'Honeywell' => 'honeywell', - 'Honor 6X' => 'honor-6x', - 'Honor 7' => 'honor-7', - 'Honor 8S' => 'honor-8s', - 'Honor 8X' => 'honor-8x', - 'Honor 8X Max' => 'honor-8x-max', - 'Honor 9' => 'honor-9', - 'Honor 9X' => 'honor-9x', - 'Honor 10' => 'honor-10', - 'Honor Band 5' => 'honor-band-5', - 'Honor Play' => 'honor-play', - 'Honor Smartphone' => 'honor', - 'Honor View 20' => 'honor-view-20', - 'Hoodie' => 'hoodie', - 'Hoover' => 'hoover', - 'Hori' => 'hori', - 'Horizon: Zero Dawn' => 'horizon-zero-dawn', - 'Hornby' => 'hornby', - 'Horse Races' => 'horse-races', - 'Hose' => 'hose', - 'HOTAS' => 'hotas', - 'Hotel' => 'hotel', - 'Hotpoint' => 'hotpoint', - 'Hotspot' => 'hotspot', - 'Hot Tub' => 'hot-tub', - 'Hot Water Bottle' => 'hot-water-bottle', - 'Hot Wheels' => 'hot-wheels', - 'Hozelock' => 'hozelock', - 'HP' => 'hp', - 'HP Envy' => 'hp-envy', - 'HP Laptop' => 'hp-laptop', - 'HP Omen' => 'hp-omen', - 'HP Printer' => 'hp-printer', - 'HTC' => 'htc', - 'HTC 10' => 'htc-10', - 'HTC Desire' => 'htc-desire', - 'HTC One' => 'htc-one', - 'HTC Smartphone' => 'htc-smartphone', - 'HTC U11' => 'htc-u11', - 'HTC Vive' => 'htc-vive', - 'Huawei' => 'huawei', - 'Huawei Freebuds 3' => 'huawei-freebuds-3', - 'Huawei Headphones' => 'huawei-headphones', - 'Huawei Mate 20' => 'huawei-mate-20', - 'Huawei Mate 20 Pro' => 'huawei-mate-20-pro', - 'Huawei Mate 30' => 'huawei-mate-30', - 'Huawei Mate 30 Lite' => 'huawei-mate-30-lite', - 'Huawei Mate 30 Pro' => 'huawei-mate-30-pro', - 'Huawei Matebook' => 'huawei-matebook', - 'Huawei MediaPad M3' => 'huawei-mediapad-m3', - 'Huawei MediaPad M5' => 'huawei-mediapad-m5', - 'Huawei MediaPad T3' => 'huawei-mediapad-t3', - 'Huawei MediaPad T5' => 'huawei-mediapad-t5', - 'Huawei P9' => 'huawei-p9', - 'Huawei P10' => 'huawei-p10', - 'Huawei P20' => 'huawei-p20', - 'Huawei P20 Lite' => 'huawei-p20-lite', - 'Huawei P20 Pro' => 'huawei-p20-pro', - 'Huawei P30' => 'huawei-p30', - 'Huawei P30 Lite' => 'huawei-p30-lite', - 'Huawei P30 Pro' => 'huawei-p30-pro', - 'Huawei P40' => 'huawei-p40', - 'Huawei P40 Lite' => 'huawei-p40-lite', - 'Huawei P40 Pro' => 'huawei-p40-pro', - 'Huawei P Smart' => 'huawei-p-smart', - 'Huawei Smartphone' => 'huawei-smartphone', - 'Huawei Smartwatch' => 'huawei-smartwatch', - 'Huawei Tablet' => 'huawei-tablet', - 'Huawei Watch 2' => 'huawei-watch-2', - 'Huawei Watch GT' => 'huawei-watch-gt', - 'Huawei Watch GT2' => 'huawei-watch-gt2', - 'Huawei Watch GT 2 Pro' => 'huawei-watch-gt-2-pro', - 'Huawei Y7' => 'huawei-y7', - 'Huggies' => 'huggies', - 'Hulk' => 'hulk', - 'Humax' => 'humax', - 'Humidifier' => 'humidifier', - 'Hunter' => 'hunter', - 'HyperX' => 'hyperx', - 'Hyrule Warriors' => 'hyrule-warriors', - 'Hyundai' => 'hyundai', - 'IAMS' => 'iams', - 'iCandy' => 'icandy', - 'Ice-Watch' => 'ice-watch', - 'Ice Cream' => 'ice-cream', - 'Ice Cream Maker' => 'ice-cream-maker', - 'iMac' => 'apple-imac', - 'iMac 2021' => 'imac-2021', - 'Impact Driver' => 'impact-driver', - 'Indesit' => 'indesit', - 'Inflatable Boats' => 'boat', - 'Inflatable Toys' => 'inflatable', - 'Injustice' => 'injustice', - 'Injustice 2' => 'injustice-2', - 'Ink Cartridge' => 'ink', - 'Inkjet Printer' => 'inkjet-printer', - 'Innocent' => 'innocent', - 'Instant Cameras' => 'instant-cameras', - 'Instant Ink' => 'instant-ink', - 'Instax Mini 9' => 'instax-mini-9', - 'Insulation' => 'insulation', - 'Insurance' => 'insurance', - 'Intel' => 'intel', - 'Intel Atom' => 'atom', - 'Intel i3' => 'i3', - 'Intel i5' => 'i5', - 'Intel i7' => 'i7', - 'Intel i9' => 'intel-i9', - 'Internet' => 'internet', - 'Internet Security' => 'internet-security', - 'In the Night Garden' => 'in-the-night-garden', - 'Intimate Care' => 'intimate-care', - 'Introduce Yourself' => 'introduce-yourself', - 'iOS Apps' => 'ios-apps', - 'iPad' => 'ipad', - 'iPad 2019' => 'ipad-2019', - 'iPad 2020' => 'ipad-2020', - 'iPad Air' => 'ipad-air', - 'iPad Air 2019' => 'ipad-air-2019', - 'iPad Air 2020' => 'ipad-air-2020', - 'iPad Case' => 'ipad-case', - 'iPad mini' => 'ipad-mini', - 'iPad Pro' => 'ipad-pro', - 'iPad Pro 11' => 'ipad-pro-11', - 'iPad Pro 12.9' => 'ipad-pro-12-9', - 'iPad Pro 2020' => 'ipad-pro-2020', - 'iPad Pro 2021' => 'ipad-pro-2021', - 'IP Camera' => 'ip-camera', - 'iPhone' => 'iphone', - 'iPhone 5s' => 'iphone-5s', - 'iPhone 6' => 'iphone-6', - 'iPhone 6 Plus' => 'iphone-6-plus', - 'iPhone 6s' => 'iphone-6s', - 'iPhone 6s Plus' => 'iphone-6s-plus', - 'iPhone 7' => 'iphone-7', - 'iPhone 7 Plus' => 'iphone-7-plus', - 'iPhone 8' => 'iphone-8', - 'iPhone 8 Plus' => 'iphone-8-plus', - 'iPhone 11' => 'iphone-11', - 'iPhone 11 Pro' => 'iphone-11-pro', - 'iPhone 11 Pro Max' => 'iphone-11-pro-max', - 'iPhone 12' => 'iphone-12', - 'iPhone 12 mini' => 'iphone-12-mini', - 'iPhone 12 Pro' => 'iphone-12-pro', - 'iPhone 12 Pro Max' => 'iphone-12-pro-max', - 'iPhone Accessories' => 'iphone-accessories', - 'iPhone Case' => 'iphone-case', - 'iPhone SE' => 'iphone-se', - 'iPhone X' => 'iphone-x', - 'iPhone Xr' => 'iphone-xr', - 'iPhone Xs' => 'iphone-xs', - 'iPhone Xs Max' => 'iphone-xs-max', - 'iPod' => 'ipod', - 'iPod Nano' => 'ipod-nano', - 'iPod Shuffle' => 'ipod-shuffle', - 'iPod Touch' => 'ipod-touch', - 'Irish Whiskey' => 'irish-whisky', - 'Irn Bru' => 'irn-bru', - 'iRobot' => 'irobot', - 'Iron' => 'iron', - 'Ironing' => 'ironing', - 'Ironing Board' => 'ironing-board', - 'Iron Man' => 'iron-man', - 'Issey Miyake' => 'issey-miyake', - 'ITV' => 'itv', - 'Jabra' => 'jabra', - 'Jabra Elite 85h' => 'jabra-elite-85h', - 'Jabra Elite Active 65t' => 'jabra-elite-active-65t', - 'Jabra Elite Active 75t' => 'jabra-elite-active-75t', - 'Jabra Headphones' => 'jabra-headphones', - 'Jack & Jones' => 'jack-and-jones', - 'Jack Daniel's' => 'jack-daniels', - 'Jacket' => 'jacket', - 'Jack Wills' => 'jack-wills', - 'Jack Wolfskin' => 'jack-wolfskin', - 'Jaffa Cakes' => 'jaffa-cakes', - 'Jägermeister' => 'jagermeister', - 'Jameson' => 'jameson', - 'Jamie Oliver' => 'jamie-oliver', - 'Jaybird' => 'jaybird', - 'JBL' => 'jbl', - 'JBL Flip' => 'jbl-flip', - 'JBL GO' => 'jbl-go', - 'JBL Headphones' => 'jbl-headphones', - 'JBL Link' => 'jbl-link', - 'JBL Live' => 'jbl-live', - 'JBL Tune' => 'jbl-tune', - 'JCB' => 'jcb', - 'Jean Paul Gaultier' => 'jean-paul-gautier', - 'Jean Paul Gaultier Le Male' => 'le-male', - 'Jeans' => 'jeans', - 'Jelly Belly' => 'jelly-belly', - 'Jewellery' => 'jewellery', - 'Jigsaw' => 'jigsaw', - 'Jim Beam' => 'jim-beam', - 'Jimmy Choo' => 'jimmy-choo', - 'JML' => 'jml', - 'Jogging Bottoms' => 'jogging-bottoms', - 'Johnnie Walker' => 'johnnie-walker', - 'Johnson's' => 'johnsons', - 'John West' => 'john-west', - 'John Wick' => 'john-wick', - 'JoJo Siwa' => 'jojo', - 'Joop' => 'joop', - 'Joseph Joseph' => 'joseph-joseph', - 'Joules' => 'joules', - 'Juice' => 'juice', - 'Juicer' => 'juicer', - 'Jumper' => 'jumper', - 'Jurassic World' => 'jurassic-world', - 'Jura Whisky' => 'jura', - 'Just Cause' => 'just-cause', - 'Just Cause 3' => 'just-cause-3', - 'Just Cause 4' => 'just-cause-4', - 'Just Dance' => 'just-dance', - 'JVC' => 'jvc', - 'K-Swiss' => 'k-swiss', - 'Karcher' => 'karcher', - 'Karcher Window Vacuum' => 'karcher-window-cleaner', - 'Karen Millen' => 'karen-millen', - 'Karrimor' => 'karrimor', - 'Kaspersky' => 'kaspersky', - 'Kayak' => 'kayak', - 'Keg' => 'keg', - 'Kellogg's' => 'kelloggs', - 'Kellogg's Cornflakes' => 'cornflakes', - 'Kellogg's Crunchy Nut' => 'crunchy-nut', - 'Kenco' => 'kenco', - 'Kenwood' => 'kenwood', - 'Kenwood kMix' => 'kmix', - 'Kenzo' => 'kenzo', - 'Ketchup' => 'ketchup', - 'Keter' => 'keter', - 'Kettle' => 'kettle', - 'Kettlebell' => 'kettlebell', - 'Keyboard' => 'keyboard', - 'KIA' => 'kia', - 'Kickers' => 'kickers', - 'Kid's Bike' => 'kids-bike', - 'Kid's Clothes' => 'kids-clothes', - 'Kid's Room' => 'kids-rooms', - 'Kid's Shoes' => 'kids-shoes', - 'Kidizoom' => 'kidizoom', - 'Killzone' => 'killzone', - 'Kilner' => 'kilner', - 'Kinder' => 'kinder', - 'Kindle' => 'kindle', - 'Kindle Book' => 'kindle-book', - 'Kindle Fire' => 'kindle-fire', - 'Kindle Oasis' => 'kindle-oasis', - 'Kindle Paperwhite' => 'kindle-paperwhite', - 'Kingdom Come: Deliverance' => 'kingdom-come-deliverance', - 'Kingdom Hearts' => 'kingdom-hearts', - 'Kingdom Hearts 3' => 'kingdom-hearts-3', - 'Kingdom Hearts: The Story So Far' => 'kingdom-hearts-the-story-so-far', - 'King Kong' => 'king-kong', - 'King Size Bed' => 'king-size', - 'Kingsmill' => 'kingsmill', - 'Kingston' => 'kingston', - 'Kitchen' => 'kitchen', - 'KitchenAid' => 'kitchenaid', - 'Kitchen Appliances' => 'kitchen-appliances', - 'Kitchen Knife' => 'knife', - 'Kitchen Roll' => 'kitchen-roll', - 'Kitchen Scale' => 'kitchen-scales', - 'Kitchen Tap' => 'kitchen-tap', - 'Kitchen Utensils' => 'kitchen-utensils', - 'Kite' => 'kite', - 'KitSound' => 'kitsound', - 'Knickers' => 'knickers', - 'Kobo' => 'kobo', - 'Kodak' => 'kodak', - 'Kodi' => 'kodi', - 'Kohinoor' => 'kohinoor', - 'Kopparberg' => 'kopparberg', - 'Kraken' => 'kraken', - 'Krispy Kreme' => 'krispy-kreme', - 'Krups' => 'krups', - 'KTC' => 'ktc', - 'Kurt Geiger' => 'kurt-geiger', - 'L'Occitane' => 'loccitane', - 'L.O.L. Surprise!' => 'lol-surprise', - 'Lacoste' => 'lacoste', - 'Ladder' => 'ladder', - 'Lamaze' => 'lamaze', - 'Lamb' => 'lamb', - 'Laminate' => 'laminate', - 'Laminator' => 'laminator', - 'Lamp' => 'lamp', - 'Lancôme' => 'lancome', - 'Landmann' => 'landmann', - 'Lantern' => 'lantern', - 'Laphroaig' => 'laphroaig', - 'Laptop' => 'laptop', - 'Laptop Accessories' => 'laptop-accessories', - 'Laptop Case' => 'laptop-case', - 'Laptop Sleeve' => 'laptop-sleeve', - 'Laser Printer' => 'laser-printer', - 'Last Minute' => 'last-minute', - 'Laundry Basket' => 'laundry-basket', - 'Laura Ashley' => 'laura-ashley', - 'Lavazza' => 'lavazza', - 'Lavender' => 'lavender', - 'Lawnmower' => 'lawnmower', - 'Lay-Z-Spa' => 'lay-z-spa', - 'LeapFrog' => 'leapfrog', - 'Le Creuset' => 'le-creuset', - 'LED Bulb' => 'led-bulbs', - 'LED Light' => 'led-light', - 'LED Strip Lights' => 'led-strip-lights', - 'LED TV' => 'led-tv', - 'Lee Stafford' => 'lee-stafford', - 'Leffe' => 'leffe', - 'Leggings' => 'leggings', - 'Lego' => 'lego', - 'Lego Advent Calendar' => 'lego-advent-calendar', - 'Lego Architecture' => 'lego-architecture', - 'Lego Art' => 'lego-art', - 'Lego Batman' => 'lego-batman', - 'Lego BrickHeadz' => 'lego-brickheadz', - 'Lego City' => 'lego-city', - 'Lego Classic' => 'lego-classic', - 'Lego Creator' => 'lego-creator', - 'Lego Dimensions' => 'lego-dimensions', - 'Lego Disney' => 'lego-disney', - 'Lego Dots' => 'lego-dots', - 'Lego Duplo' => 'lego-duplo', - 'Lego Friends' => 'lego-friends', - 'LEGO Harry Potter' => 'lego-harry-potter', - 'Lego Hidden Side' => 'lego-hidden-side', - 'Legoland' => 'legoland', - 'Lego Marvel' => 'lego-marvel', - 'Lego Mindstorms' => 'lego-mindstorms', - 'Lego Nexo Knights' => 'lego-nexo-knights', - 'Lego Ninjago' => 'lego-ninjago', - 'Lego Porsche' => 'lego-porsche', - 'Lego Simpsons' => 'lego-simpsons', - 'Lego Speed Champions' => 'lego-speed-champions', - 'Lego Star Wars' => 'lego-star-wars', - 'Lego Star Wars Millennium Falcon' => 'lego-star-wars-millennium-falcon', - 'Lego Super Mario' => 'lego-mario', - 'Lego Technic' => 'lego-technic', - 'Lego VIDIYO' => 'lego-vidiyo', - 'Lemonade' => 'lemonade', - 'Lenor' => 'lenor', - 'Lenovo' => 'lenovo', - 'Lenovo IdeaPad' => 'lenovo-ideapad', - 'Lenovo Laptop' => 'lenovo-laptop', - 'Lenovo Tablet' => 'lenovo-tablet', - 'Lenovo Thinkpad' => 'thinkpad', - 'Lenovo Yoga Laptop' => 'lenovo-yoga-laptop', - 'Lenovo Yoga Tablet' => 'lenovo-yoga', - 'Les Paul' => 'les-paul', - 'Levi's' => 'levi', - 'Lexar' => 'lexar', - 'LG' => 'lg', - 'LG G3' => 'lg-g3', - 'LG G5' => 'lg-g5', - 'LG G6' => 'lg-g6', - 'LG G7' => 'lg-g7', - 'LG G8S ThinQ' => 'lg-g8s-thinq', - 'LG OLED TV' => 'lg-oled-tv', - 'LG Smartphone' => 'lg-smartphone', - 'LG TV' => 'lg-tv', - 'LG V30' => 'lg-v30', - 'LG V40 ThinQ' => 'lg-v40-thinq', - 'Life Insurance' => 'life-insurance', - 'Life is Strange' => 'life-is-strange', - 'Light Box' => 'light-box', - 'Lighting' => 'lighting', - 'Lightning Cable' => 'lightning-cable', - 'Lightsaber' => 'lightsaber', - 'Lindor' => 'lindor', - 'Lindt' => 'lindt', - 'Lingerie' => 'lingerie', - 'Linksys' => 'linksys', - 'Linx' => 'linx', - 'Lion King' => 'lion-king', - 'Lipstick' => 'lipstick', - 'Lipsy' => 'lipsy', - 'Little Tikes' => 'little-tikes', - 'Liverpool F. C.' => 'liverpool-fc', - 'Living Room' => 'living-room', - 'Local Traffic' => 'local-traffic', - 'Lodge' => 'lodge', - 'Loft' => 'loft', - 'Logitech' => 'logitech', - 'Logitech G430' => 'logitech-g430', - 'Logitech G703' => 'logitech-g703', - 'Logitech G903' => 'logitech-g903', - 'Logitech Harmony' => 'harmony', - 'Logitech Keyboard' => 'logitech-keyboard', - 'Logitech Mouse' => 'logitech-mouse', - 'Logitech MX Master' => 'logitech-mx-master', - 'Logitech MX Master 2S' => 'logitech-mx-master-2s', - 'London Eye' => 'london-eye', - 'London Zoo' => 'london-zoo', - 'Longleat' => 'longleat', - 'Long Sleeve' => 'long-sleeve', - 'Lord of the Rings' => 'lord-of-the-rings', - 'Lottery' => 'lottery', - 'Lounger' => 'lounger', - 'Lowepro' => 'lowepro', - 'Lucozade' => 'lucozade', - 'Luigi' => 'luigi', - 'Luigi's Mansion' => 'luigis-manison', - 'Luigi's Mansion 3' => 'luigis-mansion-3', - 'Lunch Bag' => 'lunch-bag', - 'Lunch Box' => 'lunch-box', - 'Lurpak' => 'lurpak', - 'Luton' => 'luton', - 'Lyle & Scott' => 'lyle-and-scott', - 'Lynx' => 'lynx', - 'M.2 SSD' => 'm2-ssd', - 'MacBook' => 'macbook', - 'MacBook Air' => 'macbook-air', - 'MacBook Pro' => 'macbook-pro', - 'MacBook Pro 13' => 'macbook-pro-13', - 'MacBook Pro 15' => 'macbook-pro-15', - 'MacBook Pro 16' => 'macbook-pro-16', - 'Maclaren' => 'maclaren', - 'Mac mini' => 'mac-mini', - 'Madame Tussauds' => 'madame-tussauds', - 'Mad Catz' => 'madcatz', - 'Madden NFL' => 'madden', - 'Madden NFL 20' => 'madden-nfl-20', - 'Mad Max' => 'mad-max', - 'Mafia 3' => 'mafia-3', - 'Magazine' => 'magazine', - 'Magimix' => 'magimix', - 'Magners' => 'magners', - 'Magnum' => 'magnum', - 'Make Up' => 'make-up', - 'Makeup Advent Calendar' => 'makeup-advent-calendar', - 'Make Up Brush' => 'make-up-brush', - 'Makita' => 'makita', - 'Makita Drill' => 'makita-drill', - 'Malibu' => 'malibu', - 'Maltesers' => 'maltesers', - 'MAM' => 'mam', - 'Mamas & Papas' => 'mamas-and-papas', - 'Manchester United' => 'manchester-united', - 'Manfrotto' => 'manfrotto', - 'Manga' => 'manga', - 'Manuka Honey' => 'manuka-honey', - 'Marantz' => 'marantz', - 'Marc Jacobs' => 'marc-jacobs', - 'Marc Jacobs Daisy' => 'daisy', - 'Mario & Sonic at the Olympic Games: Tokyo 2020' => 'mario-and-sonic-tokyo-2020', - 'Mario + Rabbids Kingdom Battle' => 'mario-rabbids-kingdom-battle', - 'Mario Kart' => 'mario-kart', - 'Mario Kart 8' => 'mario-kart-8', - 'Mario Kart 8 Deluxe' => 'mario-kart-8-deluxe', - 'Marmite' => 'marmite', - 'Mars' => 'mars', - 'Marshall' => 'marshall', - 'Marshall Headphones' => 'marshall-headphones', - 'Marvel' => 'marvel', - 'Marvel's Spider-Man (PS4)' => 'spider-man-2018', - 'Marvel's Spider-Man: Miles Morales' => 'spiderman-miles-morales', - 'Mascara' => 'mascara', - 'Massage' => 'massage', - 'Mass Effect' => 'mass-effect', - 'Mass Effect: Andromeda' => 'mass-effect-andromeda', - 'Mastercard' => 'mastercard', - 'Masterplug' => 'masterplug', - 'Maternity & Pregnancy' => 'maternity', - 'Mattress' => 'mattress', - 'Mattress Protector' => 'mattress-protector', - 'Mattress Topper' => 'mattress-topper', - 'Mavic' => 'mavic', - 'Max Factor' => 'max-factor', - 'Maxi Cosi' => 'maxi-cosi', - 'Maximuscle' => 'maximuscle', - 'Maxtor' => 'maxtor', - 'Maybelline' => 'maybelline', - 'Mayo' => 'mayo', - 'Mazda' => 'mazda', - 'McAfee' => 'mcafee', - 'Meat & Sausages' => 'meat', - 'Meccano' => 'meccano', - 'Mechanical Keyboard' => 'mechanical-keyboard', - 'Medal of Honor' => 'medal-of-honor', - 'Medela' => 'medela', - 'Media Player' => 'media-player', - 'Medievil' => 'medievil', - 'Medion' => 'medion', - 'Mega Bloks' => 'mega-bloks', - 'Megathread' => 'megathread', - 'Melissa & Doug' => 'melissa', - 'Memory Cards' => 'memory-cards', - 'Memory Foam Mattress' => 'memory-foam', - 'Men's Boots' => 'mens-boots', - 'Men's Fragrance' => 'mens-fragrance', - 'Men's Shoes' => 'mens-shoes', - 'Men's Suit' => 'suit', - 'Mercedes' => 'mercedes', - 'Meridian' => 'meridian', - 'Merlin' => 'merlin', - 'Merrell' => 'merrell', - 'Messenger Bag' => 'messenger-bag', - 'Metal Gear Solid' => 'metal-gear-solid', - 'Metro Exodus' => 'metro-exodus', - 'Metroid' => 'metroid', - 'Metro Series' => 'metro-series', - 'Michael Kors' => 'michael-kors', - 'Michelin' => 'michelin', - 'Microphone' => 'microphone', - 'Micro SD Card' => 'micro-sd', - 'Micro SDHC' => 'micro-sdhc', - 'Micro SDXC' => 'micro-sdxc', - 'Microserver' => 'microserver', - 'Microsoft' => 'microsoft', - 'Microsoft Flight Simulator' => 'microsoft-flight-simulator', - 'Microsoft Office' => 'microsoft-office', - 'Microsoft Points' => 'microsoft-points', - 'Microsoft Software' => 'microsoft-software', - 'Microsoft Surface Book' => 'surface-book', - 'Microsoft Surface Laptop' => 'surface', - 'Microsoft Surface Pro 6' => 'surface-pro-6', - 'Microsoft Surface Pro 7' => 'surface-pro-7', - 'Microsoft Surface Tablet' => 'microsoft-surface-tablet', - 'Microwave' => 'microwave', - 'Middle Earth' => 'middle-earth', - 'Middle Earth: Shadow of Mordor' => 'shadow-of-mordor', - 'Middle Earth: Shadow of War' => 'middle-earth-shadow-of-war', - 'Miele' => 'miele', - 'Miele Vacuum Cleaner' => 'miele-vacuum-cleaner', - 'Milk' => 'milk', - 'Milk Frother' => 'milk-frother', - 'Milk Tray' => 'milk-tray', - 'Milwaukee' => 'milwaukee', - 'Mince' => 'mince', - 'Minecraft Game' => 'minecraft', - 'Mineral Water' => 'mineral-water', - 'Mini Fridge' => 'mini-fridge', - 'Minions' => 'minions', - 'Mini PC' => 'mini-pc', - 'Minky' => 'minky', - 'Mira' => 'mira', - 'Mirror' => 'mirror', - 'Mirror's Edge' => 'mirrors-edge', - 'Misc' => 'misc', - 'Misfit' => 'misfit', - 'Mitre Saw' => 'mitre-saw', - 'Mitsubishi' => 'mitsubishi', - 'Mixer & Blender' => 'mixer-and-blender', - 'Mobile Contracts' => 'mobile-contract', - 'Mobile Phone' => 'mobile-phone', - 'Model Building' => 'model-building', - 'Moët' => 'moet', - 'Molton Brown' => 'molton-brown', - 'Money Saving Tips and Tricks' => 'money-saving-tips', - 'Monitor' => 'monitor', - 'Monopoly' => 'monopoly', - 'Monsoon' => 'monsoon', - 'Monster Energy' => 'monster-energy', - 'Monster High' => 'monster-high', - 'Monster Hunter' => 'monster-hunter', - 'Monster Hunter World' => 'monster-hunter-world', - 'Mont Blanc' => 'mont-blanc', - 'Mop' => 'mop', - 'Morphy Richards' => 'morphy-richards', - 'Mortal Kombat' => 'mortal-kombat', - 'Mortal Kombat 11' => 'mortal-kombat-11', - 'Mortgage' => 'mortgage', - 'Moschino' => 'moschino', - 'Moses Basket' => 'moses-basket', - 'MOT' => 'mot', - 'Motherboard' => 'motherboard', - 'Moto 360' => 'moto-360', - 'Moto E' => 'moto-e', - 'Moto G' => 'moto-g', - 'Moto G4' => 'moto-g4', - 'Moto G5' => 'moto-g5', - 'Moto G6' => 'moto-g6', - 'Moto G7' => 'moto-g7', - 'Motorcycle' => 'motorcycle', - 'Motorcycle Accessories' => 'motorcycle-accessories', - 'Motorcycle Helmet' => 'motorcycle-helmet', - 'Motorola' => 'motorola', - 'Motorola Smartphone' => 'motorola-smartphone', - 'Moto X' => 'moto-x', - 'Moto Z' => 'moto-z', - 'Mountain Bike' => 'mountain-bike', - 'Mouse & Keyboard Bundles' => 'mouse-and-keyboard-bundle', - 'Mouse Mat' => 'mouse-mat', - 'Mouthwash' => 'mouthwash', - 'Movie and TV Box Set' => 'box-set', - 'Movies & Series' => 'movie', - 'MP3 Player' => 'mp3-player', - 'Mr Kipling' => 'mr-kipling', - 'Mr Men' => 'mr-men', - 'MSI' => 'msi', - 'MSI Laptop' => 'msi-laptop', - 'Muc-Off' => 'muc-off', - 'Mug' => 'mug', - 'Muller' => 'muller', - 'Multi-Room Audio System' => 'multi-room-audio-system', - 'Multitool' => 'multitool', - 'Museums' => 'museums', - 'Music' => 'music', - 'Musical Instruments' => 'musical-instrument', - 'Music App' => 'music-app', - 'Music Streaming' => 'music-streaming', - 'My Little Pony' => 'my-little-pony', - 'Nail Gun' => 'nail-gun', - 'Nail Polish' => 'nail-polish', - 'Nails' => 'nails', - 'Nails Inc.' => 'nails-inc', - 'Nakd' => 'nakd', - 'Nando's' => 'nandos', - 'Nappy' => 'nappy', - 'NAS' => 'nas', - 'National Express Ticket' => 'national-express', - 'National Trust' => 'national-trust', - 'Nature Observation' => 'nature-observation', - 'NatWest' => 'natwest', - 'NBA 2K' => 'nba-2k', - 'NBA Live' => 'nba', - 'Necklace' => 'necklace', - 'Need for Speed' => 'need-for-speed', - 'Need for Speed: Payback' => 'need-for-speed-payback', - 'Need for Speed Heat' => 'need-for-speed-heat', - 'Neff' => 'neff', - 'Nerf Guns' => 'nerf', - 'Nescafé Azera' => 'azera', - 'Nescafé Coffee' => 'nescafe', - 'Nespresso' => 'nespresso', - 'Nespresso Coffee Machine' => 'nespresso-coffee-machine', - 'Nest Hello' => 'nest-hello', - 'Nestlé' => 'nestle', - 'Nest Learning Thermostat' => 'nest-learning-thermostat', - 'Nestlé Cheerios' => 'cheerios', - 'Nestlé Shreddies' => 'shreddies', - 'Netatmo' => 'netatmo', - 'Netflix' => 'netflix', - 'Netgear' => 'netgear', - 'Netgear Arlo' => 'arlo', - 'New Balance' => 'new-balance', - 'New Balance Trainers' => 'new-balance-trainers', - 'New Look' => 'new-look', - 'Newspapers' => 'newspapers', - 'Nextbase' => 'nextbase', - 'NFL' => 'nfl', - 'NHL' => 'nhl', - 'NHL 20' => 'nhl-20', - 'NHS' => 'nhs', - 'NieR: Automata' => 'nier', - 'Night Light' => 'night-light', - 'Nike' => 'nike', - 'Nike Air Max' => 'nike-air-max', - 'Nike Air Max 200' => 'nike-air-max-200', - 'Nike Air Max 270' => 'nike-air-max-270', - 'Nike Air Max 720' => 'nike-air-max-720', - 'Nike Free' => 'nike-free', - 'Nike Huarache' => 'nike-huarache', - 'Nike Jordan' => 'jordan', - 'Nike Presto' => 'nike-presto', - 'Nike Roshe' => 'nike-roshe', - 'Nike Trainers' => 'nike-shoes', - 'Nikon' => 'nikon', - 'Nikon Camera' => 'nikon-camera', - 'Nikon Coolpix' => 'nikon-coolpix', - 'Nikon D3400' => 'nikon-d3400', - 'Nikon Lens' => 'nikon-lens', - 'Nilfisk' => 'nilfisk', - 'Ni No Kuni' => 'ni-no-kuni', - 'Ni No Kuni: Wrath of the White Witch' => 'ni-no-kuni-white-witch', - 'Ni No Kuni II: Revenant Kingdom' => 'ni-no-kuni-2', - 'Nintendo' => 'nintendo', - 'Nintendo 2DS' => '2ds', - 'Nintendo 3DS' => '3ds', - 'Nintendo 3DS Game' => '3ds-games', - 'Nintendo 3DS XL' => 'nintendo-3ds-xl', - 'Nintendo Accessories' => 'nintendo-accessories', - 'Nintendo Classic Mini' => 'nintendo-classic-mini', - 'Nintendo DS Game' => 'ds-games', - 'Nintendo Labo' => 'switch-labo', - 'Nintendo Switch' => 'nintendo-switch', - 'Nintendo Switch Accessories' => 'switch-accessories', - 'Nintendo Switch Case' => 'switch-case', - 'Nintendo Switch Controller' => 'switch-controller', - 'Nintendo Switch Game' => 'switch-game', - 'Nintendo Switch Joy-Con' => 'switch-joy-con', - 'Nintendo Switch Lite' => 'nintendo-switch-lite', - 'Nintendo Switch Pro Controller' => 'switch-pro-controller', - 'Nioh' => 'nioh', - 'Nissan' => 'nissan', - 'Nivea' => 'nivea', - 'No7' => 'no7', - 'Noise Cancelling Headphones' => 'noise-cancelling-headphones', - 'Nokia' => 'nokia', - 'Nokia Smartphones' => 'nokia-mobile', - 'No Man's Sky' => 'no-man-s-sky', - 'Noodles' => 'noodles', - 'Norton' => 'norton', - 'Now' => 'now-tv', - 'Numatic' => 'numatic', - 'Nursery' => 'nursery', - 'Nutella' => 'nutella', - 'NutriBullet' => 'nutribullet', - 'Nutri Ninja' => 'nutri-ninja', - 'Nuts' => 'nuts', - 'Nvidia' => 'nvidia', - 'Nvidia GeForce' => 'geforce', - 'Nvidia Shield' => 'nvidia-shield', - 'NYX' => 'nyx', - 'NZXT' => 'nzxt', - 'O2' => 'o2', - 'O2 Refresh' => 'o2-refresh', - 'Oakley' => 'oakley', - 'Octonauts' => 'octonauts', - 'Oculus Game' => 'oculus-game', - 'Oculus Go' => 'oculus-go', - 'Oculus Quest' => 'oculus-quest', - 'Oculus Rift' => 'oculus', - 'Oculus Rift S' => 'oculus-rift-s', - 'Odeon' => 'odeon', - 'Office' => 'office', - 'Office Chair' => 'office-chair', - 'Official Announcements' => 'official-announcements', - 'Olay' => 'olay', - 'OLED TV' => 'oled', - 'Olive Oil' => 'olive-oil', - 'Olympus' => 'olympus', - 'Omega Seamaster' => 'omega-seamaster', - 'Omega Speedmaster' => 'omega-speedmaster', - 'Omega Watches' => 'omega-watch', - 'OnePlus 3' => 'oneplus-3', - 'OnePlus 5' => 'oneplus-5', - 'OnePlus 6' => 'oneplus-6', - 'OnePlus 6T' => 'oneplus-6t', - 'OnePlus 7' => 'oneplus-7', - 'OnePlus 7 Pro' => 'oneplus-7-pro', - 'OnePlus 7T' => 'oneplus-7t', - 'OnePlus 7T Pro' => 'one-plus-7t-pro', - 'OnePlus 8' => 'oneplus-8', - 'OnePlus 8 Pro' => 'oneplus-8-pro', - 'OnePlus 8T' => 'oneplus-8t', - 'OnePlus 9' => 'oneplus-9', - 'OnePlus 9 Pro' => 'oneplus-9-pro', - 'OnePlus Nord' => 'oneplus-nord', - 'OnePlus Nord N10 5G' => 'oneplus-n10', - 'OnePlus Nord N100' => 'oneplus-n100', - 'OnePlus Smartphone' => 'oneplus', - 'Onesie' => 'onesie', - 'Onkyo' => 'onkyo', - 'Online Courses' => 'online-courses', - 'Operating System' => 'operating-system', - 'Oppo Find X2 Lite' => 'oppo-find-x2-lite', - 'Oppo Find X2 Neo' => 'oppo-find-x2-neo', - 'Oppo Find X2 Pro' => 'oppo-find-x2-pro', - 'Oppo Reno' => 'oppo-reno', - 'Oppo Reno4 5G' => 'oppo-reno4', - 'Oppo Reno4 Z 5G' => 'oppo-reno4-z', - 'Oppo Smartphone' => 'oppo-smartphone', - 'Opticians' => 'opticians', - 'Optoma' => 'optoma', - 'Oral-B' => 'oral-b', - 'Oral-B Toothbrush' => 'oral-b-toothbrush', - 'Oreo' => 'oreo', - 'Origin' => 'origin', - 'Original Penguin' => 'penguin', - 'Orla Kiely' => 'orla-kiely', - 'Osprey' => 'osprey', - 'Osram' => 'osram', - 'Other' => 'other-deals', - 'Ottoman' => 'ottoman', - 'Oukitel' => 'oukitel', - 'Outdoor Clothing' => 'outdoor-clothing', - 'Outdoor Lighting' => 'outdoor-lighting', - 'Outdoor Sports & Camping' => 'outdoor', - 'Outdoor Toys' => 'outdoor-toys', - 'Outlast' => 'outlast', - 'Outlet' => 'outlet', - 'Outwell' => 'outwell', - 'Oven' => 'oven', - 'Overcooked' => 'overcooked', - 'Overcooked 2' => 'overcooked-2', - 'Overwatch' => 'overwatch', - 'Oyster Card' => 'oyster', - 'Package Holidays' => 'holiday', - 'Paco Rabanne' => 'paco-rabanne', - 'Paco Rabanne 1 Million' => 'paco-rabanne-1-million', - 'Paco Rabanne Lady Million' => 'lady-million', - 'Paddling Pool' => 'paddling-pool', - 'Padlock' => 'padlock', - 'Paint' => 'paint', - 'Paint Brush' => 'paint-brush', - 'Pampers' => 'pampers', - 'Panasonic' => 'panasonic', - 'Panasonic Camera' => 'panasonic-camera', - 'Panasonic Lumix' => 'lumix', - 'Panasonic TV' => 'panasonic-tv', - 'Pandora' => 'pandora', - 'Panini' => 'panini', - 'Panini Stickers' => 'panini-stickers', - 'Papa Johns' => 'papa-johns', - 'Paper Mario' => 'paper-mario', - 'Parasol' => 'parasol', - 'Parcel and Delivery Services' => 'parcel', - 'Parka' => 'parka', - 'Parking' => 'parking', - 'Parrot' => 'parrot', - 'Paul Smith' => 'paul-smith', - 'PAW Patrol' => 'paw-patrol', - 'Payday' => 'payday', - 'Payday 2' => 'payday-2', - 'PAYG' => 'payg', - 'Pay Monthly' => 'pay-monthly', - 'PC' => 'pc', - 'PC Case' => 'pc-case', - 'PC Game' => 'pc-game', - 'PC Gaming Accessories' => 'pc-gaming-accessories', - 'PC Gaming Systems' => 'pc-gaming-systems', - 'PC Mouse' => 'mouse', - 'PC Parts' => 'pc-parts', - 'Peanut Butter' => 'peanut-butter', - 'Peanuts' => 'peanuts', - 'Pedometer' => 'pedometer', - 'Pentax' => 'pentax', - 'Peppa Pig' => 'peppa-pig', - 'PepperBonus' => 'pepperbonus', - 'Pepsi' => 'pepsi', - 'Perfume' => 'perfume', - 'Persil' => 'persil', - 'Persona' => 'persona', - 'Persona 5' => 'persona-5', - 'Personal Care & Hygiene' => 'personal-care-hygiene', - 'Petrol and Diesel' => 'petrol', - 'Pet Supplies' => 'pets', - 'Peugeot' => 'peugeot', - 'PG Tips' => 'pg-tips', - 'Philips' => 'philips', - 'Philips Alarm Clock' => 'philips-alarm-clock', - 'Philips Avent' => 'avent', - 'Philips Hue' => 'philips-hue', - 'Philips Lumea' => 'lumea', - 'Philips OneBlade' => 'philips-one-blade', - 'Philips Senseo' => 'philips-senseo', - 'Philips Senseo Coffee Machine' => 'philips-senseo-coffee-machine', - 'Philips Shaver' => 'philips-shaver', - 'Philips Sonicare' => 'sonicare', - 'Philips TV' => 'philips-tv', - 'Phone Holder' => 'phone-holder', - 'Phones & Accessories' => 'phone', - 'Photo & Cameras' => 'photo-video', - 'Photo & Video App' => 'photo-video-app', - 'Photo Editing' => 'photo-editing', - 'Photo Frame' => 'photo-frame', - 'Photo Paper' => 'photo-paper', - 'Piano' => 'piano', - 'Picnic & Outdoor Cooking' => 'picnic', - 'Pikmin 3 Deluxe' => 'pikmin-3-deluxe', - 'Pillow' => 'pillow', - 'Pimm's' => 'pimms', - 'Pioneer' => 'pioneer', - 'Pirate Toys' => 'pirates', - 'PIR Lights' => 'pir', - 'Pixel C' => 'pixel-c', - 'Piz Buin' => 'piz-buin', - 'Pizza' => 'pizza', - 'Pizza Stone' => 'pizza-stone', - 'Planer' => 'planer', - 'Planet Earth' => 'planet-earth', - 'Plant' => 'plant', - 'Plant Pot' => 'plant-pots', - 'Plants vs. Zombies: Battle for Neighborville' => 'battle-for-neighborville', - 'Plants vs Zombies' => 'plants-vs-zombies', - 'Play-Doh' => 'play-doh', - 'PlayerUnknown's Battlegrounds' => 'playerunknown-s-battlegrounds', - 'Playhouse' => 'playhouse', - 'Playing Cards' => 'playing-cards', - 'Playmat' => 'playmat', - 'Playmobil' => 'playmobil', - 'Playmobil Advent Calendar' => 'playmobil-advent-calendar', - 'PlayStation' => 'playstation', - 'PlayStation 5 DualSense Controller' => 'ps5-controller', - 'PlayStation Accessories' => 'playstation-accessories', - 'PlayStation Classic' => 'playstation-classic', - 'PlayStation Move' => 'playstation-move', - 'PlayStation Now' => 'playstation-now', - 'PlayStation Plus' => 'playstation-plus', - 'PlayStation VR' => 'playstation-vr', - 'PlayStation VR Aim Controller' => 'aim-controller-ps4', - 'Pliers' => 'pliers', - 'Plumbing & Fittings' => 'plumbing-and-fitting', - 'Plus Size' => 'plus-size', - 'PNY' => 'pny', - 'POCO F2 Pro' => 'poco-f2-pro', - 'POCO F3' => 'poco-f3', - 'Poco M3' => 'poco-m3', - 'POCO X3' => 'poco-x3', - 'POCO X3 Pro' => 'poco-x3-pro', - 'Pokémon' => 'pokemon', - 'Pokémon: Let's Go' => 'pokemon-lets-go', - 'Pokémon Go' => 'pokemon-go', - 'Pokemon Sword and Shield' => 'pokemon-sword-and-shield', - 'Pokémon Ultra Sun and Ultra Moon' => 'pokemon-ultra-sun-ultra-moon', - 'Poker' => 'poker', - 'Pokken Tournament' => 'pokken-tournament', - 'Polaroid' => 'polaroid', - 'Police Toys' => 'police', - 'Polo Shirt' => 'polo-shirt', - 'Pool' => 'pool', - 'Pool & Snooker' => 'pool-table', - 'Popcorn' => 'popcorn', - 'Pork' => 'pork', - 'Porridge & Oats' => 'porridge-and-oats', - 'Portable Wireless Speaker' => 'wireless-speaker', - 'Poster' => 'poster', - 'Pots and Pans' => 'pan', - 'Potty' => 'potty', - 'Power Bank' => 'power-bank', - 'Powerbeats Pro' => 'powerbeats-pro', - 'Power Dental Flosser' => 'floss', - 'Powerline' => 'powerline', - 'Power Rangers' => 'power-rangers', - 'Power Tool' => 'power-tool', - 'Prada' => 'prada', - 'Pram' => 'pram', - 'Pregnancy' => 'pregnancy', - 'Prescription Glasses' => 'prescription-glasses', - 'Pressure Cooker' => 'pressure-cooker', - 'Pressure Washer' => 'pressure-washer', - 'Price Glitch' => 'price-glitch', - 'Prime Gaming' => 'twitch', - 'Pringles' => 'pringles', - 'Printer & Printer Supplies' => 'printer', - 'Printer Supplies' => 'printer-supplies', - 'Productivity App' => 'productivity-app', - 'Pro Evolution Soccer' => 'pro-evolution-soccer', - 'Pro Evolution Soccer 2018' => 'pro-evolution-soccer-2018', - 'Pro Evolution Soccer 2019' => 'pro-evolution-soccer-2019', - 'Pro Evolution Soccer 2020' => 'pes-2020', - 'Project Cars' => 'project-cars', - 'Project Cars 2' => 'project-cars-2', - 'Projector' => 'projector', - 'Protein' => 'protein', - 'Protein Bars' => 'protein-bars', - 'Protein Shaker' => 'shaker', - 'PS4' => 'ps4-slim', - 'PS4 Camera' => 'ps4-camera', - 'PS4 Controller' => 'ps4-controller', - 'PS4 Games' => 'ps4-games', - 'PS4 Headset' => 'ps4-headset', - 'PS4 Pro' => 'ps4-pro', - 'PS5' => 'ps5', - 'PS5 Games' => 'ps5-game', - 'PSU' => 'psu', - 'Public Transport' => 'public-transport', - 'Pukka' => 'pukka', - 'Pulse Light Epilator' => 'pulse-light-epilator', - 'Puma' => 'puma', - 'Puma Trainers' => 'puma-trainers', - 'Puppy Supplies' => 'puppy', - 'Purse' => 'purse', - 'Pushchair' => 'pushchair', - 'Pushchairs and Strollers' => 'baby-transport', - 'Puzzle' => 'puzzle', - 'PVR' => 'pvr', - 'Pyjamas' => 'pyjamas', - 'Pyrex' => 'pyrex', - 'Q Acoustics' => 'q-acoustics', - 'QNAP' => 'qnap', - 'Qualcast' => 'qualcast', - 'Quality Street' => 'quality-street', - 'Quantum Break' => 'quantum-break', - 'Quechua' => 'quechua', - 'Quick Charge' => 'quick-charge', - 'Quiksilver' => 'quiksilver', - 'Quinny' => 'quinny', - 'Quorn' => 'quorn', - 'Rab' => 'rab', - 'Radeon RX 480' => 'rx-480', - 'Radeon RX 5700' => 'radeon-rx-5700', - 'Radeon RX 5700 XT' => 'radeon-rx-5700-xt', - 'Radeon RX 6800' => 'radeon-rx-6800', - 'Radeon RX 6800 XT' => 'radeon-rx-6800-xt', - 'Radeon RX 6900 XT' => 'radeon-rx-6900-xt', - 'Radiator' => 'radiator', - 'Radio' => 'radio', - 'Radley' => 'radley', - 'Rage 2' => 'rage-2', - 'Railcard' => 'railcard', - 'Rainbow Six' => 'rainbow-six', - 'Rake' => 'rake', - 'Ralph Lauren' => 'ralph-lauren', - 'RAM' => 'ram', - 'Raspberry Pi' => 'raspberry-pi', - 'Ratchet' => 'ratchet', - 'Ratchet and Clank' => 'ratchet-and-clank', - 'Rattan Garden Furniture' => 'rattan', - 'RAVPower' => 'ravpower', - 'Ray Ban' => 'ray-ban', - 'Razer' => 'razer', - 'Razor' => 'razor', - 'Razor Blade' => 'razor-blade', - 'Real Madrid' => 'real-madrid', - 'Realme Smartphones' => 'realme-smartphone', - 'Real Techniques' => 'real-techniques', - 'Recliner' => 'recliner', - 'ReCore' => 'recore', - 'Recreational Sports' => 'recreational-sports', - 'Red Bull' => 'red-bull', - 'Red Dead Redemption' => 'red-dead-redemption', - 'Red Dead Redemption 2' => 'red-dead-redemption-2', - 'Redex' => 'redex', - 'Red Kite' => 'red-kite', - 'Reebok' => 'reebok', - 'Reese's' => 'reeses', - 'Regatta' => 'regatta', - 'Regina' => 'regina', - 'Remington' => 'remington', - 'Remote Control Car' => 'remote-control-car', - 'Renault' => 'renault', - 'Resident Evil' => 'resident-evil', - 'Resident Evil 2' => 'resident-evil-2', - 'Resident Evil 7' => 'resident-evil-7', - 'Restaurant, Café & Pub' => 'restaurant', - 'Retailer Offers and Issues' => 'retailer-offers-and-issues', - 'Ribena' => 'ribena', - 'Rice' => 'rice', - 'Rice Cooker' => 'rice-cooker', - 'Rick and Morty' => 'rick-and-morty', - 'Ricoh' => 'ricoh', - 'Ride On' => 'ride-on', - 'Ring' => 'ring', - 'Ring Door View Cam' => 'ring-door-view-cam', - 'Ring Fit Adventures' => 'ring-fit-adventures', - 'Ring Stick Up Cam' => 'ring-stick-up-cam', - 'Ring Video Doorbell' => 'ring-video-doorbell', - 'Ring Video Doorbell 2' => 'ring-video-doorbell-2', - 'Ring Video Doorbell 3' => 'ring-video-doorbell-3', - 'Ring Video Doorbell Pro' => 'ring-video-doorbell-pro', - 'Road Bike' => 'road-bike', - 'Roaming' => 'roaming', - 'Robinsons' => 'robinsons', - 'Robotic Lawnmower' => 'robotic-lawnmower', - 'Robot Vacuum Cleaner' => 'robot-vacuum-cleaner', - 'Rock Band' => 'rock-band', - 'Rocket League' => 'rocket-league', - 'Rocking Horse' => 'rocking-horse', - 'Rogue One: A Star Wars Story' => 'rogue-one', - 'Roku' => 'roku', - 'Rolex' => 'rolex', - 'Rollerskates' => 'skate', - 'Ronseal' => 'ronseal', - 'Roof Box' => 'roof-box', - 'Roses' => 'roses', - 'Rotary' => 'rotary', - 'Router' => 'router', - 'Rowenta' => 'rowenta', - 'RTX 2060' => 'rtx-2060', - 'RTX 2070' => 'rtx-2070', - 'RTX 2080' => 'rtx-2080', - 'RTX 2080 Ti' => 'rtx-2080-ti', - 'RTX 3070' => 'rtx-3070', - 'RTX 3080' => 'rtx-3080', - 'RTX 3090' => 'rtx-3090', - 'Rug' => 'rug', - 'Rugby' => 'rugby', - 'Rum' => 'rum', - 'Running' => 'running', - 'Running Shoes' => 'running-shoes', - 'Russell Hobbs' => 'russell-hobbs', - 'RX 570' => 'rx-570', - 'RX 580' => 'rx-580', - 'RX 590' => 'rx-590', - 'RX Vega 56' => 'rx-vega-56', - 'RX Vega 64' => 'rx-vega-64', - 'Ryanair' => 'ryanair', - 'Ryobi' => 'ryobi', - 'Safari' => 'safari', - 'Safety Boots' => 'safety-boots', - 'Sage by Heston Blumenthal' => 'sage', - 'Saints Row' => 'saints-row', - 'Saitek' => 'saitek', - 'Sale' => 'sale', - 'Salmon' => 'salmon', - 'Salomon' => 'salomon', - 'Salter' => 'salter', - 'Samsonite' => 'samsonite', - 'Samsung' => 'samsung', - 'Samsung Ecobubble' => 'ecobubble', - 'Samsung Fridge' => 'samsung-fridge', - 'Samsung Galaxy' => 'samsung-galaxy', - 'Samsung Galaxy A10' => 'samsung-galaxy-a10', - 'Samsung Galaxy A20e' => 'samsung-galaxy-a20e', - 'Samsung Galaxy A40' => 'samsung-galaxy-a40', - 'Samsung Galaxy A42 5G' => 'samsung-galaxy-a42-5g', - 'Samsung Galaxy A50' => 'samsung-galaxy-a50', - 'Samsung Galaxy A51' => 'samsung-galaxy-a51', - 'Samsung Galaxy A52 5G' => 'samsung-galaxy-a52', - 'Samsung Galaxy A60' => 'samsung-galaxy-a60', - 'Samsung Galaxy A70' => 'samsung-galaxy-a70', - 'Samsung Galaxy A71' => 'samsung-galaxy-a71', - 'Samsung Galaxy A72' => 'samsung-galaxy-a72', - 'Samsung Galaxy A80' => 'samsung-galaxy-a80', - 'Samsung Galaxy A90' => 'samsung-galaxy-a90', - 'Samsung Galaxy Buds' => 'samsung-galaxy-buds', - 'Samsung Galaxy Buds+' => 'samsung-galaxy-buds-plus', - 'Samsung Galaxy Buds Live' => 'samsung-galaxy-buds-live', - 'Samsung Galaxy Buds Pro' => 'samsung-galaxy-buds-pro', - 'Samsung Galaxy Fold' => 'samsung-galaxy-fold', - 'Samsung Galaxy J5' => 'galaxy-j5', - 'Samsung Galaxy Note' => 'samsung-galaxy-note', - 'Samsung Galaxy Note 8' => 'samsung-galaxy-note-8', - 'Samsung Galaxy Note 9' => 'samsung-galaxy-note-9', - 'Samsung Galaxy Note 10' => 'samsung-galaxy-note-10', - 'Samsung Galaxy Note 10+' => 'samsung-galaxy-note-10-plus', - 'Samsung Galaxy Note20' => 'samsung-galaxy-note20', - 'Samsung Galaxy Note20 Ultra' => 'samsung-galaxy-note20-ultra', - 'Samsung Galaxy S6' => 'samsung-galaxy-s6', - 'Samsung Galaxy S7' => 'samsung-galaxy-s7', - 'Samsung Galaxy S7 Edge' => 'samsung-galaxy-s7-edge', - 'Samsung Galaxy S8' => 'samsung-galaxy-s8', - 'Samsung Galaxy S8+' => 'samsung-s8-plus', - 'Samsung Galaxy S9' => 'samsung-galaxy-s9', - 'Samsung Galaxy S9 Plus' => 'samsung-s9-plus', - 'Samsung Galaxy S10' => 'samsung-galaxy-s10', - 'Samsung Galaxy S10 Lite' => 'samsung-galaxy-s10-lite', - 'Samsung Galaxy S10 Plus' => 'samsung-galaxy-s10-plus', - 'Samsung Galaxy S10e' => 'samsung-galaxy-s10e', - 'Samsung Galaxy S20' => 'samsung-galaxy-s20', - 'Samsung Galaxy S20 FE' => 'samsung-galaxy-s20-fe', - 'Samsung Galaxy S20 Ultra' => 'samsung-galaxy-s20-ultra', - 'Samsung Galaxy S20+' => 'samsung-galaxy-s20-plus', - 'Samsung Galaxy S21 5G' => 'samsung-galaxy-s21-5g', - 'Samsung Galaxy S21 Ultra 5G' => 'samsung-galaxy-s21-ultra-5g', - 'Samsung Galaxy S21+ 5G' => 'samsung-galaxy-s21-plus-5g', - 'Samsung Galaxy Tab' => 'samsung-galaxy-tab', - 'Samsung Galaxy Tab A' => 'samsung-galaxy-tab-a', - 'Samsung Galaxy Tab A7' => 'samsung-galaxy-tab-a7', - 'Samsung Galaxy Tab S' => 'samsung-galaxy-tab-s', - 'Samsung Galaxy Tab S4' => 'samsung-galaxy-tab-s4', - 'Samsung Galaxy Tab S5e' => 'samsung-galaxy-tab-s5e', - 'Samsung Galaxy Tab S6' => 'samsung-galaxy-tab-s6', - 'Samsung Galaxy Watch' => 'samsung-galaxy-watch', - 'Samsung Galaxy Watch3' => 'samsung-galaxy-watch3', - 'Samsung Galaxy Watch Active2' => 'samsung-galaxy-watch-active-2', - 'Samsung Gear' => 'samsung-gear', - 'Samsung Gear S3' => 'gear-s3', - 'Samsung Gear VR' => 'samsung-gear-vr', - 'Samsung Headphones' => 'samsung-headphones', - 'Samsung Monitor' => 'samsung-monitor', - 'Samsung QLED TVs' => 'samsung-qled-tv', - 'Samsung Smartphone' => 'samsung-smartphone', - 'Samsung SSD' => 'samsung-ssd', - 'Samsung The Frame TV' => 'samsung-the-frame', - 'Samsung TV' => 'samsung-tv', - 'Samsung Washing Machine' => 'samsung-washing-machine', - 'Samsung Watch' => 'samsung-watch', - 'Sandals' => 'sandals', - 'Sander' => 'sander', - 'SanDisk' => 'sandisk', - 'SanDisk SSD' => 'sandisk-ssd', - 'Sand Pit' => 'sand-pit', - 'Sandwich Maker' => 'sandwich', - 'San Miguel' => 'san-miguel', - 'Santander' => 'santander', - 'Satchel' => 'satchel', - 'Sat Nav' => 'sat-nav', - 'Sauce' => 'sauce', - 'Saw' => 'saw', - 'Scalextric' => 'scalextric', - 'Scanner' => 'scanner', - 'School Bag' => 'school-bag', - 'School Supplies' => 'school', - 'School Uniform' => 'school-uniform', - 'Schwalbe' => 'schwalbe', - 'Scooby Doo' => 'scooby-doo', - 'Scooter' => 'scooter', - 'Scotch Whisky' => 'scotch', - 'Scrabble' => 'scrabble', - 'Screen Protector' => 'screen-protector', - 'Screenwash' => 'screenwash', - 'Screwdriver' => 'screwdriver', - 'Screws' => 'screws', - 'SD Cards' => 'sd-card', - 'SDHC' => 'sdhc', - 'SDXC' => 'sdxc', - 'Seagate' => 'seagate', - 'Sea Life' => 'sea-life', - 'Sea of Thieves' => 'sea-of-thieves', - 'Season Pass' => 'season-pass', - 'Seaworld' => 'seaworld', - 'Security Camera' => 'security-camera', - 'Seeds & Bulbs' => 'seeds-and-bulbs', - 'Sega' => 'sega', - 'SEGA Mega Drive Mini' => 'sega-mega-drive-mini', - 'Segway' => 'segway', - 'Seiko' => 'seiko', - 'Sekiro: Shadows Die Twice' => 'sekiro', - 'Sekonda' => 'sekonda', - 'Selfie Stick' => 'selfie-stick', - 'Sennheiser' => 'sennheiser', - 'Sennheiser Headphones' => 'sennheiser-headphones', - 'Sensodyne' => 'sensodyne', - 'Server' => 'server', - 'Services & Contracts' => 'services-contracts', - 'Services and Subscriptions' => 'service-contract', - 'Sewing' => 'sewing', - 'Sewing Machine' => 'sewing-machine', - 'Sex Toys' => 'sex-toys', - 'Shadow of the Tomb Raider' => 'shadow-of-the-tomb-raider', - 'Shampoo' => 'shampoo', - 'Shark' => 'shark', - 'Shark DuoClean' => 'shark-duoclean', - 'Shark Vacuum Cleaner' => 'shark-vacuum-cleaner', - 'Sharp' => 'sharp', - 'Sharpener' => 'sharpener', - 'Sharpie' => 'sharpie', - 'Shaver' => 'shaver', - 'Shaving & Beard Care' => 'shaving', - 'Shaving, Trimming, & Hair Removal' => 'hair-removal', - 'Shaving Foam' => 'shaving-foam', - 'Shears' => 'shears', - 'Sheba' => 'sheba', - 'Shed' => 'shed', - 'Shelter' => 'shelter', - 'Shelves' => 'shelves', - 'Shenmue I & II' => 'shenmue-one-and-two', - 'Shenmue III' => 'shenmue-3', - 'Shenmue Series' => 'shenmue-series', - 'Shimano' => 'shimano', - 'Shirt' => 'shirt', - 'Shoe Rack' => 'shoe-rack', - 'Shoes' => 'shoe', - 'Shopkins' => 'shopkins', - 'Shortbread' => 'shortbread', - 'Shorts' => 'shorts', - 'Short Trip' => 'break', - 'Shoulder Bag' => 'shoulder-bag', - 'Shovel' => 'shovel', - 'Shower Curtain' => 'shower-curtain', - 'Shower Enclosure' => 'shower-enclosure', - 'Shower Fittings' => 'shower', - 'Shower Gel' => 'shower-gel', - 'Shower Head' => 'shower-head', - 'Shredder' => 'shredder', - 'Side-by-Side-Fridge' => 'side-by-side-fridge', - 'Sideboard' => 'sideboard', - 'Sid Meier's Civilization VI' => 'civilization-vi', - 'Siemens' => 'siemens', - 'Siemens Washing Machine' => 'siemens-washing-machine', - 'Sigma' => 'sigma', - 'Silentnight' => 'silentnight', - 'Silvercrest' => 'silvercrest', - 'Silver Cross' => 'silver-cross', - 'Sim Free' => 'sim-free', - 'Sim Only' => 'sim-only', - 'Simplehuman' => 'simplehuman', - 'Simpsons' => 'simpsons', - 'Single Malt' => 'single-malt', - 'Sink' => 'sink', - 'Sistema' => 'sistema', - 'Skateboard' => 'skateboard', - 'Skating' => 'skating', - 'Skechers' => 'skechers', - 'Skiing' => 'ski', - 'Skin Care' => 'skincare', - 'Skittles' => 'skittles', - 'Skoda' => 'skoda', - 'Skullcandy' => 'skullcandy', - 'Sky' => 'sky', - 'Sky Cinema' => 'sky-cinema', - 'Skylanders' => 'skylanders', - 'Skylanders Battlecast' => 'skylanders-battlecast', - 'Skylanders Imaginators' => 'skylanders-imaginators', - 'Sleeping Bag' => 'sleeping-bag', - 'Sleeping Dogs' => 'sleeping-dogs', - 'Sleepwear' => 'sleepwear', - 'Slide' => 'slide', - 'Slimming World' => 'slimming-world', - 'Slippers' => 'slippers', - 'Slow Cooker' => 'slow-cooker', - 'Smart Clock' => 'clock', - 'Smart Doorbells' => 'smart-doorbell', - 'Smart Home' => 'smart-home', - 'Smart Light' => 'smart-light', - 'Smart Lock' => 'smart-lock', - 'Smartphone Accessories' => 'smartphone-accessories', - 'Smartphone Case' => 'smartphone-case', - 'Smartphone under £200' => 'smartphone-under-200-pounds', - 'Smartphone under £400' => 'smartphone-under-400-pounds', - 'Smart Plugs' => 'smart-plugs', - 'Smart Speaker' => 'smart-speaker', - 'Smart Tech & Gadgets' => 'smart-tech', - 'Smart Thermostat' => 'thermostat', - 'SmartThings' => 'smartthings', - 'Smart TV' => 'smart-tv', - 'Smart Watch' => 'smartwatch', - 'Smeg' => 'smeg', - 'Smirnoff' => 'smirnoff', - 'Smoke Alarm' => 'smoke-alarm', - 'Smoothie' => 'smoothie', - 'Smoothie Maker' => 'smoothie-maker', - 'Snacks' => 'snacks', - 'Sneakers' => 'sneakers', - 'SNES Nintendo Classic Mini' => 'snes-nintendo-classic', - 'Snickers' => 'snickers', - 'Sniper Elite' => 'sniper-elite', - 'Snowboard' => 'snowboard', - 'Snow Boots' => 'snow-boots', - 'Soap' => 'soap', - 'Soap and Glory' => 'soap-and-glory', - 'Socket Set' => 'socket-set', - 'Socks' => 'socks', - 'SodaStream' => 'soda-stream', - 'Sofa' => 'sofa', - 'Soft Drinks' => 'soft-drinks', - 'Soft Toy' => 'soft-toy', - 'Software' => 'software', - 'Software & Apps' => 'software-apps', - 'Solar Lights' => 'solar-lights', - 'Soldering Iron' => 'soldering', - 'Sonic' => 'sonic', - 'Sonos' => 'sonos', - 'Sonos Beam' => 'sonos-beam', - 'Sonos Move' => 'sonos-move', - 'Sonos One' => 'sonos-one', - 'Sonos PLAY:1' => 'sonos-play-1', - 'Sonos PLAY:3' => 'sonos-play-3', - 'Sonos PLAY:5' => 'sonos-play-5', - 'Sonos PLAYBAR' => 'sonos-playbar', - 'Sonos PLAYBASE' => 'sonos-playbase', - 'Sony' => 'sony', - 'Sony Camera' => 'sony-camera', - 'Sony Headphones' => 'sony-headphones', - 'Sony Pulse 3D Wireless Headset' => 'pulse-3d-wireless-headsets', - 'Sony TV' => 'sony-tv', - 'Sony WF-1000XM3' => 'sony-wf1000xm3', - 'Sony WH-1000XM3' => 'sony-wh-1000xm3', - 'Sony WH-1000XM4' => 'sony-wh1000xm4', - 'Sony Xperia' => 'xperia', - 'Sony Xperia 5' => 'sony-xperia-5', - 'Sony Xperia 10' => 'sony-xperia-10', - 'Sony Xperia Xa' => 'sony-xperia-xa', - 'Sony Xperia Z3' => 'xperia-z3', - 'Sony Xperia Z5' => 'xperia-z5', - 'Soulcalibur' => 'soulcalibur', - 'Soundbar' => 'soundbar', - 'Soundbase' => 'soundbase', - 'Sound Card' => 'sound-card', - 'Soundmagic' => 'soundmagic', - 'Soup' => 'soup', - 'Soup Maker' => 'soup-maker', - 'Sous-Vide' => 'sousvide', - 'Southern Comfort' => 'southern-comfort', - 'South Park' => 'south-park', - 'Spa' => 'spa', - 'Spade' => 'spade', - 'Spanner' => 'spanner', - 'Speaker' => 'speakers', - 'Specialized' => 'specialized', - 'Speedo' => 'speedo', - 'Sphero' => 'sphero', - 'Spice Rack' => 'spice-rack', - 'Spiderman' => 'spiderman', - 'Spiralizer' => 'spiralizer', - 'Spirit & Liqueur' => 'spirits', - 'Spirit Level' => 'spirit-level', - 'Splatoon' => 'splatoon', - 'Sports & Outdoors' => 'sports-fitness', - 'Sports Events' => 'sports-events', - 'Sports Nutrition' => 'nutrition', - 'Spreads' => 'spreads', - 'Spyro Reignited Trilogy' => 'spyro-reignited-trilogy', - 'SSD' => 'ssd', - 'SSHD' => 'sshd', - 'Staedtler' => 'staedtler', - 'Stair Gate' => 'stair-gate', - 'Stanley' => 'stanley', - 'Stapler' => 'stapler', - 'Starbucks' => 'starbucks', - 'Starlink: Battle for Atlas' => 'starlink-battle-for-atlas', - 'Star Ocean' => 'star-ocean', - 'Star Trek' => 'star-trek', - 'Star Wars' => 'star-wars', - 'Star Wars: Battlefront' => 'star-wars-battlefront', - 'Star Wars: Battlefront II' => 'star-wars-battlefront-2', - 'Star Wars: Squadrons' => 'star-wars-squadrons', - 'Star Wars Jedi: Fallen Order' => 'star-wars-jedi-fallen-order', - 'Stationery' => 'stationery', - 'Stationery & Office Supplies' => 'stationery-office-supplies', - 'Staycation' => 'staycation', - 'Steak' => 'steak', - 'Steam Cleaner' => 'steam-cleaner', - 'Steam Controller' => 'steam-controller', - 'Steamer' => 'steamer', - 'Steam Gaming' => 'steam', - 'Steam Iron' => 'steam-iron', - 'Steam Link' => 'steam-link', - 'Steam Mop' => 'steam-mop', - 'SteelSeries' => 'steelseries', - 'Steering Wheel' => 'steering-wheel', - 'Stella' => 'stella', - 'Stool' => 'stool', - 'Storage Box' => 'storage-box', - 'Stormtrooper' => 'stormtrooper', - 'Straightener' => 'straightener', - 'Streaming' => 'streaming', - 'Street Fighter' => 'street-fighter', - 'Street Fighter V' => 'street-fighter-v', - 'Streetwear' => 'streetwear', - 'Strimmer' => 'strimmer', - 'Strongbow' => 'strongbow', - 'Student Discount' => 'student-discount', - 'Subwoofer' => 'subwoofer', - 'Suitcase' => 'suitcase', - 'Suncare' => 'suncare', - 'Sun Cream' => 'sun-cream', - 'Sunglasses' => 'sunglasses', - 'Superdry' => 'superdry', - 'Superfast Broadband' => 'superfast-broadband', - 'Superking' => 'superking', - 'Super Mario' => 'mario', - 'Super Mario 3D All-Stars' => 'super-mario-3d-all-stars', - 'Super Mario 3D World' => 'super-mario-3d-world', - 'Super Mario Maker 2' => 'super-mario-maker-2', - 'Super Mario Odyssey' => 'super-mario-odyssey', - 'Super Mario Party' => 'mario-party', - 'Supermarket' => 'supermarket', - 'Super Smash Bros.' => 'super-smash-bros', - 'Surf' => 'surf', - 'Swarovski' => 'swarovski', - 'Sweets' => 'sweets', - 'Swimming' => 'swimming', - 'Swimming Goggles' => 'goggles', - 'Swimwear' => 'swimwear', - 'Swing' => 'swing', - 'Swingball' => 'swingball', - 'Syberia' => 'syberia', - 'Sylvanian' => 'sylvanian', - 'Synology' => 'synology', - 'T-Mobile' => 't-mobile', - 'T-Shirt' => 't-shirt', - 'Table Lamp' => 'table-lamp', - 'Tablet' => 'tablet', - 'Tablet Accessories' => 'tablet-accessories', - 'Table Tennis' => 'table-tennis', - 'Tableware' => 'tableware', - 'Tacx' => 'tacx', - 'Tado' => 'tado', - 'Tag Heuer' => 'tag-heuer', - 'Takeaway and Food Delivery' => 'takeaway', - 'Tales of Vesperia: Definitive Edition' => 'tales-of-vesperia-definitive-edition', - 'Talisker' => 'talisker', - 'Talkmobile' => 'talkmobile', - 'Tamron' => 'tamron', - 'Tangle Teezer' => 'tangle-teezer', - 'Tank Top' => 'tank-top', - 'Tannoy' => 'tannoy', - 'Tanqueray' => 'tanqueray', - 'Tape' => 'tape', - 'Tassimo' => 'tassimo', - 'Tassimo Coffee Machine' => 'tassimo-coffee-machine', - 'tastecard' => 'tastecard', - 'Taxi' => 'taxi', - 'Tea' => 'tea', - 'Team Sonic Racing' => 'team-sonic-racing', - 'Team Sports' => 'team-sports', - 'Teapot' => 'teapot', - 'Technika' => 'technika', - 'Techwood' => 'techwood', - 'Ted Baker' => 'ted-baker', - 'Teddy Bear' => 'teddy-bear', - 'Teenage Mutant Ninja Turtles' => 'turtle', - 'Teeth Care' => 'teeth-care', - 'Teeth Whitening' => 'teeth-whitening', - 'Tefal' => 'tefal', - 'Tefal Actifry' => 'actifry', - 'Tefal Pan' => 'tefal-pan', - 'Tekken' => 'tekken', - 'Tekken 7' => 'tekken-7', - 'Telegraph' => 'telegraph', - 'Telescope' => 'telescope', - 'Telltale' => 'telltale', - 'Tennis' => 'tennis', - 'Tent' => 'tent', - 'Tequila' => 'tequila', - 'Tesco Clothing' => 'tesco-clothing', - 'Tesla' => 'tesla', - 'Tetris' => 'tetris', - 'Tetris 99' => 'tetris-99', - 'Theatre & Musical' => 'theatre', - 'The Beatles' => 'beatles', - 'The Big Bang Theory' => 'big-bang-theory', - 'The Crew' => 'the-crew', - 'The Dark Pictures: Anthology Man of Medan' => 'the-dark-pictures-anthology-man-of-medan', - 'The Elder Scrolls' => 'elder-scrolls', - 'The Elder Scrolls V: Skyrim' => 'skyrim', - 'The Evil Within' => 'the-evil-within', - 'The Evil Within 2' => 'the-evil-within-2', - 'The Last Guardian' => 'the-last-guardian', - 'The Last of Us' => 'the-last-of-us', - 'The Last of Us Part II' => 'the-last-of-us-part-2', - 'The Legend of Zelda' => 'zelda', - 'The Legend of Zelda: Breath of the Wild' => 'zelda-breath-of-the-wild', - 'The Legend of Zelda: Link's Awakening' => 'the-legend-of-zelda-links-awakening', - 'The Legend of Zelda: Skyward Sword HD' => 'the-legend-of-zelda-skyward-sword-hd', - 'Theme Park' => 'theme-park', - 'The North Face' => 'north-face', - 'The Outer Worlds' => 'the-outer-worlds', - 'Thermos Storage' => 'thermos', - 'The Sims' => 'sims', - 'The Sims 4' => 'the-sims-4', - 'The Sinking City' => 'the-sinking-city', - 'The Sun' => 'the-sun', - 'The Sunday Times' => 'sunday-times', - 'The Walking Dead' => 'walking-dead', - 'The Witcher' => 'witcher', - 'The Witcher 3' => 'the-witcher-3', - 'Thierry Mugler' => 'thierry-mugler', - 'Thomas Sabo' => 'thomas-sabo', - 'Thomas The Tank Engine' => 'thomas-the-tank', - 'Thornton's' => 'thorntons', - 'Thorpe Park' => 'thorpe-park', - 'Throw' => 'throw', - 'Thrustmaster' => 'thrustmaster', - 'Thule' => 'thule', - 'Tickets & Shows' => 'tickets-shows', - 'Tie' => 'tie', - 'Tights' => 'tights', - 'TIGI' => 'tigi', - 'Tilda' => 'tilda', - 'Tile' => 'tile', - 'Timberland' => 'timberland', - 'Timex' => 'timex', - 'Tissot' => 'tissot', - 'Tissues' => 'tissues', - 'Titanfall' => 'titanfall', - 'Titanfall 2' => 'titanfall-2', - 'Toaster' => 'toaster', - 'Toblerone' => 'toblerone', - 'Toddler Bed' => 'toddler-bed', - 'Toilet Brush' => 'brush', - 'Toilet Cleaner' => 'toilet', - 'Toilet Roll' => 'toilet-roll', - 'Toilet Seat' => 'toilet-seat', - 'Tokyo Laundry' => 'tokyo-laundry', - 'Tomb Raider' => 'tomb-raider', - 'Tom Clancy's' => 'tom-clancy', - 'Tom Clancy's: Ghost Recon' => 'ghost-recon', - 'Tom Clancy's Ghost Recon: Wildlands' => 'ghost-recon-wildlands', - 'Tom Clancy's Ghost Recon Breakpoint' => 'tom-clancys-ghost-recon-breakpoint', - 'Tom Clancy's The Division' => 'tom-clancy-the-division', - 'Tom Clancy's The Division 2' => 'tom-clancy-the-division-2', - 'Tom Ford' => 'tom-ford', - 'Tommee Tippee' => 'tommee-tippee', - 'Tommy Hilfiger' => 'tommy-hilfiger', - 'Toms' => 'toms', - 'TomTom' => 'tomtom', - 'Tonic Water' => 'tonic-water', - 'Tony Hawk's Pro Skater 1 + 2' => 'tony-hawks-pro-skater-1-2', - 'Tools' => 'tool', - 'Toothbrush' => 'toothbrush', - 'Toothpaste' => 'toothpaste', - 'Torch' => 'torch', - 'Torque Wrench' => 'torque-wrench', - 'Toshiba' => 'toshiba', - 'Toshiba Laptop' => 'toshiba-laptop', - 'Toshiba TV' => 'toshiba-tv', - 'Total War' => 'total-war', - 'Tottenham Hotspur F. C.' => 'tottenham', - 'Towel' => 'towel', - 'Toy Box' => 'toy-box', - 'Toy Cars' => 'toy-cars', - 'Toy Castle' => 'castle', - 'Toy Digger' => 'digger', - 'Toy Helicopter' => 'helicopter', - 'Toy Kitchen' => 'toy-kitchen', - 'Toy Mask' => 'mask', - 'Toyota' => 'toyota', - 'Toys' => 'toy', - 'Toy Story' => 'toy-story', - 'Toy Tractor' => 'tractor', - 'Toy Train' => 'train', - 'TP-Link' => 'tp-link', - 'TP-Link Archer' => 'archer', - 'TP-Link Router' => 'tp-link-router', - 'Tracksuit' => 'tracksuit', - 'Trainers' => 'trainers', - 'Trains & Buses' => 'train-and-bus-ticket', - 'Train Ticket' => 'train-ticket', - 'Trampoline' => 'trampoline', - 'Transcend' => 'transcend', - 'Transformers' => 'transformers', - 'Travel' => 'travel', - 'Travel App' => 'travel-app', - 'Travel Insurance' => 'travel-insurance', - 'Travelodge' => 'travelodge', - 'Travel System' => 'travel-system', - 'Treadmill' => 'treadmill', - 'TRESemmé' => 'tresemme', - 'Trespass' => 'trespass', - 'Triathlon' => 'triathlon', - 'Trike' => 'trike', - 'Trine 4' => 'trine-4', - 'Tripod' => 'tripod', - 'Tripp' => 'tripp', - 'Triton Shower' => 'triton', - 'Trolley Bag' => 'trolley', - 'Tropico 5' => 'tropico-5', - 'Tropico 6' => 'tropico-6', - 'Tropico Series' => 'tropico-deals', - 'Trousers' => 'trousers', - 'True Wireless Earbuds' => 'wireless-earphones', - 'Trunki' => 'trunki', - 'Tumble Dryer' => 'tumble-dryer', - 'Tuna' => 'tuna', - 'Turbo Trainer' => 'turbo-trainer', - 'Turntable' => 'turntable', - 'Turtle Beach' => 'turtle-beach', - 'TV' => 'tv', - 'TV & Video' => 'tv-video', - 'TV Accessories' => 'tv-accessories', - 'TV Mount' => 'tv-mount', - 'TV Series' => 'tv-series', - 'TV Stand' => 'tv-stand', - 'Twinings' => 'twinings', - 'Twin Peaks' => 'twin-peaks', - 'Twix' => 'twix', - 'Typhoo' => 'typhoo', - 'Tyres' => 'tyres', - 'Ubisoft' => 'ubisoft', - 'UE BOOM' => 'ue-boom', - 'UE Boom 2' => 'ue-boom-2', - 'UEFA' => 'uefa', - 'UE Megablast' => 'ue-megablast', - 'UE Megaboom' => 'ue-megaboom', - 'UGG' => 'ugg', - 'Ulefone' => 'ulefone', - 'Ultrabook' => 'ultrabook', - 'Ultrawide Monitor' => 'ultrawide', - 'Umbrella' => 'umbrella', - 'UMI' => 'umidigi', - 'Uncharted' => 'uncharted', - 'Uncharted 4: A Thief's End' => 'uncharted-4', - 'Uncharted: The Lost Legacy' => 'uncharted-the-lost-legacy', - 'Under Armour' => 'under-armour', - 'Underwear' => 'underwear', - 'Unicorn' => 'unicorn', - 'UNiDAYS' => 'unidays', - 'Universal Remote' => 'universal-remote', - 'Uno' => 'uno', - 'Uplay' => 'uplay', - 'Urban Decay' => 'urban-decay', - 'Urban Sports' => 'urban-sports', - 'USB Cable' => 'usb-cable', - 'USB Hub' => 'usb-hub', - 'USB Memory Stick' => 'flash-drive', - 'USB Type C' => 'usb-type-c', - 'USN' => 'usn', - 'Vacuum Cleaner' => 'vacuum-cleaners', - 'Vacuum Flask' => 'flask', - 'Valkyria Chronicles' => 'valkyria-chronicles', - 'Valkyria Chronicles 4' => 'valkyria-chronicles-4', - 'Vango' => 'vango', - 'Vanish' => 'vanish', - 'Vans' => 'vans', - 'Vans Old Skool' => 'vans-old-skool', - 'Vans Shoes' => 'vans-shoes', - 'Vase' => 'vase', - 'Vaseline' => 'vaseline', - 'Vauxhall' => 'vauxhall', - 'VAX' => 'vax', - 'Vax Blade' => 'vax-blade', - 'Vax Vacuum Cleaner' => 'vax-vacuum', - 'Veet' => 'veet', - 'Vega 7' => 'vega-7', - 'Vegetables' => 'vegetables', - 'Vegetarian' => 'vegetarian', - 'Vehicles' => 'vehicles', - 'Velvet Comfort' => 'velvet', - 'Vera Wang' => 'vera-wang', - 'Verbatim' => 'verbatim', - 'Versace' => 'versace', - 'Vibrator' => 'vibrator', - 'Victorinox' => 'victorinox', - 'Video Games' => 'videogame', - 'Video Streaming' => 'video-streaming', - 'Viktor & Rolf Spicebomb' => 'spicebomb', - 'Vileda' => 'vileda', - 'Villeroy & Boch' => 'villeroy-boch', - 'Viners' => 'viners', - 'Vinyl' => 'vinyl', - 'Virgin' => 'virgin', - 'Vitamins & Supplements' => 'vitamins', - 'Vitamix' => 'vitamix', - 'Vodafone' => 'vodafone', - 'Vodka' => 'vodka', - 'Volvo' => 'volvo', - 'VPN' => 'vpn', - 'VR Headset' => 'vr-headset', - 'VTech' => 'vtech', - 'VTech Toot Toot' => 'toot-toot', - 'Vue' => 'vue', - 'VW' => 'vw', - 'Wacom' => 'wacom', - 'Waffle Maker' => 'waffle-maker', - 'Wahl' => 'wahl', - 'Walkers' => 'walkers', - 'Walking Boots' => 'walking-boots', - 'Wall Art' => 'wall-art', - 'Wallet' => 'wallet', - 'Wallpaper' => 'wallpaper', - 'Wardrobe' => 'wardrobe', - 'Warhammer' => 'warhammer', - 'Washbag' => 'washbag', - 'Washer Dryer' => 'washer-dryer', - 'Washing Machine' => 'washing-machine', - 'Washing Powder' => 'washing-powder', - 'Watch' => 'watch', - 'Watch Dogs' => 'watch-dogs', - 'Watch Dogs 2' => 'watch-dogs-2', - 'Watch Dogs: Legion' => 'watch-dogs-legion', - 'Water Bottle' => 'water-bottle', - 'Water Butt' => 'water-butt', - 'Water Dispenser' => 'water-dispenser', - 'Water Filter' => 'water-filter', - 'Water Gun' => 'water-gun', - 'Waterproof Camera' => 'waterproof-camera', - 'Waterproof Jacket' => 'waterproof-jacket', - 'Watersports' => 'watersport', - 'Water Toys' => 'water-toys', - 'Wayfarer' => 'wayfarer', - 'WD40' => 'wd40', - 'Wearable' => 'wearable', - 'Weather Station' => 'weather-station', - 'Webcam' => 'webcam', - 'Weber' => 'weber', - 'Web Hosting' => 'web-hosting', - 'Wedding' => 'wedding', - 'Weed Killer' => 'weed', - 'Weekend Break' => 'weekend-break', - 'Weetabix' => 'weetabix', - 'Weightlifting' => 'weightlifting', - 'Weight Watchers' => 'weight-watchers', - 'Wellies' => 'wellies', - 'Wellness and Health' => 'wellness-and-health', - 'Wenger' => 'wenger', - 'Western Digital' => 'western-digital', - 'Wetsuit' => 'wetsuit', - 'Wheelbarrow' => 'wheelbarrow', - 'Wheelchair' => 'wheelchair', - 'Whey' => 'whey', - 'Whiskas' => 'whiskas', - 'Whisky' => 'whisky', - 'Whole Home Mesh Wi-Fi System' => 'whole-home-mesh-wifi-system', - 'Wi-Fi Camera' => 'wifi-camera', - 'Wi-Fi Dongle' => 'dongle', - 'Wi-Fi Extender' => 'wifi-extender', - 'Wii' => 'wii', - 'Wii Game' => 'wii-games', - 'Wii U Game' => 'wii-u-game', - 'Wii U Pro Controller' => 'wii-u-pro-controller', - 'Wild Turkey' => 'wild-turkey', - 'Wileyfox' => 'wileyfox', - 'Wilkinson Sword Hydro 5' => 'hydro-5', - 'Wilkinson Sword Razor' => 'wilkinson-sword', - 'Wimbledon Tennis' => 'wimbledon', - 'Window Cleaner' => 'window-cleaner', - 'Windows' => 'windows', - 'Windows 8' => 'windows-8', - 'Windows 10' => 'windows-10', - 'Wine' => 'wine', - 'Wine Advent Calendar' => 'wine-advent-calendar', - 'Wine Glasses' => 'wine-glasses', - 'Winter Jacket' => 'winter-jacket', - 'Wiper Blades' => 'wiper-blades', - 'Wireless Adapter' => 'wireless-adapter', - 'Wireless Charger' => 'wireless-charger', - 'Wireless Controller' => 'wireless-controller', - 'Wireless Headphones' => 'wireless-headphones', - 'Wireless Headset' => 'wireless-headset', - 'Wireless Keyboard' => 'wireless-keyboard', - 'Wireless Mouse' => 'wireless-mouse', - 'Wok' => 'wok', - 'Wolfenstein' => 'wolfenstein', - 'Wolfenstein 2: The New Colossus' => 'wolfenstein-2', - 'Women's Boots' => 'womens-boots', - 'Women's Fragrance' => 'womens-fragrance', - 'Women's Shoes' => 'womens-shoes', - 'Workbench' => 'workbench', - 'World of Warcraft' => 'world-of-warcraft', - 'World War Z' => 'world-war-z', - 'WORX' => 'worx', - 'Wreckfest' => 'wreckfest', - 'Wuaki' => 'wuaki', - 'WWE 2K' => 'wwe', - 'Xbox' => 'xbox', - 'Xbox 360 Game' => 'xbox-360-game', - 'Xbox Accessories' => 'xbox-accessories', - 'Xbox Controller' => 'xbox-controller', - 'Xbox Game Pass' => 'xbox-game-pass', - 'Xbox Gift Card' => 'xbox-gift-card', - 'Xbox Headset' => 'xbox-headset', - 'Xbox Kinect' => 'kinect', - 'Xbox Live' => 'xbox-live', - 'Xbox One Controller' => 'xbox-one-controller', - 'Xbox One Elite Controller' => 'xbox-one-elite-controller', - 'Xbox One Games' => 'xbox-one-games', - 'Xbox One S' => 'xbox-one-s', - 'Xbox One X' => 'xbox-one-x', - 'Xbox Series S' => 'xbox-series-s', - 'Xbox Series X' => 'xbox-series-x', - 'Xbox Series X Controller' => 'xbox-series-x-controller', - 'Xbox Series X Games' => 'xbox-series-x-game', - 'Xbox Wireless Adapter' => 'xbox-wireless-adapter', - 'Xbox Wireless Headset' => 'xbox-wireless-headset', - 'XCOM' => 'xcom', - 'XCOM 2' => 'xcom-2', - 'Xenoblade Chronicles' => 'xenoblade-chronicles', - 'XFX' => 'xfx', - 'Xiaomi' => 'xiaomi', - 'Xiaomi AirDots' => 'xiaomi-airdots', - 'Xiaomi Black Shark' => 'xiaomi-black-shark', - 'Xiaomi Black Shark 2' => 'xiaomi-black-shark-2', - 'Xiaomi Headphones' => 'xiaomi-headphones', - 'Xiaomi Laptop' => 'xiaomi-laptop', - 'Xiaomi Mi 5' => 'xiaomi-mi-5', - 'Xiaomi Mi 6' => 'xiaomi-mi-6', - 'Xiaomi Mi 8' => 'xiaomi-mi-8', - 'Xiaomi Mi 8 Lite' => 'xiaomi-mi-8-lite', - 'Xiaomi Mi 8 Pro' => 'xiaomi-mi-8-pro', - 'Xiaomi Mi 9' => 'xiaomi-mi-9', - 'Xiaomi Mi 9 Lite' => 'xiaomi-mi-9-lite', - 'Xiaomi Mi 9 SE' => 'xiaomi-mi-9-se', - 'Xiaomi Mi 9T' => 'xiaomi-mi-9t', - 'Xiaomi Mi 9T Pro' => 'xiaomi-mi-9t-pro', - 'Xiaomi Mi 10' => 'xiaomi-mi-10', - 'Xiaomi Mi 10 Lite' => 'xiaomi-mi-10-lite', - 'Xiaomi Mi 10T' => 'xiaomi-mi-10t', - 'Xiaomi Mi 10T Lite' => 'xiaomi-mi-10t-lite', - 'Xiaomi Mi 10T Pro' => 'xiaomi-mi-10t-pro', - 'Xiaomi Mi 11' => 'xiaomi-mi-11', - 'Xiaomi Mi 11 Lite 4G' => 'xiaomi-mi-11-lite-4g', - 'Xiaomi Mi 11 Lite 5G' => 'xiaomi-mi-11-lite-5g', - 'Xiaomi Mi 11 Pro' => 'xiaomi-mi-11-pro', - 'Xiaomi Mi 11 Ultra' => 'xiaomi-mi-11-ultra', - 'Xiaomi Mi 11i' => 'xiaomi-mi-11i', - 'Xiaomi Mi A1' => 'xiaomi-mi-a1', - 'Xiaomi Mi A2' => 'mi-a2', - 'Xiaomi Mi A3' => 'xiaomi-mi-a3', - 'Xiaomi Mi Band' => 'xiaomi-mi-band', - 'Xiaomi Mi Band 3' => 'xiaomi-mi-band-3', - 'Xiaomi Mi Band 4' => 'xiaomi-mi-band-4', - 'Xiaomi Mi Band 5' => 'xiaomi-mi-band-5', - 'Xiaomi Mi Box' => 'xiaomi-mi-box', - 'Xiaomi Mi Max 3' => 'xiaomi-mi-max3', - 'Xiaomi Mi Mix' => 'xiaomi-mi-mix', - 'Xiaomi Mi Mix 2' => 'xiaomi-mi-mix-2', - 'Xiaomi Mi Mix 2S' => 'xiaomi-mi-mix-2s', - 'Xiaomi Mi Mix 3' => 'xiaomi-mi-mix-3', - 'Xiaomi Mi Note' => 'xiaomi-mi-note', - 'Xiaomi Mi Note 10' => 'mi-note-10', - 'Xiaomi Mi Pad 4' => 'xiaomi-mi-pad-4', - 'Xiaomi Pocophone F1' => 'pocophone-f1', - 'Xiaomi Redmi' => 'redmi', - 'Xiaomi Redmi 4' => 'xiaomi-redmi-4', - 'Xiaomi Redmi 5' => 'redmi-5', - 'Xiaomi Redmi 6' => 'redmi-6', - 'Xiaomi Redmi 8' => 'redmi-8', - 'Xiaomi Redmi Note 4' => 'note-4', - 'Xiaomi Redmi Note 5' => 'redmi-note-5', - 'Xiaomi Redmi Note 6' => 'redmi-note-6', - 'Xiaomi Redmi Note 6 Pro' => 'xiaomi-redmi-note-6-pro', - 'Xiaomi Redmi Note 7' => 'redmi-note-7', - 'Xiaomi Redmi Note 8' => 'xiaomi-redmi-note-8', - 'Xiaomi Redmi Note 8 Pro' => 'xiaomi-redmi-note-8-pro', - 'Xiaomi Redmi Note 8T' => 'redmi-note-8t', - 'Xiaomi Redmi Note 9' => 'xiaomi-redmi-note-9', - 'Xiaomi Redmi Note 9 Pro' => 'xiaomi-redmi-note-9-pro', - 'Xiaomi Redmi Note 9S' => 'xiaomi-redmi-note-9s', - 'Xiaomi Roborock' => 'xiaomi-roborock', - 'Xiaomi Roborock S5' => 'xiaomi-roborock-s5', - 'Xiaomi Scooter' => 'xiaomi-scooter', - 'Xiaomi Smartphones' => 'xiaomi-smartphone', - 'Xiaomi Tablets' => 'xiaomi-tablet', - 'Yakuza' => 'yakuza', - 'Yale' => 'yale', - 'Yale Smart Lock' => 'yale-smart-lock', - 'Yamaha' => 'yamaha', - 'Yankee Candle' => 'yankee-candle', - 'Yeelight' => 'xiaomi-yeelight', - 'Yoga' => 'yoga', - 'Yoghurt' => 'yoghurt', - 'Yoshi' => 'yoshi', - 'Yoshi's Crafted World' => 'yoshis-crafted-world', - 'YouView' => 'youview', - 'Yves Saint Laurent' => 'yves-saint-laurent', - 'Zanussi' => 'zanussi', - 'Zippo' => 'zippo', - 'Zizzi' => 'zizzi', - 'Zoo' => 'zoo', - 'Zoostorm' => 'zoostorm', - 'ZOTAC' => 'zotac', - 'ZTE' => 'zte', - 'ZTE Smartphone' => 'zte-smartphone', - 'ZyXEL' => 'zyxel', - ] + 'type' => 'text', + 'exampleValue' => 'broadband', + 'title' => 'Group name in the URL : The group name that must be entered is present after "https://www.hotukdeals.com/tag/" and before any "?". +Example: If the URL of the group displayed in the browser is : +https://www.hotukdeals.com/tag/broadband?sortBy=temp +Then enter : +broadband', + ], + 'subgroups' => [ + 'name' => 'category', + 'type' => 'text', + 'exampleValue' => '343563', + 'title' => 'Category number in the URL : The category number that must be entered is present after "groups=" and before any "&". +Example: If the URL of the group displayed in the browser is : +https://www.hotukdeals.com/tag/broadband?groups=343563&sortBy=new +Then enter : +343563', ], 'order' => [ 'name' => 'Order by', @@ -3275,12 +96,10 @@ class HotUKDealsBridge extends PepperBridgeAbstract 'uri-group' => 'tag/', 'uri-deal' => 'deals/', 'uri-merchant' => 'search/deals?merchant-id=', + 'image-host' => 'https://images.hotukdeals.com/', 'request-error' => 'Could not request HotUKDeals', 'thread-error' => 'Unable to determine the thread ID. Check the URL you entered', - 'no-results' => 'Ooops, looks like we could', - 'relative-date-indicator' => [ - 'ago', - ], + 'currency' => '£', 'price' => 'Price', 'shipping' => 'Shipping', 'origin' => 'Origin', @@ -3288,51 +107,9 @@ class HotUKDealsBridge extends PepperBridgeAbstract 'title-keyword' => 'Search', 'title-group' => 'Group', 'title-talk' => 'Discussion Monitoring', - 'local-months' => [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Occ', - 'Nov', - 'Dec', - 'st', - 'nd', - 'rd', - 'th' - ], - 'local-time-relative' => [ - 'Posted ', - 'm', - 'h,', - 'day', - 'days', - 'month', - 'year', - 'and ' - ], - 'date-prefixes' => [ - 'Posted ', - 'Found ', - 'Refreshed ', - 'Made hot ' - ], - 'relative-date-alt-prefixes' => [ - 'Made hot ', - 'Refreshed ', - 'Last updated ' - ], - 'relative-date-ignore-suffix' => [ - '/by.*$/' - ], - 'localdeal' => [ - 'Local', - 'Expires' - ] + 'deal-type' => 'Deal Type', + 'localdeal' => 'Local deal', + 'context-hot' => '-hot', + 'context-new' => '-new', ]; } diff --git a/bridges/HumbleBundleBridge.php b/bridges/HumbleBundleBridge.php new file mode 100644 index 00000000..28be3ad3 --- /dev/null +++ b/bridges/HumbleBundleBridge.php @@ -0,0 +1,133 @@ + [ + 'name' => 'Bundle type', + 'type' => 'list', + 'defaultValue' => 'bundles', + 'values' => [ + 'All' => 'bundles', + 'Books' => 'books', + 'Games' => 'games', + 'Software' => 'software', + ] + ] + ]]; + + public function collectData() + { + $page = getSimpleHTMLDOMCached($this->getURI()); + $json_text = $page->find('#landingPage-json-data', 0)->innertext; + $json = json_decode(html_entity_decode($json_text), true)['data']; + + $products = []; + $types = ['books', 'games', 'software']; + $types = $this->getInput('type') === 'bundles' ? $types : [$this->getInput('type')]; + foreach ($types as $type) { + $products = array_merge($products, $json[$type]['mosaic'][0]['products']); + } + + foreach ($products as $element) { + $dom = new simple_html_dom(); + $body = $dom->createElement('div'); + $item = [ + 'author' => $element['author'], + 'categories' => $element['hover_highlights'], + 'content' => $body, + 'timestamp' => $element['start_date|datetime'], + 'title' => $element['tile_short_name'], + 'uid' => $element['machine_name'], + 'uri' => parent::getURI() . $element['product_url'], + ]; + + array_unshift($item['categories'], explode(':', $element['tile_name'])[0]); + array_unshift($item['categories'], $element['tile_stamp']); + + $this->createChild($dom, $body, 'img', null, ['src' => $element['tile_logo']]); + $this->createChild($dom, $body, 'img', null, ['src' => $element['high_res_tile_image']]); + $this->createChild($dom, $body, 'h2', $element['short_marketing_blurb']); + $this->createChild($dom, $body, 'p', $element['detailed_marketing_blurb']); + + $this->items[] = $this->processBundle($item, $dom, $body); + } + } + + private function createChild($dom, $body, $name = null, $val = null, $args = []) + { + if ($name == null) { + $elem = $dom->createTextNode($val); + } else { + $elem = $dom->createElement($name, $val); + } + foreach ($args as $arg => $val) { + $elem->setAttribute($arg, $val); + } + $body->appendChild($elem); + return $elem; + } + + private function processBundle($item, $dom, $body) + { + $page = getSimpleHTMLDOMCached($item['uri']); + $json_text = $page->find('#webpack-bundle-page-data', 0)->innertext; + $json = json_decode(html_entity_decode($json_text), true)['bundleData']; + $tiers = $json['tier_display_data']; + ksort($tiers, SORT_NATURAL); + # `initial` element gets sorted to the end as bt# (bundle tiers) precede it alphabetically + array_unshift($tiers, array_pop($tiers)); + + $seen = []; + $toc = $this->createChild($dom, $body, 'ul'); + foreach ($tiers as $tiername => $tier) { + $this->createChild($dom, $body, 'h2', $tier['header'], ['id' => $tiername]); + $li = $this->createChild($dom, $toc, 'li'); + $this->createChild($dom, $li, 'a', $tier['header'], ['href' => "#$tiername"]); + $toc_tier = $this->createChild($dom, $toc, 'ul'); + foreach ($tier['tier_item_machine_names'] as $name) { + if (in_array($name, $seen)) { + continue; + } + array_push($seen, $name); + + $element = $json['tier_item_data'][$name]; + $head = $this->createChild($dom, $body, 'h3', null, ['id' => $name]); + $head_link = $this->createChild($dom, $head, 'a', $element['human_name'], ['id' => $name]); + $li = $this->createChild($dom, $toc_tier, 'li'); + $this->createChild($dom, $li, 'a', $element['human_name'], ['href' => "#$name"]); + $this->createChild($dom, $body, 'img', null, ['src' => $element['resolved_paths']['featured_image']]); + $this->createChild($dom, $body, 'img', null, ['src' => $element['resolved_paths']['preview_image']]); + $this->createChild($dom, $body, 'br'); + if ($element['description_text']) { + $body->appendChild(str_get_html($element['description_text'])->root); + } + if ($element['youtube_link']) { + $head_link->href = 'https://youtu.be/' . $element['youtube_link']; + } + if ($element['book_preview']) { + $head_link->href = $element['book_preview']['preview_file_link']; + } + } + } + + return $item; + } + + public function getName() + { + $name = parent::getName(); + $name .= $this->getInput('type') ? ' - ' . $this->getInput('type') : ''; + return $name; + } + + public function getURI() + { + $uri = parent::getURI() . $this->getInput('type'); + return $uri; + } +} diff --git a/bridges/HuntShowdownNewsBridge.php b/bridges/HuntShowdownNewsBridge.php new file mode 100644 index 00000000..6aca88f7 --- /dev/null +++ b/bridges/HuntShowdownNewsBridge.php @@ -0,0 +1,40 @@ +find('.col'); + + // Removing first element because it's a "load more" button + array_shift($articles); + foreach ($articles as $article) { + $item = []; + + $article_title = $article->find('h3', 0)->plaintext; + $article_content = $article->find('p', 0)->plaintext; + $article_cover = $article->find('img', 0)->src; + + // If there is a cover, add it to the content + if (!empty($article_cover)) { + $article_cover = '' . $article_title . '

    '; + $article_content = $article_cover . $article_content; + } + + $item['uri'] = $article->find('a', 0)->href; + $item['title'] = $article_title; + $item['content'] = $article_content; + $item['enclosures'] = [$article_cover]; + $item['timestamp'] = $article->find('span', 0)->plaintext; + + $this->items[] = $item; + } + } +} \ No newline at end of file diff --git a/bridges/HytaleBridge.php b/bridges/HytaleBridge.php index 7ca11af6..01fc0f38 100644 --- a/bridges/HytaleBridge.php +++ b/bridges/HytaleBridge.php @@ -18,26 +18,27 @@ class HytaleBridge extends BridgeAbstract $blogPosts = json_decode(getContents(self::_API_URL_PUBLISHED)); $length = count($blogPosts); - for ($i = 1; $i < $length; $i += 3) { + for ($i = 0; $i < $length; $i += 3) { $slug = $blogPosts[$i]->slug; $blogPost = json_decode(getContents(self::_API_URL_BLOG_POST . $slug)); - if (property_exists($blogPost, 'previous')) { - $this->addBlogPost($blogPost->previous); + if (property_exists($blogPost, 'next')) { + $this->addBlogPost($blogPost->next); } $this->addBlogPost($blogPost); - if (property_exists($blogPost, 'next')) { - $this->addBlogPost($blogPost->next); + if (property_exists($blogPost, 'previous')) { + $this->addBlogPost($blogPost->previous); } } - if ($length % 3 == 1) { - $slug = $blogPosts[count($blogPosts) - 1]->slug; + if (($length >= 3) && ($length % 3 == 0)) { + $slug = $blogPosts[$length - 1]->slug; $blogPost = json_decode(getContents(self::_API_URL_BLOG_POST . $slug)); + $this->addBlogPost($blogPost); } } diff --git a/bridges/I4wifiBridge.php b/bridges/I4wifiBridge.php new file mode 100644 index 00000000..643057a4 --- /dev/null +++ b/bridges/I4wifiBridge.php @@ -0,0 +1,293 @@ + [ + ], + ]; + + /** + * Fetches and processes data based on the selected context. + * + * This function retrieves the HTML content for the specified context's URI, + * resolves relative links within the content, and then delegates the data + * extraction to the appropriate method (currently only `collectNews`). + */ + + public function collectData() + { + $html = getSimpleHTMLDOMCached($this->getURI(), 86400); + + defaultLinkTo($html, static::URI); + + // Router + switch ($this->queriedContext) { + case 'Product news': + $this->collectNews($html); + break; + } + } + + /** + * Returns the icon for the bridge. + * + * @return string The icon URL. + */ + public function getURI() + { + $uri = static::URI; + + // URI Router + switch ($this->queriedContext) { + case 'Product news': + $uri .= '/'; + break; + } + + return $uri; + } + + /** + * Returns the name for the bridge. + * + * @return string The Name. + */ + public function getName() + { + $name = static::NAME; + + $name .= ($this->queriedContext) ? ' - ' . $this->queriedContext : ''; + + switch ($this->queriedContext) { + case 'Product news': + break; + } + + return $name; + } + + /** + * Parse most used date formats + * + * Basically strtotime doesn't convert dates correctly due to formats + * being hard to interpret. So we use the DateTime object, manually + * fixing dates and times (set to 00:00:00.000). + * + * We don't know the timezone, so just assume +00:00 (or whatever + * DateTime chooses) + */ + private function fixDate($date) + { + $df = $this->parseDateTimeFromString($date); + + return date_format($df, 'U'); + } + + /** + * Extracts the images from the article. + * + * @param object $article The article object. + * @return array An array of image URLs. + */ + private function extractImages($article) + { + // Notice: We can have zero or more images (though it should mostly be 1) + $elements = $article->find('img'); + + $images = []; + + foreach ($elements as $img) { + $images[] = $img->src; + } + + return $images; + } + + #region Articles + + /** + * Collects uri, timestamp, title, content and images in the news articles from the HTML and transforms to rss. + * + * @param object $html The HTML object. + * @return void + */ + private function collectNews($html) + { + $articles = $html->find('.timeline-item.timeline-item-right') + or throwServerException('No articles found! Layout might have changed!'); + + foreach ($articles as $article) { + $item = []; + + // get uri of product + $item['uri'] = $this->extractNewsUri($article); + // Add content + $item['content'] = $this->extractNewsDescription($article); + // Add images + $item['title'] = $this->extractNewsTitle($article); + // Add images + $item['enclosures'] = $this->extractImages($article); + // Add timestamp + $item['timestamp'] = $this->extractNewsDate($article); + + // collect sources into rss article + $this->items[] = $item; + } + } + + /** + * Extracts the URI of the news article. + * + * @param object $article The article object. + * @return string The URI of the news article. + */ + private function extractNewsUri($article) + { + // Return URI of the article + $element = $article->find('a', 0) + or throwServerException('Anchor not found!'); + + return $element->href; + } + + /** + * Extracts the date of the news article. + * + * @param object $article The article object. + * @return string The date of the news article. + */ + private function extractNewsDate($article) + { + // Check if date is set + $element = $article->find('.timeline-item-info', 0) + or throwServerException('Date not found!'); + + // Format date + return $this->fixDate($element->plaintext); + } + + /** + * Extracts the description of the news article. + * + * @param object $article The article object. + * @return string The description of the news article. + */ + private function extractNewsDescription($article) + { + // Extract description + $element = $article->find('p', 0) + or throwServerException('Description not found!'); + + return $element->innertext; + } + + /** + * Extracts the title of the news article. + * + * @param object $article The article object. + * @return string The title of the news article. + */ + private function extractNewsTitle($article) + { + // Extract title + $element = $article->find('img', 0) + or throwServerException('Title not found!'); + + return $element->alt; + } + + /** + * It attempts to recognize the date/time format in a string and create a DateTime object. + * + * It goes through the list of defined formats and tries to apply them to the input string. + * Returns the first successfully parsed DateTime object that matches the entire string. + * + * @param string $dateString A string potentially containing a date and/or time. + * @return DateTime|null A DateTime object if successfully recognized and parsed, otherwise null. + */ + private function parseDateTimeFromString(string $dateString): ?DateTime + { + // List of common formats - YOU CAN AND SHOULD EXPAND IT according to expected inputs! + // Order may matter if the formats are ambiguous. + // It is recommended to give more specific formats (with time, full year) before more general ones. + $possibleFormats = [ + // Czech formats (day.month.year) + 'd.m.Y H:i:s', // 10.04.2025 10:57:47 + 'j.n.Y H:i:s', // 10.4.2025 10:57:47 + 'd. m. Y H:i:s', // 10. 04. 2025 10:57:47 + 'j. n. Y H:i:s', // 10. 4. 2025 10:57:47 + 'd.m.Y H:i', // 10.04.2025 10:57 + 'j.n.Y H:i', // 10.4.2025 10:57 + 'd. m. Y H:i', // 10. 04. 2025 10:57 + 'j. n. Y H:i', // 10. 4. 2025 10:57 + 'd.m.Y', // 10.04.2025 + 'j.n.Y', // 10.4.2025 + 'd. m. Y', // 10. 04. 2025 + 'j. n. Y', // 10. 4. 2025 + + // ISO 8601 and international formats (year-month-day) + 'Y-m-d H:i:s', // 2025-04-10 10:57:47 + 'Y-m-d H:i', // 2025-04-10 10:57 + 'Y-m-d', // 2025-04-10 + 'YmdHis', // 20250410105747 + 'Ymd', // 20250410 + + // American formats (month/day/year) - beware of ambiguity! + 'm/d/Y H:i:s', // 04/10/2025 10:57:47 + 'n/j/Y H:i:s', // 4/10/2025 10:57:47 + 'm/d/Y H:i', // 04/10/2025 10:57 + 'n/j/Y H:i', // 4/10/2025 10:57 + 'm/d/Y', // 04/10/2025 + 'n/j/Y', // 4/10/2025 + + // Standard formats (including time zone) + DateTime::ATOM, // example. 2025-04-10T10:57:47+02:00 + DateTime::RFC3339, // example. 2025-04-10T10:57:47+02:00 + DateTime::RFC3339_EXTENDED, // example. 2025-04-10T10:57:47.123+02:00 + DateTime::RFC2822, // example. Thu, 10 Apr 2025 10:57:47 +0200 + DateTime::ISO8601, // example. 2025-04-10T105747+0200 + 'Y-m-d\TH:i:sP', // ISO 8601 s 'T' oddělovačem + 'Y-m-d\TH:i:s.uP', // ISO 8601 s mikrosekundami + + // You can add more formats as needed... + // e.g. 'd-M-Y' (10-Apr-2025) - requires English locale + // e.g. 'j. F Y' (10. abren 2025) - requires Czech locale + ]; + + // Set locale for parsing month/day names (if using F, M, l, D) + // E.g. setlocale(LC_TIME, 'cs_CZ.UTF-8'); or 'en_US.UTF-8'); + + foreach ($possibleFormats as $format) { + // We will try to create a DateTime object from the given format + $dateTime = DateTime::createFromFormat($format, $dateString); + + // We check that the parsing was successful AND ALSO + // that there were no errors or warnings during the parsing. + // This is important to ensure that the format matches the ENTIRE string. + if ($dateTime !== false) { + $errors = DateTime::getLastErrors(); + if (!($errors)) { + // Success! We found a valid format for the entire string. + return $dateTime; + } + } + } + + // If no format matches or parsing failed + return null; + } + + #endregion +} diff --git a/bridges/IPBBridge.php b/bridges/IPBBridge.php index d5db0111..32d148fa 100644 --- a/bridges/IPBBridge.php +++ b/bridges/IPBBridge.php @@ -44,7 +44,7 @@ class IPBBridge extends FeedExpander switch (parse_url($this->getInput('uri'), PHP_URL_PATH)) { case null: case '/index.php': - returnClientError('Provided URI is invalid!'); + throwClientException('Provided URI is invalid!'); break; default: break; @@ -75,7 +75,7 @@ class IPBBridge extends FeedExpander $this->collectForum($html); break; default: - returnClientError('Unknown type!'); + throwClientException('Unknown type!'); break; } } @@ -106,7 +106,7 @@ class IPBBridge extends FeedExpander $this->collectForumTable($html); break; default: - returnClientError('Unknown forum format!'); + throwClientException('Unknown forum format!'); break; } } @@ -159,7 +159,7 @@ class IPBBridge extends FeedExpander $this->collectTopicHistory($html, $limit, 'collectTopicDiv'); break; default: - returnClientError('Unknown topic format!'); + throwClientException('Unknown topic format!'); break; } } @@ -168,7 +168,7 @@ class IPBBridge extends FeedExpander { // Make sure the callback is valid! if (!method_exists($this, $callback)) { - returnServerError('Unknown function (\'' . $callback . '\')!'); + throwServerException('Unknown function (\'' . $callback . '\')!'); } $next = null; // Holds the URI of the next page diff --git a/bridges/IdealoBridge.php b/bridges/IdealoBridge.php index cef2b812..05a2ebb8 100644 --- a/bridges/IdealoBridge.php +++ b/bridges/IdealoBridge.php @@ -2,15 +2,15 @@ class IdealoBridge extends BridgeAbstract { - const NAME = 'Idealo.de Bridge'; + const NAME = 'idealo.de / idealo.fr / idealo.es Bridge'; const URI = 'https://www.idealo.de'; - const DESCRIPTION = 'Tracks the price for a product on idealo.de. Pricealarm if specific price is set'; + const DESCRIPTION = 'Tracks the price for a product on idealo.de / idealo.fr / idealo.es. Pricealarm if specific price is set'; const MAINTAINER = 'SebLaus'; const CACHE_TIMEOUT = 60 * 30; // 30 min const PARAMETERS = [ [ 'Link' => [ - 'name' => 'Idealo.de Link to productpage', + 'name' => 'idealo.de / idealo.fr / idealo.es Link to productpage', 'required' => true, 'exampleValue' => 'https://www.idealo.de/preisvergleich/OffersOfProduct/202007367_-s7-pro-ultra-roborock.html' ], @@ -35,20 +35,103 @@ class IdealoBridge extends BridgeAbstract ] ]; + private $headers = [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0', + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language: fr-FR,fr;q=0.8,en-US;q=0.5,en;q=0.3' + ]; + private $options = [ + CURLOPT_TRANSFER_ENCODING => 1, + CURLOPT_ACCEPT_ENCODING => 'gzip, deflate, br' + ]; + public function getIcon() { return 'https://cdn.idealo.com/storage/ids-assets/ico/favicon.ico'; } + /** + * Returns the RSS Feed title when a RSS feed is rendered + * @return string the RSS feed Title + */ + private function getFeedTitle() + { + $cacheDuration = 604800; + $link = $this->getInput('Link'); + $keyTITLE = $link . 'TITLE'; + $product = $this->loadCacheValue($keyTITLE); + + // The cache does not contain the title of the bridge, we must get it and save it in the cache + if ($product === null) { + $html = getSimpleHTMLDOM($link, $this->headers, $this->options); + $product = $html->find('.oopStage-title', 0)->find('span', 0)->plaintext; + $this->saveCacheValue($keyTITLE, $product); + } + + $MaxPriceUsed = $this->getInput('MaxPriceUsed'); + $MaxPriceNew = $this->getInput('MaxPriceNew'); + $titleParts = []; + + $titleParts[] = $product; + + // Add Max Prices to the title + if ($MaxPriceUsed !== null) { + $titleParts[] = 'Max Price Used : ' . $MaxPriceUsed . '€'; + } + if ($MaxPriceNew !== null) { + $titleParts[] = 'Max Price New : ' . $MaxPriceNew . '€'; + } + + $title = implode(' ', $titleParts); + + + return $title . ' - ' . $this::NAME; + } + + /** + * Returns the Price as float + * @return float rhe price converted in float + */ + private function convertPriceToFloat($price) + { + // Every price is stored / displayed as "xxx,xx €", but PHP can't convert it as float + + if ($price !== null) { + // Convert comma as dot + $price = str_replace(',', '.', $price); + // Remove the '€' char + $price = str_replace('€', '', $price); + // Convert to float + return floatval($price); + } else { + return $price; + } + } + + /** + * Returns the Price Trend emoji + * @return string the Price Trend Emoji + */ + private function getPriceTrend($NewPrice, $OldPrice) + { + $NewPrice = $this->convertPriceToFloat($NewPrice); + $OldPrice = $this->convertPriceToFloat($OldPrice); + // In case there is no old Price, then show no trend + if ($OldPrice === null || $OldPrice == 0) { + $trend = ''; + } else if ($NewPrice > $OldPrice) { + $trend = '↗'; + } else if ($NewPrice == $OldPrice) { + $trend = '➡'; + } else if ($NewPrice < $OldPrice) { + $trend = '↘'; + } + return $trend; + } public function collectData() { - // Needs header with user-agent to function properly. - $header = [ - 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' - ]; - $link = $this->getInput('Link'); - $html = getSimpleHTMLDOM($link, $header); + $html = getSimpleHTMLDOM($link, $this->headers, $this->options); // Get Productname $titleobj = $html->find('.oopStage-title', 0); @@ -65,35 +148,55 @@ class IdealoBridge extends BridgeAbstract $OldPriceNew = $this->loadCacheValue($KeyNEW); $OldPriceUsed = $this->loadCacheValue($KeyUSED); - // First button is new. Found at oopStage-conditionButton-wrapper-text class (.) - $FirstButton = $html->find('.oopStage-conditionButton-wrapper-text', 0); - if ($FirstButton) { - $PriceNew = $FirstButton->find('strong', 0)->plaintext; + // First button contains the new price. Found at oopStage-conditionButton-wrapper-text class (.) + $ActualNewPrice = $html->find('div[id=oopStage-conditionButton-new]', 0); + // Second Button contains the used product price + $ActualUsedPrice = $html->find('div[id=oopStage-conditionButton-used]', 0); + // Get the first item of the offers list to have an option if there is no New/Used Button available + $altPrice = $html->find('.productOffers-listItemOfferPrice', 0); + + if ($ActualNewPrice) { + $PriceNew = $ActualNewPrice->find('strong', 0)->plaintext; + // Save current price + $this->saveCacheValue($KeyNEW, $PriceNew); + } else if ($altPrice) { + // Get price from first List item if no New/used Buttons available + $PriceNew = trim($altPrice->plaintext); + $this->saveCacheValue($KeyNEW, $PriceNew); + } else if (($ActualNewPrice === null || $altPrice === null) && $ActualUsedPrice !== null) { + // In case there is no actual New Price and a Used Price exists, then delete the previous value in the cache + $this->cache->delete($this->getShortName() . '_' . $KeyNEW); } - // Second Button is used - $SecondButton = $html->find('.oopStage-conditionButton-wrapper-text', 1); - if ($SecondButton) { - $PriceUsed = $SecondButton->find('strong', 0)->plaintext; + // Second Button contains the used product price + if ($ActualUsedPrice) { + $PriceUsed = $ActualUsedPrice->find('strong', 0)->plaintext; + // Save current price + $this->saveCacheValue($KeyUSED, $PriceUsed); + } else if ($ActualUsedPrice === null && ($ActualNewPrice !== null || $altPrice !== null)) { + // In case there is no actual Used Price and a New Price exists, then delete the previous value in the cache + $this->cache->delete($this->getShortName() . '_' . $KeyUSED); } - // Only continue if a price has changed - if ($PriceNew != $OldPriceNew || $PriceUsed != $OldPriceUsed) { + // 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) + if (!($ActualNewPrice === null && $ActualUsedPrice === null && $altPrice === null) && ($PriceNew != $OldPriceNew || $PriceUsed != $OldPriceUsed)) { // Get Product Image $image = $html->find('.datasheet-cover-image', 0)->src; + $content = ''; + // Generate Content - if ($PriceNew > 1) { - $content = "

    Price New:
    $PriceNew

    "; + if (isset($PriceNew) && $this->convertPriceToFloat($PriceNew) > 0) { + $content .= sprintf('

    Price New:
    %s %s

    ', $PriceNew, $this->getPriceTrend($PriceNew, $OldPriceNew)); $content .= "

    Price New before:
    $OldPriceNew

    "; } if ($this->getInput('MaxPriceNew') != '') { - $content .= sprintf('

    Max Price Used:
    %s,00 €

    ', $this->getInput('MaxPriceNew')); + $content .= sprintf('

    Max Price New:
    %s,00 €

    ', $this->getInput('MaxPriceNew')); } - if ($PriceUsed > 1) { - $content .= "

    Price Used:
    $PriceUsed

    "; + if (isset($PriceUsed) && $this->convertPriceToFloat($PriceUsed) > 0) { + $content .= sprintf('

    Price Used:
    %s %s

    ', $PriceUsed, $this->getPriceTrend($PriceUsed, $OldPriceUsed)); $content .= "

    Price Used before:
    $OldPriceUsed

    "; } @@ -104,14 +207,14 @@ class IdealoBridge extends BridgeAbstract $content .= ""; - $now = date('d.m.j H:m'); + $now = date('d/m/Y H:i'); - $Pricealarm = 'Pricealarm %s: %s %s %s'; + $Pricealarm = 'Pricealarm %s: %s %s - %s'; // Currently under Max new price if ($this->getInput('MaxPriceNew') != '') { - if ($PriceNew < $this->getInput('MaxPriceNew')) { - $title = sprintf($Pricealarm, 'Used', $PriceNew, $Productname, $now); + if (isset($PriceNew) && $this->convertPriceToFloat($PriceNew) < $this->getInput('MaxPriceNew')) { + $title = sprintf($Pricealarm, 'New', $PriceNew, $Productname, $now); $item = [ 'title' => $title, 'uri' => $link, @@ -124,7 +227,7 @@ class IdealoBridge extends BridgeAbstract // Currently under Max used price if ($this->getInput('MaxPriceUsed') != '') { - if ($PriceUsed < $this->getInput('MaxPriceUsed')) { + if (isset($PriceUsed) && $this->convertPriceToFloat($PriceUsed) < $this->getInput('MaxPriceUsed')) { $title = sprintf($Pricealarm, 'Used', $PriceUsed, $Productname, $now); $item = [ 'title' => $title, @@ -136,35 +239,24 @@ class IdealoBridge extends BridgeAbstract } } - // General Priceupdate + // General Priceupdate Without any Max Price for new and Used product if ($this->getInput('MaxPriceUsed') == '' && $this->getInput('MaxPriceNew') == '') { // check if a relevant pricechange happened if ( (!$this->getInput('ExcludeNew') && $PriceNew != $OldPriceNew ) || (!$this->getInput('ExcludeUsed') && $PriceUsed != $OldPriceUsed ) ) { - $title .= 'Priceupdate! '; + $title = 'Priceupdate! '; - if (!$this->getInput('ExcludeNew')) { - if ($PriceNew < $OldPriceNew) { - $title .= 'NEW:⬇ '; // Arrow Down Emoji - } - if ($PriceNew > $OldPriceNew) { - $title .= 'NEW:⬆ '; // Arrow Up Emoji - } + if (!$this->getInput('ExcludeNew') && isset($PriceNew)) { + $title .= 'NEW' . $this->getPriceTrend($PriceNew, $OldPriceNew) . ' '; } - - if (!$this->getInput('ExcludeUsed')) { - if ($PriceUsed < $OldPriceUsed) { - $title .= 'USED:⬇ '; // Arrow Down Emoji - } - if ($PriceUsed > $OldPriceUsed) { - $title .= 'USED:⬆ '; // Arrow Up Emoji - } + if (!$this->getInput('ExcludeUsed') && isset($PriceUsed)) { + $title .= 'USED' . $this->getPriceTrend($PriceUsed, $OldPriceUsed) . ' '; } $title .= $Productname; - $title .= ' '; + $title .= ' - '; $title .= $now; $item = [ @@ -177,9 +269,33 @@ class IdealoBridge extends BridgeAbstract } } } + } - // Save current price - $this->saveCacheValue($KeyNEW, $PriceNew); - $this->saveCacheValue($KeyUSED, $PriceUsed); + /** + * Returns the RSS Feed title according to the parameters + * @return string the RSS feed Tile + */ + public function getName() + { + switch ($this->queriedContext) { + case '0': + return $this->getFeedTitle(); + default: + return parent::getName(); + } + } + + /** + * Returns the RSS Feed URL according to the parameters + * @return string the RSS feed URL + */ + public function getURI() + { + switch ($this->queriedContext) { + case '0': + return $this->getInput('Link'); + default: + return parent::getURI(); + } } } diff --git a/bridges/ImgsedBridge.php b/bridges/ImgsedBridge.php index 12466c6b..ed541adb 100644 --- a/bridges/ImgsedBridge.php +++ b/bridges/ImgsedBridge.php @@ -59,7 +59,7 @@ class ImgsedBridge extends BridgeAbstract $this->collectTaggeds(); } } catch (HttpException $e) { - throw new \Exception(sprintf('Unable to find user `%s`', $username)); + throwClientException(sprintf('Unable to find user `%s`', $username)); } } @@ -258,7 +258,7 @@ HTML, // If no content type is selected, this bridge does nothing, so we return an error if (count($types) == 0) { - returnClientError('You must select at least one of the content type : Post, Stories or Tags !'); + throwClientException('You must select at least one of the content type : Post, Stories or Tags !'); } $typesText = $types[0] ?? ''; diff --git a/bridges/InstagramBridge.php b/bridges/InstagramBridge.php index 633d6080..58e56c59 100644 --- a/bridges/InstagramBridge.php +++ b/bridges/InstagramBridge.php @@ -86,6 +86,11 @@ class InstagramBridge extends BridgeAbstract $headers = []; $sessionId = $this->getOption('session_id'); $dsUserId = $this->getOption('ds_user_id'); + $headers[] = 'x-ig-app-id: 936619743392459'; + $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'; + $headers[] = 'Accept-Language: en-US,en;q=0.9,ru;q=0.8'; + $headers[] = 'Accept-Encoding: gzip, deflate, br'; + $headers[] = 'Accept: */*'; if ($sessionId and $dsUserId) { $headers[] = 'cookie: sessionid=' . $sessionId . '; ds_user_id=' . $dsUserId; } @@ -103,15 +108,13 @@ class InstagramBridge extends BridgeAbstract if (!$pk) { $data = $this->getContents(self::URI . 'web/search/topsearch/?query=' . $username); - foreach (json_decode($data)->users as $user) { - if (strtolower($user->user->username) === strtolower($username)) { - $pk = $user->user->pk; + if (!$data) { + foreach (json_decode($data)->users as $user) { + if (strtolower($user->user->username) === strtolower($username)) { + $pk = $user->user->pk; + } } } - if (!$pk) { - returnServerError('Unable to find username in search result.'); - } - $this->cache->set($cacheKey, $pk); } return $pk; } @@ -125,8 +128,10 @@ class InstagramBridge extends BridgeAbstract return; } - if (!is_null($this->getInput('u'))) { + if (!is_null($this->getInput('u')) && !$this->fallbackMode) { $userMedia = $data->data->user->edge_owner_to_timeline_media->edges; + } elseif (!is_null($this->getInput('u')) && $this->fallbackMode) { + $userMedia = $data->context->graphql_media; } elseif (!is_null($this->getInput('h'))) { $userMedia = $data->data->hashtag->edge_hashtag_to_media->edges; } elseif (!is_null($this->getInput('l'))) { @@ -134,7 +139,12 @@ class InstagramBridge extends BridgeAbstract } foreach ($userMedia as $media) { - $media = $media->node; + // The media is not in the same element if in fallback mode than not + if (!$this->fallbackMode) { + $media = $media->node; + } else { + $media = $media->shortcode_media; + } switch ($this->getInput('media_type')) { case 'all': @@ -267,14 +277,28 @@ class InstagramBridge extends BridgeAbstract protected function getInstagramJSON($uri) { + // Sets fallbackMode to false + $this->fallbackMode = false; if (!is_null($this->getInput('u'))) { - $userId = $this->getInstagramUserId($this->getInput('u')); - $data = $this->getContents(self::URI . + try { + $userId = $this->getInstagramUserId($this->getInput('u')); + + // If the Userid is not null, try to load the data from the graphql + if (!$userId) { + $data = $this->getContents(self::URI . 'graphql/query/?query_hash=' . self::USER_QUERY_HASH . '&variables={"id"%3A"' . $userId . '"%2C"first"%3A10}'); + } else { + // In case we did not get the UserId then we must go back to the fallback mode + $data = $this->getInstagramJSONFallback(); + } + } catch (HttpException $e) { + // Even if the UserId is not nul, the graphql request could go wrong, and then we should try to use the fallback mode + $data = $this->getInstagramJSONFallback(); + } return json_decode($data); } elseif (!is_null($this->getInput('h'))) { $data = $this->getContents(self::URI . @@ -297,6 +321,31 @@ class InstagramBridge extends BridgeAbstract } } + protected function getInstagramJSONFallback() + { + // If loading the data directly failed, we fall back to the "/embed" data loading + // We are in the fallback mode : set a booolean to handle this specific case while collecting the content + $this->fallbackMode = true; + // Get the HTML code of the profile embed page, and extract the JSON of it + $username = $this->getInput('u'); + // Load the content using the integrated function to use helping headers + $htmlString = $this->getContents(self::URI . $username . '/embed/'); + // Load the String as an SimpleHTMLDom Object + $html = new simple_html_dom(); + $html->load($htmlString); + // Find the