diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 00000000000..faec9f095ab
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,8 @@
+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
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000000..6e625b8a008
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,27 @@
+{
+ "name": "rss-bridge dev",
+ "build": { "dockerfile": "Dockerfile" },
+ "customizations": {
+ // Configure properties specific to VS Code.
+ "vscode": {
+ // Set *default* container specific settings.json values on container create.
+ "settings": {
+ "php.validate.executablePath": "/usr/local/bin/php",
+ "phpSniffer.executablesFolder": "/usr/local/bin/",
+ "phpcs.executablePath": "/usr/local/bin/phpcs",
+ "phpcs.lintOnType": false
+ },
+
+ // Add the IDs of extensions you want installed when the container is created.
+ "extensions": [
+ "xdebug.php-debug",
+ "bmewburn.vscode-intelephense-client",
+ "philfontaine.autolaunch",
+ "eamodio.gitlens",
+ "shevaua.phpcs"
+ ]
+ }
+ },
+ "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"
+}
\ No newline at end of file
diff --git a/.devcontainer/launch.json b/.devcontainer/launch.json
new file mode 100644
index 00000000000..e1b473b8ec2
--- /dev/null
+++ b/.devcontainer/launch.json
@@ -0,0 +1,49 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Listen for Xdebug",
+ "type": "php",
+ "request": "launch",
+ "port": 9003,
+ "auto": true
+ },
+ {
+ "name": "Launch currently open script",
+ "type": "php",
+ "request": "launch",
+ "program": "${file}",
+ "cwd": "${fileDirname}",
+ "port": 0,
+ "runtimeArgs": [
+ "-dxdebug.start_with_request=yes"
+ ],
+ "env": {
+ "XDEBUG_MODE": "debug,develop",
+ "XDEBUG_CONFIG": "client_port=${port}"
+ }
+ },
+ {
+ "name": "Launch Built-in web server",
+ "type": "php",
+ "request": "launch",
+ "runtimeArgs": [
+ "-dxdebug.mode=debug",
+ "-dxdebug.start_with_request=yes",
+ "-S",
+ "localhost:0"
+ ],
+ "program": "",
+ "cwd": "${workspaceRoot}",
+ "port": 9003,
+ "serverReadyAction": {
+ "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started",
+ "uriFormat": "http://localhost:%s",
+ "action": "openExternally"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.devcontainer/nginx.conf b/.devcontainer/nginx.conf
new file mode 100644
index 00000000000..0e5db6dcc9c
--- /dev/null
+++ b/.devcontainer/nginx.conf
@@ -0,0 +1,17 @@
+server {
+ listen 3100 default_server;
+ root /workspaces/rss-bridge;
+ access_log /var/log/nginx/rssbridge.access.log;
+ error_log /var/log/nginx/rssbridge.error.log;
+ index index.php;
+
+ location ~ /(\.|vendor|tests) {
+ deny all;
+ return 403; # Forbidden
+ }
+
+ location ~ \.php$ {
+ include snippets/fastcgi-php.conf;
+ fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
+ }
+}
diff --git a/.devcontainer/xdebug.ini b/.devcontainer/xdebug.ini
new file mode 100644
index 00000000000..1079f0b8807
--- /dev/null
+++ b/.devcontainer/xdebug.ini
@@ -0,0 +1,7 @@
+[xdebug]
+xdebug.mode=develop,debug
+xdebug.client_host=localhost
+xdebug.client_port=9003
+xdebug.start_with_request=yes
+xdebug.discover_client_host=false
+xdebug.log='/var/www/html/xdebug.log'
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
index db313697054..90ca9f256aa 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,6 @@
.git
+!.git/HEAD
+!.git/refs/heads/*
.gitattributes
.github/*
.travis.yml
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 00000000000..d231c97e6d8
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,4 @@
+# Reformat code base to PSR12
+4f75591060d95208a301bc6bf460d875631b29cc
+# Fix coding style missed by phpbcf
+951092eef374db048b77bac85e75e3547bfac702
diff --git a/.gitattributes b/.gitattributes
index 36544021208..280532568cc 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,6 @@
# Auto detect text files and perform LF normalization
* text=auto
+*.sh text eol=lf
# Custom for Visual Studio
*.cs diff=csharp
@@ -46,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
new file mode 100644
index 00000000000..7ebb4030e82
--- /dev/null
+++ b/.github/.gitignore
@@ -0,0 +1,7 @@
+# Visual Studio Code
+.vscode/*
+
+# Generated files
+comment*.md
+comment*.txt
+*.html
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index ec47ab0dbee..69976e4169d 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,49 +1,7 @@
### Pull request policy
-* [Fix one issue per pull request](https://github.com/RSS-Bridge/rss-bridge/wiki/Pull-request-policy#fix-one-issue-per-pull-request)
-* [Respect the coding style policy](https://github.com/RSS-Bridge/rss-bridge/wiki/Pull-request-policy#respect-the-coding-style-policy)
-* [Properly name your commits](https://github.com/RSS-Bridge/rss-bridge/wiki/Pull-request-policy#properly-name-your-commits)
- * When fixing a bridge (located in the `bridges` directory), write `[BridgeName] Feature`
(i.e. `[YoutubeBridge] Fix typo in video titles`).
- * When fixing other files, use `[FileName] Feature`
(i.e. `[index.php] Add multilingual support`).
- * When fixing a general problem that applies to multiple files, write `category: feature`
(i.e. `bridges: Fix various typos`).
-
-Note that all pull-requests must pass all tests before they can be merged.
+See the [Pull request policy page on the documentation](https://rss-bridge.github.io/rss-bridge/For_Developers/Pull_Request_policy.html) for more information on the pull request policy.
### Coding style
-* [Whitespace](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitespace)
- * [Add a new line at the end of a file](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitespace#add-a-new-line-at-the-end-of-a-file)
- * [Do not add a whitespace before a semicolon](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitespace#add-a-new-line-at-the-end-of-a-file)
- * [Do not add whitespace at start or end of a file or end of a line](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitespace#do-not-add-whitespace-at-start-or-end-of-a-file-or-end-of-a-line)
-* [Indentation](https://github.com/RSS-Bridge/rss-bridge/wiki/Indentation)
- * [Use tabs for indentation](https://github.com/RSS-Bridge/rss-bridge/wiki/Indentation#use-tabs-for-indentation)
-* [Maximum line length](https://github.com/RSS-Bridge/rss-bridge/wiki/Maximum-line-length)
- * [The maximum line length should not exceed 80 characters](https://github.com/RSS-Bridge/rss-bridge/wiki/Maximum-line-length#the-maximum-line-length-should-not-exceed-80-characters)
-* [Strings](https://github.com/RSS-Bridge/rss-bridge/wiki/Strings)
- * [Whenever possible use single quoted strings](https://github.com/RSS-Bridge/rss-bridge/wiki/Strings#whenever-possible-use-single-quote-strings)
- * [Add spaces around the concatenation operator](https://github.com/RSS-Bridge/rss-bridge/wiki/Strings#add-spaces-around-the-concatenation-operator)
- * [Use a single string instead of concatenating](https://github.com/RSS-Bridge/rss-bridge/wiki/Strings#use-a-single-string-instead-of-concatenating)
-* [Constants](https://github.com/RSS-Bridge/rss-bridge/wiki/Constants)
- * [Use UPPERCASE for constants](https://github.com/RSS-Bridge/rss-bridge/wiki/Constants#use-uppercase-for-constants)
-* [Keywords](https://github.com/RSS-Bridge/rss-bridge/wiki/Keywords)
- * [Use lowercase for `true`, `false` and `null`](https://github.com/RSS-Bridge/rss-bridge/wiki/Keywords#use-lowercase-for-true-false-and-null)
-* [Operators](https://github.com/RSS-Bridge/rss-bridge/wiki/Operators)
- * [Operators must have a space around them](https://github.com/RSS-Bridge/rss-bridge/wiki/Operators#operators-must-have-a-space-around-them)
-* [Functions](https://github.com/RSS-Bridge/rss-bridge/wiki/Functions)
- * [Parameters with default values must appear last in functions](https://github.com/RSS-Bridge/rss-bridge/wiki/Functions#parameters-with-default-values-must-appear-last-in-functions)
- * [Calling functions](https://github.com/RSS-Bridge/rss-bridge/wiki/Functions#calling-functions)
- * [Do not add spaces after opening or before closing bracket](https://github.com/RSS-Bridge/rss-bridge/wiki/Functions#do-not-add-spaces-after-opening-or-before-closing-bracket)
-* [Structures](https://github.com/RSS-Bridge/rss-bridge/wiki/Structures)
- * [Structures must always be formatted as multi-line blocks](https://github.com/RSS-Bridge/rss-bridge/wiki/Structures#structures-must-always-be-formatted-as-multi-line-blocks)
-* [If-Statement](https://github.com/RSS-Bridge/rss-bridge/wiki/if-Statement)
- * [Use `elseif` instead of `else if`](https://github.com/RSS-Bridge/rss-bridge/wiki/if-Statement#use-elseif-instead-of-else-if)
- * [Do not write empty statements](https://github.com/RSS-Bridge/rss-bridge/wiki/if-Statement#do-not-write-empty-statements)
- * [Do not write unconditional if-statements](https://github.com/RSS-Bridge/rss-bridge/wiki/if-Statement#do-not-write-unconditional-if-statements)
-* [Classes](https://github.com/RSS-Bridge/rss-bridge/wiki/Classes)
- * [Use PascalCase for class names](https://github.com/RSS-Bridge/rss-bridge/wiki/Classes#use-pascalcase-for-class-names)
- * [Do not use final statements inside final classes](https://github.com/RSS-Bridge/rss-bridge/wiki/Classes#do-not-use-final-statements-inside-final-classes)
- * [Do not override methods to call their parent](https://github.com/RSS-Bridge/rss-bridge/wiki/Classes#do-not-override-methods-to-call-their-parent)
- * [abstract and final declarations MUST precede the visibility declaration](https://github.com/RSS-Bridge/rss-bridge/wiki/Classes#abstract-and-final-declarations-must-precede-the-visibility-declaration)
- * [static declaration MUST come after the visibility declaration](https://github.com/RSS-Bridge/rss-bridge/wiki/Classes#static-declaration-must-come-after-the-visibility-declaration)
-* [Casting](https://github.com/RSS-Bridge/rss-bridge/wiki/Casting)
- * [Do not add spaces when casting](https://github.com/RSS-Bridge/rss-bridge/wiki/Casting#do-not-add-spaces-when-casting)
+See the [Coding style policy page on the documentation](https://rss-bridge.github.io/rss-bridge/For_Developers/Coding_style_policy.html) for more information on the coding style of the project.
diff --git a/.github/ISSUE_TEMPLATE/bridge-request.md b/.github/ISSUE_TEMPLATE/bridge-request.md
index a0080b8b8d7..088cc3d6a5c 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
@@ -60,5 +60,5 @@ Please describe what you expect from the bridge. Whenever possible provide sampl
Keep in mind that opening a request does not guarantee the bridge being implemented! That depends entirely on the interest and time of others to make the bridge for you.
-You can also implement your own bridge (with support of the community if needed). Find more information in the [RSS-Bridge Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki/For-developers) developer section.
+You can also implement your own bridge (with support of the community if needed). Find more information in the [RSS-Bridge Documentation](https://rss-bridge.github.io/rss-bridge/For_Developers/index.html) developer section.
-->
diff --git a/.github/prtester-requirements.txt b/.github/prtester-requirements.txt
new file mode 100644
index 00000000000..4fb08b5752a
--- /dev/null
+++ b/.github/prtester-requirements.txt
@@ -0,0 +1,2 @@
+beautifulsoup4>=4.10.0
+requests>=2.26.0
\ No newline at end of file
diff --git a/.github/prtester.py b/.github/prtester.py
new file mode 100644
index 00000000000..c5c5be2274f
--- /dev/null
+++ b/.github/prtester.py
@@ -0,0 +1,208 @@
+import argparse
+import requests
+import re
+from bs4 import BeautifulSoup
+from datetime import datetime
+from typing import Iterable
+import os
+import glob
+import urllib
+
+# This script is specifically written to be used in automation for https://github.com/RSS-Bridge/rss-bridge
+#
+# This will scrape the whitelisted bridges in the current state (port 3000) and the PR state (port 3001) of
+# RSS-Bridge, generate a feed for each of the bridges and save the output as html files.
+# It also add a
'.join(map(lambda m: f'❌ `{m}`', error_messages))
+ else:
+ # if all example/default values are present, form the full request url, run the request, add a
tags from item content + item_element.decompose() + status_messages += map(lambda e: f'⚠️ `{getFirstLine(e.text)}`', soup.find_all('pre')) + status_messages = list(dict.fromkeys(status_messages)) # remove duplicates + status = '
'.join(status_messages) + status_is_ok = status == ''; + if status_is_ok: + status = '✔️' + if with_upload and (not with_reduced_upload or not status_is_ok): + filename = f'{bridge_name} {form_number}{instance_suffix}{ARTIFACT_FILE_EXTENSION}' + filename = re.sub(r'[^a-z0-9 \_\-\.]', '', filename, flags=re.I).replace(' ', '_') + with open(file=f'{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(r'^\[[^\]]+\]\s*rssbridge\.|[\|`]', '', value.strip()) + first_line = next(iter(clean_value.splitlines()), '') + max_length = 250 + if (len(first_line) > max_length): + first_line = first_line[:max_length] + '...' + return first_line + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--instances', nargs='+') + parser.add_argument('--no-upload', action='store_true') + parser.add_argument('--reduced-upload', action='store_true') + parser.add_argument('--title', default='Pull request artifacts') + parser.add_argument('--output-file', default=os.getcwd() + '/comment.txt') + args = parser.parse_args() + instances = [] + if args.instances: + for instance_arg in args.instances: + instance_arg_parts = instance_arg.split('::') + instance = Instance() + instance.name = instance_arg_parts[1].strip() if len(instance_arg_parts) >= 2 else '' + instance.url = instance_arg_parts[0].strip().rstrip("/") + instances.append(instance) + else: + instance = Instance() + instance.name = 'current' + instance.url = 'http://localhost:3000' + instances.append(instance) + instance = Instance() + instance.name = 'pr' + instance.url = 'http://localhost:3001' + instances.append(instance) + main( + instances=instances, + with_upload=not args.no_upload, + with_reduced_upload=args.reduced_upload and not args.no_upload, + title=args.title, + output_file=args.output_file + ); diff --git a/.github/workflows/dockerbuild.yml b/.github/workflows/dockerbuild.yml new file mode 100644 index 00000000000..3964555823a --- /dev/null +++ b/.github/workflows/dockerbuild.yml @@ -0,0 +1,61 @@ +name: Build Image on Commit and Release + +on: + push: + branches: + - 'master' + tags: + - '20*' + +env: + DOCKERHUB_SLUG: rssbridge/rss-bridge + GHCR_SLUG: ghcr.io/rss-bridge/rss-bridge + +jobs: + bake: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Docker meta + id: docker_meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.DOCKERHUB_SLUG }} + ${{ env.GHCR_SLUG }} + tags: | + type=raw,value=latest + type=sha + type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/20') }} + type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/20') }} + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - + name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - + name: Build and push + uses: docker/bake-action@v5 + with: + files: | + ./docker-bake.hcl + ${{ steps.docker_meta.outputs.bake-file }} + targets: image-all + push: true diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 00000000000..e0201022e73 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,27 @@ +name: Documentation + +on: + push: + paths: + - 'docs/**' + +jobs: + documentation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + - name: Install dependencies + run: composer global require daux/daux.io + - name: Generate documentation + run: daux generate + - name: Deploy same repository 🚀 + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: "static" + branch: gh-pages diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..206b53de0fb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,49 @@ +name: Lint + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + phpcs: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-versions: ['7.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: phpcs + - run: phpcs . --standard=phpcs.xml --warning-severity=0 --extensions=php -p + + phpcompatibility: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-versions: ['7.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + - run: composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true + - run: composer global require dealerdirect/phpcodesniffer-composer-installer + - run: composer global require phpcompatibility/php-compatibility + - run: ~/.composer/vendor/bin/phpcs . --standard=phpcompatibility.xml --warning-severity=0 --extensions=php -p + + executable_php_files_check: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + - run: | + if find -name "*.php" -executable -type f -print -exec false {} + + then + echo 'Good, no executable php scripts found' + else + echo 'Please unmark php scripts above as non-executable' + exit 1 + fi diff --git a/.github/workflows/prhtmlgenerator.yml b/.github/workflows/prhtmlgenerator.yml new file mode 100644 index 00000000000..163d51e3a7c --- /dev/null +++ b/.github/workflows/prhtmlgenerator.yml @@ -0,0 +1,126 @@ +name: 'PR Testing' + +on: + pull_request_target: + 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@v4 + with: + ref: ${{github.event.pull_request.head.ref}} + repository: ${{github.event.pull_request.head.repo.full_name}} + - name: Check out rss-bridge + run: | + PR=${{github.event.number}}; + wget -O requirements.txt https://raw.githubusercontent.com/$GITHUB_REPOSITORY/${{ github.event.pull_request.base.ref }}/.github/prtester-requirements.txt; + wget https://raw.githubusercontent.com/$GITHUB_REPOSITORY/${{ github.event.pull_request.base.ref }}/.github/prtester.py; + wget https://patch-diff.githubusercontent.com/raw/$GITHUB_REPOSITORY/pull/$PR.patch; + touch DEBUG; + cat $PR.patch | grep "\bbridges/[A-Za-z0-9]*Bridge\.php\b" | sed "s=.*\bbridges/\([A-Za-z0-9]*\)Bridge\.php\b.*=\1=g" | sort | uniq > whitelist.txt + - name: Start Docker - Current + run: | + docker run -d -v $GITHUB_WORKSPACE/whitelist.txt:/app/whitelist.txt -v $GITHUB_WORKSPACE/DEBUG:/app/DEBUG -p 3000:80 ghcr.io/rss-bridge/rss-bridge:latest + - name: Start Docker - PR + run: | + 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@v5 + with: + python-version: '3.13' + cache: 'pip' + - name: Install requirements + run: | + cd $GITHUB_WORKSPACE + pip install -r requirements.txt + - name: Run bridge tests + id: testrun + run: | + mkdir results; + python prtester.py; + body="$(cat comment.txt)"; + body="${body//'%'/'%25'}"; + 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@v3 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: Pull request artifacts + - name: Create or update comment + if: ${{ steps.testrun.outputs.bodylength > 130 }} + 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" + git push diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000000..93f07b0f0cd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + phpunit8: + runs-on: ubuntu-20.04 + strategy: + matrix: + php-versions: ['7.4', '8.0', '8.1'] + steps: + - 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/.gitignore b/.gitignore index 680260c7044..6ed95489e41 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ data/ *.pydevproject .project .metadata -bin/ tmp/ *.tmp *.bak @@ -213,6 +212,7 @@ pip-log.txt # Unit test / coverage reports .coverage +.phpunit.result.cache .tox #Translations @@ -228,6 +228,10 @@ pip-log.txt /whitelist.txt DEBUG config.ini.php +config/* +!config/nginx.conf +!config/php-fpm.conf +!config/php.ini ###################### ## VisualStudioCode ## diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 841ac5dbb5a..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,46 +0,0 @@ -dist: trusty -language: php - -install: - - composer global require dealerdirect/phpcodesniffer-composer-installer; - - composer global require phpcompatibility/php-compatibility; - - if [[ "$PHPUNIT" ]]; then - composer global require phpunit/phpunit ^$PHPUNIT; - fi - -script: - - phpenv rehash - # Run PHP_CodeSniffer on all versions - - ~/.config/composer/vendor/bin/phpcs . --standard=phpcs.xml --warning-severity=0 --extensions=php -p; - # Check PHP compatibility for the lowest and highest supported version - - if [[ $TRAVIS_PHP_VERSION == "5.6" || $TRAVIS_PHP_VERSION == "7.3" ]]; then - ~/.config/composer/vendor/bin/phpcs . --standard=phpcompatibility.xml --extensions=php -p; - fi - # Run unit tests on highest major version - - if [[ ${TRAVIS_PHP_VERSION:0:1} == "7" ]]; then - ~/.config/composer/vendor/bin/phpunit --configuration=phpunit.xml --include-path=lib/; - fi - -php: - - 7.3 - -env: - - PHPUNIT=6 - - PHPUNIT=7 - - PHPUNIT=8 - -matrix: - fast_finish: true - - include: - - php: 5.6 - env: PHPUNIT= - - php: 7.0 - - php: 7.1 - - php: 7.2 - - allow_failures: - - php: 7.3 - env: PHPUNIT=7 - - php: 7.3 - env: PHPUNIT=8 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000000..d27421aa969 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,225 @@ +# Contributors + +* [16mhz](https://github.com/16mhz) +* [adamchainz](https://github.com/adamchainz) +* [Ahiles3005](https://github.com/Ahiles3005) +* [akirk](https://github.com/akirk) +* [Albirew](https://github.com/Albirew) +* [aledeg](https://github.com/aledeg) +* [alex73](https://github.com/alex73) +* [alexAubin](https://github.com/alexAubin) +* [Alkarex](https://github.com/Alkarex) +* [AmauryCarrade](https://github.com/AmauryCarrade) +* [arnd-s](https://github.com/arnd-s) +* [ArthurHoaro](https://github.com/ArthurHoaro) +* [Astalaseven](https://github.com/Astalaseven) +* [Astyan-42](https://github.com/Astyan-42) +* [austinhuang0131](https://github.com/austinhuang0131) +* [axor-mst](https://github.com/axor-mst) +* [ayacoo](https://github.com/ayacoo) +* [az5he6ch](https://github.com/az5he6ch) +* [b1nj](https://github.com/b1nj) +* [benasse](https://github.com/benasse) +* [Binnette](https://github.com/Binnette) +* [BoboTiG](https://github.com/BoboTiG) +* [Bockiii](https://github.com/Bockiii) +* [brtsos](https://github.com/brtsos) +* [captn3m0](https://github.com/captn3m0) +* [chemel](https://github.com/chemel) +* [Chouchen](https://github.com/Chouchen) +* [ckiw](https://github.com/ckiw) +* [cn-tools](https://github.com/cn-tools) +* [cnlpete](https://github.com/cnlpete) +* [corenting](https://github.com/corenting) +* [couraudt](https://github.com/couraudt) +* [csisoap](https://github.com/csisoap) +* [da2x](https://github.com/da2x) +* [dabenzel](https://github.com/dabenzel) +* [Daiyousei](https://github.com/Daiyousei) +* [dawidsowa](https://github.com/dawidsowa) +* [DevonHess](https://github.com/DevonHess) +* [dhuschde](https://github.com/dhuschde) +* [disk0x](https://github.com/disk0x) +* [DJCrashdummy](https://github.com/DJCrashdummy) +* [Djuuu](https://github.com/Djuuu) +* [DnAp](https://github.com/DnAp) +* [dominik-th](https://github.com/dominik-th) +* [Draeli](https://github.com/Draeli) +* [Dreckiger-Dan](https://github.com/Dreckiger-Dan) +* [drego85](https://github.com/drego85) +* [drklee3](https://github.com/drklee3) +* [DRogueRonin](https://github.com/DRogueRonin) +* [dvikan](https://github.com/dvikan) +* [eggwhalefrog](https://github.com/eggwhalefrog) +* [em92](https://github.com/em92) +* [eMerzh](https://github.com/eMerzh) +* [EtienneM](https://github.com/EtienneM) +* [f0086](https://github.com/f0086) +* [fanch317](https://github.com/fanch317) +* [fatuuse](https://github.com/fatuuse) +* [fivefilters](https://github.com/fivefilters) +* [floviolleau](https://github.com/floviolleau) +* [fluffy-critter](https://github.com/fluffy-critter) +* [fmachen](https://github.com/fmachen) +* [Frenzie](https://github.com/Frenzie) +* [fulmeek](https://github.com/fulmeek) +* [ggiessen](https://github.com/ggiessen) +* [gileri](https://github.com/gileri) +* [Ginko-Aloe](https://github.com/Ginko-Aloe) +* [girlpunk](https://github.com/girlpunk) +* [Glandos](https://github.com/Glandos) +* [gloony](https://github.com/gloony) +* [GregThib](https://github.com/GregThib) +* [griffaurel](https://github.com/griffaurel) +* [Grummfy](https://github.com/Grummfy) +* [gsantner](https://github.com/gsantner) +* [guigot](https://github.com/guigot) +* [hollowleviathan](https://github.com/hollowleviathan) +* [hpacleb](https://github.com/hpacleb) +* [hunhejj](https://github.com/hunhejj) +* [husim0](https://github.com/husim0) +* [IceWreck](https://github.com/IceWreck) +* [imagoiq](https://github.com/imagoiq) +* [j0k3r](https://github.com/j0k3r) +* [JackNUMBER](https://github.com/JackNUMBER) +* [jacquesh](https://github.com/jacquesh) +* [jakubvalenta](https://github.com/jakubvalenta) +* [JasonGhent](https://github.com/JasonGhent) +* [jcgoette](https://github.com/jcgoette) +* [jdesgats](https://github.com/jdesgats) +* [jdigilio](https://github.com/jdigilio) +* [JeremyRand](https://github.com/JeremyRand) +* [JimDog546](https://github.com/JimDog546) +* [jNullj](https://github.com/jNullj) +* [Jocker666z](https://github.com/Jocker666z) +* [johnnygroovy](https://github.com/johnnygroovy) +* [johnpc](https://github.com/johnpc) +* [joni1993](https://github.com/joni1993) +* [jtojnar](https://github.com/jtojnar) +* [KamaleiZestri](https://github.com/KamaleiZestri) +* [kkoyung](https://github.com/kkoyung) +* [klimplant](https://github.com/klimplant) +* [KN4CK3R](https://github.com/KN4CK3R) +* [kolarcz](https://github.com/kolarcz) +* [kranack](https://github.com/kranack) +* [kraoc](https://github.com/kraoc) +* [krisu5](https://github.com/krisu5) +* [l1n](https://github.com/l1n) +* [laBecasse](https://github.com/laBecasse) +* [lagaisse](https://github.com/lagaisse) +* [lalannev](https://github.com/lalannev) +* [langfingaz](https://github.com/langfingaz) +* [lassana](https://github.com/lassana) +* [ldidry](https://github.com/ldidry) +* [Leomaradan](https://github.com/Leomaradan) +* [leyrer](https://github.com/leyrer) +* [liamka](https://github.com/liamka) +* [Limero](https://github.com/Limero) +* [LogMANOriginal](https://github.com/LogMANOriginal) +* [lorenzos](https://github.com/lorenzos) +* [lukasklinger](https://github.com/lukasklinger) +* [m0zes](https://github.com/m0zes) +* [Mar-Koeh](https://github.com/Mar-Koeh) +* [marcus-at-localhost](https://github.com/marcus-at-localhost) +* [marius8510000-bot](https://github.com/marius8510000-bot) +* [matthewseal](https://github.com/matthewseal) +* [mcbyte-it](https://github.com/mcbyte-it) +* [mdemoss](https://github.com/mdemoss) +* [melangue](https://github.com/melangue) +* [metaMMA](https://github.com/metaMMA) +* [mibe](https://github.com/mibe) +* [mickaelBert](https://github.com/mickaelBert) +* [mightymt](https://github.com/mightymt) +* [mitsukarenai](https://github.com/mitsukarenai) +* [Monocularity](https://github.com/Monocularity) +* [MonsieurPoutounours](https://github.com/MonsieurPoutounours) +* [mr-flibble](https://github.com/mr-flibble) +* [mro](https://github.com/mro) +* [mschwld](https://github.com/mschwld) +* [muekoeff](https://github.com/muekoeff) +* [mw80](https://github.com/mw80) +* [mxmehl](https://github.com/mxmehl) +* [Mynacol](https://github.com/Mynacol) +* [nel50n](https://github.com/nel50n) +* [niawag](https://github.com/niawag) +* [Niehztog](https://github.com/Niehztog) +* [NikNikYkt](https://github.com/NikNikYkt) +* [Nono-m0le](https://github.com/Nono-m0le) +* [NotsoanoNimus](https://github.com/NotsoanoNimus) +* [obsiwitch](https://github.com/obsiwitch) +* [Ololbu](https://github.com/Ololbu) +* [ORelio](https://github.com/ORelio) +* [otakuf](https://github.com/otakuf) +* [Park0](https://github.com/Park0) +* [Paroleen](https://github.com/Paroleen) +* [Patricol](https://github.com/Patricol) +* [paulchen](https://github.com/paulchen) +* [PaulVayssiere](https://github.com/PaulVayssiere) +* [pellaeon](https://github.com/pellaeon) +* [PeterDaveHello](https://github.com/PeterDaveHello) +* [Peterr-K](https://github.com/Peterr-K) +* [Piranhaplant](https://github.com/Piranhaplant) +* [pirnz](https://github.com/pirnz) +* [pit-fgfjiudghdf](https://github.com/pit-fgfjiudghdf) +* [pitchoule](https://github.com/pitchoule) +* [pmaziere](https://github.com/pmaziere) +* [Pofilo](https://github.com/Pofilo) +* [prysme01](https://github.com/prysme01) +* [pubak42](https://github.com/pubak42) +* [Qluxzz](https://github.com/Qluxzz) +* [quentinus95](https://github.com/quentinus95) +* [quickwick](https://github.com/quickwick) +* [rakoo](https://github.com/rakoo) +* [RawkBob](https://github.com/RawkBob) +* [regisenguehard](https://github.com/regisenguehard) +* [Riduidel](https://github.com/Riduidel) +* [rogerdc](https://github.com/rogerdc) +* [Roliga](https://github.com/Roliga) +* [ronansalmon](https://github.com/ronansalmon) +* [rremizov](https://github.com/rremizov) +* [s0lesurviv0r](https://github.com/s0lesurviv0r) +* [sal0max](https://github.com/sal0max) +* [sebsauvage](https://github.com/sebsauvage) +* [shutosg](https://github.com/shutosg) +* [simon816](https://github.com/simon816) +* [Simounet](https://github.com/Simounet) +* [somini](https://github.com/somini) +* [SpangleLabs](https://github.com/SpangleLabs) +* [SqrtMinusOne](https://github.com/SqrtMinusOne) +* [squeek502](https://github.com/squeek502) +* [StelFux](https://github.com/StelFux) +* [stjohnjohnson](https://github.com/stjohnjohnson) +* [Stopka](https://github.com/Stopka) +* [Strubbl](https://github.com/Strubbl) +* [sublimz](https://github.com/sublimz) +* [sunchaserinfo](https://github.com/sunchaserinfo) +* [SuperSandro2000](https://github.com/SuperSandro2000) +* [sysadminstory](https://github.com/sysadminstory) +* [t0stiman](https://github.com/t0stiman) +* [tameroski](https://github.com/tameroski) +* [teromene](https://github.com/teromene) +* [tgkenney](https://github.com/tgkenney) +* [thefranke](https://github.com/thefranke) +* [TheRadialActive](https://github.com/TheRadialActive) +* [theScrabi](https://github.com/theScrabi) +* [thezeroalpha](https://github.com/thezeroalpha) +* [thibaultcouraud](https://github.com/thibaultcouraud) +* [timendum](https://github.com/timendum) +* [TitiTestScalingo](https://github.com/TitiTestScalingo) +* [tomaszkane](https://github.com/tomaszkane) +* [tomershvueli](https://github.com/tomershvueli) +* [TotalCaesar659](https://github.com/TotalCaesar659) +* [tpikonen](https://github.com/tpikonen) +* [TReKiE](https://github.com/TReKiE) +* [triatic](https://github.com/triatic) +* [User123698745](https://github.com/User123698745) +* [VerifiedJoseph](https://github.com/VerifiedJoseph) +* [vitkabele](https://github.com/vitkabele) +* [WalterBarrett](https://github.com/WalterBarrett) +* [wtuuju](https://github.com/wtuuju) +* [xurxof](https://github.com/xurxof) +* [yamanq](https://github.com/yamanq) +* [yardenac](https://github.com/yardenac) +* [ymeister](https://github.com/ymeister) +* [yue-dongchen](https://github.com/yue-dongchen) +* [ZeNairolf](https://github.com/ZeNairolf) diff --git a/Dockerfile b/Dockerfile index fa9979d6787..1326dba0dbd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,72 @@ -FROM php:7-apache +FROM debian:12-slim AS rssbridge -ENV APACHE_DOCUMENT_ROOT=/app +LABEL description="RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one." +LABEL repository="https://github.com/RSS-Bridge/rss-bridge" +LABEL website="https://github.com/RSS-Bridge/rss-bridge" -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ - && apt-get --yes update && apt-get --yes install libxml2-dev \ - && docker-php-ext-install -j$(nproc) simplexml \ - && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \ - && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \ - && sed -ri -e 's/(MinProtocol\s*=\s*)TLSv1\.2/\1None/' /etc/ssl/openssl.cnf \ - && sed -ri -e 's/(CipherString\s*=\s*DEFAULT)@SECLEVEL=2/\1/' /etc/ssl/openssl.cnf +ARG DEBIAN_FRONTEND=noninteractive +RUN set -xe && \ + apt-get update && \ + apt-get install --yes --no-install-recommends \ + ca-certificates \ + nginx \ + nss-plugin-pem \ + php-curl \ + php-fpm \ + php-intl \ + # php-json is enabled by default with PHP 8.2 in Debian 12 + php-mbstring \ + php-memcached \ + # php-opcache is enabled by default with PHP 8.2 in Debian 12 + # php-openssl is enabled by default with PHP 8.2 in Debian 12 + php-sqlite3 \ + php-xml \ + php-zip \ + # php-zlib is enabled by default with PHP 8.2 in Debian 12 + # for downloading libcurl-impersonate + curl \ + && \ + # install curl-impersonate library + curlimpersonate_version=0.6.0 && \ + { \ + { \ + [ $(arch) = 'aarch64' ] && \ + archive="libcurl-impersonate-v${curlimpersonate_version}.aarch64-linux-gnu.tar.gz" && \ + sha512sum="d04b1eabe71f3af06aa1ce99b39a49c5e1d33b636acedcd9fad163bc58156af5c3eb3f75aa706f335515791f7b9c7a6c40ffdfa47430796483ecef929abd905d" \ + ; } \ + || { \ + [ $(arch) = 'armv7l' ] && \ + archive="libcurl-impersonate-v${curlimpersonate_version}.arm-linux-gnueabihf.tar.gz" && \ + sha512sum="05906b4efa1a6ed8f3b716fd83d476b6eea6bfc68e3dbc5212d65a2962dcaa7bd1f938c9096a7535252b11d1d08fb93adccc633585ff8cb8cec5e58bfe969bc9" \ + ; } \ + || { \ + [ $(arch) = 'x86_64' ] && \ + archive="libcurl-impersonate-v${curlimpersonate_version}.x86_64-linux-gnu.tar.gz" && \ + sha512sum="480bbe9452cd9aff2c0daaaf91f1057b3a96385f79011628a9237223757a9b0d090c59cb5982dc54ea0d07191657299ea91ca170a25ced3d7d410fcdff130ace" \ + ; } \ + } && \ + curl -LO "https://github.com/lwthiker/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 --wildcards 'libcurl-impersonate-ff.so*' && \ + rm "$archive" && \ + apt-get purge --assume-yes curl && \ + rm -rf /var/lib/apt/lists/* -COPY --chown=www-data:www-data ./ /app/ \ No newline at end of file +ENV LD_PRELOAD /usr/local/lib/curl-impersonate/libcurl-impersonate-ff.so +ENV CURL_IMPERSONATE ff91esr + +# logs should go to stdout / stderr +RUN ln -sfT /dev/stderr /var/log/nginx/error.log; \ + ln -sfT /dev/stdout /var/log/nginx/access.log; \ + chown -R --no-dereference www-data:adm /var/log/nginx/ + +COPY ./config/nginx.conf /etc/nginx/sites-available/default +COPY ./config/php-fpm.conf /etc/php/8.2/fpm/pool.d/rss-bridge.conf +COPY ./config/php.ini /etc/php/8.2/fpm/conf.d/90-rss-bridge.ini + +COPY --chown=www-data:www-data ./ /app/ + +EXPOSE 80 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index a9db8eafb38..dadf7094939 100644 --- a/README.md +++ b/README.md @@ -1,257 +1,527 @@ +# RSS-Bridge + ![RSS-Bridge](static/logo_600px.png) -=== -[![LICENSE](https://img.shields.io/badge/license-UNLICENSE-blue.svg)](UNLICENSE) [![GitHub release](https://img.shields.io/github/release/rss-bridge/rss-bridge.svg?logo=github)](https://github.com/rss-bridge/rss-bridge/releases/latest) [![Debian Release](https://img.shields.io/badge/dynamic/json.svg?logo=debian&label=debian%20release&url=https%3A%2F%2Fsources.debian.org%2Fapi%2Fsrc%2Frss-bridge%2F&query=%24.versions%5B0%5D.version&colorB=blue)](https://tracker.debian.org/pkg/rss-bridge) [![Guix Release](https://img.shields.io/badge/guix%20release-unknown-blue.svg)](https://www.gnu.org/software/guix/packages/R/) [![Build Status](https://travis-ci.org/RSS-Bridge/rss-bridge.svg?branch=master)](https://travis-ci.org/RSS-Bridge/rss-bridge) [![Docker Build Status](https://img.shields.io/docker/build/rssbridge/rss-bridge.svg?logo=docker)](https://hub.docker.com/r/rssbridge/rss-bridge/) -RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites that don't have one. It can be used on webservers or as a stand-alone application in CLI mode. +RSS-Bridge is a PHP web application. + +It generates web feeds for websites that don't have one. + +Officially hosted instance: https://rss-bridge.org/bridge01/ + +IRC channel #rssbridge at https://libera.chat/ + +[Full documentation](https://rss-bridge.github.io/rss-bridge/index.html) + +Alternatively find another +[public instance](https://rss-bridge.github.io/rss-bridge/General/Public_Hosts.html). + +Requires minimum PHP 7.4. + + +[![LICENSE](https://img.shields.io/badge/license-UNLICENSE-blue.svg)](UNLICENSE) +[![GitHub release](https://img.shields.io/github/release/rss-bridge/rss-bridge.svg?logo=github)](https://github.com/rss-bridge/rss-bridge/releases/latest) +[![irc.libera.chat](https://img.shields.io/badge/irc.libera.chat-%23rssbridge-blue.svg)](https://web.libera.chat/#rssbridge) +[![Actions Status](https://img.shields.io/github/actions/workflow/status/RSS-Bridge/rss-bridge/tests.yml?branch=master&label=GitHub%20Actions&logo=github)](https://github.com/RSS-Bridge/rss-bridge/actions) + +||| +|:-:|:-:| +|![Screenshot #1](/static/screenshot-1.png?raw=true)|![Screenshot #2](/static/screenshot-2.png?raw=true)| +|![Screenshot #3](/static/screenshot-3.png?raw=true)|![Screenshot #4](/static/screenshot-4.png?raw=true)| +|![Screenshot #5](/static/screenshot-5.png?raw=true)|![Screenshot #6](/static/screenshot-6.png?raw=true)| + +## A subset of bridges (15/447) + +* `CssSelectorBridge`: [Scrape out a feed using CSS selectors](https://rss-bridge.org/bridge01/#bridge-CssSelectorBridge) +* `FeedMergeBridge`: [Combine multiple feeds into one](https://rss-bridge.org/bridge01/#bridge-FeedMergeBridge) +* `FeedReducerBridge`: [Reduce a noisy feed by some percentage](https://rss-bridge.org/bridge01/#bridge-FeedReducerBridge) +* `FilterBridge`: [Filter a feed by excluding/including items by keyword](https://rss-bridge.org/bridge01/#bridge-FilterBridge) +* `GettrBridge`: [Fetches the latest posts from a GETTR user](https://rss-bridge.org/bridge01/#bridge-GettrBridge) +* `MastodonBridge`: [Fetches statuses from a Mastodon (ActivityPub) instance](https://rss-bridge.org/bridge01/#bridge-MastodonBridge) +* `RedditBridge`: [Fetches posts from a user/subredit (with filtering options)](https://rss-bridge.org/bridge01/#bridge-RedditBridge) +* `RumbleBridge`: [Fetches channel/user videos](https://rss-bridge.org/bridge01/#bridge-RumbleBridge) +* `SoundcloudBridge`: [Fetches music by username](https://rss-bridge.org/bridge01/#bridge-SoundcloudBridge) +* `TelegramBridge`: [Fetches posts from a public channel](https://rss-bridge.org/bridge01/#bridge-TelegramBridge) +* `ThePirateBayBridge:` [Fetches torrents by search/user/category](https://rss-bridge.org/bridge01/#bridge-ThePirateBayBridge) +* `TikTokBridge`: [Fetches posts by username](https://rss-bridge.org/bridge01/#bridge-TikTokBridge) +* `TwitchBridge`: [Fetches videos from channel](https://rss-bridge.org/bridge01/#bridge-TwitchBridge) +* `XPathBridge`: [Scrape out a feed using XPath expressions](https://rss-bridge.org/bridge01/#bridge-XPathBridge) +* `YoutubeBridge`: [Fetches videos by username/channel/playlist/search](https://rss-bridge.org/bridge01/#bridge-YoutubeBridge) +* `YouTubeCommunityTabBridge`: [Fetches posts from a channel's community tab](https://rss-bridge.org/bridge01/#bridge-YouTubeCommunityTabBridge) + +## Tutorial + +### How to install on traditional shared web hosting + +RSS-Bridge can basically be unzipped into a web folder. Should be working instantly. + +Latest zip: +https://github.com/RSS-Bridge/rss-bridge/archive/refs/heads/master.zip (2MB) + +### How to install on Debian 12 (nginx + php-fpm) + +These instructions have been tested on a fresh Debian 12 VM from Digital Ocean (1vcpu-512mb-10gb, 5 USD/month). + +```shell +timedatectl set-timezone Europe/Oslo + +apt install git nginx php8.2-fpm php-mbstring php-simplexml php-curl php-intl + +# Create a user account +useradd --shell /bin/bash --create-home rss-bridge + +cd /var/www + +# Create folder and change its ownership to rss-bridge +mkdir rss-bridge && chown rss-bridge:rss-bridge rss-bridge/ + +# Become rss-bridge +su rss-bridge + +# Clone master branch into existing folder +git clone https://github.com/RSS-Bridge/rss-bridge.git rss-bridge/ +cd rss-bridge + +# Copy over the default config (OPTIONAL) +cp -v config.default.ini.php config.ini.php + +# Recursively give full permissions to user/owner +chmod 700 --recursive ./ + +# Give read and execute to others on folder ./static +chmod o+rx ./ ./static + +# Recursively give give read to others on folder ./static +chmod o+r --recursive ./static +``` + +Nginx config: + +```nginx +# /etc/nginx/sites-enabled/rss-bridge.conf + +server { + listen 80; + + # TODO: change to your own server name + server_name example.com; + + access_log /var/log/nginx/rss-bridge.access.log; + error_log /var/log/nginx/rss-bridge.error.log; + log_not_found off; + + # Intentionally not setting a root folder + + # Static content only served here + location /static/ { + alias /var/www/rss-bridge/static/; + } + + # Pass off to php-fpm only when location is EXACTLY == / + location = / { + root /var/www/rss-bridge/; + include snippets/fastcgi-php.conf; + fastcgi_read_timeout 45s; + fastcgi_pass unix:/run/php/rss-bridge.sock; + } -**Important**: RSS-Bridge is __not__ a feed reader or feed aggregator, but a tool to generate feeds that are consumed by feed readers and feed aggregators. Find a list of feed aggregators on [Wikipedia](https://en.wikipedia.org/wiki/Comparison_of_feed_aggregators). + # Reduce log noise + location = /favicon.ico { + access_log off; + } -Supported sites/pages (examples) -=== + # Reduce log noise + location = /robots.txt { + access_log off; + } +} +``` -* `Bandcamp` : Returns last release from [bandcamp](https://bandcamp.com/) for a tag -* `Cryptome` : Returns the most recent documents from [Cryptome.org](http://cryptome.org/) -* `DansTonChat`: Most recent quotes from [danstonchat.com](http://danstonchat.com/) -* `DuckDuckGo`: Most recent results from [DuckDuckGo.com](https://duckduckgo.com/) -* `Facebook` : Returns the latest posts on a page or profile on [Facebook](https://facebook.com/) -* `FlickrExplore` : [Latest interesting images](http://www.flickr.com/explore) from Flickr -* `GoogleSearch` : Most recent results from Google Search -* `Identi.ca` : Identica user timeline (Should be compatible with other Pump.io instances) -* `Instagram`: Most recent photos from an Instagram user -* `OpenClassrooms`: Lastest tutorials from [fr.openclassrooms.com](http://fr.openclassrooms.com/) -* `Pinterest`: Most recent photos from user or search -* `ScmbBridge`: Newest stories from [secouchermoinsbete.fr](http://secouchermoinsbete.fr/) -* `ThePirateBay` : Returns the newest indexed torrents from [The Pirate Bay](https://thepiratebay.se/) with keywords -* `Twitter` : Return keyword/hashtag search or user timeline -* `Wikipedia`: highlighted articles from [Wikipedia](https://wikipedia.org/) in English, German, French or Esperanto -* `YouTube` : YouTube user channel, playlist or search +PHP FPM pool config: +```ini +; /etc/php/8.2/fpm/pool.d/rss-bridge.conf -And [many more](bridges/), thanks to the community! +[rss-bridge] -Output format -=== +user = rss-bridge +group = rss-bridge -RSS-Bridge is capable of producing several output formats: +listen = /run/php/rss-bridge.sock -* `Atom` : Atom feed, for use in feed readers -* `Html` : Simple HTML page -* `Json` : JSON, for consumption by other applications -* `Mrss` : MRSS feed, for use in feed readers -* `Plaintext` : Raw text, for consumption by other applications +listen.owner = www-data +listen.group = www-data -You can extend RSS-Bridge with your own format, using the [Format API](https://github.com/RSS-Bridge/rss-bridge/wiki/Format-API)! +; Create 10 workers standing by to serve requests +pm = static +pm.max_children = 10 -Screenshot -=== +; Respawn worker after 500 requests (workaround for memory leaks etc.) +pm.max_requests = 500 +``` -Welcome screen: +PHP ini config: +```ini +; /etc/php/8.2/fpm/conf.d/30-rss-bridge.ini -![Screenshot](https://github.com/RSS-Bridge/rss-bridge/wiki/images/screenshot_rss-bridge_welcome.png) +max_execution_time = 15 +memory_limit = 64M +``` -*** +Restart fpm and nginx: -RSS-Bridge hashtag (#rss-bridge) search on Twitter, in Atom format (as displayed by Firefox): +```shell +# Lint and restart php-fpm +php-fpm8.2 -t && systemctl restart php8.2-fpm -![Screenshot](https://github.com/RSS-Bridge/rss-bridge/wiki/images/screenshot_twitterbridge_atom.png) +# Lint and restart nginx +nginx -t && systemctl restart nginx +``` -Requirements -=== +### How to install from Composer -RSS-Bridge requires PHP 5.6 or higher with following extensions enabled: +Install the latest release. - - [`openssl`](https://secure.php.net/manual/en/book.openssl.php) - - [`libxml`](https://secure.php.net/manual/en/book.libxml.php) - - [`mbstring`](https://secure.php.net/manual/en/book.mbstring.php) - - [`simplexml`](https://secure.php.net/manual/en/book.simplexml.php) - - [`curl`](https://secure.php.net/manual/en/book.curl.php) - - [`json`](https://secure.php.net/manual/en/book.json.php) - - [`sqlite3`](http://php.net/manual/en/book.sqlite3.php) (only when using SQLiteCache) +```shell +cd /var/www +composer create-project -v --no-dev --no-scripts rss-bridge/rss-bridge +``` -Find more information on our [Wiki](https://github.com/rss-bridge/rss-bridge/wiki) +### How to install with Caddy -Enable / Disable bridges -=== +TODO. See https://github.com/RSS-Bridge/rss-bridge/issues/3785 -RSS-Bridge allows you to take full control over which bridges are displayed to the user. That way you can host your own RSS-Bridge service with your favorite collection of bridges! +### Install from Docker Hub: -Find more information on the [Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitelisting) +Install by downloading the docker image from Docker Hub: -**Notice**: By default, RSS-Bridge will only show a small subset of bridges. Make sure to read up on [whitelisting](https://github.com/RSS-Bridge/rss-bridge/wiki/Whitelisting) to unlock the full potential of RSS-Bridge! +```bash +# Create container +docker create --name=rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rssbridge/rss-bridge +``` -Deploy -=== +You can put custom `config.ini.php` and bridges into `./config`. -Thanks to the community, hosting your own instance of RSS-Bridge is as easy as clicking a button! +**You must restart container for custom changes to take effect.** + +See `docker-entrypoint.sh` for details. + +```bash +# Start container +docker start rss-bridge +``` + +Browse http://localhost:3000/ + +### Install by locally building from Dockerfile + +```bash +# Build image from Dockerfile +docker build -t rss-bridge . + +# Create container +docker create --name rss-bridge --publish 3000:80 --volume $(pwd)/config:/config rss-bridge +``` + +You can put custom `config.ini.php` and bridges into `./config`. + +**You must restart container for custom changes to take effect.** + +See `docker-entrypoint.sh` for details. + +```bash +# Start container +docker start rss-bridge +``` + +Browse http://localhost:3000/ + +### Install with docker-compose (using Docker Hub) + +You can put custom `config.ini.php` and bridges into `./config`. + +**You must restart container for custom changes to take effect.** + +See `docker-entrypoint.sh` for details. + +```bash +docker-compose up +``` + +Browse http://localhost:3000/ + +### Other installation methods [![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/sebsauvage/rss-bridge) [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) +[![Deploy to Cloudron](https://cloudron.io/img/button.svg)](https://www.cloudron.io/store/com.rssbridgeapp.cloudronapp.html) +[![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=rssbridge) -Getting involved -=== - -There are many ways for you to getting involved with RSS-Bridge. Here are a few things: - -- Share RSS-Bridge with your friends (Twitter, Facebook, ..._you name it_...) -- Report broken bridges or bugs by opening [Issues](https://github.com/RSS-Bridge/rss-bridge/issues) on GitHub -- Request new features or suggest ideas (via [Issues](https://github.com/RSS-Bridge/rss-bridge/issues)) -- Discuss bugs, features, ideas or [issues](https://github.com/RSS-Bridge/rss-bridge/issues) -- Add new bridges or improve the API -- Improve the [Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki) -- Host an instance of RSS-Bridge for your personal use or make it available to the community :sparkling_heart: - -Authors -=== - -We are RSS-Bridge community, a group of developers continuing the project initiated by sebsauvage, webmaster of [sebsauvage.net](http://sebsauvage.net), author of [Shaarli](http://sebsauvage.net/wiki/doku.php?id=php:shaarli) and [ZeroBin](http://sebsauvage.net/wiki/doku.php?id=php:zerobin). - -**Contributors** (sorted alphabetically): - - -* [16mhz](https://github.com/16mhz) -* [adamchainz](https://github.com/adamchainz) -* [Ahiles3005](https://github.com/Ahiles3005) -* [Albirew](https://github.com/Albirew) -* [aledeg](https://github.com/aledeg) -* [alex73](https://github.com/alex73) -* [alexAubin](https://github.com/alexAubin) -* [AmauryCarrade](https://github.com/AmauryCarrade) -* [AntoineTurmel](https://github.com/AntoineTurmel) -* [ArthurHoaro](https://github.com/ArthurHoaro) -* [Astalaseven](https://github.com/Astalaseven) -* [Astyan-42](https://github.com/Astyan-42) -* [az5he6ch](https://github.com/az5he6ch) -* [azdkj532](https://github.com/azdkj532) -* [b1nj](https://github.com/b1nj) -* [benasse](https://github.com/benasse) -* [captn3m0](https://github.com/captn3m0) -* [chemel](https://github.com/chemel) -* [ckiw](https://github.com/ckiw) -* [cnlpete](https://github.com/cnlpete) -* [corenting](https://github.com/corenting) -* [couraudt](https://github.com/couraudt) -* [cyberjacob](https://github.com/cyberjacob) -* [da2x](https://github.com/da2x) -* [Daiyousei](https://github.com/Daiyousei) -* [dawidsowa](https://github.com/dawidsowa) -* [disk0x](https://github.com/disk0x) -* [DJCrashdummy](https://github.com/DJCrashdummy) -* [Djuuu](https://github.com/Djuuu) -* [DnAp](https://github.com/DnAp) -* [dominik-th](https://github.com/dominik-th) -* [Draeli](https://github.com/Draeli) -* [Dreckiger-Dan](https://github.com/Dreckiger-Dan) -* [em92](https://github.com/em92) -* [eMerzh](https://github.com/eMerzh) -* [EtienneM](https://github.com/EtienneM) -* [floviolleau](https://github.com/floviolleau) -* [fluffy-critter](https://github.com/fluffy-critter) -* [Frenzie](https://github.com/Frenzie) -* [fulmeek](https://github.com/fulmeek) -* [Ginko-Aloe](https://github.com/Ginko-Aloe) -* [Glandos](https://github.com/Glandos) -* [gloony](https://github.com/gloony) -* [GregThib](https://github.com/GregThib) -* [griffaurel](https://github.com/griffaurel) -* [Grummfy](https://github.com/Grummfy) -* [hunhejj](https://github.com/hunhejj) -* [husim0](https://github.com/husim0) -* [IceWreck](https://github.com/IceWreck) -* [j0k3r](https://github.com/j0k3r) -* [JackNUMBER](https://github.com/JackNUMBER) -* [jdigilio](https://github.com/jdigilio) -* [JeremyRand](https://github.com/JeremyRand) -* [Jocker666z](https://github.com/Jocker666z) -* [johnnygroovy](https://github.com/johnnygroovy) -* [killruana](https://github.com/killruana) -* [klimplant](https://github.com/klimplant) -* [kranack](https://github.com/kranack) -* [kraoc](https://github.com/kraoc) -* [l1n](https://github.com/l1n) -* [laBecasse](https://github.com/laBecasse) -* [lagaisse](https://github.com/lagaisse) -* [lalannev](https://github.com/lalannev) -* [ldidry](https://github.com/ldidry) -* [Leomaradan](https://github.com/Leomaradan) -* [Limero](https://github.com/Limero) -* [LogMANOriginal](https://github.com/LogMANOriginal) -* [lorenzos](https://github.com/lorenzos) -* [lukasklinger](https://github.com/lukasklinger) -* [m0zes](https://github.com/m0zes) -* [matthewseal](https://github.com/matthewseal) -* [mcbyte-it](https://github.com/mcbyte-it) -* [mdemoss](https://github.com/mdemoss) -* [melangue](https://github.com/melangue) -* [metaMMA](https://github.com/metaMMA) -* [mitsukarenai](https://github.com/mitsukarenai) -* [MonsieurPoutounours](https://github.com/MonsieurPoutounours) -* [mr-flibble](https://github.com/mr-flibble) -* [mro](https://github.com/mro) -* [mxmehl](https://github.com/mxmehl) -* [nel50n](https://github.com/nel50n) -* [niawag](https://github.com/niawag) -* [Nono-m0le](https://github.com/Nono-m0le) -* [ObsidianWitch](https://github.com/ObsidianWitch) -* [OliverParoczai](https://github.com/OliverParoczai) -* [oratosquilla-oratoria](https://github.com/oratosquilla-oratoria) -* [ORelio](https://github.com/ORelio) -* [PaulVayssiere](https://github.com/PaulVayssiere) -* [pellaeon](https://github.com/pellaeon) -* [Piranhaplant](https://github.com/Piranhaplant) -* [pit-fgfjiudghdf](https://github.com/pit-fgfjiudghdf) -* [pitchoule](https://github.com/pitchoule) -* [pmaziere](https://github.com/pmaziere) -* [Pofilo](https://github.com/Pofilo) -* [prysme01](https://github.com/prysme01) -* [quentinus95](https://github.com/quentinus95) -* [regisenguehard](https://github.com/regisenguehard) -* [Riduidel](https://github.com/Riduidel) -* [rogerdc](https://github.com/rogerdc) -* [Roliga](https://github.com/Roliga) -* [sebsauvage](https://github.com/sebsauvage) -* [shutosg](https://github.com/shutosg) -* [somini](https://github.com/somini) -* [squeek502](https://github.com/squeek502) -* [stjohnjohnson](https://github.com/stjohnjohnson) -* [Strubbl](https://github.com/Strubbl) -* [sublimz](https://github.com/sublimz) -* [sunchaserinfo](https://github.com/sunchaserinfo) -* [sysadminstory](https://github.com/sysadminstory) -* [tameroski](https://github.com/tameroski) -* [teromene](https://github.com/teromene) -* [thefranke](https://github.com/thefranke) -* [ThePadawan](https://github.com/ThePadawan) -* [TheRadialActive](https://github.com/TheRadialActive) -* [TitiTestScalingo](https://github.com/TitiTestScalingo) -* [triatic](https://github.com/triatic) -* [VerifiedJoseph](https://github.com/VerifiedJoseph) -* [WalterBarrett](https://github.com/WalterBarrett) -* [wtuuju](https://github.com/wtuuju) -* [xurxof](https://github.com/xurxof) -* [yardenac](https://github.com/yardenac) -* [ZeNairolf](https://github.com/ZeNairolf) - -Licenses -=== +The Heroku quick deploy currently does not work. It might work if you fork this repo and +modify the `repository` in `scalingo.json`. See https://github.com/RSS-Bridge/rss-bridge/issues/2688 -The source code for RSS-Bridge is [Public Domain](UNLICENSE). +Learn more in +[Installation](https://rss-bridge.github.io/rss-bridge/For_Hosts/Installation.html). -RSS-Bridge uses third party libraries with their own license: +## How-to + +### How to fix "Access denied." + +Output is from php-fpm. It is unable to read index.php. + + chown rss-bridge:rss-bridge /var/www/rss-bridge/index.php + +### How to password-protect the instance (token) + +Modify `config.ini.php`: + + [authentication] + + token = "hunter2" + +### How to remove all cache items + +As current user: + + bin/cache-clear + +As user rss-bridge: + + sudo -u rss-bridge bin/cache-clear + +As root: + + sudo bin/cache-clear + +### How to remove all expired cache items + + bin/cache-prune + +### How to fix "PHP Fatal error: Uncaught Exception: The FileCache path is not writable" - * [`PHP Simple HTML DOM Parser`](http://simplehtmldom.sourceforge.net/) licensed under the [MIT License](http://opensource.org/licenses/MIT) - * [`php-urljoin`](https://github.com/fluffy-critter/php-urljoin) licensed under the [MIT License](http://opensource.org/licenses/MIT) +```shell +# Give rss-bridge ownership +chown rss-bridge:rss-bridge -R /var/www/rss-bridge/cache -Technical notes -=== +# Or, give www-data ownership +chown www-data:www-data -R /var/www/rss-bridge/cache + +# Or, give everyone write permission +chmod 777 -R /var/www/rss-bridge/cache + +# Or last ditch effort (CAREFUL) +rm -rf /var/www/rss-bridge/cache/ && mkdir /var/www/rss-bridge/cache/ +``` + +### How to fix "attempt to write a readonly database" + +The sqlite files (db, wal and shm) are not writeable. + + chown -v rss-bridge:rss-bridge cache/* + +### How to fix "Unable to prepare statement: 1, no such table: storage" + + rm cache/* + +### How to create a new bridge from scratch + +Create the new bridge in e.g. `bridges/BearBlogBridge.php`: + +```php +find('.blog-posts li') as $li) { + $a = $li->find('a', 0); + $this->items[] = [ + 'title' => $a->plaintext, + 'uri' => 'https://herman.bearblog.dev' . $a->href, + ]; + } + } +} +``` + +Learn more in [bridge api](https://rss-bridge.github.io/rss-bridge/Bridge_API/index.html). + +### How to enable all bridges + + enabled_bridges[] = * + +### How to enable some bridges + +``` +enabled_bridges[] = TwitchBridge +enabled_bridges[] = GettrBridge +``` + +### How to enable debug mode + +The +[debug mode](https://rss-bridge.github.io/rss-bridge/For_Developers/Debug_mode.html) +disables the majority of caching operations. + + enable_debug_mode = true + +### How to switch to memcached as cache backend + +``` +[cache] + +; Cache backend: file (default), sqlite, memcached, null +type = "memcached" +``` + +### How to switch to sqlite3 as cache backend + + type = "sqlite" + +### How to disable bridge errors (as feed items) + +When a bridge fails, RSS-Bridge will produce a feed with a single item describing the error. + +This way, feed readers pick it up and you are notified. + +If you don't want this behaviour, switch the error output to `http`: + + [error] + + ; Defines how error messages are returned by RSS-Bridge + ; + ; "feed" = As part of the feed (default) + ; "http" = As HTTP error message + ; "none" = No errors are reported + output = "http" + +### How to accumulate errors before finally reporting it + +Modify `report_limit` so that an error must occur 3 times before it is reported. + + ; Defines how often an error must occur before it is reported to the user + report_limit = 3 + +The report count is reset to 0 each day. + +### How to password-protect the instance (HTTP Basic Auth) + + [authentication] + + enable = true + username = "alice" + password = "cat" + +Will typically require feed readers to be configured with the credentials. + +It may also be possible to manually include the credentials in the URL: + +https://alice:cat@rss-bridge.org/bridge01/?action=display&bridge=FabriceBellardBridge&format=Html + +### How to create a new output format + +See `formats/PlaintextFormat.php` for an example. + +### How to run unit tests and linter + +These commands require that you have installed the dev dependencies in `composer.json`. + +Run all tests: + + ./vendor/bin/phpunit + +Run a single test class: + + ./vendor/bin/phpunit --filter UrlTest + +Run linter: + + ./vendor/bin/phpcs --standard=phpcs.xml --warning-severity=0 --extensions=php -p ./ + +https://github.com/squizlabs/PHP_CodeSniffer/wiki + +### How to spawn a minimal development environment + + php -S 127.0.0.1:9001 + +http://127.0.0.1:9001/ + +## Explanation + +We are RSS-Bridge community, a group of developers continuing the project initiated by sebsauvage, +webmaster of +[sebsauvage.net](https://sebsauvage.net), author of +[Shaarli](https://sebsauvage.net/wiki/doku.php?id=php:shaarli) and +[ZeroBin](https://sebsauvage.net/wiki/doku.php?id=php:zerobin). + +See [CONTRIBUTORS.md](CONTRIBUTORS.md) + +RSS-Bridge uses caching to prevent services from banning your server for repeatedly updating feeds. +The specific cache duration can be different between bridges. + +RSS-Bridge allows you to take full control over which bridges are displayed to the user. +That way you can host your own RSS-Bridge service with your favorite collection of bridges! + +Current maintainers (as of 2024): @dvikan and @Mynacol #2519 + +## Reference + +### Feed item structure + +This is the feed item structure that bridges are expected to produce. + +```php + $item = [ + 'uri' => 'https://example.com/blog/hello', + 'title' => 'Hello world', + // Publication date in unix timestamp + 'timestamp' => 1668706254, + 'author' => 'Alice', + 'content' => 'Here be item content', + 'enclosures' => [ + 'https://example.com/foo.png', + 'https://example.com/bar.png' + ], + 'categories' => [ + 'news', + 'tech', + ], + // Globally unique id + 'uid' => 'e7147580c8747aad', + ] +``` + +### Output formats + +* `Atom`: Atom feed, for use in feed readers +* `Html`: Simple HTML page +* `Json`: JSON, for consumption by other applications +* `Mrss`: MRSS feed, for use in feed readers +* `Plaintext`: Raw text, for consumption by other applications +* `Sfeed`: Text, TAB separated + +### Cache backends + +* `File` +* `SQLite` +* `Memcached` +* `Array` +* `Null` + +### Licenses + +The source code for RSS-Bridge is [Public Domain](UNLICENSE). + +RSS-Bridge uses third party libraries with their own license: - * RSS-Bridge uses caching to prevent services from banning your server for repeatedly updating feeds. The specific cache duration can be different between bridges. Cached files are deleted automatically after 24 hours. - * You can implement your own bridge, [following these instructions](https://github.com/RSS-Bridge/rss-bridge/wiki/Bridge-API). - * You can enable debug mode to disable caching. Find more information on the [Wiki](https://github.com/RSS-Bridge/rss-bridge/wiki/Debug-mode) + * [`Parsedown`](https://github.com/erusev/parsedown) licensed under the [MIT License](https://opensource.org/licenses/MIT) + * [`PHP Simple HTML DOM Parser`](https://simplehtmldom.sourceforge.io/docs/1.9/index.html) licensed under the [MIT License](https://opensource.org/licenses/MIT) + * [`php-urljoin`](https://github.com/fluffy-critter/php-urljoin) licensed under the [MIT License](https://opensource.org/licenses/MIT) + * [`Laravel framework`](https://github.com/laravel/framework/) licensed under the [MIT License](https://opensource.org/licenses/MIT) -Rant -=== +## Rant *Dear so-called "social" websites.* @@ -261,6 +531,6 @@ You're not social when you hamper sharing by removing feeds. You're happy to hav We want to share with friends, using open protocols: RSS, Atom, XMPP, whatever. Because no one wants to have *your* service with *your* applications using *your* API force-feeding them. Friends must be free to choose whatever software and service they want. -We are rebuilding bridges you have wilfully destroyed. +We are rebuilding bridges you have willfully destroyed. Get your shit together: Put RSS/Atom back in. diff --git a/actions/ConnectivityAction.php b/actions/ConnectivityAction.php index 69272ddade1..e4e1e7c2724 100644 --- a/actions/ConnectivityAction.php +++ b/actions/ConnectivityAction.php @@ -1,15 +1,4 @@ userData['bridge'])) { - $this->returnEntryPage(); - return; - } - - $bridgeName = $this->userData['bridge']; - - $this->reportBridgeConnectivity($bridgeName); - - } - - /** - * Generates a report about the bridge connectivity status and sends it back - * to the user. - * - * The report is generated as Json-formatted string in the format - * { - * "bridge": "", - * "successful": true/false - * } - * - * @param string $bridgeName Name of the bridge to generate the report for - * @return void - */ - private function reportBridgeConnectivity($bridgeName) { - - $bridgeFac = new \BridgeFactory(); - $bridgeFac->setWorkingDir(PATH_LIB_BRIDGES); - - if(!$bridgeFac->isWhitelisted($bridgeName)) { - header('Content-Type: text/html'); - returnServerError('Bridge is not whitelisted!'); - } - - header('Content-Type: text/json'); - - $retVal = array( - 'bridge' => $bridgeName, - 'successful' => false, - 'http_code' => 200, - ); - - $bridge = $bridgeFac->create($bridgeName); - - if($bridge === false) { - echo json_encode($retVal); - return; - } - - $curl_opts = array( - CURLOPT_CONNECTTIMEOUT => 5 - ); - - try { - $reply = getContents($bridge::URI, array(), $curl_opts, true); - - if($reply) { - $retVal['successful'] = true; - if (isset($reply['header'])) { - if (strpos($reply['header'], 'HTTP/1.1 301 Moved Permanently') !== false) { - $retVal['http_code'] = 301; - } - } - } - } catch(Exception $e) { - $retVal['successful'] = false; - } - - echo json_encode($retVal); - - } - - private function returnEntryPage() { - echo << - - - - - - - - - - -- - -EOD; - } +class ConnectivityAction implements ActionInterface +{ + private BridgeFactory $bridgeFactory; + + public function __construct( + BridgeFactory $bridgeFactory + ) { + $this->bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response + { + if (!Debug::isEnabled()) { + return new Response('This action is only available in debug mode!', 403); + } + + $bridgeName = $request->get('bridge'); + if (!$bridgeName) { + return new Response(render_template('connectivity.html.php')); + } + $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName); + if (!$bridgeClassName) { + return new Response('Bridge not found', 404); + } + return $this->reportBridgeConnectivity($bridgeClassName); + } + + private function reportBridgeConnectivity($bridgeClassName) + { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { + throw new \Exception('Bridge is not whitelisted!'); + } + + $bridge = $this->bridgeFactory->create($bridgeClassName); + $curl_opts = [ + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_FOLLOWLOCATION => true, + ]; + $result = [ + 'bridge' => $bridgeClassName, + 'successful' => false, + 'http_code' => null, + ]; + try { + $response = getContents($bridge::URI, [], $curl_opts, true); + $result['http_code'] = $response->getCode(); + if (in_array($result['http_code'], [200])) { + $result['successful'] = true; + } + } catch (\Exception $e) { + } + + return new Response(Json::encode($result), 200, ['content-type' => 'text/json']); + } } diff --git a/actions/DetectAction.php b/actions/DetectAction.php index 86605de41d8..8d3d6263913 100644 --- a/actions/DetectAction.php +++ b/actions/DetectAction.php @@ -1,53 +1,51 @@ userData['url'] - or returnClientError('You must specify a url!'); - - $format = $this->userData['format'] - or returnClientError('You must specify a format!'); - - $bridgeFac = new \BridgeFactory(); - $bridgeFac->setWorkingDir(PATH_LIB_BRIDGES); - - foreach($bridgeFac->getBridgeNames() as $bridgeName) { - - if(!$bridgeFac->isWhitelisted($bridgeName)) { - continue; - } - - $bridge = $bridgeFac->create($bridgeName); - - if($bridge === false) { - continue; - } - - $bridgeParams = $bridge->detectParameters($targetURL); - - if(is_null($bridgeParams)) { - continue; - } - - $bridgeParams['bridge'] = $bridgeName; - $bridgeParams['format'] = $format; - - header('Location: ?action=display&' . http_build_query($bridgeParams), true, 301); - die(); - - } - - returnClientError('No bridge found for given URL: ' . $targetURL); - } +class DetectAction implements ActionInterface +{ + 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'); + + if (!$url) { + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a url'])); + } + if (!$format) { + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format'])); + } + + foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { + continue; + } + + $bridge = $this->bridgeFactory->create($bridgeClassName); + + $bridgeParams = $bridge->detectParameters($url); + + if (!$bridgeParams) { + continue; + } + + $query = [ + 'action' => 'display', + 'bridge' => $bridgeClassName, + 'format' => $format, + ]; + $query = array_merge($query, $bridgeParams); + return new Response('', 301, ['location' => '?' . http_build_query($query)]); + } + + return new Response(render(__DIR__ . '/../templates/error.html.php', [ + 'message' => 'No bridge found for given URL: ' . $url, + ])); + } } diff --git a/actions/DisplayAction.php b/actions/DisplayAction.php index 89930cfbe6a..10af8ad7257 100644 --- a/actions/DisplayAction.php +++ b/actions/DisplayAction.php @@ -1,258 +1,231 @@ getCode(); - if ($returnCode === 301 || $returnCode === 302) { - # Don't pass redirect codes to the exterior - $returnCode = 508; - } - return $returnCode; - } - - public function execute() { - $bridge = array_key_exists('bridge', $this->userData) ? $this->userData['bridge'] : null; - - $format = $this->userData['format'] - or returnClientError('You must specify a format!'); - - $bridgeFac = new \BridgeFactory(); - $bridgeFac->setWorkingDir(PATH_LIB_BRIDGES); - - // whitelist control - if(!$bridgeFac->isWhitelisted($bridge)) { - throw new \Exception('This bridge is not whitelisted', 401); - die; - } - - // Data retrieval - $bridge = $bridgeFac->create($bridge); - - $noproxy = array_key_exists('_noproxy', $this->userData) - && filter_var($this->userData['_noproxy'], FILTER_VALIDATE_BOOLEAN); - - if(defined('PROXY_URL') && PROXY_BYBRIDGE && $noproxy) { - define('NOPROXY', true); - } - - // Cache timeout - $cache_timeout = -1; - if(array_key_exists('_cache_timeout', $this->userData)) { - - if(!CUSTOM_CACHE_TIMEOUT) { - unset($this->userData['_cache_timeout']); - $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) . '?' . http_build_query($this->userData); - header('Location: ' . $uri, true, 301); - die(); - } - - $cache_timeout = filter_var($this->userData['_cache_timeout'], FILTER_VALIDATE_INT); - - } else { - $cache_timeout = $bridge->getCacheTimeout(); - } - - // Remove parameters that don't concern bridges - $bridge_params = array_diff_key( - $this->userData, - array_fill_keys( - array( - 'action', - 'bridge', - 'format', - '_noproxy', - '_cache_timeout', - '_error_time' - ), '') - ); - - // Remove parameters that don't concern caches - $cache_params = array_diff_key( - $this->userData, - array_fill_keys( - array( - 'action', - 'format', - '_noproxy', - '_cache_timeout', - '_error_time' - ), '') - ); - - // Initialize cache - $cacheFac = new CacheFactory(); - $cacheFac->setWorkingDir(PATH_LIB_CACHES); - $cache = $cacheFac->create(Configuration::getConfig('cache', 'type')); - $cache->setScope(''); - $cache->purgeCache(86400); // 24 hours - $cache->setKey($cache_params); - - $items = array(); - $infos = array(); - $mtime = $cache->getTime(); - - if($mtime !== false - && (time() - $cache_timeout < $mtime) - && !Debug::isEnabled()) { // Load cached data - - // Send "Not Modified" response if client supports it - // Implementation based on https://stackoverflow.com/a/10847262 - if(isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { - $stime = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); - - if($mtime <= $stime) { // Cached data is older or same - header('Last-Modified: ' . gmdate('D, d M Y H:i:s ', $mtime) . 'GMT', true, 304); - die(); - } - } - - $cached = $cache->loadData(); - - if(isset($cached['items']) && isset($cached['extraInfos'])) { - foreach($cached['items'] as $item) { - $items[] = new \FeedItem($item); - } - - $infos = $cached['extraInfos']; - } - - } else { // Collect new data - - try { - $bridge->setDatas($bridge_params); - $bridge->collectData(); - - $items = $bridge->getItems(); - - // Transform "legacy" items to FeedItems if necessary. - // Remove this code when support for "legacy" items ends! - if(isset($items[0]) && is_array($items[0])) { - $feedItems = array(); - - foreach($items as $item) { - $feedItems[] = new \FeedItem($item); - } - - $items = $feedItems; - } - - $infos = array( - 'name' => $bridge->getName(), - 'uri' => $bridge->getURI(), - 'icon' => $bridge->getIcon() - ); - } catch(Error $e) { - error_log($e); - - if(logBridgeError($bridge::NAME, $e->getCode()) >= Configuration::getConfig('error', 'report_limit')) { - if(Configuration::getConfig('error', 'output') === 'feed') { - $item = new \FeedItem(); - - // Create "new" error message every 24 hours - $this->userData['_error_time'] = urlencode((int)(time() / 86400)); - - // Error 0 is a special case (i.e. "trying to get property of non-object") - if($e->getCode() === 0) { - $item->setTitle( - 'Bridge encountered an unexpected situation! (' - . $this->userData['_error_time'] - . ')' - ); - } else { - $item->setTitle( - 'Bridge returned error ' - . $e->getCode() - . '! (' - . $this->userData['_error_time'] - . ')' - ); - } - - $item->setURI( - (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '') - . '?' - . http_build_query($this->userData) - ); - - $item->setTimestamp(time()); - $item->setContent(buildBridgeException($e, $bridge)); - - $items[] = $item; - } elseif(Configuration::getConfig('error', 'output') === 'http') { - header('Content-Type: text/html', true, get_return_code($e)); - die(buildTransformException($e, $bridge)); - } - } - } catch(Exception $e) { - error_log($e); - - if(logBridgeError($bridge::NAME, $e->getCode()) >= Configuration::getConfig('error', 'report_limit')) { - if(Configuration::getConfig('error', 'output') === 'feed') { - $item = new \FeedItem(); - - // Create "new" error message every 24 hours - $this->userData['_error_time'] = urlencode((int)(time() / 86400)); - - $item->setURI( - (isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : '') - . '?' - . http_build_query($this->userData) - ); - - $item->setTitle( - 'Bridge returned error ' - . $e->getCode() - . '! (' - . $this->userData['_error_time'] - . ')' - ); - $item->setTimestamp(time()); - $item->setContent(buildBridgeException($e, $bridge)); - - $items[] = $item; - } elseif(Configuration::getConfig('error', 'output') === 'http') { - header('Content-Type: text/html', true, get_return_code($e)); - die(buildTransformException($e, $bridge)); - } - } - } - - // Store data in cache - $cache->saveData(array( - 'items' => array_map(function($i){ return $i->toArray(); }, $items), - 'extraInfos' => $infos - )); - - } - - // Data transformation - try { - $formatFac = new FormatFactory(); - $formatFac->setWorkingDir(PATH_LIB_FORMATS); - $format = $formatFac->create($format); - $format->setItems($items); - $format->setExtraInfos($infos); - $format->setLastModified($cache->getTime()); - $format->display(); - } catch(Error $e) { - error_log($e); - header('Content-Type: text/html', true, $e->getCode()); - die(buildTransformException($e, $bridge)); - } catch(Exception $e) { - error_log($e); - header('Content-Type: text/html', true, $e->getCode()); - die(buildTransformException($e, $bridge)); - } - } +class DisplayAction implements ActionInterface +{ + private CacheInterface $cache; + private Logger $logger; + private BridgeFactory $bridgeFactory; + + public function __construct( + CacheInterface $cache, + Logger $logger, + BridgeFactory $bridgeFactory + ) { + $this->cache = $cache; + $this->logger = $logger; + $this->bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response + { + $bridgeName = $request->get('bridge'); + $format = $request->get('format'); + $noproxy = $request->get('_noproxy'); + + if (!$bridgeName) { + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Missing bridge name parameter']), 400); + } + $bridgeClassName = $this->bridgeFactory->createBridgeClassName($bridgeName); + if (!$bridgeClassName) { + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'Bridge not found']), 404); + } + + if (!$format) { + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'You must specify a format']), 400); + } + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { + return new Response(render(__DIR__ . '/../templates/error.html.php', ['message' => 'This bridge is not whitelisted']), 400); + } + + // Disable proxy (if enabled and per user's request) + if ( + Configuration::getConfig('proxy', 'url') + && Configuration::getConfig('proxy', 'by_bridge') + && $noproxy + ) { + // This const is only used once in getContents() + define('NOPROXY', true); + } + + $cacheKey = 'http_' . json_encode($request->toArray()); + + $bridge = $this->bridgeFactory->create($bridgeClassName); + + $response = $this->createResponse($request, $bridge, $format); + + if ($response->getCode() === 200) { + $ttl = $request->get('_cache_timeout'); + if (Configuration::getConfig('cache', 'custom_timeout') && $ttl) { + $ttl = (int) $ttl; + } else { + $ttl = $bridge->getCacheTimeout(); + } + $this->cache->set($cacheKey, $response, $ttl); + } + + return $response; + } + + private function createResponse(Request $request, BridgeAbstract $bridge, string $format) + { + $items = []; + + try { + $bridge->loadConfiguration(); + // Remove parameters that don't concern bridges + $remove = [ + 'token', + 'action', + 'bridge', + 'format', + '_noproxy', + '_cache_timeout', + '_error_time', + '_', // Some RSS readers add a cache-busting parameter (_=- --- - ... - -- -) to feed URLs, detect and ignore them. + ]; + $requestArray = $request->toArray(); + $input = array_diff_key($requestArray, array_fill_keys($remove, '')); + $bridge->setInput($input); + $bridge->collectData(); + $items = $bridge->getItems(); + } catch (\Throwable $e) { + if ($e instanceof RateLimitException) { + // These are internally generated by bridges + $this->logger->info(sprintf('RateLimitException 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 instanceof HttpException) { + if (in_array($e->getCode(), [429, 503])) { + // Log with debug, immediately reproduce and return + $this->logger->debug(sprintf('Exception in DisplayAction(%s): %s', $bridge->getShortName(), create_sane_exception_message($e))); + return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), $e->getCode()); + } + // Some other status code which we let fail normally (but don't log it) + } else { + // Log error if it's not an HttpException + $this->logger->error(sprintf('Exception in DisplayAction(%s)', $bridge->getShortName()), ['e' => $e]); + } + $errorOutput = Configuration::getConfig('error', 'output'); + $reportLimit = Configuration::getConfig('error', 'report_limit'); + $errorCount = 1; + if ($reportLimit > 1) { + $errorCount = $this->logBridgeError($bridge->getName(), $e->getCode()); + } + // Let clients know about the error if we are passed the report limit + if ($errorCount >= $reportLimit) { + if ($errorOutput === 'feed') { + // Render the exception as a feed item + $items = [$this->createFeedItemFromException($e, $bridge)]; + } elseif ($errorOutput === 'http') { + return new Response(render(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), 500); + } elseif ($errorOutput === 'none') { + // Do nothing (produces an empty feed) + } + } + } + + $formatFactory = new FormatFactory(); + $format = $formatFactory->create($format); + + $format->setItems($items); + $format->setFeed($bridge->getFeed()); + $now = time(); + $format->setLastModified($now); + $headers = [ + 'last-modified' => gmdate('D, d M Y H:i:s ', $now) . 'GMT', + 'content-type' => $format->getMimeType() . '; charset=UTF-8', + ]; + $body = $format->render(); + + // This is supposed to remove non-utf8 byte sequences, but I'm unsure if it works + ini_set('mbstring.substitute_character', 'none'); + $body = mb_convert_encoding($body, 'UTF-8', 'UTF-8'); + + return new Response($body, 200, $headers); + } + + private function createFeedItemFromException($e, BridgeAbstract $bridge): array + { + $item = []; + + // Create a unique identifier every 24 hours + $uniqueIdentifier = urlencode((int)(time() / 86400)); + $title = sprintf('Bridge returned error %s! (%s)', $e->getCode(), $uniqueIdentifier); + + $item['title'] = $title; + $item['uri'] = get_current_url(); + $item['timestamp'] = time(); + + // Create an item identifier for feed readers e.g. "staysafetv twitch videos_19389" + $item['uid'] = $bridge->getName() . '_' . $uniqueIdentifier; + + $content = render_template(__DIR__ . '/../templates/bridge-error.html.php', [ + 'error' => render_template(__DIR__ . '/../templates/exception.html.php', ['e' => $e]), + 'searchUrl' => self::createGithubSearchUrl($bridge), + 'issueUrl' => self::createGithubIssueUrl($bridge, $e), + 'maintainer' => $bridge->getMaintainer(), + ]); + $item['content'] = $content; + + return $item; + } + + private function logBridgeError($bridgeName, $code) + { + // todo: it's not really necessary to json encode $report + $cacheKey = 'error_reporting_' . $bridgeName . '_' . $code; + $report = $this->cache->get($cacheKey); + if ($report) { + $report = Json::decode($report); + $report['time'] = time(); + $report['count']++; + } else { + $report = [ + 'error' => $code, + 'time' => time(), + 'count' => 1, + ]; + } + $ttl = 86400 * 5; + $this->cache->set($cacheKey, Json::encode($report), $ttl); + return $report['count']; + } + + private static function createGithubIssueUrl(BridgeAbstract $bridge, \Throwable $e): string + { + $maintainer = $bridge->getMaintainer(); + if (str_contains($maintainer, ',')) { + $maintainers = explode(',', $maintainer); + } else { + $maintainers = [$maintainer]; + } + $maintainers = array_map('trim', $maintainers); + + $queryString = $_SERVER['QUERY_STRING'] ?? ''; + $query = [ + 'title' => $bridge->getName() . ' failed with: ' . $e->getMessage(), + 'body' => sprintf( + "```\n%s\n\n%s\n\nQuery string: %s\nVersion: %s\nOs: %s\nPHP version: %s\n```\nMaintainer: @%s", + create_sane_exception_message($e), + implode("\n", trace_to_call_points(trace_from_exception($e))), + $queryString, + Configuration::getVersion(), + PHP_OS_FAMILY, + phpversion() ?: 'Unknown', + implode(', @', $maintainers), + ), + 'labels' => 'Bridge-Broken', + 'assignee' => $maintainer[0], + ]; + + return 'https://github.com/RSS-Bridge/rss-bridge/issues/new?' . http_build_query($query); + } + + private static function createGithubSearchUrl($bridge): string + { + return sprintf( + 'https://github.com/RSS-Bridge/rss-bridge/issues?q=%s', + urlencode('is:issue is:open ' . $bridge->getName()) + ); + } } diff --git a/actions/FindfeedAction.php b/actions/FindfeedAction.php new file mode 100644 index 00000000000..e18c3e1db55 --- /dev/null +++ b/actions/FindfeedAction.php @@ -0,0 +1,95 @@ +bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response + { + $url = $request->get('url'); + $format = $request->get('format'); + + if (!$url) { + return new Response('You must specify a url', 400); + } + if (!$format) { + return new Response('You must specify a format', 400); + } + + $results = []; + foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { + if (!$this->bridgeFactory->isEnabled($bridgeClassName)) { + continue; + } + + $bridge = $this->bridgeFactory->create($bridgeClassName); + + $bridgeParams = $bridge->detectParameters($url); + + if ($bridgeParams === null) { + continue; + } + + // It's allowed to have no 'context' in a bridge (only a default context without any name) + // In this case, the reference to the parameters are found in the first element of the PARAMETERS array + + $context = $bridgeParams['context'] ?? 0; + + $bridgeData = []; + // Construct the array of parameters + foreach ($bridgeParams as $key => $value) { + // 'context' is a special case : it's a bridge parameters, there is no "name" for this parameter + if ($key == 'context') { + $bridgeData[$key]['name'] = 'Context'; + $bridgeData[$key]['value'] = $value; + } else { + $bridgeData[$key]['name'] = $this->getParameterName($bridge, $context, $key); + $bridgeData[$key]['value'] = $value; + } + } + + $bridgeParams['bridge'] = $bridgeClassName; + $bridgeParams['format'] = $format; + $content = [ + 'url' => './?action=display&' . http_build_query($bridgeParams), + 'bridgeParams' => $bridgeParams, + 'bridgeData' => $bridgeData, + 'bridgeMeta' => [ + 'name' => $bridge::NAME, + 'description' => $bridge::DESCRIPTION, + 'parameters' => $bridge::PARAMETERS, + 'icon' => $bridge->getIcon(), + ], + ]; + $results[] = $content; + } + if ($results === []) { + return new Response(Json::encode(['message' => 'No bridge found for given url']), 404, ['content-type' => 'application/json']); + } + return new Response(Json::encode($results), 200, ['content-type' => 'application/json']); + } + + // Get parameter name in the actual context, or in the global parameter + private function getParameterName($bridge, $context, $key) + { + if (isset($bridge::PARAMETERS[$context][$key]['name'])) { + $name = $bridge::PARAMETERS[$context][$key]['name']; + } else if (isset($bridge::PARAMETERS['global'][$key]['name'])) { + $name = $bridge::PARAMETERS['global'][$key]['name']; + } else { + $name = 'Variable "' . $key . '" (No name provided)'; + } + return $name; + } +} diff --git a/actions/FrontpageAction.php b/actions/FrontpageAction.php new file mode 100644 index 00000000000..79ffb4f5762 --- /dev/null +++ b/actions/FrontpageAction.php @@ -0,0 +1,49 @@ +bridgeFactory = $bridgeFactory; + } + + public function __invoke(Request $request): Response + { + $token = $request->getAttribute('token'); + + $messages = []; + $activeBridges = 0; + + $bridgeClassNames = $this->bridgeFactory->getBridgeClassNames(); + + foreach ($this->bridgeFactory->getMissingEnabledBridges() as $missingEnabledBridge) { + $messages[] = [ + 'body' => sprintf('Warning : Bridge "%s" not found', $missingEnabledBridge), + 'level' => 'warning' + ]; + } + + $body = ''; + foreach ($bridgeClassNames as $bridgeClassName) { + if ($this->bridgeFactory->isEnabled($bridgeClassName)) { + $body .= BridgeCard::render($this->bridgeFactory, $bridgeClassName, $token); + $activeBridges++; + } + } + + $response = new Response(render(__DIR__ . '/../templates/frontpage.html.php', [ + 'messages' => $messages, + 'admin_email' => Configuration::getConfig('admin', 'email'), + 'admin_telegram' => Configuration::getConfig('admin', 'telegram'), + 'bridges' => $body, + 'active_bridges' => $activeBridges, + 'total_bridges' => count($bridgeClassNames), + ])); + + // TODO: The rendered template could be cached, but beware config changes that changes the html + return $response; + } +} diff --git a/actions/HealthAction.php b/actions/HealthAction.php new file mode 100644 index 00000000000..13365a3c83d --- /dev/null +++ b/actions/HealthAction.php @@ -0,0 +1,15 @@ + 200, + 'message' => 'all is good', + ]; + return new Response(Json::encode($response), 200, ['content-type' => 'application/json']); + } +} diff --git a/actions/ListAction.php b/actions/ListAction.php index 92aef0e0f5e..f6347f9c457 100644 --- a/actions/ListAction.php +++ b/actions/ListAction.php @@ -1,56 +1,36 @@ bridges = array(); - $list->total = 0; - - $bridgeFac = new \BridgeFactory(); - $bridgeFac->setWorkingDir(PATH_LIB_BRIDGES); - - foreach($bridgeFac->getBridgeNames() as $bridgeName) { - - $bridge = $bridgeFac->create($bridgeName); - - if($bridge === false) { // Broken bridge, show as inactive - - $list->bridges[$bridgeName] = array( - 'status' => 'inactive' - ); - - continue; - - } - - $status = $bridgeFac->isWhitelisted($bridgeName) ? 'active' : 'inactive'; - - $list->bridges[$bridgeName] = array( - 'status' => $status, - 'uri' => $bridge->getURI(), - 'name' => $bridge->getName(), - 'icon' => $bridge->getIcon(), - 'parameters' => $bridge->getParameters(), - 'maintainer' => $bridge->getMaintainer(), - 'description' => $bridge->getDescription() - ); - - } - - $list->total = count($list->bridges); - - header('Content-Type: application/json'); - echo json_encode($list, JSON_PRETTY_PRINT); - } +class ListAction implements ActionInterface +{ + 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; + + foreach ($this->bridgeFactory->getBridgeClassNames() as $bridgeClassName) { + $bridge = $this->bridgeFactory->create($bridgeClassName); + + $list->bridges[$bridgeClassName] = [ + 'status' => $this->bridgeFactory->isEnabled($bridgeClassName) ? 'active' : 'inactive', + 'uri' => $bridge->getURI(), + 'donationUri' => $bridge->getDonationURI(), + 'name' => $bridge->getName(), + 'icon' => $bridge->getIcon(), + 'parameters' => $bridge->getParameters(), + 'maintainer' => $bridge->getMaintainer(), + 'description' => $bridge->getDescription() + ]; + } + $list->total = count($list->bridges); + return new Response(Json::encode($list), 200, ['content-type' => 'application/json']); + } } diff --git a/app.json b/app.json index f18479957f5..f79d7138f87 100644 --- a/app.json +++ b/app.json @@ -1,8 +1,8 @@ { "service": "Heroku", - "name": "RSS-Bridge", + "name": "rss-bridge-heroku", "description": "RSS-Bridge is a PHP project capable of generating RSS and Atom feeds for websites which don't have one.", - "repository": "https://github.com/RSS-Bridge/rss-bridge", + "repository": "https://github.com/RSS-Bridge/rss-bridge?1651005770", "keywords": ["php", "rss-bridge", "rss"] } diff --git a/bin/cache-clear b/bin/cache-clear new file mode 100755 index 00000000000..2ca84ce6774 --- /dev/null +++ b/bin/cache-clear @@ -0,0 +1,16 @@ +#!/usr/bin/env php +clear(); diff --git a/bin/cache-prune b/bin/cache-prune new file mode 100755 index 00000000000..bb72c4ac99a --- /dev/null +++ b/bin/cache-prune @@ -0,0 +1,24 @@ +#!/usr/bin/env php +prune(); diff --git a/bin/test b/bin/test new file mode 100755 index 00000000000..746924107a9 --- /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 new file mode 100644 index 00000000000..154eb489215 --- /dev/null +++ b/bridges/ABCNewsBridge.php @@ -0,0 +1,49 @@ + [ + 'type' => 'list', + 'name' => 'Region', + 'title' => 'Choose state', + 'values' => [ + 'ACT' => 'act', + 'NSW' => 'nsw', + 'NT' => 'nt', + 'QLD' => 'qld', + 'SA' => 'sa', + 'TAS' => 'tas', + 'VIC' => 'vic', + 'WA' => 'wa' + ], + ] + ] + ]; + + public function collectData() + { + $url = sprintf('https://www.abc.net.au/news/%s', $this->getInput('topic')); + $dom = getSimpleHTMLDOM($url); + $dom = $dom->find('div[data-component="PaginationList"]', 0); + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('article[data-component="DetailCard"]') as $article) { + $a = $article->find('a', 0); + $this->items[] = [ + 'title' => $a->plaintext, + 'uri' => $a->href, + 'content' => $article->find('p', 0)->plaintext, + 'timestamp' => strtotime($article->find('time', 0)->datetime), + ]; + } + } +} diff --git a/bridges/ABCTabsBridge.php b/bridges/ABCTabsBridge.php deleted file mode 100644 index ef2c75b109c..00000000000 --- a/bridges/ABCTabsBridge.php +++ /dev/null @@ -1,42 +0,0 @@ -find('table#myTable', 0)->children(1); - - foreach ($table->find('tr') as $tab) { - $item = array(); - $item['author'] = $tab->find('td', 1)->plaintext - . ' - ' - . $tab->find('td', 2)->plaintext; - - $item['title'] = $tab->find('td', 1)->plaintext - . ' - ' - . $tab->find('td', 2)->plaintext; - - $item['content'] = 'Le ' - . $tab->find('td', 0)->plaintext - . '
Par: ' - . $tab->find('td', 5)->plaintext - . '
Type: ' - . $tab->find('td', 3)->plaintext; - - $item['id'] = static::URI - . $tab->find('td', 2)->find('a', 0)->getAttribute('href'); - - $item['uri'] = static::URI - . $tab->find('td', 2)->find('a', 0)->getAttribute('href'); - - $this->items[] = $item; - } - } -} diff --git a/bridges/ABolaBridge.php b/bridges/ABolaBridge.php new file mode 100644 index 00000000000..1f1c5da1954 --- /dev/null +++ b/bridges/ABolaBridge.php @@ -0,0 +1,116 @@ + [ + 'name' => 'News Feed', + 'type' => 'list', + 'title' => 'Feeds from the Portuguese sports newspaper A BOLA.PT', + 'values' => [ + 'Últimas' => 'Nnh/Noticias', + 'Seleção Nacional' => 'Selecao/Noticias', + 'Futebol Nacional' => [ + 'Notícias' => 'Nacional/Noticias', + 'Primeira Liga' => 'Nacional/Liga/Noticias', + 'Liga 2' => 'Nacional/Liga2/Noticias', + 'Liga 3' => 'Nacional/Liga3/Noticias', + 'Liga Revelação' => 'Nacional/Liga-Revelacao/Noticias', + 'Campeonato de Portugal' => 'Nacional/Campeonato-Portugal/Noticias', + 'Distritais' => 'Nacional/Distritais/Noticias', + 'Taça de Portugal' => 'Nacional/TPortugal/Noticias', + 'Futebol Feminino' => 'Nacional/FFeminino/Noticias', + 'Futsal' => 'Nacional/Futsal/Noticias', + ], + 'Futebol Internacional' => [ + 'Notícias' => 'Internacional/Noticias/Noticias', + 'Liga dos Campeões' => 'Internacional/Liga-dos-campeoes/Noticias', + 'Liga Europa' => 'Internacional/Liga-europa/Noticias', + 'Liga Conferência' => 'Internacional/Liga-conferencia/Noticias', + 'Liga das Nações' => 'Internacional/Liga-das-nacoes/Noticias', + 'UEFA Youth League' => 'Internacional/Uefa-Youth-League/Noticias', + ], + 'Mercado' => 'Mercado', + 'Modalidades' => 'Modalidades/Noticias', + 'Motores' => 'Motores/Noticias', + ] + ] + ] + ]; + + public function getIcon() + { + return 'https://abola.pt/img/icons/favicon-96x96.png'; + } + + public function getName() + { + return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME; + } + + public function getURI() + { + return self::URI . $this->getInput('feed'); + } + + public function collectData() + { + $url = sprintf('https://abola.pt/%s', $this->getInput('feed')); + $dom = getSimpleHTMLDOM($url); + if ($this->getInput('feed') !== 'Mercado') { + $dom = $dom->find('div#body_Todas1_upNoticiasTodas', 0); + } else { + $dom = $dom->find('div#body_NoticiasMercado_upNoticiasTodas', 0); + } + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('div.media') as $key => $article) { + //Get thumbnail + $image = $article->find('.media-img', 0)->style; + $image = preg_replace('/background-image: url\(/i', '', $image); + $image = substr_replace($image, '', -4); + $image = preg_replace('/https:\/\//i', '', $image); + $image = preg_replace('/www\./i', '', $image); + $image = preg_replace('/\/\//', '/', $image); + $image = preg_replace('/\/\/\//', '//', $image); + $image = substr($image, 7); + $image = 'https://' . $image; + $image = preg_replace('/ptimg/', 'pt/img', $image); + $image = preg_replace('/\/\/bola/', 'www.abola', $image); + //Timestamp + $date = date('Y/m/d'); + if (!is_null($article->find("span#body_Todas1_rptNoticiasTodas_lblData_$key", 0))) { + $date = $article->find("span#body_Todas1_rptNoticiasTodas_lblData_$key", 0)->plaintext; + $date = preg_replace('/\./', '/', $date); + } + $time = $article->find("span#body_Todas1_rptNoticiasTodas_lblHora_$key", 0)->plaintext; + $date = explode('/', $date); + $time = explode(':', $time); + $year = $date[0]; + $month = $date[1]; + $day = $date[2]; + $hour = $time[0]; + $minute = $time[1]; + $timestamp = mktime($hour, $minute, 0, $month, $day, $year); + //Content + $image = ''; + $description = '' . $article->find('.media-texto > span', 0)->plaintext . '
'; + $content = $image . '' . $description; + $a = $article->find('.media-body > a', 0); + $this->items[] = [ + 'title' => $a->find('h4 span', 0)->plaintext, + 'uri' => $a->href, + 'content' => $content, + 'timestamp' => $timestamp, + ]; + } + } +} diff --git a/bridges/AO3Bridge.php b/bridges/AO3Bridge.php index 9a3b5c8ffa8..7e18b657f58 100644 --- a/bridges/AO3Bridge.php +++ b/bridges/AO3Bridge.php @@ -1,121 +1,238 @@ array( - 'url' => array( - 'name' => 'url', - 'required' => true, - // Example: F/F tag, complete works only - 'exampleValue' => self::URI - . 'works?work_search[complete]=T&tag_id=F*s*F', - ), - ), - 'Bookmarks' => array( - 'user' => array( - 'name' => 'user', - 'required' => true, - // Example: Nyaaru's bookmarks - 'exampleValue' => 'Nyaaru', - ), - ), - 'Work' => array( - 'id' => array( - 'name' => 'id', - 'required' => true, - // Example: latest chapters from A Better Past by LysSerris - 'exampleValue' => '18181853', - ), - ) - ); - - // Feed for lists of works (e.g. recent works, search results, filtered tags, - // bookmarks, series, collections). - private function collectList($url) { - $html = getSimpleHTMLDOM($url) - or returnServerError('could not request AO3'); - $html = defaultLinkTo($html, self::URI); - - foreach($html->find('.index.group > li') as $element) { - $item = array(); - - $title = $element->find('div h4 a', 0); - if (!isset($title)) 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); - - $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"; - - $this->items[] = $item; - } - } - - // Feed for recent chapters of a specific work. - private function collectWork($id) { - $url = self::URI . "/works/$id/navigate"; - $html = getSimpleHTMLDOM($url) - or returnServerError('could not request AO3'); - $html = defaultLinkTo($html, self::URI); - - $this->title = $html->find('h2 a', 0)->plaintext; - - foreach($html->find('ol.index.group > li') as $element) { - $item = array(); - - $item['title'] = $element->find('a', 0)->plaintext; - $item['content'] = $element; - $item['uri'] = $element->find('a', 0)->href; - - $strdate = $element->find('span.datetime', 0)->plaintext; - $strdate = str_replace('(', '', $strdate); - $strdate = str_replace(')', '', $strdate); - $item['timestamp'] = strtotime($strdate); - - $item['uid'] = $item['uri'] . "/$strdate"; - - $this->items[] = $item; - } - - $this->items = array_reverse($this->items); - } - - public function collectData() { - switch($this->queriedContext) { - case 'Bookmarks': - $user = $this->getInput('user'); - $this->title = $user; - $url = self::URI - . '/users/' . $user - . '/bookmarks?bookmark_search[sort_column]=bookmarkable_date'; - return $this->collectList($url); - case 'List': return $this->collectList( - $this->getInput('url') - ); - case 'Work': return $this->collectWork( - $this->getInput('id') - ); - } - } - - public function getName() { - $name = parent::getName() . " $this->queriedContext"; - if (isset($this->title)) $name .= " - $this->title"; - return $name; - } - - public function getIcon() { - return self::URI . '/favicon.ico'; - } +class AO3Bridge extends BridgeAbstract +{ + const NAME = 'AO3'; + const URI = 'https://archiveofourown.org/'; + const CACHE_TIMEOUT = 1800; + const DESCRIPTION = 'Returns works or chapters from Archive of Our Own'; + const MAINTAINER = 'Obsidienne'; + const PARAMETERS = [ + 'List' => [ + 'url' => [ + 'name' => 'url', + 'required' => true, + // Example: F/F tag + 'exampleValue' => 'https://archiveofourown.org/tags/F*s*F/works', + ], + 'range' => [ + 'name' => 'Chapter Content', + 'title' => 'Chapter(s) to include in each work\'s feed entry', + 'defaultValue' => null, + 'type' => 'list', + 'values' => [ + 'None' => null, + 'First' => 'first', + 'Latest' => 'last', + 'Entire work' => 'all', + ], + ], + 'limit' => self::LIMIT, + ], + 'Bookmarks' => [ + 'user' => [ + 'name' => 'user', + 'required' => true, + // Example: Nyaaru's bookmarks + 'exampleValue' => 'Nyaaru', + ], + ], + 'Work' => [ + 'id' => [ + 'name' => 'id', + 'required' => true, + // Example: latest chapters from A Better Past by LysSerris + 'exampleValue' => '18181853', + ], + ] + ]; + private $title; + + public function collectData() + { + switch ($this->queriedContext) { + case 'Bookmarks': + $this->collectList($this->getURI()); + break; + case 'List': + $this->collectList($this->getURI()); + break; + case 'Work': + $this->collectWork($this->getURI()); + break; + } + } + + /** + * Feed for lists of works (e.g. recent works, search results, filtered tags, + * bookmarks, series, collections). + */ + private function collectList($url) + { + $version = 'v0.0.1'; + $headers = [ + "useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)" + ]; + $response = getContents($url, $headers); + + $html = \str_get_html($response); + $html = defaultLinkTo($html, self::URI); + + // Get list title. Will include page range + count in some cases + $heading = ($html->find('#main h2', 0)); + if ($heading->find('a.tag')) { + $heading = $heading->find('a.tag', 0); + } + $this->title = $heading->plaintext; + + $limit = $this->getInput('limit') ?? 3; + $count = 0; + foreach ($html->find('.index.group > li') as $element) { + $item = []; + + $title = $element->find('div h4 a', 0); + if (!isset($title)) { + continue; // discard deleted works + } + $item['title'] = $title->plaintext; + $item['uri'] = $title->href; + + $strdate = $element->find('div p.datetime', 0)->plaintext; + $item['timestamp'] = strtotime($strdate); + + // detach from rest of page because remove() is buggy + $element = str_get_html($element->outertext()); + $tags = $element->find('ul.required-tags', 0); + foreach ($tags->childNodes() as $tag) { + $item['categories'][] = html_entity_decode($tag->plaintext); + } + $tags->remove(); + $tags = $element->find('ul.tags', 0); + foreach ($tags->childNodes() as $tag) { + $item['categories'][] = html_entity_decode($tag->plaintext); + } + $tags->remove(); + + $item['content'] = implode('', $element->childNodes()); + + $chapters = $element->find('dl dd.chapters', 0); + // bookmarked series and external works do not have a chapters count + $chapters = (isset($chapters) ? $chapters->plaintext : 0); + $item['uid'] = $item['uri'] . "/$strdate/$chapters"; + + // Fetch workskin of desired chapter(s) in list + if ($this->getInput('range') && ($limit == 0 || $count++ < $limit)) { + $url = $item['uri']; + switch ($this->getInput('range')) { + case ('all'): + $url .= '?view_full_work=true'; + break; + case ('first'): + break; + case ('last'): + // only way to get this is using the navigate page unfortunately + $url .= '/navigate'; + $response = getContents($url, $headers); + $html = \str_get_html($response); + $html = defaultLinkTo($html, self::URI); + $url = $html->find('ol.index.group > li > a', -1)->href; + break; + } + $response = getContents($url, $headers); + + $html = \str_get_html($response); + $html = defaultLinkTo($html, self::URI); + // remove duplicate fic summary + if ($ficsum = $html->find('#workskin > .preface > .summary', 0)) { + $ficsum->remove(); + } + $item['content'] .= $html->find('#workskin', 0); + } + + // Use predictability of download links to generate enclosures + $wid = explode('/', $item['uri'])[4]; + foreach (['azw3', 'epub', 'mobi', 'pdf', 'html'] as $ext) { + $item['enclosures'][] = 'https://archiveofourown.org/downloads/' . $wid . '/work.' . $ext; + } + + $this->items[] = $item; + } + } + + /** + * Feed for recent chapters of a specific work. + */ + private function collectWork($url) + { + $version = 'v0.0.1'; + $headers = [ + "useragent: rss-bridge $version (https://github.com/RSS-Bridge/rss-bridge)" + ]; + $response = getContents($url . '/navigate', $headers); + + $html = \str_get_html($response); + $html = defaultLinkTo($html, self::URI); + + $response = getContents($url . '?view_full_work=true', $headers); + + $workhtml = \str_get_html($response); + $workhtml = defaultLinkTo($workhtml, self::URI); + + $this->title = $html->find('h2 a', 0)->plaintext; + + $nav = $html->find('ol.index.group > li'); + for ($i = 0; $i < count($nav); $i++) { + $item = []; + + $element = $nav[$i]; + $item['title'] = $element->find('a', 0)->plaintext; + $item['content'] = $workhtml->find('#chapter-' . ($i + 1), 0); + $item['uri'] = $element->find('a', 0)->href; + + $strdate = $element->find('span.datetime', 0)->plaintext; + $strdate = str_replace('(', '', $strdate); + $strdate = str_replace(')', '', $strdate); + $item['timestamp'] = strtotime($strdate); + + $item['uid'] = $item['uri'] . "/$strdate"; + + $this->items[] = $item; + } + + $this->items = array_reverse($this->items); + } + + public function getName() + { + $name = parent::getName() . " $this->queriedContext"; + if (isset($this->title)) { + $name .= " - $this->title"; + } + return $name; + } + + public function getIcon() + { + return self::URI . '/favicon.ico'; + } + + public function getURI() + { + $url = parent::getURI(); + switch ($this->queriedContext) { + case 'Bookmarks': + $user = $this->getInput('user'); + $url = self::URI + . '/users/' . $user + . '/bookmarks?bookmark_search[sort_column]=bookmarkable_date'; + break; + case 'List': + $url = $this->getInput('url'); + break; + case 'Work': + $url = self::URI . '/works/' . $this->getInput('id'); + break; + } + return $url; + } } diff --git a/bridges/ARDAudiothekBridge.php b/bridges/ARDAudiothekBridge.php new file mode 100644 index 00000000000..02b6b00778d --- /dev/null +++ b/bridges/ARDAudiothekBridge.php @@ -0,0 +1,173 @@ +icon + * @const IMAGEEXTENSION + */ + const IMAGEEXTENSION = '.jpg'; + + const PARAMETERS = [ + [ + 'path' => [ + 'name' => 'Show Link or ID', + 'required' => true, + 'title' => 'Link to the show page or just its numeric suffix', + 'defaultValue' => 'https://www.ardaudiothek.de/sendung/kalk-welk/10777871/' + ], + 'limit' => self::LIMIT, + ] + ]; + + + /** + * Holds the title of the current show + * + * @var string + */ + private $title; + + /** + * Holds the URI of the show + * + * @var string + */ + private $uri; + + /** + * Holds the icon of the feed + * + */ + private $icon; + + public function collectData() + { + $path = $this->getInput('path'); + $limit = $this->getInput('limit'); + + $oldTz = date_default_timezone_get(); + date_default_timezone_set('Europe/Berlin'); + + $pathComponents = explode('/', $path); + if (empty($pathComponents)) { + returnClientError('Path may not be empty'); + } + if (count($pathComponents) < 2) { + $showID = $pathComponents[0]; + } else { + $lastKey = count($pathComponents) - 1; + $showID = $pathComponents[$lastKey]; + if (strlen($showID) === 0) { + $showID = $pathComponents[$lastKey - 1]; + } + } + + $url = self::APIENDPOINT . 'programsets/' . $showID . '/'; + $json1 = getContents($url); + $data1 = Json::decode($json1, false); + $processedJSON = $data1->data->programSet; + if (!$processedJSON) { + throw new \Exception('Unable to find show id: ' . $showID); + } + + $answerLength = 1; + $offset = 0; + $numberOfElements = 1; + + while ($answerLength != 0 && $offset < $numberOfElements && (is_null($limit) || $offset < $limit)) { + $json2 = getContents($url . '?offset=' . $offset); + $data2 = Json::decode($json2, false); + $processedJSON = $data2->data->programSet; + + $answerLength = count($processedJSON->items->nodes); + $offset = $offset + $answerLength; + $numberOfElements = $processedJSON->numberOfElements; + + foreach ($processedJSON->items->nodes as $audio) { + $item = []; + $item['uri'] = $audio->sharingUrl; + $item['title'] = $audio->title; + $imageSquare = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $audio->image->url1X1); + $image = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $audio->image->url); + $item['enclosures'] = [ + $audio->audios[0]->url, + $imageSquare + ]; + // synopsis in list is shortened, full synopsis is available using one request per item + $item['content'] = '' . $audio->synopsis . '
'; + $item['timestamp'] = $audio->publicationStartDateAndTime; + $item['uid'] = $audio->id; + $item['author'] = $audio->programSet->publicationService->title; + + $category = $audio->programSet->editorialCategories->title ?? null; + if ($category) { + $item['categories'] = [$category]; + } + + $item['itunes'] = [ + 'duration' => $audio->duration, + ]; + + $this->items[] = $item; + } + } + $this->title = $processedJSON->title; + $this->uri = $processedJSON->sharingUrl; + $this->icon = str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $processedJSON->image->url1X1); + // add image file extension to URL so icon is shown in generated RSS feeds, see + // https://github.com/RSS-Bridge/rss-bridge/blob/4aed05c7b678b5673386d61374bba13637d15487/formats/MrssFormat.php#L76 + $this->icon = $this->icon . self::IMAGEEXTENSION; + + $this->items = array_slice($this->items, 0, $limit); + + date_default_timezone_set($oldTz); + } + + /** {@inheritdoc} */ + public function getURI() + { + if (!empty($this->uri)) { + return $this->uri; + } + return parent::getURI(); + } + + /** {@inheritdoc} */ + public function getName() + { + if (!empty($this->title)) { + return $this->title; + } + return parent::getName(); + } + + /** {@inheritdoc} */ + public function getIcon() + { + if (!empty($this->icon)) { + return $this->icon; + } + return parent::getIcon(); + } +} diff --git a/bridges/ARDMediathekBridge.php b/bridges/ARDMediathekBridge.php new file mode 100644 index 00000000000..da11dd642ae --- /dev/null +++ b/bridges/ARDMediathekBridge.php @@ -0,0 +1,114 @@ + [ + 'name' => 'Show Link or ID', + 'required' => true, + 'title' => 'Link to the show page or just its alphanumeric suffix', + 'defaultValue' => 'https://www.ardmediathek.de/sendung/45-min/Y3JpZDovL25kci5kZS8xMzkx/' + ] + ] + ]; + + public function collectData() + { + $oldTz = date_default_timezone_get(); + + date_default_timezone_set('Europe/Berlin'); + + $pathComponents = explode('/', $this->getInput('path')); + if (empty($pathComponents)) { + returnClientError('Path may not be empty'); + } + if (count($pathComponents) < 2) { + $showID = $pathComponents[0]; + } else { + $lastKey = count($pathComponents) - 1; + $showID = $pathComponents[$lastKey]; + if (strlen($showID) === 0) { + $showID = $pathComponents[$lastKey - 1]; + } + } + + $url = self::APIENDPOINT . $showID . '?pageSize=' . self::PAGESIZE; + $rawJSON = getContents($url); + $processedJSON = json_decode($rawJSON); + + foreach ($processedJSON->teasers as $video) { + $item = []; + // there is also ->links->self->id, ->links->self->urlId, ->links->target->id, ->links->target->urlId + $item['uri'] = self::VIDEOLINKPREFIX . $video->id . '/'; + // there is also ->mediumTitle and ->shortTitle + $item['title'] = $video->longTitle; + // in the test, aspect16x9 was the only child of images, not sure whether that is always true + $item['enclosures'] = [ + str_replace(self::IMAGEWIDTHPLACEHOLDER, self::IMAGEWIDTH, $video->images->aspect16x9->src) + ]; + $item['content'] = ''; + $item['timestamp'] = $video->broadcastedOn; + $item['uid'] = $video->id; + $item['author'] = $video->publicationService->name; + $this->items[] = $item; + } + + $this->title = $processedJSON->title; + + date_default_timezone_set($oldTz); + } + + /** {@inheritdoc} */ + public function getName() + { + if (!empty($this->title)) { + return $this->title; + } + return parent::getName(); + } +} diff --git a/bridges/ASRockNewsBridge.php b/bridges/ASRockNewsBridge.php new file mode 100644 index 00000000000..1a3279784a0 --- /dev/null +++ b/bridges/ASRockNewsBridge.php @@ -0,0 +1,63 @@ +find('div.inner > a') as $index => $a) { + $item = []; + + $articlePath = $a->href; + + $articlePageHtml = getSimpleHTMLDOMCached($articlePath, self::CACHE_TIMEOUT); + + $articlePageHtml = defaultLinkTo($articlePageHtml, self::URI); + + $contents = $articlePageHtml->find('div.Contents', 0); + + $item['uri'] = $articlePath; + $item['title'] = $contents->find('h3', 0)->innertext; + + $contents->find('h3', 0)->outertext = ''; + + $item['content'] = $contents->innertext; + $item['timestamp'] = $this->extractDate($a->plaintext); + + $img = $a->find('img', 0); + if ($img) { + $item['enclosures'][] = $img->src; + } + + $this->items[] = $item; + + if (count($this->items) >= 10) { + break; + } + } + } + + private function extractDate($text) + { + $dateRegex = '/^([0-9]{4}\/[0-9]{1,2}\/[0-9]{1,2})/'; + + $text = trim($text); + + if (preg_match($dateRegex, $text, $matches)) { + return $matches[1]; + } + + return ''; + } +} diff --git a/bridges/AcrimedBridge.php b/bridges/AcrimedBridge.php index 7e0fb6b2a6d..f7bbd58ef0a 100644 --- a/bridges/AcrimedBridge.php +++ b/bridges/AcrimedBridge.php @@ -1,24 +1,37 @@ collectExpandableDatas(static::URI . 'spip.php?page=backend'); - } + const PARAMETERS = [ + [ + 'limit' => [ + 'name' => 'limit', + 'type' => 'number', + 'defaultValue' => -1, + ] + ] + ]; - protected function parseItem($newsItem){ - $item = parent::parseItem($newsItem); + public function collectData() + { + $url = 'https://www.acrimed.org/spip.php?page=backend'; + $limit = $this->getInput('limit'); + $this->collectExpandableDatas($url, $limit); + } - $articlePage = getSimpleHTMLDOM($newsItem->link); - $article = sanitize($articlePage->find('article.article1', 0)->innertext); - $article = defaultLinkTo($article, static::URI); - $item['content'] = $article; + protected function parseItem(array $item) + { + $articlePage = getSimpleHTMLDOM($item['uri']); + $article = sanitize($articlePage->find('article.article1', 0)->innertext); + $article = defaultLinkTo($article, static::URI); + $item['content'] = $article; - return $item; - } + return $item; + } } diff --git a/bridges/ActivisionResearchBridge.php b/bridges/ActivisionResearchBridge.php new file mode 100644 index 00000000000..88af4b46499 --- /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 new file mode 100644 index 00000000000..272c74ee121 --- /dev/null +++ b/bridges/AirBreizhBridge.php @@ -0,0 +1,56 @@ + [ + 'theme' => [ + 'name' => 'Thematique', + 'type' => 'list', + 'values' => [ + 'Tout' => '', + 'Rapport d\'activite' => 'rapport-dactivite', + 'Etude' => 'etudes', + 'Information' => 'information', + 'Autres documents' => 'autres-documents', + 'Plan Régional de Surveillance de la qualité de l’air' => 'prsqa', + 'Transport' => 'transport' + ] + ] + ] + ]; + + public function getIcon() + { + return 'https://www.airbreizh.asso.fr/voy_content/uploads/2017/11/favicon.png'; + } + + public function collectData() + { + $html = ''; + $html = getSimpleHTMLDOM(static::URI . 'publications/?fwp_publications_thematiques=' . $this->getInput('theme')); + + foreach ($html->find('article') as $article) { + $item = []; + // Title + $item['title'] = $article->find('h2', 0)->plaintext; + // Author + $item['author'] = 'Air Breizh'; + // Image + $imagelink = $article->find('.card__image', 0)->find('img', 0)->getAttribute('src'); + // Content preview + $item['content'] = ' +
' + . $article->find('.card__text', 0)->plaintext; + // URL + $item['uri'] = $article->find('.publi__buttons', 0)->find('a', 0)->getAttribute('href'); + // ID + $item['id'] = $article->find('.publi__buttons', 0)->find('a', 0)->getAttribute('href'); + $this->items[] = $item; + } + } +} diff --git a/bridges/AlbionOnlineBridge.php b/bridges/AlbionOnlineBridge.php new file mode 100644 index 00000000000..4b191b18171 --- /dev/null +++ b/bridges/AlbionOnlineBridge.php @@ -0,0 +1,76 @@ + [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => true, + 'title' => 'Maximum number of items to return', + 'defaultValue' => 5, + ], + 'language' => [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'English' => 'en', + 'Deutsch' => 'de', + 'Polski' => 'pl', + 'Français' => 'fr', + 'Русский' => 'ru', + 'Português' => 'pt', + 'Español' => 'es', + ], + 'title' => 'Language of changelog posts', + 'defaultValue' => 'en', + ], + 'full' => [ + 'name' => 'Full changelog', + 'type' => 'checkbox', + 'required' => false, + 'title' => 'Enable to receive the full changelog post for each item' + ], + ]]; + + public function collectData() + { + $api = 'https://albiononline.com/'; + // Example: https://albiononline.com/en/changelog/1/5 + $url = $api . $this->getInput('language') . '/changelog/1/' . $this->getInput('postcount'); + + $html = getSimpleHTMLDOM($url); + + foreach ($html->find('li') as $data) { + $item = []; + $item['uri'] = self::URI . $data->find('a', 0)->getAttribute('href'); + $item['title'] = trim(explode('|', $data->find('span', 0)->plaintext)[0]); + // Time below work only with en lang. Need to think about solution. May be separate request like getFullChangelog, but to english list for all language + //print_r( date_parse_from_format( 'M j, Y' , 'Sep 9, 2020') ); + //$item['timestamp'] = $this->extractDate($a->plaintext); + $item['author'] = 'albiononline.com'; + if ($this->getInput('full')) { + $item['content'] = $this->getFullChangelog($item['uri']); + } else { + //$item['content'] = trim(preg_replace('/\s+/', ' ', $data->find('span', 0)->plaintext)); + // Just use title, no info at all or use title and date, see above + $item['content'] = $item['title']; + } + $item['uid'] = hash('sha256', $item['title']); + $this->items[] = $item; + } + } + + private function getFullChangelog($url) + { + $html = getSimpleHTMLDOMCached($url); + $html = defaultLinkTo($html, self::URI); + return $html->find('div.small-12.columns', 1)->innertext; + } +} diff --git a/bridges/AlfaBankByBridge.php b/bridges/AlfaBankByBridge.php new file mode 100644 index 00000000000..7c13c14dbfa --- /dev/null +++ b/bridges/AlfaBankByBridge.php @@ -0,0 +1,87 @@ + [ + 'business' => [ + 'name' => 'Альфа Бизнес', + 'type' => 'list', + 'title' => 'В зависимости от выбора, возращает уведомления для" . + " клиентов физ. лиц либо для клиентов-юридических лиц и ИП', + 'values' => [ + 'Новости' => 'news', + 'Новости бизнеса' => 'newsBusiness' + ], + 'defaultValue' => 'news' + ], + 'fullContent' => [ + 'name' => 'Включать содержимое', + 'type' => 'checkbox', + 'title' => 'Если выбрано, содержимое уведомлений вставляется в поток (работает медленно)' + ] + ] + ]; + + public function collectData() + { + $business = $this->getInput('business') == 'newsBusiness'; + $fullContent = $this->getInput('fullContent') == 'on'; + + $mainPageUrl = self::URI . '/about/articles/uvedomleniya/'; + if ($business) { + $mainPageUrl .= '?business=true'; + } + $html = getSimpleHTMLDOM($mainPageUrl); + $limit = 0; + + foreach ($html->find('a.notifications__item') as $element) { + if ($limit < 10) { + $item = []; + $item['uid'] = 'urn:sha1:' . hash('sha1', $element->getAttribute('data-notification-id')); + $item['title'] = $element->find('div.item-title', 0)->innertext; + $item['timestamp'] = DateTime::createFromFormat( + 'd M Y', + $this->ruMonthsToEn($element->find('div.item-date', 0)->innertext) + )->getTimestamp(); + + $itemUrl = self::URI . $element->href; + if ($business) { + $itemUrl = str_replace('?business=true', '', $itemUrl); + } + $item['uri'] = $itemUrl; + + if ($fullContent) { + $itemHtml = getSimpleHTMLDOM($itemUrl); + if ($itemHtml) { + $item['content'] = $itemHtml->find('div.now-p__content-text', 0)->innertext; + } + } + + $this->items[] = $item; + $limit++; + } + } + } + + public function getIcon() + { + return static::URI . '/local/images/favicon.ico'; + } + + private function ruMonthsToEn($date) + { + $ruMonths = [ + 'Января', 'Февраля', 'Марта', 'Апреля', 'Мая', 'Июня', + 'Июля', 'Августа', 'Сентября', 'Октября', 'Ноября', 'Декабря' ]; + $enMonths = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' ]; + return str_replace($ruMonths, $enMonths, $date); + } +} diff --git a/bridges/AllSidesBridge.php b/bridges/AllSidesBridge.php new file mode 100644 index 00000000000..d71195efe54 --- /dev/null +++ b/bridges/AllSidesBridge.php @@ -0,0 +1,85 @@ + [ + 'limit' => [ + 'name' => 'Number of posts to return', + 'type' => 'number', + 'defaultValue' => 10, + 'required' => false, + 'title' => 'Zero or negative values return all posts (ignored if not fetching full article)', + ], + 'fetch' => [ + 'name' => 'Fetch full article content', + 'type' => 'checkbox', + 'defaultValue' => 'checked', + ], + ], + 'Headline Roundups' => [], + ]; + + private const ROUNDUPS_URI = self::URI . '/headline-roundups'; + + public function collectData() + { + switch ($this->queriedContext) { + case 'Headline Roundups': + $index = getSimpleHTMLDOM(self::ROUNDUPS_URI); + defaultLinkTo($index, self::ROUNDUPS_URI); + $entries = $index->find('table.views-table > tbody > tr'); + + $limit = (int) $this->getInput('limit'); + $fetch = (bool) $this->getInput('fetch'); + + if ($limit > 0 && $fetch) { + $entries = array_slice($entries, 0, $limit); + } + + foreach ($entries as $entry) { + $item = [ + 'title' => $entry->find('.views-field-name', 0)->text(), + 'uri' => $entry->find('a', 0)->href, + 'timestamp' => $entry->find('.date-display-single', 0)->content, + 'author' => 'AllSides Staff', + ]; + + if ($fetch) { + $article = getSimpleHTMLDOMCached($item['uri']); + defaultLinkTo($article, $item['uri']); + + $item['content'] = $article->find('.story-id-page-description', 0); + + foreach ($article->find('.page-tags a') as $tag) { + $item['categories'][] = $tag->text(); + } + } + + $this->items[] = $item; + } + break; + } + } + + public function getName() + { + if ($this->queriedContext) { + return self::NAME . " - {$this->queriedContext}"; + } + return self::NAME; + } + + public function getURI() + { + switch ($this->queriedContext) { + case 'Headline Roundups': + return self::ROUNDUPS_URI; + } + return self::URI; + } +} diff --git a/bridges/AllegroBridge.php b/bridges/AllegroBridge.php new file mode 100644 index 00000000000..55e9f116040 --- /dev/null +++ b/bridges/AllegroBridge.php @@ -0,0 +1,157 @@ + [ + 'name' => 'Search URL', + 'title' => 'Copy the URL from your browser\'s address bar after searching for your items and paste it here', + 'exampleValue' => 'https://allegro.pl/kategoria/swieze-warzywa-cebula-318660', + 'required' => true, + ], + 'cookie' => [ + 'name' => 'The complete cookie value', + 'title' => 'Paste the value of the cookie value from your browser if you want to prevent Allegro imposing rate limits', + 'required' => false, + ], + 'includeSponsoredOffers' => [ + 'type' => 'checkbox', + 'name' => 'Include Sponsored Offers', + 'defaultValue' => 'checked' + ], + 'includePromotedOffers' => [ + 'type' => 'checkbox', + 'name' => 'Include Promoted Offers', + 'defaultValue' => 'checked' + ] + ]]; + + public function getName() + { + $url = $this->getInput('url'); + if (!$url) { + return parent::getName(); + } + $parsedUrl = parse_url($url, PHP_URL_QUERY); + if (!$parsedUrl) { + return parent::getName(); + } + parse_str($parsedUrl, $fields); + + if (array_key_exists('string', $fields)) { + $f = urldecode($fields['string']); + } else { + $f = false; + } + if ($f) { + return $f; + } + + return parent::getName(); + } + + public function getURI() + { + return $this->getInput('url') ?? parent::getURI(); + } + + public function collectData() + { + # make sure we order by the most recently listed offers + $url = preg_replace('/([?&])order=[^&]+(&|$)/', '$1', $this->getInput('url')); + $url .= (parse_url($url, PHP_URL_QUERY) ? '&' : '?') . 'order=n'; + + $opts = []; + + // If a cookie is provided + if ($cookie = $this->getInput('cookie')) { + $opts[CURLOPT_COOKIE] = $cookie; + } + + $html = getSimpleHTMLDOM($url, [], $opts); + + # 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'] = $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; + + 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; + } + + $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 + . '
'; + + $this->items[] = $item; + } + } +} diff --git a/bridges/AllocineFRBridge.php b/bridges/AllocineFRBridge.php index 17da9031edf..e7b2adb2785 100644 --- a/bridges/AllocineFRBridge.php +++ b/bridges/AllocineFRBridge.php @@ -1,85 +1,107 @@ array( - 'name' => 'category', - 'type' => 'list', - 'exampleValue' => 'Faux Raccord', - 'title' => 'Select your category', - 'values' => array( - 'Faux Raccord' => 'faux-raccord', - 'Top 5' => 'top-5', - 'Tueurs en Séries' => 'tueurs-en-serie' - ) - ) - )); +class AllocineFRBridge extends BridgeAbstract +{ + const MAINTAINER = 'superbaillot.net'; + const NAME = 'Allo Cine Bridge'; + const CACHE_TIMEOUT = 25200; // 7h + const URI = 'https://www.allocine.fr'; + const DESCRIPTION = 'Bridge for allocine.fr'; + const PARAMETERS = [ [ + 'category' => [ + 'name' => 'Emission', + 'type' => 'list', + 'title' => 'Sélectionner l\'emission', + 'values' => [ + 'Faux Raccord' => 'faux-raccord', + 'Fanzone' => 'fanzone', + 'Game In Ciné' => 'game-in-cine', + 'Pour la faire courte' => 'pour-la-faire-courte', + 'Home Cinéma' => 'home-cinema', + 'PILS - Par Ici Les Sorties' => 'pils-par-ici-les-sorties', + 'AlloCiné : l\'émission, sur LeStream' => 'allocine-lemission-sur-lestream', + 'Give Me Five' => 'give-me-five', + 'Aviez-vous remarqué ?' => 'aviez-vous-remarque', + 'Et paf, il est mort' => 'et-paf-il-est-mort', + 'The Big Fan Theory' => 'the-big-fan-theory', + 'Clichés' => 'cliches', + 'Complètement...' => 'completement', + '#Fun Facts' => 'fun-facts', + 'Origin Story' => 'origin-story', + ] + ] + ]]; - public function getURI(){ - if(!is_null($this->getInput('category'))) { + public function getURI() + { + if (!is_null($this->getInput('category'))) { + $categories = [ + 'faux-raccord' => '/video/programme-12284/', + 'fanzone' => '/video/programme-12298/', + 'game-in-cine' => '/video/programme-12288/', + 'pour-la-faire-courte' => '/video/programme-20960/', + 'home-cinema' => '/video/programme-12287/', + 'pils-par-ici-les-sorties' => '/video/programme-25789/', + 'allocine-lemission-sur-lestream' => '/video/programme-25123/', + 'give-me-five' => '/video/programme-21919/saison-34518/', + 'aviez-vous-remarque' => '/video/programme-19518/', + 'et-paf-il-est-mort' => '/video/programme-25113/', + 'the-big-fan-theory' => '/video/programme-20403/', + 'cliches' => '/video/programme-24834/', + 'completement' => '/video/programme-23859/', + 'fun-facts' => '/video/programme-23040/', + 'origin-story' => '/video/programme-25667/' + ]; - switch($this->getInput('category')) { - case 'faux-raccord': - $uri = static::URI . 'video/programme-12284/saison-32180/'; - break; - case 'top-5': - $uri = static::URI . 'video/programme-12299/saison-29561/'; - break; - case 'tueurs-en-serie': - $uri = static::URI . 'video/programme-12286/saison-22938/'; - break; - } + $category = $this->getInput('category'); + if (array_key_exists($category, $categories)) { + return static::URI . $this->getLastSeasonURI($categories[$category]); + } else { + returnClientError('Emission inconnue'); + } + } - return $uri; - } + return parent::getURI(); + } - return parent::getURI(); - } + private function getLastSeasonURI($category) + { + $html = getSimpleHTMLDOMCached(static::URI . $category, 86400); + $seasonLink = $html->find('section[class=section-wrap section]', 0)->find('div[class=cf]', 0)->find('a', 0); + $URI = $seasonLink->href; + return $URI; + } - public function getName(){ - if(!is_null($this->getInput('category'))) { - return self::NAME . ' : ' - . array_search( - $this->getInput('category'), - self::PARAMETERS[$this->queriedContext]['category']['values'] - ); - } + public function getName() + { + if (!is_null($this->getInput('category'))) { + return self::NAME . ' : ' . $this->getKey('category'); + } - return parent::getName(); - } + return parent::getName(); + } - public function collectData(){ + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request ' . $this->getURI() . ' !'); + foreach ($html->find('div[class=gd-col-left]', 0)->find('div[class*=video-card]') as $element) { + $item = []; - $category = array_search( - $this->getInput('category'), - self::PARAMETERS[$this->queriedContext]['category']['values'] - ); + $title = $element->find('a[class*=meta-title-link]', 0); + $content = trim(defaultLinkTo($element->outertext, static::URI)); - foreach($html->find('.media-meta-list figure.media-meta-fig') as $element) { - $item = array(); + // Replace image 'src' with the one in 'data-src' + $content = preg_replace('@src="data:image/gif;base64,[A-Za-z0-9+\/]*"@', '', $content); + $content = preg_replace('@data-src=@', 'src=', $content); - $title = $element->find('div.titlebar h3.title a', 0); - $content = trim($element->innertext); - $figCaption = strpos($content, $category); + // Remove date in the content to prevent content update while the video is getting older + $content = preg_replace('@ @', '', $content); - if($figCaption !== false) { - $content = str_replace('src="/', 'src="' . static::URI, $content); - $content = str_replace('href="/', 'href="' . static::URI, $content); - $content = str_replace('src=\'/', 'src=\'' . static::URI, $content); - $content = str_replace('href=\'/', 'href=\'' . static::URI, $content); - $item['content'] = $content; - $item['title'] = trim($title->innertext); - $item['uri'] = static::URI . $title->href; - $this->items[] = $item; - } - } - } + $item['content'] = $content; + $item['title'] = trim($title->innertext); + $item['uri'] = static::URI . '/' . substr($title->href, 1); + $this->items[] = $item; + } + } } diff --git a/bridges/AllocineFRSortiesBridge.php b/bridges/AllocineFRSortiesBridge.php new file mode 100644 index 00000000000..a75187bec87 --- /dev/null +++ b/bridges/AllocineFRSortiesBridge.php @@ -0,0 +1,66 @@ +getURI()); + + foreach ($html->find('section.section.section-wrap', 0)->find('li.mdl') as $element) { + $item = []; + + $thumb = $element->find('figure.thumbnail', 0); + $meta = $element->find('div.meta-body', 0); + $synopsis = $element->find('div.synopsis', 0); + $date = $element->find('span.date', 0); + + $title = $element->find('a[class*=meta-title-link]', 0); + $content = trim(defaultLinkTo($thumb->outertext . $meta->outertext . $synopsis->outertext, static::URI)); + + // Replace image 'src' with the one in 'data-src' + $content = preg_replace('@src="data:image/gif;base64,[A-Za-z0-9=+\/]*"@', '', $content); + $content = preg_replace('@data-src=@', 'src=', $content); + + $item['content'] = $content; + $item['title'] = trim($title->innertext); + $item['timestamp'] = $this->frenchPubDateToTimestamp($date->plaintext); + $item['uri'] = static::BASE_URI . '/' . substr($title->href, 1); + $this->items[] = $item; + } + } + + private function frenchPubDateToTimestamp($date) + { + return strtotime( + strtr( + strtolower($date), + [ + 'janvier' => 'jan', + 'février' => 'feb', + 'mars' => 'march', + 'avril' => 'apr', + 'mai' => 'may', + 'juin' => 'jun', + 'juillet' => 'jul', + 'août' => 'aug', + 'septembre' => 'sep', + 'octobre' => 'oct', + 'novembre' => 'nov', + 'décembre' => 'dec' + ] + ) + ); + } +} diff --git a/bridges/AmazonBridge.php b/bridges/AmazonBridge.php index bcd83dcce78..6d2aa424aa2 100644 --- a/bridges/AmazonBridge.php +++ b/bridges/AmazonBridge.php @@ -1,95 +1,105 @@ array( - 'name' => 'Keyword', - 'required' => true, - ), - 'sort' => array( - 'name' => 'Sort by', - 'type' => 'list', - 'values' => array( - 'Relevance' => 'relevanceblender', - 'Price: Low to High' => 'price-asc-rank', - 'Price: High to Low' => 'price-desc-rank', - 'Average Customer Review' => 'review-rank', - 'Newest Arrivals' => 'date-desc-rank', - ), - 'defaultValue' => 'relevanceblender', - ), - 'tld' => array( - 'name' => 'Country', - 'type' => 'list', - 'values' => array( - 'Australia' => 'com.au', - 'Brazil' => 'com.br', - 'Canada' => 'ca', - 'China' => 'cn', - 'France' => 'fr', - 'Germany' => 'de', - 'India' => 'in', - 'Italy' => 'it', - 'Japan' => 'co.jp', - 'Mexico' => 'com.mx', - 'Netherlands' => 'nl', - 'Spain' => 'es', - 'United Kingdom' => 'co.uk', - 'United States' => 'com', - ), - 'defaultValue' => 'com', - ), - )); - - public function getName(){ - if(!is_null($this->getInput('tld')) && !is_null($this->getInput('q'))) { - return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('q'); - } - - return parent::getName(); - } - - public function collectData() { - - $uri = 'https://www.amazon.' . $this->getInput('tld') . '/'; - $uri .= 's/?field-keywords=' . urlencode($this->getInput('q')) . '&sort=' . $this->getInput('sort'); - - $html = getSimpleHTMLDOM($uri) - or returnServerError('Could not request Amazon.'); - - foreach($html->find('li.s-result-item') as $element) { - - $item = array(); - - // Title - $title = $element->find('h2', 0); - if (is_null($title)) { - continue; - } - - $item['title'] = html_entity_decode($title->innertext, ENT_QUOTES); - - // Url - $uri = $title->parent()->getAttribute('href'); - $uri = substr($uri, 0, strrpos($uri, '/')); - - $item['uri'] = substr($uri, 0, strrpos($uri, '/')); - - // Content - $image = $element->find('img', 0); - $price = $element->find('span.s-price', 0); - $price = ($price) ? $price->innertext : ''; - - $item['content'] = '
' . $price; - - $this->items[] = $item; - } - } +class AmazonBridge extends BridgeAbstract +{ + const MAINTAINER = 'Alexis CHEMEL'; + const NAME = 'Amazon'; + const URI = 'https://www.amazon.com/'; + const CACHE_TIMEOUT = 3600; // 1h + const DESCRIPTION = 'Returns products from Amazon search'; + + const PARAMETERS = [[ + 'q' => [ + 'name' => 'Keyword', + 'required' => true, + 'exampleValue' => 'watch', + ], + 'sort' => [ + 'name' => 'Sort by', + 'type' => 'list', + 'values' => [ + 'Relevance' => 'relevanceblender', + 'Price: Low to High' => 'price-asc-rank', + 'Price: High to Low' => 'price-desc-rank', + 'Average Customer Review' => 'review-rank', + 'Newest Arrivals' => 'date-desc-rank', + ], + 'defaultValue' => 'relevanceblender', + ], + 'tld' => [ + 'name' => 'Country', + 'type' => 'list', + 'values' => [ + 'Australia' => 'com.au', + 'Brazil' => 'com.br', + 'Canada' => 'ca', + 'China' => 'cn', + 'France' => 'fr', + 'Germany' => 'de', + 'India' => 'in', + 'Italy' => 'it', + 'Japan' => 'co.jp', + 'Mexico' => 'com.mx', + 'Netherlands' => 'nl', + 'Poland' => 'pl', + 'Spain' => 'es', + 'Sweden' => 'se', + 'Turkey' => 'com.tr', + 'United Kingdom' => 'co.uk', + 'United States' => 'com', + ], + 'defaultValue' => 'com', + ], + ]]; + + public function collectData() + { + $baseUrl = sprintf('https://www.amazon.%s', $this->getInput('tld')); + + $url = sprintf( + '%s/s/?field-keywords=%s&sort=%s', + $baseUrl, + urlencode($this->getInput('q')), + $this->getInput('sort') + ); + + $dom = getSimpleHTMLDOM($url); + + $elements = $dom->find('div.s-result-item'); + + foreach ($elements as $element) { + $item = []; + + $title = $element->find('h2', 0); + if (!$title) { + continue; + } + + $item['title'] = $title->innertext; + + $itemUrl = $element->find('a', 0)->href; + $item['uri'] = urljoin($baseUrl, $itemUrl); + + $image = $element->find('img', 0); + if ($image) { + $item['content'] = '
'; + } + + $price = $element->find('span.a-price > .a-offscreen', 0); + if ($price) { + $item['content'] .= $price->innertext; + } + + $this->items[] = $item; + } + } + + public function getName() + { + if (!is_null($this->getInput('tld')) && !is_null($this->getInput('q'))) { + return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('q'); + } + + return parent::getName(); + } } diff --git a/bridges/AmazonPriceTrackerBridge.php b/bridges/AmazonPriceTrackerBridge.php index 950178a7e24..5f93eb49b64 100644 --- a/bridges/AmazonPriceTrackerBridge.php +++ b/bridges/AmazonPriceTrackerBridge.php @@ -1,186 +1,253 @@ array( - 'name' => 'ASIN', - 'required' => true, - 'exampleValue' => 'B071GB1VMQ', - // https://stackoverflow.com/a/12827734 - 'pattern' => 'B[\dA-Z]{9}|\d{9}(X|\d)', - ), - 'tld' => array( - 'name' => 'Country', - 'type' => 'list', - 'values' => array( - 'Australia' => 'com.au', - 'Brazil' => 'com.br', - 'Canada' => 'ca', - 'China' => 'cn', - 'France' => 'fr', - 'Germany' => 'de', - 'India' => 'in', - 'Italy' => 'it', - 'Japan' => 'co.jp', - 'Mexico' => 'com.mx', - 'Netherlands' => 'nl', - 'Spain' => 'es', - 'United Kingdom' => 'co.uk', - 'United States' => 'com', - ), - 'defaultValue' => 'com', - ), - )); - - protected $title; - - /** - * Generates domain name given a amazon TLD - */ - private function getDomainName() { - return 'https://www.amazon.' . $this->getInput('tld'); - } - - /** - * Generates URI for a Amazon product page - */ - public function getURI() { - if (!is_null($this->getInput('asin'))) { - return $this->getDomainName() . '/dp/' . $this->getInput('asin') . '/'; - } - return parent::getURI(); - } - - /** - * Scrapes the product title from the html page - * returns the default title if scraping fails - */ - private function getTitle($html) { - $titleTag = $html->find('#productTitle', 0); - - if (!$titleTag) { - return $this->getDefaultTitle(); - } else { - return trim(html_entity_decode($titleTag->innertext, ENT_QUOTES)); - } - } - - /** - * Title used by the feed if none could be found - */ - private function getDefaultTitle() { - return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('asin'); - } - - /** - * Returns name for the feed - * Uses title (already scraped) if it has one - */ - public function getName() { - if (isset($this->title)) { - return $this->title; - } else { - return parent::getName(); - } - } - - private function parseDynamicImage($attribute) { - $json = json_decode(html_entity_decode($attribute), true); - - if ($json and count($json) > 0) { - return array_keys($json)[0]; - } - } - - /** - * Returns a generated image tag for the product - */ - private function getImage($html) { - $imageSrc = $html->find('#main-image-container img', 0); - - if ($imageSrc) { - $hiresImage = $imageSrc->getAttribute('data-old-hires'); - $dynamicImageAttribute = $imageSrc->getAttribute('data-a-dynamic-image'); - $image = $hiresImage ?: $this->parseDynamicImage($dynamicImageAttribute); - } - $image = $image ?: 'https://placekitten.com/200/300'; - - return <<[ + 'name' => 'ASIN', + 'required' => true, + 'exampleValue' => 'B071GB1VMQ', + // https://stackoverflow.com/a/12827734 + 'pattern' => 'B[\dA-Z]{9}|\d{9}(X|\d)', + ], + 'tld' => [ + 'name' => 'Country', + 'type' => 'list', + 'values' => [ + 'Australia' => 'com.au', + 'Brazil' => 'com.br', + 'Canada' => 'ca', + 'China' => 'cn', + 'France' => 'fr', + 'Germany' => 'de', + 'India' => 'in', + 'Italy' => 'it', + 'Japan' => 'co.jp', + 'Mexico' => 'com.mx', + 'Netherlands' => 'nl', + 'Poland' => 'pl', + 'Spain' => 'es', + 'Sweden' => 'se', + 'Turkey' => 'com.tr', + 'United Kingdom' => 'co.uk', + 'United States' => 'com', + ], + 'defaultValue' => 'com', + ], + ]]; + + const PRICE_SELECTORS = [ + '#priceblock_ourprice', + '.priceBlockBuyingPriceString', + '#newBuyBoxPrice', + '#tp_price_block_total_price_ww', + 'span.offer-price', + '.a-color-price', + ]; + + const WHITESPACE = " \t\n\r\0\x0B\xC2\xA0"; + + protected $title; + + /** + * Generates domain name given a amazon TLD + */ + private function getDomainName() + { + return 'https://www.amazon.' . $this->getInput('tld'); + } + + /** + * Generates URI for a Amazon product page + */ + public function getURI() + { + if (!is_null($this->getInput('asin'))) { + return $this->getDomainName() . '/dp/' . $this->getInput('asin'); + } + return parent::getURI(); + } + + /** + * Scrapes the product title from the html page + * returns the default title if scraping fails + */ + private function getTitle($html) + { + $titleTag = $html->find('#productTitle', 0); + + if (!$titleTag) { + return $this->getDefaultTitle(); + } else { + return trim(html_entity_decode($titleTag->innertext, ENT_QUOTES)); + } + } + + /** + * Title used by the feed if none could be found + */ + private function getDefaultTitle() + { + return 'Amazon.' . $this->getInput('tld') . ': ' . $this->getInput('asin'); + } + + /** + * Returns name for the feed + * Uses title (already scraped) if it has one + */ + public function getName() + { + if (isset($this->title)) { + return $this->title; + } else { + return parent::getName(); + } + } + + private function parseDynamicImage($attribute) + { + $json = json_decode(html_entity_decode($attribute), true); + + if ($json and count($json) > 0) { + return array_keys($json)[0]; + } + } + + /** + * Returns a generated image tag for the product + */ + private function getImage($html) + { + $image = 'https://placekitten.com/200/300'; + $imageSrc = $html->find('#main-image-container img', 0); + if ($imageSrc) { + $hiresImage = $imageSrc->getAttribute('data-old-hires'); + $dynamicImageAttribute = $imageSrc->getAttribute('data-a-dynamic-image'); + $image = $hiresImage ?: $this->parseDynamicImage($dynamicImageAttribute); + } + + return << EOT; - } - - /** - * Return \simple_html_dom object - * for the entire html of the product page - */ - private function getHtml() { - $uri = $this->getURI(); - - return getSimpleHTMLDOM($uri) ?: returnServerError('Could not request Amazon.'); - } - - private function scrapePriceFromMetrics($html) { - $asinData = $html->find('#cerberus-data-metrics', 0); - - // - if ($asinData) { - return array( - 'price' => $asinData->getAttribute('data-asin-price'), - 'currency' => $asinData->getAttribute('data-asin-currency-code'), - 'shipping' => $asinData->getAttribute('data-asin-shipping') - ); - } - - return false; - } - - private function scrapePriceGeneric($html) { - $priceDiv = $html->find('span.offer-price', 0) ?: $html->find('.a-color-price', 0); - - preg_match('/^\s*([A-Z]{3}|£|\$)\s?([\d.,]+)\s*$/', $priceDiv->plaintext, $matches); - - if (count($matches) === 3) { - return array( - 'price' => $matches[2], - 'currency' => $matches[1], - 'shipping' => '0' - ); - } - - return false; - } - - /** - * Scrape method for Amazon product page - * @return [type] [description] - */ - public function collectData() { - $html = $this->getHtml(); - $this->title = $this->getTitle($html); - $imageTag = $this->getImage($html); - - $data = $this->scrapePriceFromMetrics($html) ?: $this->scrapePriceGeneric($html); - - $item = array( - 'title' => $this->title, - 'uri' => $this->getURI(), - 'content' => "$imageTag
Price: {$data['price']} {$data['currency']}", - ); - - if ($data['shipping'] !== '0') { - $item['content'] .= "
Shipping: {$data['shipping']} {$data['currency']}"; - } - - $this->items[] = $item; - } + } + + /** + * Return \simple_html_dom object + * for the entire html of the product page + */ + private function getHtml() + { + $uri = $this->getURI(); + + return getSimpleHTMLDOM($uri); + } + + private function scrapePriceFromMetrics($html) + { + $asinData = $html->find('#cerberus-data-metrics', 0); + + // + if ($asinData) { + return [ + 'price' => $asinData->getAttribute('data-asin-price'), + 'currency' => $asinData->getAttribute('data-asin-currency-code'), + 'shipping' => $asinData->getAttribute('data-asin-shipping') + ]; + } + + return false; + } + + private function scrapePriceTwister($html) + { + $str = $html->find('.twister-plus-buying-options-price-data', 0); + + $data = json_decode($str->innertext, true); + if (count($data) === 1) { + $data = $data[0]; + return [ + 'displayPrice' => $data['displayPrice'], + 'currency' => $data['currency'], + 'shipping' => '0', + ]; + } + + return false; + } + + private function scrapePriceGeneric($html) + { + $default = [ + 'price' => null, + 'displayPrice' => null, + 'currency' => null, + 'shipping' => null, + ]; + $priceDiv = null; + + foreach (self::PRICE_SELECTORS as $sel) { + $priceDiv = $html->find($sel, 0); + if ($priceDiv) { + break; + } + } + + if (!$priceDiv) { + return $default; + } + + $priceString = str_replace(str_split(self::WHITESPACE), '', $priceDiv->plaintext); + preg_match('/(\d+\.\d{0,2})/', $priceString, $matches); + + $price = $matches[0] ?? null; + $currency = str_replace($price, '', $priceString); + + if ($price != null && $currency != null) { + return [ + 'price' => $price, + 'displayPrice' => null, + 'currency' => $currency, + 'shipping' => '0' + ]; + } + return $default; + } + + public function collectData() + { + $html = $this->getHtml(); + $this->title = $this->getTitle($html); + $image = $this->getImage($html); + $data = $this->scrapePriceGeneric($html); + + // render + $content = ''; + $price = $data['displayPrice']; + if (!$price) { + $price = sprintf('%s %s', $data['price'], $data['currency']); + } + $content .= sprintf('%s
Price: %s', $image, $price); + if ($data['shipping'] !== '0') { + $content .= sprintf('
Shipping: %s %s', $data['shipping'], $data['currency']); + } + + $item = [ + 'title' => $this->title, + 'uri' => $this->getURI(), + 'content' => $content, + // This is to ensure that feed readers notice the price change + 'uid' => md5($data['price']) + ]; + + $this->items[] = $item; + } } diff --git a/bridges/AnfrBridge.php b/bridges/AnfrBridge.php new file mode 100644 index 00000000000..391fde77298 --- /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
%sFréquences
%s
', + $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 ae387c90fdd..6d41365be57 100644 --- a/bridges/AnidexBridge.php +++ b/bridges/AnidexBridge.php @@ -1,207 +1,218 @@ array( - 'name' => 'Category', - 'type' => 'list', - 'values' => array( - 'All categories' => '0', - 'Anime' => '1,2,3', - 'Anime - Sub' => '1', - 'Anime - Raw' => '2', - 'Anime - Dub' => '3', - 'Live Action' => '4,5', - 'Live Action - Sub' => '4', - 'Live Action - Raw' => '5', - 'Light Novel' => '6', - 'Manga' => '7,8', - 'Manga - Translated' => '7', - 'Manga - Raw' => '8', - 'Music' => '9,10,11', - 'Music - Lossy' => '9', - 'Music - Lossless' => '10', - 'Music - Video' => '11', - 'Games' => '12', - 'Applications' => '13', - 'Pictures' => '14', - 'Adult Video' => '15', - 'Other' => '16' - ) - ), - 'lang_id' => array( - 'name' => 'Language', - 'type' => 'list', - 'values' => array( - 'All languages' => '0', - 'English' => '1', - 'Japanese' => '2', - 'Polish' => '3', - 'Serbo-Croatian' => '4', - 'Dutch' => '5', - 'Italian' => '6', - 'Russian' => '7', - 'German' => '8', - 'Hungarian' => '9', - 'French' => '10', - 'Finnish' => '11', - 'Vietnamese' => '12', - 'Greek' => '13', - 'Bulgarian' => '14', - 'Spanish (Spain)' => '15', - 'Portuguese (Brazil)' => '16', - 'Portuguese (Portugal)' => '17', - 'Swedish' => '18', - 'Arabic' => '19', - 'Danish' => '20', - 'Chinese (Simplified)' => '21', - 'Bengali' => '22', - 'Romanian' => '23', - 'Czech' => '24', - 'Mongolian' => '25', - 'Turkish' => '26', - 'Indonesian' => '27', - 'Korean' => '28', - 'Spanish (LATAM)' => '29', - 'Persian' => '30', - 'Malaysian' => '31' - ) - ), - 'group_id' => array( - 'name' => 'Group ID', - 'type' => 'number' - ), - 'r' => array( - 'name' => 'Hide Remakes', - 'type' => 'checkbox' - ), - 'b' => array( - 'name' => 'Only Batches', - 'type' => 'checkbox' - ), - 'a' => array( - 'name' => 'Only Authorized', - 'type' => 'checkbox' - ), - 'q' => array( - 'name' => 'Keyword', - 'description' => 'Keyword(s)', - 'type' => 'text' - ), - 'h' => array( - 'name' => 'Adult content', - 'type' => 'list', - 'values' => array( - 'No filter' => '0', - 'Hide +18' => '1', - 'Only +18' => '2' - ) - ) - ) - ); +class AnidexBridge extends BridgeAbstract +{ + const MAINTAINER = 'ORelio'; + const NAME = 'Anidex'; + const URI = 'http://anidex.info/'; // anidex.info has ddos-guard so we need to use anidex.moe + const ALTERNATE_URI = 'https://anidex.moe/'; // anidex.moe returns 301 unless Host is set to anidex.info + const ALTERNATE_HOST = 'anidex.info'; // Correct host for requesting anidex.moe without 301 redirect + const DESCRIPTION = 'Returns the newest torrents, with optional search criteria.'; + const PARAMETERS = [ + [ + 'id' => [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'All categories' => '0', + 'Anime' => '1,2,3', + 'Anime - Sub' => '1', + 'Anime - Raw' => '2', + 'Anime - Dub' => '3', + 'Live Action' => '4,5', + 'Live Action - Sub' => '4', + 'Live Action - Raw' => '5', + 'Light Novel' => '6', + 'Manga' => '7,8', + 'Manga - Translated' => '7', + 'Manga - Raw' => '8', + 'Music' => '9,10,11', + 'Music - Lossy' => '9', + 'Music - Lossless' => '10', + 'Music - Video' => '11', + 'Games' => '12', + 'Applications' => '13', + 'Pictures' => '14', + 'Adult Video' => '15', + 'Other' => '16' + ] + ], + 'lang_id' => [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'All languages' => '0', + 'English' => '1', + 'Japanese' => '2', + 'Polish' => '3', + 'Serbo-Croatian' => '4', + 'Dutch' => '5', + 'Italian' => '6', + 'Russian' => '7', + 'German' => '8', + 'Hungarian' => '9', + 'French' => '10', + 'Finnish' => '11', + 'Vietnamese' => '12', + 'Greek' => '13', + 'Bulgarian' => '14', + 'Spanish (Spain)' => '15', + 'Portuguese (Brazil)' => '16', + 'Portuguese (Portugal)' => '17', + 'Swedish' => '18', + 'Arabic' => '19', + 'Danish' => '20', + 'Chinese (Simplified)' => '21', + 'Bengali' => '22', + 'Romanian' => '23', + 'Czech' => '24', + 'Mongolian' => '25', + 'Turkish' => '26', + 'Indonesian' => '27', + 'Korean' => '28', + 'Spanish (LATAM)' => '29', + 'Persian' => '30', + 'Malaysian' => '31' + ] + ], + 'group_id' => [ + 'name' => 'Group ID', + 'type' => 'number' + ], + 'r' => [ + 'name' => 'Hide Remakes', + 'type' => 'checkbox' + ], + 'b' => [ + 'name' => 'Only Batches', + 'type' => 'checkbox' + ], + 'a' => [ + 'name' => 'Only Authorized', + 'type' => 'checkbox' + ], + 'q' => [ + 'name' => 'Keyword', + 'description' => 'Keyword(s)', + 'type' => 'text' + ], + 'h' => [ + 'name' => 'Adult content', + 'type' => 'list', + 'values' => [ + 'No filter' => '0', + 'Hide +18' => '1', + 'Only +18' => '2' + ] + ] + ] + ]; - public function collectData() { + public function collectData() + { + // Build Search URL from user-provided parameters + $search_url = self::ALTERNATE_URI . '?s=upload_timestamp&o=desc'; + foreach (['id', 'lang_id', 'group_id'] as $param_name) { + $param = $this->getInput($param_name); + if (!empty($param) && intval($param) != 0 && ctype_digit(str_replace(',', '', $param))) { + $search_url .= '&' . $param_name . '=' . $param; + } + } + foreach (['r', 'b', 'a'] as $param_name) { + $param = $this->getInput($param_name); + if (!empty($param) && boolval($param)) { + $search_url .= '&' . $param_name . '=1'; + } + } + $query = $this->getInput('q'); + if (!empty($query)) { + $search_url .= '&q=' . urlencode($query); + } + $opt = []; + $h = $this->getInput('h'); + if (!empty($h) && intval($h) != 0 && ctype_digit($h)) { + $opt[CURLOPT_COOKIE] = 'anidex_h_toggle=' . $h; + } - // Build Search URL from user-provided parameters - $search_url = self::URI . '?s=upload_timestamp&o=desc'; - foreach (array('id', 'lang_id', 'group_id') as $param_name) { - $param = $this->getInput($param_name); - if (!empty($param) && intval($param) != 0 && ctype_digit(str_replace(',', '', $param))) { - $search_url .= '&' . $param_name . '=' . $param; - } - } - foreach (array('r', 'b', 'a') as $param_name) { - $param = $this->getInput($param_name); - if (!empty($param) && boolval($param)) { - $search_url .= '&' . $param_name . '=1'; - } - } - $query = $this->getInput('q'); - if (!empty($query)) { - $search_url .= '&q=' . urlencode($query); - } - $opt = array(); - $h = $this->getInput('h'); - if (!empty($h) && intval($h) != 0 && ctype_digit($h)) { - $opt[CURLOPT_COOKIE] = 'anidex_h_toggle=' . $h; - } + // We need to use a different Host HTTP header to reach the correct page on ALTERNATE_URI + $headers = ['Host: ' . self::ALTERNATE_HOST]; - // Retrieve torrent listing from search results, which does not contain torrent description - $html = getSimpleHTMLDOM($search_url, array(), $opt) - or returnServerError('Could not request Anidex: ' . $search_url); - $links = $html->find('a'); - $results = array(); - foreach ($links as $link) - if (strpos($link->href, '/torrent/') === 0 && !in_array($link->href, $results)) - $results[] = $link->href; - if (empty($results) && empty($this->getInput('q'))) - returnServerError('No results from Anidex: ' . $search_url); + // The HTTPS certificate presented by anidex.moe is for anidex.info. We need to ignore this. + // As a consequence, the bridge is intentionally marked as insecure by setting self::URI to http:// + $opt[CURLOPT_SSL_VERIFYHOST] = 0; + $opt[CURLOPT_SSL_VERIFYPEER] = 0; - //Process each item individually - foreach ($results as $element) { + // Retrieve torrent listing from search results, which does not contain torrent description + $html = getSimpleHTMLDOM($search_url, $headers, $opt); + $links = $html->find('a'); + $results = []; + foreach ($links as $link) { + if (strpos($link->href, '/torrent/') === 0 && !in_array($link->href, $results)) { + $results[] = $link->href; + } + } + if (empty($results) && empty($this->getInput('q'))) { + returnServerError('No results from Anidex: ' . $search_url); + } - //Limit total amount of requests - if(count($this->items) >= 20) { - break; - } + //Process each item individually + foreach ($results as $element) { + //Limit total amount of requests + if (count($this->items) >= 20) { + break; + } - $torrent_id = str_replace('/torrent/', '', $element); + $torrent_id = str_replace('/torrent/', '', $element); - //Ignore entries without valid torrent ID - if ($torrent_id != 0 && ctype_digit($torrent_id)) { + //Ignore entries without valid torrent ID + if ($torrent_id != 0 && ctype_digit($torrent_id)) { + //Retrieve data for this torrent ID + $item_browse_uri = self::URI . 'torrent/' . $torrent_id; + $item_fetch_uri = self::ALTERNATE_URI . 'torrent/' . $torrent_id; - //Retrieve data for this torrent ID - $item_uri = self::URI . 'torrent/' . $torrent_id; + //Retrieve full description from torrent page (cached for 24 hours: 86400 seconds) + if ($item_html = getSimpleHTMLDOMCached($item_fetch_uri, 86400, $headers, $opt)) { + //Retrieve data from page contents + $item_title = str_replace(' (Torrent) - AniDex ', '', $item_html->find('title', 0)->plaintext); + $item_desc = $item_html->find('div.panel-body', 0); + $item_author = trim($item_html->find('span.fa-user', 0)->parent()->plaintext); + $item_date = strtotime(trim($item_html->find('span.fa-clock', 0)->parent()->plaintext)); + $item_image = $this->getURI() . 'images/user_logos/default.png'; - //Retrieve full description from torrent page - if ($item_html = getSimpleHTMLDOMCached($item_uri)) { + //Check for description-less torrent andn optionally extract image + $desc_title_found = false; + foreach ($item_html->find('h3.panel-title') as $h3) { + if (strpos($h3, 'Description') !== false) { + $desc_title_found = true; + break; + } + } + if ($desc_title_found) { + //Retrieve image for thumbnail or generic logo fallback + foreach ($item_desc->find('img') as $img) { + if (strpos($img->src, 'prez') === false) { + $item_image = $img->src; + break; + } + } + $item_desc = trim($item_desc->innertext); + } else { + $item_desc = 'No description.'; + } - //Retrieve data from page contents - $item_title = str_replace(' (Torrent) - AniDex ', '', $item_html->find('title', 0)->plaintext); - $item_desc = $item_html->find('div.panel-body', 0); - $item_author = trim($item_html->find('span.fa-user', 0)->parent()->plaintext); - $item_date = strtotime(trim($item_html->find('span.fa-clock', 0)->parent()->plaintext)); - $item_image = $this->getURI() . 'images/user_logos/default.png'; - - //Check for description-less torrent andn optionally extract image - $desc_title_found = false; - foreach ($item_html->find('h3.panel-title') as $h3) { - if (strpos($h3, 'Description') !== false) { - $desc_title_found = true; - break; - } - } - if ($desc_title_found) { - //Retrieve image for thumbnail or generic logo fallback - foreach ($item_desc->find('img') as $img) { - if (strpos($img->src, 'prez') === false) { - $item_image = $img->src; - break; - } - } - $item_desc = trim($item_desc->innertext); - } else { - $item_desc = 'No description.'; - } - - //Build and add final item - $item = array(); - $item['uri'] = $item_uri; - $item['title'] = $item_title; - $item['author'] = $item_author; - $item['timestamp'] = $item_date; - $item['enclosures'] = array($item_image); - $item['content'] = $item_desc; - $this->items[] = $item; - } - } - $element = null; - } - $results = null; - } + //Build and add final item + $item = []; + $item['uri'] = $item_browse_uri; + $item['title'] = $item_title; + $item['author'] = $item_author; + $item['timestamp'] = $item_date; + $item['enclosures'] = [$item_image]; + $item['content'] = $item_desc; + $this->items[] = $item; + } + } + $element = null; + } + $results = null; + } } diff --git a/bridges/AnimeUltimeBridge.php b/bridges/AnimeUltimeBridge.php index bc1dd7bc5e7..15353b0a257 100644 --- a/bridges/AnimeUltimeBridge.php +++ b/bridges/AnimeUltimeBridge.php @@ -1,140 +1,133 @@ array( - 'name' => 'Type', - 'type' => 'list', - 'values' => array( - 'Everything' => '', - 'Anime' => 'A', - 'Drama' => 'D', - 'Tokusatsu' => 'T' - ) - ) - )); - - private $filter = 'Releases'; - - public function collectData(){ - - //Add type filter if provided - $typeFilter = array_search( - $this->getInput('type'), - self::PARAMETERS[$this->queriedContext]['type']['values'] - ); - - //Build date and filters for making requests - $thismonth = date('mY') . $typeFilter; - $lastmonth = date('mY', mktime(0, 0, 0, date('n') - 1, 1, date('Y'))) . $typeFilter; - - //Process each HTML page until having 10 releases - $processedOK = 0; - foreach (array($thismonth, $lastmonth) as $requestFilter) { - - //Retrive page contents - $url = self::URI . 'history-0-1/' . $requestFilter; - $html = getSimpleHTMLDOM($url) - or returnServerError('Could not request Anime-Ultime: ' . $url); - - //Relases are sorted by day : process each day individually - foreach($html->find('div.history', 0)->find('h3') as $daySection) { - - //Retrieve day and build date information - $dateString = $daySection->plaintext; - $day = intval(substr($dateString, strpos($dateString, ' ') + 1, 2)); - $item_date = strtotime(str_pad($day, 2, '0', STR_PAD_LEFT) - . '-' - . substr($requestFilter, 0, 2) - . '-' - . substr($requestFilter, 2, 4)); - - //day
+CONTENT; + $this->items[] = $item; + } + } +} diff --git a/bridges/OMonlineBridge.php b/bridges/OMonlineBridge.php new file mode 100644 index 00000000000..50e2726f650 --- /dev/null +++ b/bridges/OMonlineBridge.php @@ -0,0 +1,70 @@ + [ + 'name' => 'Ortsname', + 'title' => 'Für die Anzeige von Beitragen nur aus einem Ort oder mehreren Orten + geben einen Orstnamen ein. Mehrere Ortsnamen müssen mit / getrennt eingeben werden, + z.B. Vechta/Cloppenburg. Groß- und Kleinschreibung beachten!' + ] + ] + ]; + + public function collectData() + { + if (!empty($this->getInput('ort'))) { + $url = sprintf('%s/ort/%s', self::URI, $this->getInput('ort')); + } else { + $url = sprintf('%s', self::URI); + } + + $html = getSimpleHTMLDOM($url); + + $html = defaultLinkTo($html, $url); + + foreach ($html->find('div.molecule-teaser > a ') as $index => $a) { + $item = []; + + $articlePath = $a->href; + + $articlePageHtml = getSimpleHTMLDOMCached($articlePath, self::CACHE_TIMEOUT); + + $articlePageHtml = defaultLinkTo($articlePageHtml, self::URI); + + $contents = $articlePageHtml->find('div.molecule-article', 0); + + $item['uri'] = $articlePath; + $item['title'] = $contents->find('h1', 0)->innertext; + + $contents->find('div.col-12 col-md-10 offset-0 offset-md-1', 0); + + $item['content'] = $contents->innertext; + $item['timestamp'] = $this->extractDate2($a->plaintext); + $this->items[] = $item; + + if (count($this->items) >= 10) { + break; + } + } + } + + private function extractDate2($text) + { + $dateRegex = '/^([0-9]{4}\/[0-9]{1,2}\/[0-9]{1,2})/'; + + $text = trim($text); + + if (preg_match($dateRegex, $text, $matches)) { + return $matches[1]; + } + + return ''; + } +} diff --git a/bridges/OglafBridge.php b/bridges/OglafBridge.php new file mode 100644 index 00000000000..1f4bc1aff9e --- /dev/null +++ b/bridges/OglafBridge.php @@ -0,0 +1,35 @@ + [ + 'name' => 'limit (max 20)', + 'type' => 'number', + 'defaultValue' => 10, + 'required' => true, + ] + ] + ]; + + public function collectData() + { + $url = self::URI . 'feeds/rss/'; + $limit = min(20, $this->getInput('limit')); + $this->collectExpandableDatas($url, $limit); + } + + protected function parseItem($item) + { + $html = getSimpleHTMLDOMCached($item['uri']); + $comicImage = $html->find('img[id="strip"]', 0); + $item['content'] = $comicImage; + + return $item; + } +} diff --git a/bridges/OllamaBridge.php b/bridges/OllamaBridge.php new file mode 100644 index 00000000000..f93e37ce889 --- /dev/null +++ b/bridges/OllamaBridge.php @@ -0,0 +1,61 @@ + [ + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => true, + 'defaultValue' => 10 + ], + ] + ]; + + public function collectData() + { + $html = getSimpleHTMLDOM(self::URI . '/blog/'); + $limit = $this->getInput('limit'); + + $posts = $html->find('main > section > a.group'); + for ($i = 0; $i < min(count($posts), $limit); $i++) { + $post = $posts[$i]; + $title = $post->find('h2', 0)->plaintext; + $date_text = $post->find('h3[datetime]', 0)->getAttribute('datetime'); + $timestamp = (new DateTime(mb_substr($date_text, 0, 19)))->format('U'); + $uri = self::URI . $post->getAttribute('href'); + $this->items[] = [ + 'uri' => $uri, + 'title' => $title, + 'timestamp' => $timestamp, + 'content' => $this->parsePage($uri), + 'uid' => $uri + ]; + } + } + + private function parsePage($uri) + { + $html = getSimpleHTMLDOMCached( + $uri, + 86400, + [], + [], + true, + true, + DEFAULT_TARGET_CHARSET, + false // Do not strip \n from
<-- useful data in table rows - $release = $daySection->next_sibling()->next_sibling()->first_child(); - - //Process each release of that day, ignoring first table row: contains table headers - while(!is_null($release = $release->next_sibling())) { - if(count($release->find('td')) > 0) { - - //Retrieve metadata from table columns - $item_link_element = $release->find('td', 0)->find('a', 0); - $item_uri = self::URI . $item_link_element->href; - $item_name = html_entity_decode($item_link_element->plaintext); - - $item_image = self::URI . substr( - $item_link_element->onmouseover, - 37, - strpos($item_link_element->onmouseover, ' ', 37) - 37 - ); - - $item_episode = html_entity_decode( - str_pad( - $release->find('td', 1)->plaintext, - 2, - '0', - STR_PAD_LEFT - ) - ); - - $item_fansub = $release->find('td', 2)->plaintext; - $item_type = $release->find('td', 4)->plaintext; - - if(!empty($item_uri)) { - - // Retrieve description from description page - $html_item = getContents($item_uri) - or returnServerError('Could not request Anime-Ultime: ' . $item_uri); - $item_description = substr( - $html_item, - strpos($html_item, 'class="principal_contain" align="center">') + 41 - ); - $item_description = substr($item_description, - 0, - strpos($item_description, ' ') - ); - - // Convert relative image src into absolute image src, remove line breaks - $item_description = defaultLinkTo($item_description, self::URI); - $item_description = str_replace("\r", '', $item_description); - $item_description = str_replace("\n", '', $item_description); - $item_description = utf8_encode($item_description); - - //Build and add final item - $item = array(); - $item['uri'] = $item_uri; - $item['title'] = $item_name . ' ' . $item_type . ' ' . $item_episode; - $item['author'] = $item_fansub; - $item['timestamp'] = $item_date; - $item['enclosures'] = array($item_image); - $item['content'] = $item_description; - $this->items[] = $item; - $processedOK++; - - //Stop processing once limit is reached - if ($processedOK >= 10) - return; - } - } - } - } - } - } - - public function getName() { - if(!is_null($this->getInput('type'))) { - $typeFilter = array_search( - $this->getInput('type'), - self::PARAMETERS[$this->queriedContext]['type']['values'] - ); - - return 'Latest ' . $typeFilter . ' - Anime-Ultime Bridge'; - } - - return parent::getName(); - } + +class AnimeUltimeBridge extends BridgeAbstract +{ + const MAINTAINER = 'ORelio'; + const NAME = 'Anime-Ultime'; + const URI = 'http://www.anime-ultime.net/'; + const CACHE_TIMEOUT = 10800; // 3h + const DESCRIPTION = 'Returns the newest releases posted on Anime-Ultime.'; + const PARAMETERS = [ [ + 'type' => [ + 'name' => 'Type', + 'type' => 'list', + 'values' => [ + 'Everything' => '', + 'Anime' => 'A', + 'Drama' => 'D', + 'Tokusatsu' => 'T' + ] + ] + ]]; + + private $filter = 'Releases'; + + public function collectData() + { + //Add type filter if provided + $typeFilter = $this->getKey('type'); + + //Build date and filters for making requests + $thismonth = date('mY') . $typeFilter; + $lastmonth = date('mY', mktime(0, 0, 0, date('n') - 1, 1, date('Y'))) . $typeFilter; + + //Process each HTML page until having 10 releases + $processedOK = 0; + foreach ([$thismonth, $lastmonth] as $requestFilter) { + $url = self::URI . 'history-0-1/' . $requestFilter; + $html = getContents($url); + // Convert html from iso-8859-1 => utf8 + $html = utf8_encode($html); + $html = str_get_html($html); + + //Relases are sorted by day : process each day individually + foreach ($html->find('div.history', 0)->find('h3') as $daySection) { + //Retrieve day and build date information + $dateString = $daySection->plaintext; + $day = intval(substr($dateString, strpos($dateString, ' ') + 1, 2)); + $item_date = strtotime(str_pad($day, 2, '0', STR_PAD_LEFT) + . '-' + . substr($requestFilter, 0, 2) + . '-' + . substr($requestFilter, 2, 4)); + + //day
+EOD; + } + + private function getShortDigestId($digest) + { + $parts = explode(':', $digest); + return substr($parts[1], 0, 12); + } +} diff --git a/bridges/DonnonsBridge.php b/bridges/DonnonsBridge.php new file mode 100644 index 00000000000..1afdc4f2f71 --- /dev/null +++ b/bridges/DonnonsBridge.php @@ -0,0 +1,135 @@ + [ + 'name' => 'Url de recherche', + 'required' => true, + 'exampleValue' => '/Sport/Ile-de-France', + 'pattern' => '\/.*', + 'title' => 'Faites une recherche sur le site. Puis copiez ici la fin de l’url. Doit commencer par /', + ], + 'p' => [ + 'name' => 'Nombre de pages à scanner', + 'type' => 'number', + 'required' => true, + 'defaultValue' => 5, + 'title' => 'Indique le nombre de pages de donnons.org qui seront scannées' + ] + ] + ]; + + public function collectData() + { + $pages = $this->getInput('p'); + + for ($i = 1; $i <= $pages; $i++) { + $this->collectDataByPage($i); + } + } + + private function collectDataByPage($page) + { + $uri = $this->getPageURI($page); + + $dom = getSimpleHTMLDOM($uri); + + $searchDiv = $dom->find('div[id=search]', 0); + + if (! $searchDiv) { + return; + } + + $elements = $searchDiv->find('a.lst-annonce'); + foreach ($elements as $element) { + $item = []; + + // Lien vers le don + $item['uri'] = self::URI . $element->href; + // Id de l'objet + $item['uid'] = $element->getAttribute('data-id'); + + // Grab info from json + $jsonString = $element->find('script', 0)->innertext; + $json = json_decode($jsonString, true); + + $name = $json['name']; + $category = $json['category']; + $date = $json['availabilityStarts']; + $description = $json['description']; + $city = $json['availableAtOrFrom']['address']['addressLocality']; + $region = $json['availableAtOrFrom']['address']['addressRegion']; + + // Grab info from HTML + $imageSrc = $element->find('img.ima-center', 0)->getAttribute('src'); + // Use large image instead of small one + $imageSrc = str_replace('/xs/', '/lg/', $imageSrc); + $image = self::URI . $imageSrc; + $author = $element->find('div.avatar-holder', 0)->plaintext; + + $content = ' + +
<-- useful data in table rows + $release = $daySection->next_sibling()->next_sibling()->first_child(); + + //Process each release of that day, ignoring first table row: contains table headers + while (!is_null($release = $release->next_sibling())) { + if (count($release->find('td')) > 0) { + //Retrieve metadata from table columns + $item_link_element = $release->find('td', 0)->find('a', 0); + $item_uri = self::URI . $item_link_element->href; + $item_name = html_entity_decode($item_link_element->plaintext); + + $item_image = self::URI . substr( + $item_link_element->onmouseover, + 37, + strpos($item_link_element->onmouseover, ' ', 37) - 37 + ); + + $item_episode = html_entity_decode( + str_pad( + $release->find('td', 1)->plaintext, + 2, + '0', + STR_PAD_LEFT + ) + ); + + $item_fansub = $release->find('td', 2)->plaintext; + $item_type = $release->find('td', 4)->plaintext; + + if (!empty($item_uri)) { + // Retrieve description from description page + $html_item = getContents($item_uri); + // Convert html from iso-8859-1 => utf8 + $html_item = utf8_encode($html_item); + $item_description = substr( + $html_item, + strpos($html_item, 'class="principal_contain" align="center">') + 41 + ); + $item_description = substr( + $item_description, + 0, + strpos($item_description, ' +EOT; + } + + return "') + ); + + // Convert relative image src into absolute image src, remove line breaks + $item_description = defaultLinkTo($item_description, self::URI); + $item_description = str_replace("\r", '', $item_description); + $item_description = str_replace("\n", '', $item_description); + + //Build and add final item + $item = []; + $item['uri'] = $item_uri; + $item['title'] = $item_name . ' ' . $item_type . ' ' . $item_episode; + $item['author'] = $item_fansub; + $item['timestamp'] = $item_date; + $item['enclosures'] = [$item_image]; + $item['content'] = $item_description; + $this->items[] = $item; + $processedOK++; + + //Stop processing once limit is reached + if ($processedOK >= 10) { + return; + } + } + } + } + } + } + } + + public function getName() + { + if (!is_null($this->getInput('type'))) { + return 'Latest ' . $this->getKey('type') . ' - Anime-Ultime Bridge'; + } + + return parent::getName(); + } } diff --git a/bridges/AnisearchBridge.php b/bridges/AnisearchBridge.php new file mode 100644 index 00000000000..c6f3d291d2b --- /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 new file mode 100644 index 00000000000..b857fadfc80 --- /dev/null +++ b/bridges/AnnasArchiveBridge.php @@ -0,0 +1,183 @@ + [ + 'name' => 'Query', + 'exampleValue' => 'apothecary diaries', + 'required' => true, + ], + 'ext' => [ + 'name' => 'Extension', + 'type' => 'list', + 'values' => [ + 'Any' => null, + 'azw3' => 'azw3', + 'cbr' => 'cbr', + 'cbz' => 'cbz', + 'djvu' => 'djvu', + 'epub' => 'epub', + 'fb2' => 'fb2', + 'fb2.zip' => 'fb2.zip', + 'mobi' => 'mobi', + 'pdf' => 'pdf', + ] + ], + 'lang' => [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'Any' => null, + 'Afrikaans [af]' => 'af', + 'Arabic [ar]' => 'ar', + 'Bangla [bn]' => 'bn', + 'Belarusian [be]' => 'be', + 'Bulgarian [bg]' => 'bg', + 'Catalan [ca]' => 'ca', + 'Chinese [zh]' => 'zh', + 'Church Slavic [cu]' => 'cu', + 'Croatian [hr]' => 'hr', + 'Czech [cs]' => 'cs', + 'Danish [da]' => 'da', + 'Dongxiang [sce]' => 'sce', + 'Dutch [nl]' => 'nl', + 'English [en]' => 'en', + 'French [fr]' => 'fr', + 'German [de]' => 'de', + 'Greek [el]' => 'el', + 'Hebrew [he]' => 'he', + 'Hindi [hi]' => 'hi', + 'Hungarian [hu]' => 'hu', + 'Indonesian [id]' => 'id', + 'Irish [ga]' => 'ga', + 'Italian [it]' => 'it', + 'Japanese [ja]' => 'ja', + 'Kazakh [kk]' => 'kk', + 'Korean [ko]' => 'ko', + 'Latin [la]' => 'la', + 'Latvian [lv]' => 'lv', + 'Lithuanian [lt]' => 'lt', + 'Luxembourgish [lb]' => 'lb', + 'Ndolo [ndl]' => 'ndl', + 'Norwegian [no]' => 'no', + 'Persian [fa]' => 'fa', + 'Polish [pl]' => 'pl', + 'Portuguese [pt]' => 'pt', + 'Romanian [ro]' => 'ro', + 'Russian [ru]' => 'ru', + 'Serbian [sr]' => 'sr', + 'Spanish [es]' => 'es', + 'Swedish [sv]' => 'sv', + 'Tamil [ta]' => 'ta', + 'Traditional Chinese [zh‑Hant]' => 'zh‑Hant', + 'Turkish [tr]' => 'tr', + 'Ukrainian [uk]' => 'uk', + 'Unknown language' => '_empty', + 'Unknown language [und]' => 'und', + 'Unknown language [urdu]' => 'urdu', + 'Urdu [ur]' => 'ur', + 'Vietnamese [vi]' => 'vi', + 'Welsh [cy]' => 'cy', + ] + ], + 'content' => [ + 'name' => 'Type', + 'type' => 'list', + 'values' => [ + 'Any' => null, + 'Book (fiction)' => 'book_fiction', + 'Book (non‑fiction)' => 'book_nonfiction', + 'Book (unknown)' => 'book_unknown', + 'Comic book' => 'book_comic', + 'Journal article' => 'journal_article', + 'Magazine' => 'magazine', + 'Standards document' => 'standards_document', + ] + ], + 'src' => [ + 'name' => 'Source', + 'type' => 'list', + 'values' => [ + 'Any' => null, + 'Internet Archive' => 'ia', + 'Libgen.li' => 'lgli', + 'Libgen.rs' => 'lgrs', + 'Sci‑Hub' => 'scihub', + 'Z‑Library' => 'zlib', + ] + ], + ] + ]; + + public function collectData() + { + $url = $this->getURI(); + $list = getSimpleHTMLDOMCached($url); + $list = defaultLinkTo($list, self::URI); + + // Don't attempt to do anything if not found message is given + if ($list->find('.js-not-found-additional')) { + return; + } + + $elements = $list->find('.w-full > .mb-4 > 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']; + + $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; + } + } + } + + public function getName() + { + $name = parent::getName(); + if ($this->getInput('q') != null) { + $name .= ' - ' . $this->getInput('q'); + } + return $name; + } + + public function getURI() + { + $params = array_filter([ // Filter to remove non-provided parameters + 'q' => $this->getInput('q'), + 'ext' => $this->getInput('ext'), + 'lang' => $this->getInput('lang'), + 'src' => $this->getInput('src'), + 'content' => $this->getInput('content'), + ]); + $url = parent::getURI() . 'search?sort=newest&' . http_build_query($params); + return $url; + } +} diff --git a/bridges/AnthropicBridge.php b/bridges/AnthropicBridge.php new file mode 100644 index 00000000000..1272d35f579 --- /dev/null +++ b/bridges/AnthropicBridge.php @@ -0,0 +1,147 @@ + [ + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => true, + 'defaultValue' => 10 + ], + ] + ]; + + public function collectData() + { + // Anthropic sometimes returns 500 for no reason. The contents are still there. + $html = $this->getHTMLIgnoreError(self::URI . '/research'); + $limit = $this->getInput('limit'); + + $page_data = $this->extractPageData($html); + $pages = $this->parsePageData($page_data); + for ($i = 0; $i < min(count($pages), $limit); $i++) { + $page = $pages[$i]; + $page['content'] = $this->parsePage($page['uri']); + $this->items[] = $page; + } + } + + private function getHTMLIgnoreError($url, $ttl = null) + { + if ($ttl != null) { + $cacheKey = 'pages_' . $url; + $content = $this->cache->get($cacheKey); + if ($content) { + return str_get_html($content); + } + } + + try { + $content = getContents($url); + } catch (HttpException $e) { + $content = $e->response->getBody(); + } + if ($ttl != null) { + $this->cache->set($cacheKey, $content, $ttl); + } + return str_get_html($content); + } + + private function extractPageData($html) + { + foreach ($html->find('script') as $script) { + $js_code = $script->innertext; + if (!str_starts_with($js_code, 'self.__next_f.push(')) { + continue; + } + $push_data = (string)json_decode(mb_substr($js_code, 22, mb_strlen($js_code) - 2 - 22)); + $square_bracket = mb_strpos($push_data, '['); + $push_array = json_decode(mb_substr($push_data, $square_bracket), true); + if ($push_array == null || count($push_array) < 4) { + continue; + } + $page_data = $push_array[3]; + if ($page_data != null && array_key_exists('page', $page_data)) { + return $page_data; + } + } + } + + private function parsePageData($page_data) + { + $result = []; + foreach ($page_data['page']['sections'] as $section) { + if ( + !array_key_exists('internalName', $section) || + $section['internalName'] != 'Research Teams' + ) { + continue; + } + foreach ($section['tabPages'] as $tabPage) { + if ($tabPage['label'] != 'Overview') { + continue; + } + foreach ($tabPage['sections'] as $section1) { + if ( + !array_key_exists('title', $section1) + || $section1['title'] != 'Publications' + ) { + continue; + } + foreach ($section1['posts'] as $post) { + $enc = []; + if ($post['cta'] != null && array_key_exists('url', $post['cta'])) { + $enc = [$post['cta']['url']]; + } + $result[] = [ + 'title' => $post['title'], + 'timestamp' => $post['publishedOn'], + 'uri' => self::URI . '/research/' . $post['slug']['current'], + 'categories' => array_map( + fn($s) => $s['label'], + $post['subjects'], + ), + 'enclosures' => $enc, + ]; + } + break; + } + break; + } + break; + } + return $result; + } + + private function parsePage($url) + { + // Again, 500 for no reason. + $html = $this->getHTMLIgnoreError($url, 7 * 24 * 60 * 60); + + $content = ''; + + // Main content + $main = $html->find('div[class*="PostDetail_post-detail"] > article', 0); + + // Mostly YouTube videos + $iframes = $main->find('iframe'); + foreach ($iframes as $iframe) { + $iframe->parent->removeAttribute('style'); + $iframe->outertext = '' . $iframe->src . ''; + } + + $main = convertLazyLoading($main); + $main = defaultLinkTo($main, self::URI); + $content .= $main; + return $content; + } +} diff --git a/bridges/AppleAppStoreBridge.php b/bridges/AppleAppStoreBridge.php index c1403fe04a6..a5fab59bd6f 100644 --- a/bridges/AppleAppStoreBridge.php +++ b/bridges/AppleAppStoreBridge.php @@ -1,149 +1,171 @@ array( - 'name' => 'Application ID', - 'required' => true, - 'exampleValue' => '310633997' - ), - 'p' => array( - 'name' => 'Platform', - 'type' => 'list', - 'values' => array( - 'iPad' => 'ipad', - 'iPhone' => 'iphone', - 'Mac' => 'mac', - - // The following 2 are present in responses - // but not yet tested - 'Web' => 'web', - 'Apple TV' => 'appletv', - ), - 'defaultValue' => 'iphone', - ), - 'country' => array( - 'name' => 'Store Country', - 'type' => 'list', - 'values' => array( - 'US' => 'US', - 'India' => 'IN', - 'Canada' => 'CA' - ), - 'defaultValue' => 'US', - ), - )); - - const PLATFORM_MAPPING = array( - 'iphone' => 'ios', - 'ipad' => 'ios', - ); - - private function makeHtmlUrl($id, $country){ - return 'https://apps.apple.com/' . $country . '/app/id' . $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 = array( - "Authorization: Bearer $token", - ); - - $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() { - $id = $this->getInput('id'); - $country = $this->getInput('country'); - $platform = $this->getInput('p'); - - switch ($platform) { - case 'mac': - $data = $this->getDataFromShoebox($id, $platform, $country); - break; - - default: - $token = $this->getJWTToken($id, $platform, $country); - $data = $this->getAppData($id, $platform, $country, $token); - } - - $versionHistory = $this->getVersionHistory($data, $platform); - $name = $this->name = $data['attributes']['name']; - $author = $data['attributes']['artistName']; - - foreach ($versionHistory as $row) { - $item = array(); - - $item['content'] = nl2br($row['releaseNotes']); - $item['title'] = $name . ' - ' . $row['versionDisplay']; - $item['timestamp'] = $row['releaseDate']; - $item['author'] = $author; - - $item['uri'] = $this->makeHtmlUrl($id, $country); - - $this->items[] = $item; - } - } +class AppleAppStoreBridge extends BridgeAbstract +{ + const MAINTAINER = 'captn3m0'; + const NAME = 'Apple App Store'; + const URI = 'https://apps.apple.com/'; + const CACHE_TIMEOUT = 3600; // 1h + const DESCRIPTION = 'Returns version updates for a specific application'; + + const PARAMETERS = [[ + 'id' => [ + 'name' => 'Application ID', + 'required' => true, + 'exampleValue' => '310633997' + ], + 'p' => [ + 'name' => 'Platform', + 'type' => 'list', + 'values' => [ + 'iPad' => 'ipad', + 'iPhone' => 'iphone', + 'Mac' => 'mac', + + // The following 2 are present in responses + // but not yet tested + 'Web' => 'web', + 'Apple TV' => 'appletv', + ], + 'defaultValue' => 'iphone', + ], + 'country' => [ + 'name' => 'Store Country', + 'type' => 'list', + 'values' => [ + 'US' => 'US', + 'India' => 'IN', + 'Canada' => 'CA', + 'Germany' => 'DE', + 'Netherlands' => 'NL', + 'Belgium (NL)' => 'BENL', + 'Belgium (FR)' => 'BEFR', + 'France' => 'FR', + 'Italy' => 'IT', + 'United Kingdom' => 'UK', + 'Spain' => 'ES', + 'Portugal' => 'PT', + 'Australia' => 'AU', + 'New Zealand' => 'NZ', + 'Indonesia' => 'ID', + 'Brazil' => 'BR', + ], + 'defaultValue' => 'US', + ], + ]]; + + const PLATFORM_MAPPING = [ + 'iphone' => 'ios', + 'ipad' => 'ios', + ]; + + private function makeHtmlUrl($id, $country) + { + return 'https://apps.apple.com/' . $country . '/app/id' . $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() + { + $id = $this->getInput('id'); + $country = $this->getInput('country'); + $platform = $this->getInput('p'); + + switch ($platform) { + case 'mac': + $data = $this->getDataFromShoebox($id, $platform, $country); + break; + + default: + $token = $this->getJWTToken($id, $platform, $country); + $data = $this->getAppData($id, $platform, $country, $token); + } + + $versionHistory = $this->getVersionHistory($data, $platform); + $name = $this->name = $data['attributes']['name']; + $author = $data['attributes']['artistName']; + + foreach ($versionHistory as $row) { + $item = []; + + $item['content'] = nl2br($row['releaseNotes']); + $item['title'] = $name . ' - ' . $row['versionDisplay']; + $item['timestamp'] = $row['releaseDate']; + $item['author'] = $author; + + $item['uri'] = $this->makeHtmlUrl($id, $country); + + $this->items[] = $item; + } + } } diff --git a/bridges/AppleMusicBridge.php b/bridges/AppleMusicBridge.php index 3011977766d..b633c69f9b3 100644 --- a/bridges/AppleMusicBridge.php +++ b/bridges/AppleMusicBridge.php @@ -1,62 +1,117 @@ array( - 'name' => 'Artist URL', - 'exampleValue' => 'https://itunes.apple.com/us/artist/dunderpatrullen/329796274', - 'required' => true, - ), - 'imgSize' => array( - 'name' => 'Image size for thumbnails (in px)', - 'type' => 'number', - 'defaultValue' => 512, - 'required' => true, - ) - )); - const CACHE_TIMEOUT = 21600; // 6 hours - - public function collectData() { - $url = $this->getInput('url'); - $html = getSimpleHTMLDOM($url) - or returnServerError('Could not request: ' . $url); - - $imgSize = $this->getInput('imgSize'); - - // Grab the json data from the page - $html = $html->find('script[id=shoebox-ember-data-store]', 0); - $html = strstr($html, '{'); - $html = substr($html, 0, -9); - $json = json_decode($html); - - // Loop through each object - foreach ($json->included as $obj) { - if ($obj->type === 'lockup/album') { - $this->items[] = array( - 'title' => $obj->attributes->artistName . ' - ' . $obj->attributes->name, - 'uri' => $obj->attributes->url, - 'timestamp' => $obj->attributes->releaseDate, - 'enclosures' => $obj->relationships->artwork->data->id, - ); - } elseif ($obj->type === 'image') { - $images[$obj->id] = $obj->attributes->url; - } - } - - // Add the images to each item - foreach ($this->items as &$item) { - $item['enclosures'] = array( - str_replace('{w}x{h}bb.{f}', $imgSize . 'x0w.jpg', $images[$item['enclosures']]), - ); - } - - // Sort the order to put the latest albums first - usort($this->items, function($a, $b){ - return $a['timestamp'] < $b['timestamp']; - }); - } +class AppleMusicBridge extends BridgeAbstract +{ + const NAME = 'Apple Music'; + const URI = 'https://www.apple.com'; + const DESCRIPTION = 'Fetches the latest releases from an artist'; + const MAINTAINER = 'bockiii'; + const PARAMETERS = [[ + 'artist' => [ + 'name' => 'Artist ID', + 'exampleValue' => '909253', + 'required' => true, + ], + 'limit' => [ + 'name' => 'Latest X Releases (max 50)', + 'defaultValue' => '10', + 'required' => true, + ], + ]]; + const CACHE_TIMEOUT = 60 * 60 * 6; // 6 hours + + private $title; + + public function collectData() + { + $items = $this->getJson(); + $artist = $this->getArtist($items); + + $this->title = $artist->artistName; + + foreach ($items as $item) { + if ($item->wrapperType === 'collection') { + $copyright = $item->copyright ?? ''; + $artworkUrl500 = str_replace('/100x100', '/500x500', $item->artworkUrl100); + $artworkUrl2000 = str_replace('/100x100', '/2000x2000', $item->artworkUrl100); + $escapedCollectionName = htmlspecialchars($item->collectionName); + + $this->items[] = [ + 'title' => $item->collectionName, + 'uri' => $item->collectionViewUrl, + 'timestamp' => $item->releaseDate, + 'enclosures' => $artworkUrl500, + 'author' => $item->artistName, + 'content' => "", + ]; + } + } + } + + private function getJson() + { + # Limit the amount of releases to 50 + if ($this->getInput('limit') > 50) { + $limit = 50; + } else { + $limit = $this->getInput('limit'); + } + + $url = 'https://itunes.apple.com/lookup?id=' . $this->getInput('artist') . '&entity=album&limit=' . $limit . '&sort=recent'; + $html = getSimpleHTMLDOM($url); + $json = json_decode($html); + $result = $json->results; + + if (!is_array($result) || count($result) == 0) { + returnServerError('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 new file mode 100644 index 00000000000..ac722dc91f0 --- /dev/null +++ b/bridges/ArsTechnicaBridge.php @@ -0,0 +1,118 @@ + [ + 'name' => 'Site section', + 'type' => 'list', + 'defaultValue' => 'index', + 'values' => [ + 'All' => 'index', + 'Apple' => 'apple', + 'Board Games' => 'cardboard', + 'Cars' => 'cars', + 'Features' => 'features', + 'Gaming' => 'gaming', + 'Information Technology' => 'technology-lab', + 'Science' => 'science', + 'Staff Blogs' => 'staff-blogs', + 'Tech Policy' => 'tech-policy', + 'Tech' => 'gadgets', + ] + ] + ]]; + + public function collectData() + { + $url = 'https://feeds.arstechnica.com/arstechnica/' . $this->getInput('section'); + $this->collectExpandableDatas($url, 10); + } + + protected function parseItem(array $item) + { + $item_html = getSimpleHTMLDOMCached($item['uri']); + $item_html = defaultLinkTo($item_html, self::URI); + + $content = ''; + $header = $item_html->find('article header', 0); + $leading = $header->find('p[class*=leading]', 0); + if ($leading != null) { + $content .= '' . $leading->innertext . '
'; + } + $intro_image = $header->find('img.intro-image', 0); + if ($intro_image != null) { + $content .= ''; + } + + 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 .= ''; + } + } + $lightbox->innertext = $lightbox_content; + } + + // remove various ars advertising + foreach ($item['content']->find('.ars-interlude-container') as $ad) { + $ad->remove(); + } + foreach ($item['content']->find('.toc-container') as $toc) { + $toc->remove(); + } + + // Mostly YouTube videos + $iframes = $item['content']->find('iframe'); + foreach ($iframes as $iframe) { + $iframe->outertext = '' . $iframe->src . ''; + } + // This fixed padding around the former iframes and actual inline videos + foreach ($item['content']->find('div[style*=aspect-ratio]') as $styled) { + $styled->removeAttribute('style'); + } + + $item['content'] = backgroundToImg($item['content']); + $item['uid'] = strval($parsely_json['post_id']); + return $item; + } +} diff --git a/bridges/ArtStationBridge.php b/bridges/ArtStationBridge.php index 9c12add5ed9..5a2be59dd87 100644 --- a/bridges/ArtStationBridge.php +++ b/bridges/ArtStationBridge.php @@ -1,93 +1,101 @@ array( - 'q' => array( - 'name' => 'Search term', - 'required' => true - ) - ) - ); - - public function getIcon() { - return 'https://www.artstation.com/assets/favicon-58653022bc38c1905ac7aa1b10bffa6b.ico'; - } - - public function getName() { - return self::NAME . ': ' . $this->getInput('q'); - } - - private function fetchSearch($searchQuery) { - $data = '{"query":"' . $searchQuery . '","page":1,"per_page":50,"sorting":"date",'; - $data .= '"pro_first":"1","filters":[],"additional_fields":[]}'; - - $header = array( - 'Content-Type: application/json', - 'Accept: application/json' - ); - - $opts = array( - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $data, - CURLOPT_RETURNTRANSFER => true - ); - - $jsonSearchURL = self::URI . '/api/v2/search/projects.json'; - $jsonSearchStr = getContents($jsonSearchURL, $header, $opts) - or returnServerError('Could not fetch JSON for search query.'); - return json_decode($jsonSearchStr); - } - - private function fetchProject($hashID) { - $jsonProjectURL = self::URI . '/projects/' . $hashID . '.json'; - $jsonProjectStr = getContents($jsonProjectURL) - or returnServerError('Could not fetch JSON for project.'); - return json_decode($jsonProjectStr); - } - - public function collectData() { - $searchTerm = $this->getInput('q'); - $jsonQuery = $this->fetchSearch($searchTerm); - - foreach($jsonQuery->data as $media) { - // get detailed info about media item - $jsonProject = $this->fetchProject($media->hash_id); - - // create item - $item = array(); - $item['title'] = $media->title; - $item['uri'] = $media->url; - $item['timestamp'] = strtotime($jsonProject->published_at); - $item['author'] = $media->user->full_name; - $item['categories'] = implode(',', $jsonProject->tags); - - $item['content'] = '' - . $jsonProject->description - . '
'; - - $numAssets = count($jsonProject->assets); - - if ($numAssets > 1) - $item['content'] .= 'Project contains ' - . ($numAssets - 1) - . ' more item(s).
'; - - $this->items[] = $item; - - if (count($this->items) >= 10) - break; - } - } + +class ArtStationBridge extends BridgeAbstract +{ + const NAME = 'ArtStation'; + const URI = 'https://www.artstation.com'; + const DESCRIPTION = 'Fetches the latest ten artworks from a search query on ArtStation.'; + const MAINTAINER = 'thefranke'; + const CACHE_TIMEOUT = 3600; // 1h + + const PARAMETERS = [ + 'Search Query' => [ + 'q' => [ + 'name' => 'Search term', + 'required' => true, + 'exampleValue' => 'bird' + ] + ] + ]; + + public function getIcon() + { + return 'https://www.artstation.com/assets/favicon-58653022bc38c1905ac7aa1b10bffa6b.ico'; + } + + public function getName() + { + return self::NAME . ': ' . $this->getInput('q'); + } + + private function fetchSearch($searchQuery) + { + $data = '{"query":"' . $searchQuery . '","page":1,"per_page":50,"sorting":"date",'; + $data .= '"pro_first":"1","filters":[],"additional_fields":[]}'; + + $header = [ + 'Content-Type: application/json', + 'Accept: application/json' + ]; + + $opts = [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $data, + CURLOPT_RETURNTRANSFER => true + ]; + + $jsonSearchURL = self::URI . '/api/v2/search/projects.json'; + $jsonSearchStr = getContents($jsonSearchURL, $header, $opts); + return json_decode($jsonSearchStr); + } + + private function fetchProject($hashID) + { + $jsonProjectURL = self::URI . '/projects/' . $hashID . '.json'; + $jsonProjectStr = getContents($jsonProjectURL); + return json_decode($jsonProjectStr); + } + + public function collectData() + { + $searchTerm = $this->getInput('q'); + $jsonQuery = $this->fetchSearch($searchTerm); + + foreach ($jsonQuery->data as $media) { + // get detailed info about media item + $jsonProject = $this->fetchProject($media->hash_id); + + // create item + $item = []; + $item['title'] = $media->title; + $item['uri'] = $media->url; + $item['timestamp'] = strtotime($jsonProject->published_at); + $item['author'] = $media->user->full_name; + $item['categories'] = implode(',', $jsonProject->tags); + + $item['content'] = '' + . $jsonProject->description + . '
'; + + $numAssets = count($jsonProject->assets); + + if ($numAssets > 1) { + $item['content'] .= 'Project contains ' + . ($numAssets - 1) + . ' more item(s).
'; + } + + $this->items[] = $item; + + if (count($this->items) >= 10) { + break; + } + } + } } diff --git a/bridges/Arte7Bridge.php b/bridges/Arte7Bridge.php index 562f648f03d..5898e881d49 100644 --- a/bridges/Arte7Bridge.php +++ b/bridges/Arte7Bridge.php @@ -1,123 +1,166 @@ array( - 'catfr' => array( - 'type' => 'list', - 'name' => 'Catégorie', - 'values' => array( - 'Toutes les vidéos (français)' => null, - 'Actu & société' => 'ACT', - 'Séries & fiction' => 'SER', - 'Cinéma' => 'CIN', - 'Arts & spectacles classiques' => 'ARS', - 'Culture pop' => 'CPO', - 'Découverte' => 'DEC', - 'Histoire' => 'HIST', - 'Science' => 'SCI', - 'Autre' => 'AUT' - ) - ) - ), - 'Collection (Français)' => array( - 'colfr' => array( - 'name' => 'Collection id', - 'required' => true, - 'title' => 'ex. RC-014095 pour https://www.arte.tv/fr/videos/RC-014095/blow-up/' - ) - ), - 'Catégorie (Allemand)' => array( - 'catde' => array( - 'type' => 'list', - 'name' => 'Catégorie', - 'values' => array( - 'Alle Videos (deutsch)' => null, - 'Aktuelles & Gesellschaft' => 'ACT', - 'Fernsehfilme & Serien' => 'SER', - 'Kino' => 'CIN', - 'Kunst & Kultur' => 'ARS', - 'Popkultur & Alternativ' => 'CPO', - 'Entdeckung' => 'DEC', - 'Geschichte' => 'HIST', - 'Wissenschaft' => 'SCI', - 'Sonstiges' => 'AUT' - ) - ) - ), - 'Collection (Allemand)' => array( - 'colde' => array( - 'name' => 'Collection id', - 'required' => true, - 'title' => 'ex. RC-014095 pour https://www.arte.tv/de/videos/RC-014095/blow-up/' - ) - ) - ); - - public function collectData(){ - switch($this->queriedContext) { - case 'Catégorie (Français)': - $category = $this->getInput('catfr'); - $lang = 'fr'; - break; - case 'Collection (Français)': - $lang = 'fr'; - $collectionId = $this->getInput('colfr'); - break; - case 'Catégorie (Allemand)': - $category = $this->getInput('catde'); - $lang = 'de'; - break; - case 'Collection (Allemand)': - $lang = 'de'; - $collectionId = $this->getInput('colde'); - break; - } - - $url = 'https://api.arte.tv/api/opa/v3/videos?sort=-lastModified&limit=10&language=' - . $lang - . ($category != null ? '&category.code=' . $category : '') - . ($collectionId != null ? '&collections.collectionId=' . $collectionId : ''); - - $header = array( - 'Authorization: Bearer ' . self::API_TOKEN - ); - - $input = getContents($url, $header) - or returnServerError('Could not request ARTE.'); - $input_json = json_decode($input, true); - - foreach($input_json['videos'] as $element) { - - $item = array(); - $item['uri'] = $element['url']; - $item['id'] = $element['id']; - - $item['timestamp'] = strtotime($element['videoRightsBegin']); - $item['title'] = $element['title']; - - if(!empty($element['subtitle'])) - $item['title'] = $element['title'] . ' | ' . $element['subtitle']; - - $item['duration'] = round((int)$element['durationSeconds'] / 60); - $item['content'] = $element['teaserText'] - . '
' - . $item['duration'] - . 'min
'; - - $this->items[] = $item; - } - } + +class Arte7Bridge extends BridgeAbstract +{ + const NAME = 'Arte +7'; + const URI = 'https://www.arte.tv/'; + const CACHE_TIMEOUT = 1800; // 30min + const DESCRIPTION = 'Returns newest videos from ARTE +7'; + + const API_TOKEN = 'Nzc1Yjc1ZjJkYjk1NWFhN2I2MWEwMmRlMzAzNjI5NmU3NWU3ODg4ODJjOWMxNTMxYzEzZGRjYjg2ZGE4MmIwOA'; + + const PARAMETERS = [ + 'global' => [ + 'sort_by' => [ + 'type' => 'list', + 'name' => 'Sort by', + 'required' => false, + 'defaultValue' => null, + 'values' => [ + 'Default' => null, + 'Video rights start date' => 'videoRightsBegin', + 'Video rights end date' => 'videoRightsEnd', + 'Brodcast date' => 'broadcastBegin', + 'Creation date' => 'creationDate', + 'Last modified' => 'lastModified', + 'Number of views' => 'views', + 'Number of views per period' => 'viewsPeriod', + 'Available screens' => 'availableScreens', + 'Episode' => 'episode' + ], + ], + 'sort_direction' => [ + 'type' => 'list', + 'name' => 'Sort direction', + 'required' => false, + 'defaultValue' => 'DESC', + 'values' => [ + 'Ascending' => 'ASC', + 'Descending' => 'DESC' + ], + ], + 'exclude_trailers' => [ + 'name' => 'Exclude trailers', + 'type' => 'checkbox', + 'required' => false, + 'defaultValue' => false + ], + ], + 'Category' => [ + 'lang' => [ + 'type' => 'list', + 'name' => 'Language', + 'values' => [ + 'Français' => 'fr', + 'Deutsch' => 'de', + 'English' => 'en', + 'Español' => 'es', + 'Polski' => 'pl', + 'Italiano' => 'it' + ], + ], + 'cat' => [ + 'type' => 'list', + 'name' => 'Category', + 'values' => [ + 'All videos' => null, + 'News & society' => 'ACT', + 'Series & fiction' => 'SER', + 'Cinema' => 'CIN', + 'Culture' => 'ARS', + 'Culture pop' => 'CPO', + 'Discovery' => 'DEC', + 'History' => 'HIST', + 'Science' => 'SCI', + 'Other' => 'AUT' + ] + ], + ], + 'Collection' => [ + 'lang' => [ + 'type' => 'list', + 'name' => 'Language', + 'values' => [ + 'Français' => 'fr', + 'Deutsch' => 'de', + 'English' => 'en', + 'Español' => 'es', + 'Polski' => 'pl', + 'Italiano' => 'it' + ] + ], + 'col' => [ + 'name' => 'Collection id', + 'required' => true, + 'title' => 'ex. RC-014095 pour https://www.arte.tv/de/videos/RC-014095/blow-up/', + 'exampleValue' => 'RC-014095' + ] + ] + ]; + + public function collectData() + { + switch ($this->queriedContext) { + case 'Category': + $category = $this->getInput('cat'); + $collectionId = null; + break; + case 'Collection': + $collectionId = $this->getInput('col'); + $category = null; + break; + } + + $lang = $this->getInput('lang'); + $sort_by = $this->getInput('sort_by'); + $sort_direction = $this->getInput('sort_direction') == 'ASC' ? '' : '-'; + + $url = 'https://api.arte.tv/api/opa/v3/videos?limit=15&language=' + . $lang + . ($sort_by != null ? '&sort=' . $sort_direction . $sort_by : '') + . ($category != null ? '&category.code=' . $category : '') + . ($collectionId != null ? '&collections.collectionId=' . $collectionId : ''); + + $header = [ + 'Authorization: Bearer ' . self::API_TOKEN + ]; + + $input = getContents($url, $header); + $input_json = json_decode($input, true); + + foreach ($input_json['videos'] as $element) { + if ($this->getInput('exclude_trailers') && $element['platform'] == 'EXTRAIT') { + continue; + } + + $durationSeconds = $element['durationSeconds']; + + $item = []; + $item['uri'] = $element['url']; + $item['id'] = $element['id']; + + $item['timestamp'] = strtotime($element['videoRightsBegin']); + $item['title'] = $element['title']; + + if (!empty($element['subtitle'])) { + $item['title'] = $element['title'] . ' | ' . $element['subtitle']; + } + + $durationMinutes = round((int)$durationSeconds / 60); + $item['content'] = $element['teaserText'] + . '
' + . $durationMinutes + . 'min
'; + + $item['itunes'] = [ + 'duration' => $durationSeconds, + ]; + + $this->items[] = $item; + } + } } diff --git a/bridges/AsahiShimbunAJWBridge.php b/bridges/AsahiShimbunAJWBridge.php index 0ceb0381513..873eb351cce 100644 --- a/bridges/AsahiShimbunAJWBridge.php +++ b/bridges/AsahiShimbunAJWBridge.php @@ -1,72 +1,80 @@ array( - 'type' => 'list', - 'name' => 'Section', - 'values' => array( - 'Japan » Social Affairs' => 'japan/social', - 'Japan » People' => 'japan/people', - 'Japan » 3/11 Disaster' => 'japan/0311disaster', - 'Japan » Sci & Tech' => 'japan/sci_tech', - 'Politics' => 'politics', - 'Business' => 'business', - 'Culture » Style' => 'culture/style', - 'Culture » Movies' => 'culture/movies', - 'Culture » Manga & Anime' => 'culture/manga_anime', - 'Asia » China' => 'asia/china', - 'Asia » Korean Peninsula' => 'asia/korean_peninsula', - 'Asia » Around Asia' => 'asia/around_asia', - 'Opinion » Editorial' => 'opinion/editorial', - 'Opinion » Vox Populi' => 'opinion/vox', - ), - 'defaultValue' => 'Politics', - ) - ) - ); - private function getSectionURI($section) { - return self::getURI() . $section . '/'; - } +class AsahiShimbunAJWBridge extends BridgeAbstract +{ + const NAME = 'Asahi Shimbun AJW'; + const BASE_URI = 'http://www.asahi.com'; + const URI = self::BASE_URI . '/ajw/'; + const DESCRIPTION = 'Asahi Shimbun - Asia & Japan Watch'; + const MAINTAINER = 'somini'; + const PARAMETERS = [ + [ + 'section' => [ + 'type' => 'list', + 'name' => 'Section', + 'values' => [ + 'Japan » Social Affairs' => 'japan/social', + 'Japan » People' => 'japan/people', + 'Japan » 3/11 Disaster' => 'japan/0311disaster', + 'Japan » Sci & Tech' => 'japan/sci_tech', + 'Politics' => 'politics', + 'Business' => 'business', + 'Culture » Style' => 'culture/style', + 'Culture » Movies' => 'culture/movies', + 'Culture » Manga & Anime' => 'culture/manga_anime', + 'Asia » China' => 'asia_world/china', + 'Asia » Korean Peninsula' => 'asia_world/korean_peninsula', + 'Asia » Around Asia' => 'asia_world/around_asia', + 'Asia » World' => 'asia_world/world', + 'Opinion » Editorial' => 'opinion/editorial', + 'Opinion » Vox Populi' => 'opinion/vox', + ], + 'defaultValue' => 'politics', + ] + ] + ]; - public function collectData() { - $html = getSimpleHTMLDOM($this->getSectionURI($this->getInput('section'))) - or returnServerError('Could not load content'); + private function getSectionURI($section) + { + return $this->getURI() . $section . '/'; + } - foreach($html->find('#MainInner li a') as $element) { - if ($element->parent()->class == 'HeadlineTopImage-S') { - Debug::log('Skip Headline, it is repeated below'); - continue; - } - $item = array(); + public function collectData() + { + $html = getSimpleHTMLDOM($this->getSectionURI($this->getInput('section'))); - $item['uri'] = self::BASE_URI . $element->href; - $e_lead = $element->find('span.Lead', 0); - if ($e_lead) { - $item['content'] = $e_lead->innertext; - $e_lead->outertext = ''; - } else { - $item['content'] = $element->innertext; - } - $e_date = $element->find('span.EnDate', 0); - if ($e_date) { - $item['timestamp'] = strtotime($e_date->innertext); - $e_date->outertext = ''; - } - $e_video = $element->find('span.EnVideo', 0); - if ($e_video) { - $e_video->outertext = ''; - $element->innertext = "VIDEO: $element->innertext"; - } - $item['title'] = $element->innertext; + foreach ($html->find('#MainInner li a') as $element) { + if ($element->parent()->class == 'HeadlineTopImage-S') { + continue; + } + $item = []; - $this->items[] = $item; - } - } + $item['uri'] = self::BASE_URI . $element->href; + $e_lead = $element->find('span.Lead', 0); + if ($e_lead) { + $item['content'] = $e_lead->innertext; + $e_lead->outertext = ''; + } else { + $item['content'] = $element->innertext; + } + $e_date = $element->find('span.EnDate', 0); + if ($e_date) { + $item['timestamp'] = strtotime($e_date->innertext); + $e_date->outertext = ''; + } + $e_video = $element->find('span.EnVideo', 0); + if ($e_video) { + $e_video->outertext = ''; + $element->innertext = "VIDEO: $element->innertext"; + } + $e_title = $element->find('.title', 0); + if ($e_title) { + $item['title'] = $e_title->innertext; + } else { + $item['title'] = $element->innertext; + } + + $this->items[] = $item; + } + } } diff --git a/bridges/AskfmBridge.php b/bridges/AskfmBridge.php index b76d51b3349..d0422890804 100644 --- a/bridges/AskfmBridge.php +++ b/bridges/AskfmBridge.php @@ -1,74 +1,80 @@ array( - 'u' => array( - 'name' => 'Username', - 'required' => true - ) - ) - ); +class AskfmBridge extends BridgeAbstract +{ + const MAINTAINER = 'az5he6ch, logmanoriginal'; + const NAME = 'Ask.fm Answers'; + const URI = 'https://ask.fm/'; + const CACHE_TIMEOUT = 300; //5 min + const DESCRIPTION = 'Returns answers from an Ask.fm user'; + const PARAMETERS = [ + 'Ask.fm username' => [ + 'u' => [ + 'name' => 'Username', + 'required' => true, + 'exampleValue' => 'ApprovedAndReal' + ] + ] + ]; - public function collectData(){ - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Requested username can\'t be found.'); + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); - $html = defaultLinkTo($html, self::URI); + $html = defaultLinkTo($html, self::URI); - foreach($html->find('article.streamItem-answer') as $element) { - $item = array(); - $item['uri'] = $element->find('a.streamItem_meta', 0)->href; - $question = trim($element->find('header.streamItem_header', 0)->innertext); + 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['title'] = trim( + htmlspecialchars_decode( + $element->find('header.streamItem_header', 0)->plaintext, + ENT_QUOTES + ) + ); - $item['timestamp'] = strtotime($element->find('time', 0)->datetime); + $item['timestamp'] = strtotime($element->find('time', 0)->datetime); - $answer = trim($element->find('div.streamItem_content', 0)->innertext); + $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; - } + // 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; - } - } + // 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 . '
'; + $item['content'] = '' . $question + . '
' . $answer + . '
' . $visual . '
'; - $this->items[] = $item; - } - } + $this->items[] = $item; + } + } - public function getName(){ - if(!is_null($this->getInput('u'))) { - return self::NAME . ' : ' . $this->getInput('u'); - } + public function getName() + { + if (!is_null($this->getInput('u'))) { + return self::NAME . ' : ' . $this->getInput('u'); + } - return parent::getName(); - } + return parent::getName(); + } - public function getURI(){ - if(!is_null($this->getInput('u'))) { - return self::URI . urlencode($this->getInput('u')); - } + public function getURI() + { + if (!is_null($this->getInput('u'))) { + return self::URI . urlencode($this->getInput('u')); + } - return parent::getURI(); - } + return parent::getURI(); + } } diff --git a/bridges/AssociatedPressNewsBridge.php b/bridges/AssociatedPressNewsBridge.php new file mode 100644 index 00000000000..db62c82692b --- /dev/null +++ b/bridges/AssociatedPressNewsBridge.php @@ -0,0 +1,280 @@ + [ + 'topic' => [ + 'name' => 'Topic', + 'type' => 'list', + 'values' => [ + 'AP Top News' => 'apf-topnews', + 'Sports' => 'apf-sports', + 'Entertainment' => 'apf-entertainment', + 'Oddities' => 'apf-oddities', + 'Travel' => 'apf-Travel', + 'Technology' => 'apf-technology', + 'Lifestyle' => 'apf-lifestyle', + 'Business' => 'apf-business', + 'U.S. News' => 'apf-usnews', + 'Health' => 'apf-Health', + 'Science' => 'apf-science', + 'World News' => 'apf-WorldNews', + 'Politics' => 'apf-politics', + 'Religion' => 'apf-religion', + 'Photo Galleries' => 'PhotoGalleries', + 'Fact Checks' => 'APFactCheck', + 'Videos' => 'apf-videos', + ], + 'defaultValue' => 'apf-topnews', + ], + ], + 'Custom Topic' => [ + 'topic' => [ + 'name' => 'Topic', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'europe' + ], + ] + ]; + + const CACHE_TIMEOUT = 900; // 15 mins + + private $detectParamRegex = '/^https?:\/\/(?:www\.)?apnews\.com\/(?:[tag|hub]+\/)?([\w-]+)$/'; + private $tagEndpoint = 'https://afs-prod.appspot.com/api/v2/feed/tag?tags='; + private $feedName = ''; + + public function detectParameters($url) + { + $params = []; + + if (preg_match($this->detectParamRegex, $url, $matches) > 0) { + $params['topic'] = $matches[1]; + $params['context'] = 'Custom Topic'; + return $params; + } + + return null; + } + + public function collectData() + { + switch ($this->getInput('topic')) { + case 'Podcasts': + returnClientError('Podcasts topic feed is not supported'); + break; + case 'PressReleases': + returnClientError('PressReleases topic feed is not supported'); + break; + default: + $this->collectCardData(); + } + } + + public function getURI() + { + if (!is_null($this->getInput('topic'))) { + return self::URI . $this->getInput('topic'); + } + + return parent::getURI(); + } + + public function getName() + { + if (!empty($this->feedName)) { + return $this->feedName . ' - Associated Press'; + } + + return parent::getName(); + } + + private function getTagURI() + { + if (!is_null($this->getInput('topic'))) { + return $this->tagEndpoint . $this->getInput('topic'); + } + + return parent::getURI(); + } + + private function collectCardData() + { + $json = getContents($this->getTagURI()); + + $tagContents = json_decode($json, true); + + if (empty($tagContents['tagObjs'])) { + returnClientError('Topic not found: ' . $this->getInput('topic')); + } + + $this->feedName = $tagContents['tagObjs'][0]['name']; + + foreach ($tagContents['cards'] as $card) { + $item = []; + + // skip hub peeks & Notifications + if ($card['cardType'] == 'Hub Peek' || $card['cardType'] == 'Notification') { + continue; + } + + $storyContent = $card['contents'][0]; + + switch ($storyContent['contentType']) { + case 'web': // Skip link only content + continue 2; + + case 'video': + $html = $this->processVideo($storyContent); + + $item['enclosures'][] = 'https://storage.googleapis.com/afs-prod/media/' + . $storyContent['media'][0]['id'] . '/800.jpeg'; + break; + default: + if (empty($storyContent['storyHTML'])) { // Skip if no storyHTML + continue 2; + } + + $html = defaultLinkTo($storyContent['storyHTML'], self::URI); + $html = str_get_html($html); + + $this->processMediaPlaceholders($html, $storyContent['id']); + $this->processHubLinks($html, $storyContent); + $this->processIframes($html); + + if (!is_null($storyContent['leadPhotoId'])) { + $leadPhotoUrl = sprintf('https://storage.googleapis.com/afs-prod/media/%s/800.jpeg', $storyContent['leadPhotoId']); + $leadPhotoImageTag = sprintf('', $leadPhotoUrl); + // Move the image to the beginning of the content + $html = $leadPhotoImageTag . $html; + // Explicitly not adding it to the item's enclosures! + } + } + + $item['title'] = $card['contents'][0]['headline']; + $item['uri'] = self::URI . $card['shortId']; + + if ($card['contents'][0]['localLinkUrl']) { + $item['uri'] = $card['contents'][0]['localLinkUrl']; + } + + $item['timestamp'] = $storyContent['published']; + + if (is_null($storyContent['bylines']) === false) { + // Remove 'By' from the bylines + if (substr($storyContent['bylines'], 0, 2) == 'By') { + $item['author'] = ltrim($storyContent['bylines'], 'By '); + } else { + $item['author'] = $storyContent['bylines']; + } + } + + $item['content'] = $html; + + foreach ($storyContent['tagObjs'] as $tag) { + $item['categories'][] = $tag['name']; + } + + $this->items[] = $item; + + if (count($this->items) >= 15) { + break; + } + } + } + + private function processMediaPlaceholders($html, $id) + { + if ($html->find('div.media-placeholder', 0)) { + // Fetch page content + $json = getContents('https://afs-prod.appspot.com/api/v2/content/' . $id); + $storyContent = json_decode($json, true); + + foreach ($html->find('div.media-placeholder') as $div) { + $key = array_search($div->id, $storyContent['mediumIds']); + + if (!isset($storyContent['media'][$key])) { + continue; + } + + $media = $storyContent['media'][$key]; + + if ($media['type'] === 'Photo') { + $mediaUrl = $media['gcsBaseUrl'] . $media['imageRenderedSizes'][0] . $media['imageFileExtension']; + $mediaCaption = $media['caption']; + + $div->outertext = <<{$mediaCaption} +EOD; + } + + if ($media['type'] === 'YouTube') { + $div->outertext = <<+ +EOD; + } + } + } + } + + /* + Create full coverage links (HubLinks) + */ + private function processHubLinks($html, $storyContent) + { + if (!empty($storyContent['richEmbeds'])) { + foreach ($storyContent['richEmbeds'] as $embed) { + if ($embed['type'] === 'Hub Link') { + $url = self::URI . $embed['tag']['id']; + $div = $html->find('div[id=' . $embed['id'] . ']', 0); + + if ($div) { + $div->outertext = << {$embed['calloutText']} {$embed['displayName']} +EOD; + } + } + } + } + } + + private function processVideo($storyContent) + { + $video = $storyContent['media'][0]; + + if ($video['type'] === 'YouTube') { + $url = 'https://www.youtube.com/embed/' . $video['externalId']; + $html = << +EOD; + } else { + $html = << + $table
"; + } + + private function generateStandardHtml($event) + { + $table = $this->generateEventDetailsTable($event); + + $imgsrc = $event['BannerURL']; + $FShareURL = $event['FShareURL']; + + return <<+
+ $table +
+ More Details are available on the BookMyShow website. + EOT; + } + + /** + * Converts some movie details from child entries, such as language + * into a single row item, either as a list, or as a Y/N + */ + private function generateInnerMovieDetails($data) + { + // Show list of languages and list of formats + $headers = ['EventLanguage', 'EventDimension']; + // if any of these has a Y for any of the screenings, mark it as YES + $booleanHeaders = [ + 'EventIsAtmosEnabled', 'IsMovieClubEnabled' + ]; + + $items = []; + + // Throw values inside $items[$headerName] + foreach ($data as $row) { + foreach ($headers as $header) { + $items[$header][] = $row[$header]; + } + foreach ($booleanHeaders as $header) { + $items[$header][] = $row[$header]; + } + } + + // Remove duplicate values (if all screenings are 2D for eg) + foreach ($headers as $header) { + $items[$header] = array_unique($items[$header]); + + if ($header == 'EventLanguage') { + $this->languages = $items[$header]; + } + } + + $html = ''; + + // Generate a list for first kind of entries + foreach ($headers as $header) { + $html .= self::INNER_MOVIE_HEADERS[$header] . ': ' . join(', ', $items[$header]) . '
'; + } + + // Put a yes for the boolean entries + foreach ($booleanHeaders as $header) { + if (in_array('Y', $items[$header])) { + $html .= self::INNER_MOVIE_HEADERS[$header] . ': Yes
'; + } + } + + return $html; + } + + private function generateMovieHtml($eventGroup) + { + $data = $eventGroup['ChildEvents'][0]; + $table = $this->generateEventDetailsTable($data, self::MOVIE_TABLE_HEADERS); + + $imgsrc = sprintf(self::MOVIES_IMAGE_BASE_FORMAT, $data['EventImageCode']); + + $url = $this->generateMovieUrl($eventGroup); + + $innerHtml = $this->generateInnerMovieDetails($eventGroup['ChildEvents']); + + $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; + } + + /** + * Generates a canonical movie URL + */ + private function generateMovieUrl($eventGroup) + { + return self::URI . '/movies/' . $eventGroup['EventURLTitle'] . '/' . $eventGroup['EventCode']; + } + + private function generateMoviesData($eventGroup) + { + // Additional data picked up from the first Child Event + $data = $eventGroup['ChildEvents'][0]; + $date = new DateTime($data['EventDate']); + + return [ + 'uri' => $this->generateMovieUrl($eventGroup), + 'title' => $eventGroup['EventTitle'], + 'timestamp' => $date->format('U'), + 'author' => 'BookMyShow', + 'content' => $this->generateMovieHtml($eventGroup), + 'enclosures' => [ + sprintf(self::MOVIES_IMAGE_BASE_FORMAT, $data['EventImageCode']), + ], + // Sample Input = |ADVENTURE|ANIMATION|COMEDY| + // Sample Output = ['Adventure', 'Animation', 'Comedy'] + 'categories' => array_filter( + explode('|', ucwords(strtolower($eventGroup['EventGrpGenre']), '|')) + ), + 'uid' => $eventGroup['EventGroup'] + ]; + } + + private function generateEventData($event, $category) + { + if ($category == self::MOVIES) { + return $this->generateMoviesData($event); + } + + return $this->generateGenericEventData($event, $category); + } + + /** + * Takes an event data as array and returns data for RSS Post + */ + private function generateGenericEventData($event, $category) + { + $datetime = $event['Event_dtmCreated']; + if (empty($datetime)) { + return null; + } + $date = new DateTime($event['Event_dtmCreated']); + + return [ + 'uri' => $event['FShareURL'], + 'title' => $event['EventTitle'], + 'timestamp' => $date->format('U'), + 'author' => 'BookMyShow', + 'content' => $this->generateEventHtml($event, $category), + 'enclosures' => [ + $event['BannerURL'], + ], + 'categories' => array_merge( + [self::CATEGORIES[$category]], + $event['GenreArray'] + ), + 'uid' => $event['EventGroupCode'], + ]; + } + + /** + * Check if this is an online event. We can't rely on + * EventIsWebView, since that is set to Y for everything + */ + private function isEventOnline($event) + { + if (isset($event['arrVenues']) && count($event['arrVenues']) === 1) { + if (preg_match('/(Online|Zoom)/i', $event['arrVenues'][0]['VenueName'])) { + return true; + } + } + + return false; + } + + private function matchesLanguage() + { + if ($this->getInput('language') !== 'all') { + $language = $this->getInput('language'); + return in_array($language, $this->languages); + } + return true; + } + + private function matchesOnline($event) + { + if ($this->getInput('include_online')) { + return true; + } + return (!$this->isEventOnline($event)); + } + + /** + * Currently only checks if the language filter matches + */ + private function matchesFilters($category, $event) + { + return $this->matchesLanguage() and $this->matchesOnline($event); + } + + /** + * Generates the RSS Feed title + */ + public function getName() + { + $city = $this->getInput('city'); + $category = $this->getInput('category'); + if (!is_null($city) and !is_null($category)) { + $categoryName = self::CATEGORIES[$category]; + $cityNames = array_flip(self::CITIES); + $cityName = $cityNames[$city]; + if ($this->getInput('language') !== 'null') { + $l = ucwords($this->getInput('language')); + // Sample: English Movies in Delhi + return "BookMyShow: $l $categoryName in $cityName"; + } + return "BookMyShow: $categoryName in $cityName"; + } + + return parent::getName(); + } + + /** + * Returns + * @param string $city City Code + * @return array list of headers + */ + private function makeHeaders($city) + { + $uniqid = uniqid(); + $rgn = urlencode("|Code=$city|"); + return [ + "Cookie: bmsId=$uniqid; Rgn=$rgn;" + ]; + } + + /** + * Generates various URLs as per https://tools.ietf.org/html/rfc5870 + * and other standards + */ + private function generateDirectionsHtml($lat, $long, $address = '') + { + $address = urlencode($address); + + $links = [ + 'Apple Maps' => 'http://maps.apple.com/maps?q=%s,%s"', + 'Google Maps' => 'http://maps.google.com/maps?ll=%s,%s', + // 'Google Maps (Android)' => 'geo:%s,%s?q=%s', + // 'Google Maps (iOS)' => 'comgooglemaps://?center=%s,%s&zoom=12&views=traffic', + 'OpenStreetMap' => 'https://www.openstreetmap.org/?mlat=%s&mlon=%s&zoom=12', + 'GeoURI' => 'geo:%s,%s?q=%s', + ]; + + $html = ''; + + foreach ($links as $app => $str) { + $url = sprintf($str, $lat, $long, $address); + $locations[] = "$app"; + } + + $html .= implode(', ', $locations) . ''; + + return $html; + } +} diff --git a/bridges/BooruprojectBridge.php b/bridges/BooruprojectBridge.php index 6815d3700eb..761fd08404d 100644 --- a/bridges/BooruprojectBridge.php +++ b/bridges/BooruprojectBridge.php @@ -1,45 +1,77 @@ array( - 'p' => array( - 'name' => 'page', - 'type' => 'number' - ), - 't' => array( - 'name' => 'tags' - ) - ), - 'Booru subdomain (subdomain.booru.org)' => array( - 'i' => array( - 'name' => 'Subdomain', - 'required' => true - ) - ) - ); - - const PIDBYPAGE = 20; - - public function getURI(){ - if(!is_null($this->getInput('i'))) { - return 'http://' . $this->getInput('i') . '.booru.org/'; - } - - return parent::getURI(); - } - - public function getName(){ - if(!is_null($this->getInput('i'))) { - return static::NAME . ' ' . $this->getInput('i'); - } - - return parent::getName(); - } + +class BooruprojectBridge extends DanbooruBridge +{ + const MAINTAINER = 'mitsukarenai'; + const NAME = 'Booruproject'; + const URI = 'https://booru.org/'; + const DESCRIPTION = 'Returns images from given page of booruproject'; + const PARAMETERS = [ + 'global' => [ + 'p' => [ + 'name' => 'page', + 'defaultValue' => 0, + 'type' => 'number' + ], + 't' => [ + 'name' => 'tags', + 'required' => true, + 'exampleValue' => 'tagme', + 'title' => 'Use "all" to get all posts' + ] + ], + 'Booru subdomain (subdomain.booru.org)' => [ + 'i' => [ + 'name' => 'Subdomain', + 'required' => true, + 'exampleValue' => 'rm' + ] + ] + ]; + + const PATHTODATA = '.thumb'; + const IDATTRIBUTE = 'id'; + const TAGATTRIBUTE = 'title'; + const PIDBYPAGE = 20; + + protected function getFullURI() + { + return $this->getURI() + . 'index.php?page=post&s=list&pid=' + . ($this->getInput('p') ? ($this->getInput('p') - 1) * static::PIDBYPAGE : '') + . '&tags=' . urlencode($this->getInput('t')); + } + + protected function getTags($element) + { + $tags = parent::getTags($element); + $tags = explode(' ', $tags); + + // Remove statistics from the tags list (identified by colon) + foreach ($tags as $key => $tag) { + if (strpos($tag, ':') !== false) { + unset($tags[$key]); + } + } + + return implode(' ', $tags); + } + + public function getURI() + { + if (!is_null($this->getInput('i'))) { + return 'https://' . $this->getInput('i') . '.booru.org/'; + } + + return parent::getURI(); + } + + public function getName() + { + if (!is_null($this->getInput('i'))) { + return static::NAME . ' ' . $this->getInput('i'); + } + + return parent::getName(); + } } diff --git a/bridges/BrutBridge.php b/bridges/BrutBridge.php index 32265b69bc7..0db0851a18f 100644 --- a/bridges/BrutBridge.php +++ b/bridges/BrutBridge.php @@ -1,157 +1,73 @@ array( - 'name' => 'Category', - 'type' => 'list', - 'values' => array( - 'News' => 'news', - 'International' => 'international', - 'Economy' => 'economy', - 'Science and Technology' => 'science-and-technology', - 'Entertainment' => 'entertainment', - 'Sports' => 'sport', - 'Nature' => 'nature', - ), - 'defaultValue' => 'news', - ), - 'edition' => array( - 'name' => ' Edition', - 'type' => 'list', - 'values' => array( - 'United States' => 'us', - 'United Kingdom' => 'uk', - 'France' => 'fr', - 'India' => 'in', - 'Mexico' => 'mx', - ), - 'defaultValue' => 'us', - ) - ) - ); - const CACHE_TIMEOUT = 1800; // 30 mins - - private $videoId = ''; - private $videoType = ''; - private $videoImage = ''; - - public function collectData() { - - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request: ' . $this->getURI()); - - $results = $html->find('div.results', 0); - - foreach($results->find('li.col-6.col-sm-4.col-md-3.col-lg-2.px-2.pb-4') as $index => $li) { - $item = array(); - - $videoPath = self::URI . $li->children(0)->href; - - $videoPageHtml = getSimpleHTMLDOMCached($videoPath, 3600) - or returnServerError('Could not request: ' . $videoPath); - - $this->videoImage = $videoPageHtml->find('meta[name="twitter:image"]', 0)->content; - - $this->processTwitterImage(); - - $description = $videoPageHtml->find('div.description', 0); - - $item['uri'] = $videoPath; - $item['title'] = $description->find('h1', 0)->plaintext; - - if ($description->find('div.date', 0)->children(0)) { - $description->find('div.date', 0)->children(0)->outertext = ''; - } - - $item['content'] = $this->processContent( - $description - ); - - $item['timestamp'] = $this->processDate($description); - $item['enclosures'][] = $this->videoImage; - - $this->items[] = $item; - - if (count($this->items) >= 5) { - break; - } - } - } - - public function getURI() { - - if (!is_null($this->getInput('edition')) && !is_null($this->getInput('category'))) { - return self::URI . '/' . $this->getInput('edition') . '/' . $this->getInput('category'); - } - - return parent::getURI(); - } - - public function getName() { - - if (!is_null($this->getInput('edition')) && !is_null($this->getInput('category'))) { - $parameters = $this->getParameters(); - - $editionValues = array_flip($parameters[0]['edition']['values']); - $categoryValues = array_flip($parameters[0]['category']['values']); - - return $categoryValues[$this->getInput('category')] . ' - ' . - $editionValues[$this->getInput('edition')] . ' - Brut.'; - } - - return parent::getName(); - } - - private function processDate($description) { - - if ($this->getInput('edition') === 'uk') { - $date = DateTime::createFromFormat('d/m/Y H:i', $description->find('div.date', 0)->innertext); - return strtotime($date->format('Y-m-d H:i:s')); - } - - return strtotime($description->find('div.date', 0)->innertext); - } - - private function processContent($description) { - - $content = ''; - $content .= '' . $description->find('h2.mb-1', 0)->innertext . '
'; - - if ($description->find('div.text.pb-3', 0)->children(1)->class != 'date') { - $content .= '' . $description->find('div.text.pb-3', 0)->children(1)->innertext . '
'; - } - - return $content; - } - - private function processTwitterImage() { - /** - * Extract video ID + type from twitter image - * - * Example (wrapped): - * https://img.brut.media/thumbnail/ - * the-life-of-rita-moreno-2cce75b5-d448-44d2-a97c-ca50d6470dd4-square.jpg - * ?ts=1559337892 - */ - $fpath = parse_url($this->videoImage, PHP_URL_PATH); - $fname = basename($fpath); - $fname = substr($fname, 0, strrpos($fname, '.')); - $parts = explode('-', $fname); - - if (end($parts) === 'auto') { - $key = array_search('auto', $parts); - unset($parts[$key]); - } - - $this->videoId = implode('-', array_splice($parts, -6, 5)); - $this->videoType = end($parts); - } +class BrutBridge extends BridgeAbstract +{ + const NAME = 'Brut Bridge'; + const URI = 'https://www.brut.media'; + const DESCRIPTION = 'Returns 10 newest videos by category and edition'; + const MAINTAINER = 'VerifiedJoseph'; + const PARAMETERS = [[ + 'category' => [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'News' => 'news', + 'International' => 'international', + 'Economy' => 'economy', + 'Science and Technology' => 'science-and-technology', + 'Entertainment' => 'entertainment', + 'Sports' => 'sport', + 'Nature' => 'nature', + 'Health' => 'health', + ], + 'defaultValue' => 'news', + ], + 'edition' => [ + 'name' => ' Edition', + 'type' => 'list', + 'values' => [ + 'United States' => 'us', + 'United Kingdom' => 'uk', + 'France' => 'fr', + 'Spain' => 'es', + 'India' => 'in', + 'Mexico' => 'mx', + ], + 'defaultValue' => 'us', + ] + ] + ]; + + public function collectData() + { + $url = $this->getURI(); + $html = getSimpleHTMLDOM($url); + $regex = '/window.__PRELOADED_STATE__ = (.*);/'; + preg_match($regex, $html, $parts); + $data = Json::decode($parts[1], false); + foreach ($data->medias->index as $uid => $media) { + $this->items[] = [ + 'uid' => $uid, + 'title' => $media->metadata->slug, + 'uri' => $media->share_url, + 'timestamp' => $media->published_at, + ]; + } + } + + public function getURI() + { + if (!is_null($this->getInput('edition')) && !is_null($this->getInput('category'))) { + return self::URI . '/' . $this->getInput('edition') . '/' . $this->getInput('category'); + } + return parent::getURI(); + } + + public function getName() + { + if (!is_null($this->getInput('edition')) && !is_null($this->getInput('category'))) { + return $this->getKey('category') . ' - ' . $this->getKey('edition') . ' - Brut.'; + } + return parent::getName(); + } } diff --git a/bridges/BugzillaBridge.php b/bridges/BugzillaBridge.php new file mode 100644 index 00000000000..23e93eb8fcd --- /dev/null +++ b/bridges/BugzillaBridge.php @@ -0,0 +1,198 @@ + [ + 'instance' => [ + 'name' => 'Instance URL', + 'required' => true, + 'exampleValue' => 'https://bugzilla.mozilla.org' + ] + ], + 'Bug comments' => [ + 'id' => [ + 'name' => 'Bug tracking ID', + 'type' => 'number', + 'required' => true, + 'title' => 'Insert bug tracking ID', + 'exampleValue' => 121241 + ], + 'limit' => [ + 'name' => 'Number of comments to return', + 'type' => 'number', + 'required' => false, + 'title' => 'Specify number of comments to return', + 'defaultValue' => -1 + ], + 'skiptags' => [ + 'name' => 'Skip offtopic comments', + 'type' => 'checkbox', + 'title' => 'Excludes comments tagged as advocacy, metoo, or offtopic from the feed' + ] + ] + ]; + + const SKIPPED_ACTIVITY = [ + 'cc' => true, + 'comment_tag' => true + ]; + + const SKIPPED_TAGS = ['advocacy', 'metoo', 'offtopic']; + + private $instance; + private $bugid; + private $buguri; + private $title; + + public function getName() + { + if (!is_null($this->title)) { + return $this->title; + } + return parent::getName(); + } + + public function getURI() + { + return $this->buguri ?? parent::getURI(); + } + + public function collectData() + { + $this->instance = rtrim($this->getInput('instance'), '/'); + $this->bugid = $this->getInput('id'); + $this->buguri = $this->instance . '/show_bug.cgi?id=' . $this->bugid; + + $url = $this->instance . '/rest/bug/' . $this->bugid; + $this->getTitle($url); + $this->collectComments($url . '/comment'); + $this->collectUpdates($url . '/history'); + + usort($this->items, function ($a, $b) { + return $b['timestamp'] <=> $a['timestamp']; + }); + + if ($this->getInput('limit') > 0) { + $this->items = array_slice($this->items, 0, $this->getInput('limit')); + } + } + + protected function getTitle($url) + { + // Only request the summary for a faster request + $json = self::getJSON($url . '?include_fields=summary'); + $this->title = 'Bug ' . $this->bugid . ' - ' . + $json['bugs'][0]['summary'] . ' - ' . + // Remove https:// + substr($this->instance, 8); + } + + protected function collectComments($url) + { + $json = self::getJSON($url); + + // Array of comments is here + if (!isset($json['bugs'][$this->bugid]['comments'])) { + returnClientError('Cannot find REST endpoint'); + } + + foreach ($json['bugs'][$this->bugid]['comments'] as $comment) { + $item = []; + if ( + $this->getInput('skiptags') and + array_intersect(self::SKIPPED_TAGS, $comment['tags']) + ) { + continue; + } + $item['categories'] = $comment['tags']; + $item['uri'] = $this->buguri . '#c' . $comment['count']; + $item['title'] = 'Comment ' . $comment['count']; + $item['timestamp'] = $comment['creation_time']; + $item['author'] = $this->getUser($comment['creator']); + $item['content'] = $comment['text']; + if (isset($comment['is_markdown']) and $comment['is_markdown']) { + $item['content'] = markdownToHtml($item['content']); + } + if (!is_null($comment['attachment_id'])) { + $item['enclosures'] = [$this->instance . '/attachment.cgi?id=' . $comment['attachment_id']]; + } + $this->items[] = $item; + } + } + + protected function collectUpdates($url) + { + $json = self::getJSON($url); + + // Array of changesets which contain an array of changes + if (!isset($json['bugs']['0']['history'])) { + returnClientError('Cannot find REST endpoint'); + } + + foreach ($json['bugs']['0']['history'] as $changeset) { + $author = $this->getUser($changeset['who']); + $timestamp = $changeset['when']; + foreach ($changeset['changes'] as $change) { + // Skip updates to the cc list and comment tagging + if (isset(self::SKIPPED_ACTIVITY[$change['field_name']])) { + continue; + } + + $item = []; + $item['uri'] = $this->buguri; + $item['title'] = 'Updated'; + $item['timestamp'] = $timestamp; + $item['author'] = $author; + $item['content'] = ucfirst($change['field_name']) . ': ' . + ($change['removed'] === '' ? '[nothing]' : $change['removed']) . ' -> ' . + ($change['added'] === '' ? '[nothing]' : $change['added']); + $this->items[] = $item; + } + } + } + + protected function getUser($user) + { + // Check if the user endpoint is available + if ($this->loadCacheValue($this->instance . 'userEndpointClosed')) { + return $user; + } + + $cache = $this->loadCacheValue($this->instance . $user); + if ($cache) { + return $cache; + } + + $url = $this->instance . '/rest/user/' . $user . '?include_fields=real_name'; + try { + $json = self::getJSON($url); + if (isset($json['error']) and $json['error']) { + throw new Exception(); + } + } catch (Exception $e) { + $this->saveCacheValue($this->instance . 'userEndpointClosed', true); + return $user; + } + + $username = $json['users']['0']['real_name']; + + if (empty($username)) { + $username = $user; + } + $this->saveCacheValue($this->instance . $user, $username); + return $username; + } + + protected static function getJSON($url) + { + $headers = [ + 'Accept: application/json', + ]; + return json_decode(getContents($url, $headers), true); + } +} diff --git a/bridges/BukowskisBridge.php b/bridges/BukowskisBridge.php new file mode 100644 index 00000000000..14889889dc2 --- /dev/null +++ b/bridges/BukowskisBridge.php @@ -0,0 +1,220 @@ + [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'All categories' => '', + 'Art' => [ + 'All' => 'art', + 'Classic Art' => 'art.classic-art', + 'Classic Finnish Art' => 'art.classic-finnish-art', + 'Classic Swedish Art' => 'art.classic-swedish-art', + 'Contemporary' => 'art.contemporary', + 'Modern Finnish Art' => 'art.modern-finnish-art', + 'Modern International Art' => 'art.modern-international-art', + 'Modern Swedish Art' => 'art.modern-swedish-art', + 'Old Masters' => 'art.old-masters', + 'Other' => 'art.other', + 'Photographs' => 'art.photographs', + 'Prints' => 'art.prints', + 'Sculpture' => 'art.sculpture', + 'Swedish Old Masters' => 'art.swedish-old-masters', + ], + 'Asian Ceramics & Works of Art' => [ + 'All' => 'asian-ceramics-works-of-art', + 'Other' => 'asian-ceramics-works-of-art.other', + 'Porcelain' => 'asian-ceramics-works-of-art.porcelain', + ], + 'Books & Manuscripts' => [ + 'All' => 'books-manuscripts', + 'Books' => 'books-manuscripts.books', + ], + 'Carpets, rugs & textiles' => [ + 'All' => 'carpets-rugs-textiles', + 'European' => 'carpets-rugs-textiles.european', + 'Oriental' => 'carpets-rugs-textiles.oriental', + 'Rest of the world' => 'carpets-rugs-textiles.rest-of-the-world', + 'Scandinavian' => 'carpets-rugs-textiles.scandinavian', + ], + 'Ceramics & porcelain' => [ + 'All' => 'ceramics-porcelain', + 'Ceramic ware' => 'ceramics-porcelain.ceramic-ware', + 'European' => 'ceramics-porcelain.european', + 'Rest of the world' => 'ceramics-porcelain.rest-of-the-world', + 'Scandinavian' => 'ceramics-porcelain.scandinavian', + ], + 'Collectibles' => [ + 'All' => 'collectibles', + 'Advertising & Retail' => 'collectibles.advertising-retail', + 'Memorabilia' => 'collectibles.memorabilia', + 'Movies & music' => 'collectibles.movies-music', + 'Other' => 'collectibles.other', + 'Retro & Popular Culture' => 'collectibles.retro-popular-culture', + 'Technica & Nautica' => 'collectibles.technica-nautica', + 'Toys' => 'collectibles.toys', + ], + 'Design' => [ + 'All' => 'design', + 'Art glass' => 'design.art-glass', + 'Furniture' => 'design.furniture', + 'Other' => 'design.other', + ], + 'Folk art' => [ + 'All' => 'folk-art', + 'All categories' => 'lots', + ], + 'Furniture' => [ + 'All' => 'furniture', + 'Armchairs & Sofas' => 'furniture.armchairs-sofas', + 'Cabinets & Bureaus' => 'furniture.cabinets-bureaus', + 'Chairs' => 'furniture.chairs', + 'Garden furniture' => 'furniture.garden-furniture', + 'Mirrors' => 'furniture.mirrors', + 'Other' => 'furniture.other', + 'Shelves & Book cases' => 'furniture.shelves-book-cases', + 'Tables' => 'furniture.tables', + ], + 'Glassware' => [ + 'All' => 'glassware', + 'Glassware' => 'glassware.glassware', + 'Other' => 'glassware.other', + ], + 'Jewellery' => [ + 'All' => 'jewellery', + 'Bracelets' => 'jewellery.bracelets', + 'Brooches' => 'jewellery.brooches', + 'Earrings' => 'jewellery.earrings', + 'Necklaces & Pendants' => 'jewellery.necklaces-pendants', + 'Other' => 'jewellery.other', + 'Rings' => 'jewellery.rings', + ], + 'Lighting' => [ + 'All' => 'lighting', + 'Candle sticks & Candelabras' => 'lighting.candle-sticks-candelabras', + 'Ceiling lights' => 'lighting.ceiling-lights', + 'Chandeliers' => 'lighting.chandeliers', + 'Floor lights' => 'lighting.floor-lights', + 'Other' => 'lighting.other', + 'Table lights' => 'lighting.table-lights', + 'Wall lights' => 'lighting.wall-lights', + ], + 'Militaria' => [ + 'All' => 'militaria', + 'Honors & Medals' => 'militaria.honors-medals', + 'Other militaria' => 'militaria.other-militaria', + 'Weaponry' => 'militaria.weaponry', + ], + 'Miscellaneous' => [ + 'All' => 'miscellaneous', + 'Brass, Copper & Pewter' => 'miscellaneous.brass-copper-pewter', + 'Nickel silver' => 'miscellaneous.nickel-silver', + 'Oriental' => 'miscellaneous.oriental', + 'Other' => 'miscellaneous.other', + ], + 'Silver' => [ + 'All' => 'silver', + 'Candle sticks' => 'silver.candle-sticks', + 'Cups & Bowls' => 'silver.cups-bowls', + 'Cutlery' => 'silver.cutlery', + 'Other' => 'silver.other', + ], + 'Timepieces' => [ + 'All' => 'timepieces', + 'Other' => 'timepieces.other', + 'Pocket watches' => 'timepieces.pocket-watches', + 'Table clocks' => 'timepieces.table-clocks', + 'Wrist watches' => 'timepieces.wrist-watches', + ], + 'Vintage & Fashion' => [ + 'All' => 'vintage-fashion', + 'Accessories' => 'vintage-fashion.accessories', + 'Bags & Trunks' => 'vintage-fashion.bags-trunks', + 'Clothes' => 'vintage-fashion.clothes', + ], + ] + ], + 'sort_order' => [ + 'name' => 'Sort order', + 'type' => 'list', + 'values' => [ + 'Ending soon' => 'ending', + 'Most recent' => 'recent', + 'Most bids' => 'most', + 'Fewest bids' => 'fewest', + 'Lowest price' => 'lowest', + 'Highest price' => 'highest', + 'Lowest estimate' => 'low', + 'Highest estimate' => 'high', + 'Alphabetical' => 'alphabetical', + ], + ], + 'language' => [ + 'name' => 'Language', + 'type' => 'list', + 'values' => [ + 'English' => 'en', + 'Swedish' => 'sv', + 'Finnish' => 'fi' + ], + ], + ]]; + + const CACHE_TIMEOUT = 3600; // 1 hour + + private $title; + + public function collectData() + { + $baseUrl = 'https://www.bukowskis.com'; + $category = $this->getInput('category'); + $language = $this->getInput('language'); + $sort_order = $this->getInput('sort_order'); + + $url = $baseUrl . '/' . $language . '/lots'; + + if ($category) { + $url = $url . '/category/' . $category; + } + + if ($sort_order) { + $url = $url . '/sort/' . $sort_order; + } + + $html = getSimpleHTMLDOM($url); + + $this->title = htmlspecialchars_decode($html->find('title', 0)->innertext); + + foreach ($html->find('div.c-lot-index-lot') as $lot) { + $title = $lot->find('a.c-lot-index-lot__title', 0)->plaintext; + $relative_url = $lot->find('a.c-lot-index-lot__link', 0)->href; + $images = json_decode( + htmlspecialchars_decode( + $lot + ->find('img.o-aspect-ratio__image', 0) + ->getAttribute('data-thumbnails') + ) + ); + + $this->items[] = [ + 'title' => $title, + 'uri' => $baseUrl . $relative_url, + 'uid' => $lot->getAttribute('data-lot-id'), + 'content' => count($images) > 0 ? "
$title" : $title, + 'enclosures' => array_slice($images, 1), + ]; + } + } + + public function getName() + { + return $this->title ?: parent::getName(); + } +} diff --git a/bridges/BundesbankBridge.php b/bridges/BundesbankBridge.php index b64a6425b97..0c6b943f678 100644 --- a/bridges/BundesbankBridge.php +++ b/bridges/BundesbankBridge.php @@ -1,85 +1,90 @@ array( - 'name' => 'Language', - 'type' => 'list', - 'defaultValue' => self::LANG_DE, - 'values' => array( - 'English' => self::LANG_EN, - 'Deutsch' => self::LANG_DE - ) - ) - ) - ); - - public function getIcon() { - return self::URI . 'resource/crblob/1890/a7f48ee0ae35348748121770ba3ca009/mL/favicon-ico-data.ico'; - } - - public function getURI() { - switch($this->getInput(self::PARAM_LANG)) { - case self::LANG_EN: return self::URI . 'en/publications/reports/studies'; - case self::LANG_DE: return self::URI . 'de/publikationen/berichte/studien'; - } - - return parent::getURI(); - } - - public function collectData() { - - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('No response for ' . $this->getURI()); - - $html = defaultLinkTo($html, $this->getURI()); - - foreach($html->find('ul.resultlist li') as $study) { - $item = array(); - - $item['uri'] = $study->find('.teasable__link', 0)->href; - - // Get title without child elements (i.e. subtitle) - $title = $study->find('.teasable__title div.h2', 0); - - foreach($title->children as &$child) { - $child->outertext = ''; - } - - $item['title'] = $title->innertext; - - // Add subtitle to the content if it exists - $item['content'] = ''; - - if($subtitle = $study->find('.teasable__subtitle', 0)) { - $item['content'] .= '' . $study->find('.teasable__subtitle', 0)->plaintext . ''; - } - - $item['content'] .= '' . $study->find('.teasable__text', 0)->plaintext . '
'; - - $item['timestamp'] = strtotime($study->find('.teasable__date', 0)->plaintext); - - // Downloads and older studies don't have images - if($study->find('.teasable__image', 0)) { - $item['enclosures'] = array( - $study->find('.teasable__image img', 0)->src - ); - } - - $this->items[] = $item; - } - - } +class BundesbankBridge extends BridgeAbstract +{ + const PARAM_LANG = 'lang'; + + const LANG_EN = 'en'; + const LANG_DE = 'de'; + + const NAME = 'Bundesbank Bridge'; + const URI = 'https://www.bundesbank.de/'; + const DESCRIPTION = 'Returns the latest studies of the Bundesbank (Germany)'; + const MAINTAINER = 'logmanoriginal'; + const CACHE_TIMEOUT = 86400; // 24 hours + + const PARAMETERS = [ + [ + self::PARAM_LANG => [ + 'name' => 'Language', + 'type' => 'list', + 'defaultValue' => self::LANG_DE, + 'values' => [ + 'English' => self::LANG_EN, + 'Deutsch' => self::LANG_DE + ] + ] + ] + ]; + + public function getIcon() + { + return self::URI . 'resource/crblob/1890/a7f48ee0ae35348748121770ba3ca009/mL/favicon-ico-data.ico'; + } + + public function getURI() + { + switch ($this->getInput(self::PARAM_LANG)) { + case self::LANG_EN: + return self::URI . 'en/publications/reports/studies'; + case self::LANG_DE: + return self::URI . 'de/publikationen/berichte/studien'; + } + + return parent::getURI(); + } + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + + $html = defaultLinkTo($html, $this->getURI()); + + foreach ($html->find('ul.resultlist li') as $study) { + $item = []; + + $item['uri'] = $study->find('.teasable__link', 0)->href; + + // Get title without child elements (i.e. subtitle) + $title = $study->find('.teasable__title div.h2', 0); + + foreach ($title->children as &$child) { + $child->outertext = ''; + } + + $item['title'] = $title->innertext; + + // Add subtitle to the content if it exists + $item['content'] = ''; + + if ($subtitle = $study->find('.teasable__subtitle', 0)) { + $item['content'] .= '' . $study->find('.teasable__subtitle', 0)->plaintext . ''; + } + + $teasable = $study->find('.teasable__text', 0); + $teasableText = $teasable->plaintext ?? ''; + $item['content'] .= '' . $teasableText . '
'; + + $item['timestamp'] = strtotime($study->find('.teasable__date', 0)->plaintext); + + // Downloads and older studies don't have images + if ($study->find('.teasable__image', 0)) { + $item['enclosures'] = [ + $study->find('.teasable__image img', 0)->src + ]; + } + + $this->items[] = $item; + } + } } diff --git a/bridges/BundestagParteispendenBridge.php b/bridges/BundestagParteispendenBridge.php new file mode 100644 index 00000000000..773f9129e72 --- /dev/null +++ b/bridges/BundestagParteispendenBridge.php @@ -0,0 +1,92 @@ +Partei:
%s +Spendenbetrag:
+
%sSpender:
+
%sEingang der Spende:
+TMPL; + + public function getIcon() + { + return 'https://www.bundestag.de/static/appdata/includes/images/layout/favicon.ico'; + } + + public function collectData() + { + $ajaxUri = <<
%sfind('a', 0) + or returnServerError('Could not find the proper HTML element.'); + + $url = $firstAnchor->href; + + // Get the actual page with the soft money donations + $html = getSimpleHTMLDOMCached($url, self::CACHE_TIMEOUT); + + $rows = $html->find('table.table > tbody > tr') + or returnServerError('Could not find the proper HTML elements.'); + + foreach ($rows as $row) { + $item = $this->generateItemFromRow($row); + if (is_array($item)) { + $item['uri'] = $url; + $this->items[] = $item; + } + } + } + + private function generateItemFromRow(simple_html_dom_node $row) + { + // The row must have 5 columns. There are monthly header rows, which are ignored here. + if (count($row->children) != 5) { + return null; + } + + $item = []; + + // | column | paragraph inside column + $party = $row->children[0]->children[0]->innertext; + $amount = $row->children[1]->children[0]->innertext . ' €'; + $donor = $row->children[2]->children[0]->innertext; + $date = $row->children[3]->children[0]->innertext; + $dip = $row->children[4]->children[0]->find('a.dipLink', 0); + + // Strip whitespace from date string. + $date = str_replace(' ', '', $date); + + $content = sprintf(self::CONTENT_TEMPLATE, $party, $amount, $donor, $date); + + $item = [ + 'title' => $party . ': ' . $amount, + 'content' => $content, + 'uid' => sha1($content), + ]; + + // Try to get the link to the official document + if ($dip != null) { + $item['enclosures'] = [$dip->href]; + } + + // Try to parse the date + $dateTime = DateTime::createFromFormat('d.m.Y', $date); + if ($dateTime !== false) { + $item['timestamp'] = $dateTime->getTimestamp(); + } + + return $item; + } +} diff --git a/bridges/BundesverbandFuerFreieKammernBridge.php b/bridges/BundesverbandFuerFreieKammernBridge.php new file mode 100644 index 00000000000..147f2d47e37 --- /dev/null +++ b/bridges/BundesverbandFuerFreieKammernBridge.php @@ -0,0 +1,28 @@ +setTime(0, 0, 0); + return $dti->getTimestamp(); + } +} diff --git a/bridges/CBCEditorsBlogBridge.php b/bridges/CBCEditorsBlogBridge.php new file mode 100644 index 00000000000..a9c0a4dcc6d --- /dev/null +++ b/bridges/CBCEditorsBlogBridge.php @@ -0,0 +1,38 @@ +find('div.contentListCards', 0)->find('a[data-test=type-story]') as $element) { + $headline = ($element->find('.headline', 0))->innertext; + $timestamp = ($element->find('time', 0))->datetime; + $articleUri = 'https://www.cbc.ca' . $element->href; + $summary = ($element->find('div.description', 0))->innertext; + $thumbnailUris = ($element->find('img[loading=lazy]', 0))->srcset; + $thumbnailUri = rtrim(explode(',', $thumbnailUris)[0], ' 300w'); + + // Fill item + $item = []; + $item['uri'] = $articleUri; + $item['id'] = $item['uri']; + $item['timestamp'] = $timestamp; + $item['title'] = $headline; + $item['content'] = '
' . $summary; + $item['author'] = 'Editor\'s Blog'; + + if (isset($item['title'])) { + $this->items[] = $item; + } + } + } +} diff --git a/bridges/CNETBridge.php b/bridges/CNETBridge.php index 564b817a942..17c05e9b0eb 100644 --- a/bridges/CNETBridge.php +++ b/bridges/CNETBridge.php @@ -1,109 +1,118 @@ array( - 'name' => 'Topic', - 'type' => 'list', - 'values' => array( - 'All articles' => '', - 'Apple' => 'apple', - 'Google' => 'google', - 'Microsoft' => 'tags-microsoft', - 'Computers' => 'topics-computers', - 'Mobile' => 'topics-mobile', - 'Sci-Tech' => 'topics-sci-tech', - 'Security' => 'topics-security', - 'Internet' => 'topics-internet', - 'Tech Industry' => 'topics-tech-industry' - ) - ) - ) - ); - - private function cleanArticle($article_html) { - $offset_p = strpos($article_html, ''); - $offset_figure = strpos($article_html, '
{$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 - ); - - } + 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 00000000000..1507589836c --- /dev/null +++ b/bridges/DRKBlutspendeBridge.php @@ -0,0 +1,107 @@ + [ + '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, + ] + ] + ]; + + public function collectData() + { + $limitItems = intval($this->getInput('limit_items')); + $this->collectExpandableDatas(self::buildAppointmentsURI(), $limitItems); + } + + protected function parseItem(array $item) + { + $html = getSimpleHTMLDOM($item['uri']); + + $detailsElement = $html->find('.details', 0); + + $dateElement = $detailsElement->find('.datum', 0); + $dateLines = self::explodeLines($dateElement->plaintext); + + $addressElement = $detailsElement->find('.adresse', 0); + $addressLines = self::explodeLines($addressElement->plaintext); + + $infoElement = $detailsElement->find('.angebote > h4 + p', 0); + $info = $infoElement ? $infoElement->innertext : ''; + + $imageElements = $detailsElement->find('.fotos img'); + + $item['title'] = $dateLines[0] . ' ' . $dateLines[1] . ' ' . $addressLines[0] . ' - ' . $addressLines[1]; + + $item['content'] = <<{$dateLines[0]} {$dateLines[1]} +{$addressElement->innertext}
+{$info}
+ HTML; + + foreach ($imageElements as $imageElement) { + $src = $imageElement->getAttribute('src'); + $item['content'] .= << + HTML; + } + + $item['description'] = null; + + 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; + } + + /** + * 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 00000000000..a031706e010 --- /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 .= $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 new file mode 100644 index 00000000000..fa219a09439 --- /dev/null +++ b/bridges/DagensNyheterDirektBridge.php @@ -0,0 +1,56 @@ +find('article') as $element) { + $link = $element->find('button', 0)->getAttribute('data-link'); + $datetime = $element->getAttribute('data-publication-time'); + $url = self::BASEURL . $link; + $title = $element->find('h2', 0)->plaintext; + $author = $element->find('div.ds-byline__titles', 0)->plaintext; + + $article_content = $element->find('div.direkt-post__content', 0); + $article_html = ''; + + $figure = $element->find('figure', 0); + + if ($figure) { + $article_html = $figure->find('img', 0) . '' . $figure->find('figcaption', 0) . '
'; + } + + foreach ($article_content->find('p') as $p) { + $article_html = $article_html . $p; + } + + $this->items[] = [ + 'uri' => $url, + 'title' => $title, + 'author' => trim($author), + 'timestamp' => $datetime, + 'content' => trim($article_html), + ]; + + if (count($this->items) > self::LIMIT) { + break; + } + } + } +} diff --git a/bridges/DailymotionBridge.php b/bridges/DailymotionBridge.php index dc4f5d3f986..5d892954bba 100644 --- a/bridges/DailymotionBridge.php +++ b/bridges/DailymotionBridge.php @@ -1,203 +1,209 @@ array( - 'u' => array( - 'name' => 'username', - 'required' => true - ) - ), - 'By playlist id' => array( - 'p' => array( - 'name' => 'playlist id', - 'required' => true - ) - ), - 'From search results' => array( - 's' => array( - 'name' => 'Search keyword', - 'required' => true - ), - 'pa' => array( - 'name' => 'Page', - 'type' => 'number', - 'defaultValue' => 1, - ) - ) - ); - - private $feedName = ''; - - private $apiUrl = 'https://api.dailymotion.com'; - private $apiFields = 'created_time,description,id,owner.screenname,tags,thumbnail_url,title,url'; - - public function getIcon() { - return 'https://static1-ssl.dmcdn.net/images/neon/favicons/android-icon-36x36.png.vf806ca4ed0deed812'; - } - - public function collectData() { - - if ($this->queriedContext === 'By username' || $this->queriedContext === 'By playlist id') { - - $apiJson = getContents($this->getApiUrl()) - or returnServerError('Could not request: ' . $this->getApiUrl()); - - $apiData = json_decode($apiJson, true); - - $this->feedName = $this->getPlaylistTitle($this->getInput('p')); - - foreach ($apiData['list'] as $apiItem) { - $item = array(); - - $item['uri'] = $apiItem['url']; - $item['uid'] = $apiItem['id']; - $item['title'] = $apiItem['title']; - $item['timestamp'] = $apiItem['created_time']; - $item['author'] = $apiItem['owner.screenname']; - $item['content'] = '+ +class DailymotionBridge extends BridgeAbstract +{ + const MAINTAINER = 'mitsukarenai'; + const NAME = 'Dailymotion Bridge'; + const URI = 'https://www.dailymotion.com/'; + const CACHE_TIMEOUT = 3600; // 1h + const DESCRIPTION = 'Returns the 5 newest videos by username/playlist or search'; + + const PARAMETERS = [ + 'By username' => [ + 'u' => [ + 'name' => 'username', + 'required' => true, + 'exampleValue' => 'moviepilot', + ] + ], + 'By playlist id' => [ + 'p' => [ + 'name' => 'playlist id', + 'required' => true, + 'exampleValue' => 'x6xyc6', + ] + ], + 'From search results' => [ + 's' => [ + 'name' => 'Search keyword', + 'required' => true, + 'exampleValue' => 'matrix', + ], + 'pa' => [ + 'name' => 'Page', + 'type' => 'number', + 'defaultValue' => 1, + ] + ] + ]; + + private $feedName = ''; + + private $apiUrl = 'https://api.dailymotion.com'; + private $apiFields = 'created_time,description,id,owner.screenname,tags,thumbnail_url,title,url'; + + public function getIcon() + { + return 'https://static1-ssl.dmcdn.net/images/neon/favicons/android-icon-36x36.png.vf806ca4ed0deed812'; + } + + public function collectData() + { + if ($this->queriedContext === 'By username' || $this->queriedContext === 'By playlist id') { + $apiJson = getContents($this->getApiUrl()); + + $apiData = json_decode($apiJson, true); + + $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'] = '
', $img->src); + } + + $image = $dom->find('div.main-image-caption-container img', 0); + $image = $image ? '' . $apiItem['description'] . '
'; - $item['categories'] = $apiItem['tags']; - $item['enclosures'][] = $apiItem['thumbnail_url']; - - $this->items[] = $item; - } - } - - if ($this->queriedContext === 'From search results') { - - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request Dailymotion.'); - - foreach($html->find('div.media a.preview_link') as $element) { - $item = array(); - - $item['id'] = str_replace('/video/', '', strtok($element->href, '_')); - $metadata = $this->getMetadata($item['id']); - - if(empty($metadata)) { - continue; - } - - $item['uri'] = $metadata['uri']; - $item['title'] = $metadata['title']; - $item['timestamp'] = $metadata['timestamp']; - - $item['content'] = '
' - . $item['title'] - . ''; - - $this->items[] = $item; - - if (count($this->items) >= 5) { - break; - } - } - } - } - - public function getName() { - switch($this->queriedContext) { - case 'By username': - $specific = $this->getInput('u'); - break; - case 'By playlist id': - $specific = strtok($this->getInput('p'), '_'); - - if ($this->feedName) { - $specific = $this->feedName; - } - - break; - case 'From search results': - $specific = $this->getInput('s'); - break; - default: return parent::getName(); - } - - return $specific . ' : Dailymotion'; - } - - public function getURI(){ - $uri = self::URI; - switch($this->queriedContext) { - case 'By username': - $uri .= 'user/' . urlencode($this->getInput('u')); - break; - case 'By playlist id': - $uri .= 'playlist/' . urlencode(strtok($this->getInput('p'), '_')); - break; - case 'From search results': - $uri .= 'search/' . urlencode($this->getInput('s')); - - if(!is_null($this->getInput('pa'))) { - $pa = $this->getInput('pa'); - - if ($this->getInput('pa') < 1) { - $pa = 1; - } - - $uri .= '/' . $pa; - } - break; - default: return parent::getURI(); - } - return $uri; - } - - private function getMetadata($id) { - $metadata = array(); - - $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) - or returnServerError('Could not request: ' . $url); - - $title = $html->find('meta[property=og:title]', 0)->getAttribute('content'); - return $title; - } - - private function getApiUrl() { - - switch($this->queriedContext) { - case 'By username': - return $this->apiUrl . '/user/' . $this->getInput('u') - . '/videos?fields=' . urlencode($this->apiFields) . '&availability=1&sort=recent&limit=5'; - break; - case 'By playlist id': - return $this->apiUrl . '/playlist/' . $this->getInput('p') - . '/videos?fields=' . urlencode($this->apiFields) . '&limit=5'; - break; - } - } + $item['categories'] = $apiItem['tags']; + $item['enclosures'][] = $apiItem['thumbnail_url']; + + $this->items[] = $item; + } + } + + if ($this->queriedContext === 'From search results') { + $html = getSimpleHTMLDOM($this->getURI()); + + foreach ($html->find('div.media a.preview_link') as $element) { + $item = []; + + $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; + } + } + } + } + + public function getName() + { + switch ($this->queriedContext) { + case 'By username': + $specific = $this->getInput('u'); + break; + case 'By playlist id': + $specific = strtok($this->getInput('p'), '_'); + + if ($this->feedName) { + $specific = $this->feedName; + } + + break; + case 'From search results': + $specific = $this->getInput('s'); + break; + default: + return parent::getName(); + } + + return $specific . ' : Dailymotion'; + } + + public function getURI() + { + $uri = self::URI; + switch ($this->queriedContext) { + case 'By username': + $uri .= 'user/' . urlencode($this->getInput('u')); + break; + case 'By playlist id': + $uri .= 'playlist/' . urlencode(strtok($this->getInput('p'), '_')); + break; + case 'From search results': + $uri .= 'search/' . urlencode($this->getInput('s')); + + if (!is_null($this->getInput('pa'))) { + $pa = $this->getInput('pa'); + + if ($this->getInput('pa') < 1) { + $pa = 1; + } + + $uri .= '/' . $pa; + } + break; + default: + return parent::getURI(); + } + return $uri; + } + + private function 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; + } + + private function getApiUrl() + { + switch ($this->queriedContext) { + case 'By username': + return $this->apiUrl . '/user/' . $this->getInput('u') + . '/videos?fields=' . urlencode($this->apiFields) . '&availability=1&sort=recent&limit=5'; + break; + case 'By playlist id': + return $this->apiUrl . '/playlist/' . $this->getInput('p') + . '/videos?fields=' . urlencode($this->apiFields) . '&limit=5'; + break; + } + } } diff --git a/bridges/DailythanthiBridge.php b/bridges/DailythanthiBridge.php new file mode 100644 index 00000000000..4e891e1c464 --- /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('' . $image->outertext . '
' : ''; + + return $image . $article; + } +} diff --git a/bridges/DanbooruBridge.php b/bridges/DanbooruBridge.php index ea4b2be8047..3ca4476e0ce 100644 --- a/bridges/DanbooruBridge.php +++ b/bridges/DanbooruBridge.php @@ -1,136 +1,73 @@ array( - 'p' => array( - 'name' => 'page', - 'defaultValue' => 1, - 'type' => 'number' - ), - 't' => array( - 'name' => 'tags' - ) - ), - 0 => array() - ); - - const PATHTODATA = 'article'; - const IDATTRIBUTE = 'data-id'; - const TAGATTRIBUTE = 'alt'; - - protected function getFullURI(){ - return $this->getURI() - . 'posts?&page=' . $this->getInput('p') - . '&tags=' . urlencode($this->getInput('t')); - } - - protected function getTags($element){ - return $element->find('img', 0)->getAttribute(static::TAGATTRIBUTE); - } - - protected function getItemFromElement($element){ - // Fix links - defaultLinkTo($element, $this->getURI()); - - $item = array(); - $item['uri'] = html_entity_decode($element->find('a', 0)->href); - $item['postid'] = (int)preg_replace('/[^0-9]/', '', $element->getAttribute(static::IDATTRIBUTE)); - $item['timestamp'] = time(); - $thumbnailUri = $element->find('img', 0)->src; - $item['tags'] = $this->getTags($element); - $item['title'] = $this->getName() . ' | ' . $item['postid']; - $item['content'] = '
Tags: ' - . $item['tags']; - - return $item; - } - - public function collectData(){ - $content = getContents($this->getFullURI()) - or returnServerError('Could not request ' . $this->getName()); - - $html = Fix_Simple_Html_Dom::str_get_html($content); - - foreach($html->find(static::PATHTODATA) as $element) { - $this->items[] = $this->getItemFromElement($element); - } - } -} - -/** - * This class is a monkey patch to 'extend' simplehtmldom to recognize++ '; + + // Titre du don + $item['title'] = '[' . $category . '] ' . $name; + $item['timestamp'] = $date; + $item['author'] = $author; + $item['content'] = $content; + $item['enclosures'] = [$image]; + + $this->items[] = $item; + } + } + + private function getPageURI($page) + { + $uri = $this->getURI(); + $haveQueryParams = strpos($uri, '?') !== false; + + if ($haveQueryParams) { + return $uri . '&page=' . $page; + } else { + return $uri . '?page=' . $page; + } + } + + public function getURI() + { + if (!is_null($this->getInput('q'))) { + return self::URI . $this->getInput('q'); + } + + return parent::getURI(); + } + + public function getName() + { + if (!is_null($this->getInput('q'))) { + return 'Donnons.org - ' . $this->getInput('q'); + } + + return parent::getName(); + } +} diff --git a/bridges/DoujinStyleBridge.php b/bridges/DoujinStyleBridge.php new file mode 100644 index 00000000000..0df96280d10 --- /dev/null +++ b/bridges/DoujinStyleBridge.php @@ -0,0 +1,148 @@ + [], + 'Randomly selected items' => [], + 'From search results' => [ + 'query' => [ + 'name' => 'Search query', + 'required' => true, + 'exampleValue' => 'FELT', + ], + 'flac' => [ + 'name' => 'Include FLAC', + 'type' => 'checkbox', + 'defaultValue' => false, + ], + 'mp3' => [ + 'name' => 'Include MP3', + 'type' => 'checkbox', + 'defaultValue' => false, + ], + 'tta' => [ + 'name' => 'Include TTA', + 'type' => 'checkbox', + 'defaultValue' => false, + ], + 'opus' => [ + 'name' => 'Include Opus', + 'type' => 'checkbox', + 'defaultValue' => false, + ], + 'ogg' => [ + 'name' => 'Include OGG', + 'type' => 'checkbox', + 'defaultValue' => false, + ] + ] + ]; + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + $html = defaultLinkTo($html, $this->getURI()); + + $submissions = $html->find('.gridBox .gridDetails'); + foreach ($submissions as $submission) { + $item = []; + + $item['uri'] = $submission->find('a', 0)->href; + + $content = getSimpleHTMLDOM($item['uri']); + $content = defaultLinkTo($content, $this->getURI()); + + $title = $content->find('h2', 0)->plaintext; + + $cover = $content->find('#imgClick a', 0); + if (is_null($cover)) { + $cover = $content->find('.coverWrap', 0)->src; + } else { + $cover = $cover->href; + } + + $item['content'] = ""; + + $keys = []; + foreach ($content->find('.pageWrap .pageSpan1') as $key) { + $keys[] = $key->plaintext; + } + + $values = $content->find('.pageWrap .pageSpan2'); + $metadata = array_combine($keys, $values); + + $format = 'Unknown'; + + foreach ($metadata as $key => $value) { + switch ($key) { + case 'Artist': + $artist = $value->find('a', 0)->plaintext; + $item['title'] = "$artist - $title"; + $item['content'] .= "' . $name . '
+' . $description . '
+Lieu : ' . $city . ' - ' . $region . '
+Par : ' . $author . '
+Date : ' . $date . '
+
Artist: $artist"; + break; + case 'Tags:': + $item['categories'] = []; + foreach ($value->find('a') as $tag) { + $tag = str_replace('-', '-', $tag->plaintext); + $item['categories'][] = $tag; + } + + $item['content'] .= '
Tags: ' . join(', ', $item['categories']); + break; + case 'Format:': + $item['content'] .= "
Format: $value->plaintext"; + break; + case 'Date Added:': + $item['timestamp'] = $value->plaintext; + break; + case 'Provided By:': + $item['author'] = $value->find('a', 0)->plaintext; + break; + } + } + + $this->items[] = $item; + } + } + + public function getURI() + { + $url = self::URI; + + switch ($this->queriedContext) { + case 'From search results': + $url .= '?p=search&type=blanket'; + $url .= '&result=' . $this->getInput('query'); + + if ($this->getInput('flac') == 1) { + $url .= '&format0=on'; + } + if ($this->getInput('mp3') == 1) { + $url .= '&format1=on'; + } + if ($this->getInput('tta') == 1) { + $url .= '&format2=on'; + } + if ($this->getInput('opus') == 1) { + $url .= '&format3=on'; + } + if ($this->getInput('ogg') == 1) { + $url .= '&format4=on'; + } + break; + case 'Randomly selected items': + $url .= '?p=random'; + break; + } + + return $url; + } +} diff --git a/bridges/DownDetectorBridge.php b/bridges/DownDetectorBridge.php deleted file mode 100644 index 4aef3728a86..00000000000 --- a/bridges/DownDetectorBridge.php +++ /dev/null @@ -1,6195 +0,0 @@ - array( - 'country' => array( - 'type' => 'list', - 'name' => 'Country', - 'values' => array( - 'Argentina' => 'https://downdetector.com.ar', - 'Australia' => 'https://downdetector.com.au', - 'België' => 'https://allestoringen.be', - 'Brasil' => 'https://downdetector.com.br', - 'Canada' => 'https://downdetector.ca', - 'Chile' => 'https://downdetector.cl', - 'Colombia' => 'https://downdetector.com.co', - 'Danmark' => 'https://downdetector.dk', - 'Deutschland' => 'https://allestörungen.de', - 'Ecuador' => 'https://downdetector.ec', - 'España' => 'https://downdetector.es', - 'France' => 'https://downdetector.fr', - 'Hong Kong' => 'https://downdetector.hk', - 'Hrvatska' => 'https://downdetector.hr', - 'India' => 'https://downdetector.in', - 'Indonesia' => 'https://downdetector.id', - 'Ireland' => 'https://downdetector.ie', - 'Italia' => 'https://downdetector.it', - 'Magyarország' => 'https://downdetector.hu', - 'Malaysia' => 'https://downdetector.my', - 'México' => 'https://downdetector.mx', - 'Nederland' => 'https://allestoringen.nl', - 'New Zealand' => 'https://downdetector.co.nz', - 'Norge' => 'https://downdetector.no', - 'Pakistan' => 'https://downdetector.pk', - 'Perú' => 'https://downdetector.pe', - 'Pilipinas' => 'https://downdetector.ph', - 'Polska' => 'https://downdetector.pl', - 'Portugal' => 'https://downdetector.pt', - 'România' => 'https://downdetector.ro', - 'Schweiz' => 'https://allestörungen.ch', - 'Singapore' => 'https://downdetector.sg', - 'Slovensko' => 'https://downdetector.sk', - 'South Africa' => 'https://downdetector.co.za', - 'Suomi' => 'https://downdetector.fi', - 'Sverige' => 'https://downdetector.se', - 'Türkiye' => 'https://downdetector.web.tr', - 'UAE' => 'https://downdetector.ae', - 'UK' => 'https://downdetector.co.uk', - 'United States' => 'https://downdetector.com', - 'Österreich' => 'https://allestörungen.at', - 'Česko' => 'https://downdetector.cz', - 'Ελλάς' => 'https://downdetector.gr', - 'Россия' => 'https://downdetector.ru', - '日本' => 'https://downdetector.jp' - ) - ) - ), - 'Specific Website' => array( - 'website' => array( - 'type' => 'list', - 'name' => 'Website', - 'values' => array( - 'Österreich' => array( - '1&1' => 35086, - '3 (Drei)' => 33546, - 'A1' => 33543, - 'Alexa' => 36919, - 'Amazon' => 33506, - 'Amazon Prime Video' => 35085, - 'Amino Apps' => 39034, - 'Anthem' => 38200, - 'Apex Legends' => 38117, - 'App Store' => 35584, - 'Bank Austria' => 34715, - 'Battlefield' => 38051, - 'BAWAG' => 34716, - 'Binance' => 36938, - 'Blizzard Battle.net' => 35087, - 'Bob' => 34953, - 'Boom Beach' => 34781, - 'Bwin' => 35071, - 'Call of Duty' => 34156, - 'Car2Go' => 34554, - 'Clash of Clans' => 35088, - 'Clash Royale' => 38357, - 'Coinbase' => 36804, - 'Counter-strike' => 35055, - 'Crunchyroll' => 38092, - 'Dazn' => 36508, - 'Dead By Daylight' => 37414, - 'Deezer' => 33832, - 'Destiny' => 34954, - 'DHL' => 36747, - 'Discord' => 36768, - 'Dota 2' => 35398, - 'Dropbox' => 33509, - 'EA' => 34502, - 'Easybank' => 36992, - 'eBay' => 33510, - 'Emerion' => 34613, - 'Epic Games Store' => 39021, - 'Erste Bank und Sparkasse' => 36724, - 'Facebook' => 33511, - 'Facebook Messenger' => 33512, - 'Fifa' => 37605, - 'Flickr' => 33513, - 'For Honor' => 35996, - 'Fortnite' => 36689, - 'Ghost Recon' => 36009, - 'Gmail' => 33514, - 'GMX' => 33515, - 'Google' => 33516, - 'Google Hangouts' => 33517, - 'Google Play' => 33518, - 'GTA 5' => 35082, - 'Guild Wars 2' => 36473, - 'Handy Parken' => 34316, - 'Hay Day' => 34854, - 'Hello Bank' => 37010, - 'HoT' => 38751, - 'iCloud' => 35501, - 'ICQ' => 33520, - 'ING DiBa' => 35129, - 'Instagram' => 33522, - 'iTunes' => 33523, - 'Kabelplus' => 34473, - 'Kik' => 33524, - 'Kraken' => 36770, - 'League of Legends' => 34350, - 'LinkedIn' => 33525, - 'Liwest' => 34471, - 'Lovoo' => 35079, - 'Magenta' => 38440, - 'Maxdome' => 35084, - 'Minecraft' => 36432, - 'Mittwald' => 36987, - 'N26' => 38834, - 'Netatmo' => 37956, - 'Netflix' => 34631, - 'Nintendo Network' => 35523, - 'Nitrado' => 35548, - 'NordVPN' => 38587, - 'Office 365' => 35120, - 'OneDrive' => 35412, - 'ORF' => 35600, - 'Origin' => 36889, - 'Outlook' => 35083, - 'Overwatch' => 36153, - 'Path of Exile' => 37730, - 'Paypal' => 35399, - 'Playerunknown\'s Battlegrounds' => 36488, - 'Playstation Network' => 33526, - 'Pokémon Go' => 35745, - 'Quizduell' => 34528, - 'Raiffeisen Bank' => 38750, - 'Rainbow Six' => 35563, - 'Red Dead Redemption' => 37739, - 'Reddit' => 36827, - 'Rocket League' => 35485, - 'roNET' => 37041, - 'Salzburg AG Cablelink' => 35601, - 'Shpock' => 38681, - 'Sky' => 35081, - 'Sky Ticket' => 35142, - 'Skype' => 33527, - 'Smart Hub' => 35400, - 'Snapchat' => 33528, - 'Spotify' => 33529, - 'Spusu' => 35598, - 'Steam' => 34117, - 'Teamviewer' => 35686, - 'Tele2' => 34339, - 'Telegram' => 34903, - 'Telering' => 34952, - 'The Division' => 35599, - 'The elder scrolls online' => 37160, - 'The Simpsons Tapped Out' => 37283, - 'Threema' => 34255, - 'Tinder' => 34243, - 'Tipico' => 36515, - 'Tumblr' => 33530, - 'Twitch' => 35024, - 'Twitter' => 33531, - 'Uplay PC' => 34689, - 'Viber' => 33532, - 'Viewster' => 34326, - 'Vimeo' => 33533, - 'Volksbank' => 34717, - 'Warface' => 37524, - 'Warframe' => 37136, - 'Waze' => 33534, - 'Whatsapp' => 33535, - 'Wikipedia' => 33536, - 'Willhaben.at' => 35859, - 'World of Tanks' => 36674, - 'World of Warcraft' => 36998, - 'World of Warships' => 38009, - 'Xbox Live' => 33538, - 'Yahoo Mail' => 33539, - 'Yahoo Messenger' => 33540, - 'Yesss' => 35346, - 'Youtube' => 33541, - 'Z1 Battle Royale' => 35489, - ), - 'Deutschland' => array( - '1&1' => 32554, - '1blu' => 37319, - '2k' => 37731, - '3CX' => 36439, - '3sat' => 35797, - 'Afterbuy' => 37015, - 'Airbnb' => 35422, - 'Albion Online' => 38799, - 'Aldi Talk' => 32579, - 'Alexa' => 35912, - 'All-inkl' => 35162, - 'Amazon' => 32572, - 'Amazon Prime Music' => 37584, - 'Amazon Prime Video' => 34495, - 'Amazon Web Services' => 36326, - 'Amino Apps' => 39033, - 'Amplus' => 35446, - 'Anthem' => 38055, - 'Anydesk' => 37487, - 'AOL' => 34377, - 'Apex Legends' => 38112, - 'App Store' => 35579, - 'Apple Music' => 35246, - 'Apple Store' => 34448, - 'Arche NetVision' => 35593, - 'ArcheAge' => 35062, - 'Arcor' => 34374, - 'ARK: Survival Evolved' => 39065, - 'Arma 3' => 35063, - 'Asana' => 38024, - 'Assassin\'s Creed' => 35010, - 'Badoo' => 35011, - 'Base' => 32568, - 'Battlefield' => 36092, - 'Baur' => 35043, - 'Bet3000' => 35035, - 'Bet365' => 35034, - 'Bethesda' => 38317, - 'BILDmobil' => 32609, - 'Binance' => 36942, - 'Bing' => 34708, - 'Bitfinex' => 36833, - 'Bitstamp' => 36808, - 'Blackberry' => 3, - 'Blade &Soul' => 35512, - 'Blau' => 37655, - 'Blau' => 34108, - 'Blizzard Battle.net' => 34483, - 'Blogger' => 32606, - 'BMW ConnectedDrive' => 38034, - 'Boom Beach' => 34365, - 'Bornet' => 34944, - 'Bwin' => 34491, - 'Call of Duty' => 34155, - 'Candy Crush' => 35148, - 'Candy Crush Soda Saga' => 35174, - 'Car-Net' => 37031, - 'Car2Go' => 34555, - 'Centurylink' => 35624, - 'Checkdomain' => 36096, - 'Clash of Clans' => 34289, - 'Clash Royale' => 35588, - 'Cloudflare' => 34881, - 'Coinbase' => 36798, - 'Colt' => 37653, - 'Comdirect' => 34774, - 'Comedy Central' => 34122, - 'Commerzbank' => 32607, - 'Comunio' => 34719, - 'Congstar' => 32590, - 'Consors Bank' => 35545, - 'CosmosDirect' => 35042, - 'Counter-strike' => 34887, - 'Crunchyroll' => 36718, - 'DABbank' => 35921, - 'Das Erste' => 34386, - 'Dazn' => 35956, - 'Dead By Daylight' => 35951, - 'Deezer' => 33831, - 'DeGiro' => 38203, - 'Deliveroo' => 37546, - 'Destiny' => 34895, - 'Deutsche Bahn' => 32569, - 'Deutsche Bank' => 32611, - 'Deutsche Glasfaser' => 36087, - 'Deutsche Telefon' => 35851, - 'DeutschlandSIM' => 34635, - 'DFP' => 35004, - 'DHL' => 34896, - 'Discord' => 35796, - 'DKB' => 32767, - 'DNSNET' => 35670, - 'DomainFactory' => 37381, - 'Dota 2' => 34900, - 'dpd' => 36120, - 'Dragon Ball' => 36380, - 'Drillisch' => 36019, - 'Driveclub' => 34928, - 'Dropbox' => 32585, - 'E-Plus' => 10121, - 'EA' => 34497, - 'Easybell' => 34370, - 'eBay' => 32567, - 'Ecotel' => 36794, - 'Elite: Dangerous' => 37077, - 'Emailn' => 34687, - 'Emerion' => 34614, - 'Entega' => 37039, - 'Epic Games Store' => 38827, - 'Escape from Tarkov' => 37977, - 'Etoro' => 36965, - 'Eurosport Player' => 36559, - 'Eventim' => 37621, - 'Evernote ' => 36506, - 'Ewe TEL' => 33559, - 'Facebook' => 32552, - 'Facebook Messenger' => 32560, - 'Faceit' => 37147, - 'Facetime' => 34603, - 'Fallout' => 35433, - 'Farm Heroes Saga' => 35175, - 'Fidor Bank' => 35894, - 'Fifa' => 35469, - 'Finya' => 37060, - 'Fitbit' => 37972, - 'Fl!nk' => 35170, - 'Flickr' => 32604, - 'Fonic' => 32594, - 'For Honor' => 35985, - 'Fortnite' => 36626, - 'Forza' => 37568, - 'Freenet' => 34354, - 'Friday the 13th The Game' => 37561, - 'Fyve' => 34378, - 'G-Portal' => 37155, - 'Game of war' => 35230, - 'Gameduell' => 37566, - 'Gardena Smart' => 37350, - 'Garmin' => 37049, - 'Gears of War' => 35940, - 'Gems of war' => 38802, - 'Geocaching' => 37178, - 'Ghost Recon' => 36001, - 'Giropay' => 38204, - 'GitHub' => 35348, - 'GLS' => 36119, - 'Gmail' => 32584, - 'GMX' => 32563, - 'Go Daddy' => 34874, - 'Goneo' => 37576, - 'Google' => 32553, - 'Google Drive' => 36603, - 'Google Hangouts' => 32600, - 'Google Kalender' => 38603, - 'Google Play' => 32593, - 'Gran Turismo' => 36917, - 'Grindr' => 35532, - 'GTA 5' => 34754, - 'Guild Wars 2' => 35061, - 'Halo' => 35419, - 'Halo Wars' => 36034, - 'Harry Potter: Wizards Unite' => 38686, - 'Hay Day' => 34366, - 'Helinet' => 34959, - 'Hermes' => 35196, - 'Hetzner ' => 35943, - 'Hipchat' => 34869, - 'Hitbox.TV' => 35932, - 'Hitman' => 35586, - 'Homematic' => 36795, - 'Hosteurope' => 37958, - 'Htp' => 34400, - 'Hue' => 37810, - 'Hunt: Showdown' => 38787, - 'HypoVereinsbank' => 34965, - 'iCloud' => 32549, - 'ICQ' => 33123, - 'Idealo' => 37156, - 'iMessage' => 32671, - 'Inexio' => 36371, - 'ING DiBa' => 35156, - 'Ingress' => 35765, - 'Innogy Highspeed' => 37459, - 'Instagram' => 32599, - 'Intercity Express (ICE)' => 32581, - 'iTunes' => 32672, - 'Jappy' => 32596, - 'Jira' => 36066, - 'Jobst DSL' => 36727, - 'Jodel' => 36793, - 'Joyn' => 38604, - 'Jurassic World Alive' => 37310, - 'K-Classic Mobil' => 33986, - 'Kabel eins' => 34392, - 'Kickbase' => 37451, - 'Kicker' => 37450, - 'Kicktipp' => 35824, - 'Kik' => 33353, - 'Klarmobil' => 32605, - 'KMS' => 34399, - 'Knuddels' => 35865, - 'Kraken' => 36749, - 'Ladbrokes' => 35161, - 'LastPass' => 34780, - 'League of Legends' => 34112, - 'Lebara' => 35791, - 'Line' => 34300, - 'LinkedIn' => 32557, - 'Lotto24' => 35040, - 'Lottohelden' => 36882, - 'Lovoo' => 34814, - 'Lufthansa' => 32574, - 'Lycamobile' => 34939, - 'M-net' => 32571, - 'Madden' => 38906, - 'Mail.de' => 34909, - 'Mailbox' => 34832, - 'Markt.de' => 37465, - 'Maxdome' => 34749, - 'MDCC' => 34760, - 'MDDSL' => 37056, - 'Mercedes Me' => 37153, - 'MiCoach' => 34936, - 'Microsoft Azure' => 36093, - 'Microsoft Teams' => 38185, - 'Minecraft' => 32556, - 'Mittwald' => 33412, - 'Mixer' => 38770, - 'Mobilcom Debitel' => 32610, - 'MTV' => 34120, - 'My Fitness Pal' => 37637, - 'Mybet' => 35036, - 'N26' => 35719, - 'NBA 2k' => 38595, - 'Need for Speed' => 35429, - 'Netatmo' => 37452, - 'Netbeat' => 35906, - 'NetCologne' => 32587, - 'netcombw' => 35638, - 'netcup' => 35632, - 'Netflix' => 34630, - 'Netkom' => 37201, - 'Neverwinter' => 35768, - 'Nfon' => 35792, - 'Nintendo Network' => 35519, - 'Nintendo Switch Online' => 37244, - 'Nitrado' => 34941, - 'No Man\'s Sky' => 35794, - 'NordVPN' => 38584, - 'Norisbank' => 34964, - 'Nvidia' => 37462, - 'Nvidia' => 39069, - 'O2' => 10122, - 'Office 365' => 34730, - 'OkCupid' => 37330, - 'OLB' => 37057, - 'OneDrive' => 35408, - 'Onleihe' => 35635, - 'Origin' => 34371, - 'Osnatel' => 33873, - 'Otelo' => 35480, - 'Otto' => 32598, - 'Outlook' => 32546, - 'Overwatch' => 35684, - 'Paladins' => 35925, - 'Path of Exile' => 36479, - 'Payback' => 37654, - 'Paypal' => 34375, - 'pcvisit' => 37643, - 'PES' => 37952, - 'Pet Rescue Saga' => 35176, - 'Pinterest' => 37585, - 'Placetel' => 35128, - 'Playerunknown\'s Battlegrounds' => 36332, - 'Playstation Network' => 32551, - 'Pokémon Duel' => 35976, - 'Pokémon Go' => 35724, - 'Pokerstars' => 37439, - 'Postbank' => 32589, - 'Posteo' => 34309, - 'Primacom' => 33502, - 'ProSieben' => 34390, - 'PŸUR' => 32592, - 'QSC' => 33560, - 'Quizduell' => 34292, - 'Rainbow Six' => 35479, - 'Razer' => 38699, - 'Realm Royale' => 37288, - 'Red Dead Redemption' => 37601, - 'Reddit' => 35882, - 'RFT Kabel' => 35831, - 'Roblox' => 35815, - 'Rocket League' => 35252, - 'Royal Games' => 38217, - 'RTL II' => 34393, - 'RTL Television' => 34387, - 'Runescape' => 35108, - 'RWE' => 36116, - 'RWW' => 37955, - 'Ryanair' => 37518, - 'Salesforce' => 34733, - 'Santander Consumer Bank' => 32603, - 'Sat.1' => 34389, - 'Save.TV' => 37193, - 'Sea of Thieves' => 37112, - 'Shpock' => 37311, - 'Signal' => 37072, - 'Simply' => 36376, - 'Simquadrat' => 34945, - 'Simsme' => 34795, - 'Simyo' => 32591, - 'Sipgate' => 34127, - 'SKL' => 35041, - 'Sky' => 32562, - 'Sky Ticket' => 35141, - 'Skype' => 32561, - 'Skype for Business' => 35351, - 'Slack' => 35936, - 'Smart Hub' => 35181, - 'Smite' => 34803, - 'Snapchat' => 33377, - 'Soundcloud' => 34353, - 'Sparda' => 35370, - 'Sparkasse' => 32757, - 'Speedtest' => 38780, - 'Spiegel' => 36050, - 'Spotify' => 32564, - 'Star Citizen' => 39035, - 'Star Wars Battlefront' => 35460, - 'Steam' => 32559, - 'Strato' => 33413, - 'Strava' => 38731, - 'Streetspotr' => 34920, - 'Summoners War' => 37420, - 'SWB' => 34515, - 'Tado' => 36634, - 'Tango' => 33731, - 'Targobank' => 34966, - 'Teamspeak' => 33346, - 'Teamviewer' => 34344, - 'Tele2' => 33503, - 'Tele5' => 35727, - 'Telegram' => 34229, - 'Telekom' => 10117, - 'Tellonym' => 37248, - 'The Crew 2' => 37252, - 'The Division' => 35570, - 'The elder scrolls online' => 34480, - 'The Simpsons Tapped Out' => 34701, - 'Threema' => 34253, - 'Tiktok' => 39044, - 'Tinder' => 34242, - 'Tipico' => 34714, - 'TNG' => 38772, - 'Todoist' => 36462, - 'TomTom' => 35813, - 'Tumblr' => 32588, - 'TuneIn' => 38763, - 'TV Now' => 35143, - 'Tweakbox' => 38349, - 'Twitch' => 34376, - 'Twitter' => 32583, - 'Udemy' => 38388, - 'Unitymedia' => 32548, - 'Uplay PC' => 34380, - 'Usenext' => 36117, - 'Vero' => 37064, - 'Versatel' => 33561, - 'Viber' => 33372, - 'Viewster' => 34324, - 'Vimeo' => 32582, - 'Visa' => 37238, - 'VIVA' => 34121, - 'Vodafone' => 10120, - 'Volksbanken und Raiffeisenbanken' => 32758, - 'VOX' => 34391, - 'Warface' => 35944, - 'Warframe' => 36366, - 'Watchbox' => 36537, - 'Wattpad' => 34700, - 'Waze' => 33411, - 'Web.de' => 32586, - 'WeChat' => 34301, - 'Weebly' => 34914, - 'Weight Watchers' => 35662, - 'WeTransfer' => 36433, - 'Wetter.com' => 36051, - 'WetterOnline' => 34810, - 'Whatsapp' => 32555, - 'Wikipedia' => 32565, - 'Wilhelm.tel' => 34398, - 'Wish' => 38153, - 'WiSoTEL' => 37543, - 'Wobcom' => 35991, - 'Wordpress' => 32570, - 'World of Tanks' => 35524, - 'World of Warcraft' => 34373, - 'World of Warships' => 36638, - 'Wüstenrot' => 36977, - 'Xbox Live' => 32573, - 'Xing' => 34822, - 'Yahoo Mail' => 32597, - 'Yahoo Messenger' => 32580, - 'Yourfone' => 33415, - 'Youtube' => 32578, - 'Youtube Music' => 37586, - 'Z1 Battle Royale' => 35147, - 'Zattoo' => 35367, - 'ZDF' => 34388, - 'Zynga' => 32608, - ), - 'Nederland' => array( - '112' => 10011, - '3FM' => 10174, - '9292.nl' => 33376, - 'ABN-Amro' => 29, - 'ABP pensioenfonds' => 35862, - 'Adobe Creative Cloud' => 34916, - 'ADP' => 32669, - 'Adyen' => 37610, - 'Aegon Bank' => 10132, - 'Afas' => 10093, - 'Afterpay' => 34769, - 'Airbnb' => 35423, - 'Airmiles' => 37516, - 'Albert Heijn' => 34010, - 'Alex' => 10056, - 'Algemeen Dagblad' => 1, - 'AliExpress' => 37384, - 'Amazon' => 37379, - 'Amazon Prime Video' => 36976, - 'AMSIX' => 10008, - 'Antagonist' => 35160, - 'Anthem' => 38054, - 'Apex Legends' => 38118, - 'App Store' => 35580, - 'Apple Music' => 35245, - 'Apple Store' => 34446, - 'Argenta' => 10158, - 'Argeweb' => 35073, - 'Arriva' => 2, - 'ASN Bank' => 10048, - 'Assassin\'s Creed' => 35009, - 'Badoo' => 37650, - 'Battlefield' => 37094, - 'Belastingdienst' => 10002, - 'Ben' => 10053, - 'Bibliotheek' => 34132, - 'Binance' => 36879, - 'Binck' => 10055, - 'Bing' => 34707, - 'BlaBlaCar' => 36088, - 'Bliep' => 33998, - 'Blizzard Battle.net' => 34485, - 'Bol.com' => 10112, - 'Booking.com' => 37574, - 'Boom Beach' => 34363, - 'Brabant Water' => 10071, - 'Brawl Stars' => 38818, - 'Budgetphone' => 35132, - 'Buienalarm' => 37245, - 'Buienradar' => 33565, - 'Bunq' => 35820, - 'Byte' => 32764, - 'Caiway' => 4, - 'CAK' => 37289, - 'Call of Duty' => 34154, - 'CanalDigitaal' => 31, - 'Candy Crush' => 34025, - 'Cbizz' => 37659, - 'Centraal Beheer Achmea' => 10157, - 'Centraal Bureau voor de Statistiek' => 10168, - 'CheapConnect' => 36122, - 'Chelloo' => 35546, - 'Choozze' => 35096, - 'Clash of Clans' => 34290, - 'Clash Royale' => 37522, - 'Cloudflare' => 38623, - 'Cloudhosting.nl' => 33486, - 'Coinbase' => 36236, - 'Comedy Central' => 34033, - 'Concepts' => 10043, - 'Connexxion' => 5, - 'Coolblue' => 35543, - 'Counter-strike' => 36644, - 'Credit Europe Bank' => 10194, - 'Crunchyroll' => 36722, - 'Dead By Daylight' => 37412, - 'Deezer' => 33769, - 'DeGiro' => 34019, - 'Delight Mobile' => 33987, - 'Delta' => 10123, - 'Destiny' => 34891, - 'Deutsche Bank' => 10119, - 'DFP' => 35003, - 'DHL' => 35223, - 'DigiD' => 10001, - 'Digipoort' => 34520, - 'Discord' => 36043, - 'Disney+' => 38319, - 'Dota 2' => 36397, - 'Dpd' => 35619, - 'Dropbox' => 10192, - 'Dumpert' => 33375, - 'Dunea' => 10074, - 'EA' => 34501, - 'Easynet' => 33997, - 'eBay' => 33857, - 'Elite: Dangerous' => 37777, - 'Enduris' => 35999, - 'Eneco' => 10124, - 'Energielabel voor Woningen' => 35117, - 'Enexis' => 10014, - 'Ennatuurlijk' => 35159, - 'Epic Games Store' => 39020, - 'Escape from Tarkov' => 39059, - 'Esprit Telecom' => 10150, - 'Essent' => 10114, - 'Etoro' => 36958, - 'Eurosport Player' => 35821, - 'Eventim' => 37622, - 'Evides' => 10072, - 'Eweka' => 10039, - 'Exact Online' => 34815, - 'Exchange Online' => 34728, - 'Facebook' => 6, - 'Facebook Messenger' => 10185, - 'Facetime' => 34607, - 'Fallout' => 35434, - 'FBTO' => 34015, - 'Feedly' => 34712, - 'Fiber' => 10103, - 'Fifa' => 35474, - 'Flickr' => 37948, - 'Flitsmeister' => 34369, - 'For Honor' => 35987, - 'Fortnite' => 36625, - 'Fox Sports' => 10096, - 'Friday the 13th The Game' => 37560, - 'Funda' => 32576, - 'Game of war' => 35231, - 'Garmin' => 38165, - 'Garmin Connect' => 38171, - 'Gatehub' => 36957, - 'Ghost Recon' => 36010, - 'GitHub' => 35347, - 'Glashart Media' => 10105, - 'GLS' => 36118, - 'Gmail' => 10041, - 'Google' => 10010, - 'Google Agenda' => 38602, - 'Google Drive' => 34276, - 'Google Hangouts' => 10062, - 'Google Play' => 10038, - 'Gran Turismo' => 36873, - 'Greenchoice' => 37959, - 'Greenwheels' => 34007, - 'Grindr' => 35531, - 'GTA 5' => 34753, - 'Guild Wars 2' => 36464, - 'GVB' => 7, - 'Halo' => 35420, - 'Happn' => 37014, - 'Harry Potter: Wizards Unite' => 38689, - 'Hay Day' => 34368, - 'Hearthstone' => 38564, - 'Hollandsnieuwe' => 10040, - 'Hosted.nl' => 33483, - 'Hostnet' => 32761, - 'HTM' => 8, - 'Hue' => 37812, - 'iCloud' => 30, - 'iDeal' => 10025, - 'IEX' => 35782, - 'IG' => 34307, - 'iMessage' => 10130, - 'Indeed' => 35941, - 'Infopact' => 37565, - 'ING' => 9, - 'Instagram' => 32558, - 'International Card Services (ICS)' => 10169, - 'InterNLnet' => 34252, - 'Interpolis' => 34017, - 'ITDev Solutions' => 35533, - 'iTunes' => 10151, - 'Jira' => 36067, - 'Jonaz' => 37348, - 'Jumbo' => 36184, - 'Jurassic World Alive' => 37334, - 'JustEat' => 10164, - 'Kabel Noord' => 10113, - 'Kabeltex' => 10138, - 'Kadaster' => 37652, - 'Kamer van Koophandel' => 10167, - 'KickXL' => 10098, - 'Kik' => 10191, - 'Kliksafe' => 10109, - 'KLM' => 10028, - 'Knab' => 10131, - 'KNMI' => 34013, - 'KPN' => 11, - 'Kraken' => 36748, - 'Kruidvat' => 37312, - 'LastPass' => 34779, - 'League of Legends' => 35169, - 'LeasePlan Bank' => 10160, - 'Leaseweb' => 32762, - 'Lebara' => 10128, - 'Lexa.nl' => 35838, - 'Liander' => 10013, - 'Lijbrandt' => 10045, - 'LinkedIn' => 10201, - 'Litebit' => 36836, - 'Lloyds Bank' => 10154, - 'LOI' => 34947, - 'Lotto' => 34923, - 'Lycamobile' => 10129, - 'Lynx' => 10085, - 'Magister' => 35274, - 'Marktplaats' => 10170, - 'Microsoft Azure' => 36114, - 'Microsoft Teams' => 38186, - 'Mijnbroker' => 10088, - 'Mijndomein' => 34348, - 'MijnOverheid' => 10015, - 'Minecraft' => 36173, - 'Mobicross' => 34898, - 'Moneyou' => 10155, - 'Motto' => 35482, - 'MTV' => 34031, - 'Multisafepay' => 34877, - 'My Fitness Pal' => 37636, - 'MyOrder' => 34291, - 'Nationale Nederlanden' => 10166, - 'Nest' => 35189, - 'Net 5' => 10181, - 'Netatmo' => 37453, - 'Netflix' => 33849, - 'Neverwinter' => 36676, - 'NIBC Direct' => 10159, - 'Nintendo eShop' => 34105, - 'Nintendo Network' => 35520, - 'Nintendo Switch Online' => 37242, - 'Nitrado' => 35549, - 'NLE' => 37651, - 'NLziet' => 34219, - 'NordVPN' => 38585, - 'NOS.nl' => 10063, - 'NPO 1' => 10179, - 'NPO 2' => 10180, - 'NPO 3' => 10182, - 'NPO Start' => 10064, - 'NS' => 21, - 'Nu.nl' => 12, - 'Nuon' => 10084, - 'Oasen' => 10078, - 'Office 365' => 35119, - 'OHRA' => 34819, - 'ON' => 10097, - 'OneDrive' => 32779, - 'Online.nl' => 10193, - 'OnlineWerkplekken.nl' => 33485, - 'Onsbrabantnet' => 10044, - 'Origin' => 34372, - 'Outlook' => 10042, - 'OV-chipkaart' => 10145, - 'Overwatch' => 35723, - 'Paladins' => 37407, - 'Park Mobile' => 10139, - 'Park-line' => 10137, - 'Path of Exile' => 37729, - 'Pathé Thuis' => 33848, - 'Paypal' => 10021, - 'Paysafecard' => 10152, - 'PCextreme' => 32763, - 'Picnic' => 37503, - 'PIN' => 10009, - 'Player Unknown\'s Battlegrounds' => 36336, - 'Playstation Network' => 10006, - 'Plinq' => 10108, - 'Pokémon Go' => 35731, - 'Pokerstars' => 37438, - 'PostNL' => 32766, - 'PWN Waterleidingbedrijf Noord-Holland' => 10073, - 'Q-Park' => 10111, - 'Quizduel' => 34293, - 'Qurrent' => 35729, - 'Rabobank' => 13, - 'Radio 1' => 10175, - 'Radio 2' => 10176, - 'Raet' => 32670, - 'Rainbow Six' => 35564, - 'Realm Royale' => 37315, - 'Red Dead Redemption' => 38286, - 'Reddit' => 36822, - 'Redworks' => 34773, - 'Reggefiber' => 10104, - 'RegioBank' => 10050, - 'Rekam' => 10195, - 'Rendo Netwerken' => 10126, - 'RET' => 14, - 'Reviced' => 36061, - 'Robeco' => 10153, - 'Robin Mobile' => 35695, - 'Roblox' => 35814, - 'Rocket League' => 35254, - 'RoutIT' => 34349, - 'RTL 5' => 10183, - 'RTL 8' => 10184, - 'RTL XL' => 10066, - 'RTL4' => 10178, - 'Runescape' => 35701, - 'Ruzzle' => 33968, - 'Ryanair' => 37517, - 'Salesforce' => 10016, - 'SBS6' => 10177, - 'Schiphol' => 10027, - 'Scorito' => 34726, - 'Sea of Thieves' => 37113, - 'SEOshop' => 35592, - 'Sharepoint Online' => 34732, - 'Signal' => 37073, - 'Signet' => 34407, - 'Simpel' => 10037, - 'Simyo' => 10054, - 'Skype' => 10024, - 'Skype for Business' => 34729, - 'Slack' => 37336, - 'Smart Hub' => 35180, - 'Snapchat' => 10187, - 'Snappet' => 37613, - 'SNS' => 10019, - 'Sociale Verzekeringsbank (SVB)' => 10186, - 'Sofort Banking' => 10149, - 'Solcon' => 10099, - 'Soundcloud' => 34352, - 'Sparql' => 34519, - 'Speurders' => 34805, - 'Spotify' => 10003, - 'Staatsloterij' => 10094, - 'Star Wars Battlefront' => 36918, - 'Steam' => 34106, - 'Stedin' => 10012, - 'Steep' => 37823, - 'Stemwijzer' => 35980, - 'Stichting Kabeltelevisie Pijnacker' => 34143, - 'Stipte' => 10102, - 'Strato' => 35045, - 'Strava' => 36046, - 'Studiemeter' => 37075, - 'Studystore' => 36600, - 'Surfnet' => 34396, - 'T-Mobile' => 19, - 'Tado' => 36633, - 'Tango' => 33740, - 'Teamviewer' => 34342, - 'Tele2' => 16, - 'Telegraaf' => 17, - 'Telegram' => 34221, - 'Telfort' => 18, - 'The Division' => 35569, - 'The elder scrolls online' => 37329, - 'Threema' => 34254, - 'Thuisbezorgd.nl' => 10163, - 'Ticketmaster' => 10133, - 'Tickney ' => 35923, - 'Tikkie' => 36737, - 'Tiktok' => 39074, - 'Tinder' => 34238, - 'Today\'s' => 10087, - 'TomTom Live' => 34917, - 'Toto' => 35590, - 'TradersOnly' => 10090, - 'Transavia' => 35168, - 'TransIP' => 32771, - 'Trined' => 10107, - 'Triodos Bank' => 10051, - 'True' => 36576, - 'Tumblr' => 36714, - 'TuneIn' => 38762, - 'Tweak' => 10100, - 'Tweakbox' => 38348, - 'Tweakers' => 37376, - 'Twinfield' => 36725, - 'Twitch' => 35025, - 'Twitter' => 22, - 'Unet' => 34408, - 'Uplay PC' => 34379, - 'UWV en Werk.nl' => 10017, - 'Van Lanschot Bankiers' => 10052, - 'VDX' => 35780, - 'Vectone' => 35237, - 'Veolia' => 32612, - 'Veronica' => 34493, - 'Versio' => 32765, - 'VGZ' => 34946, - 'Viber' => 10172, - 'Videoland' => 33850, - 'Vimeo' => 38534, - 'Vimexx' => 35924, - 'VirtualComputing.nl' => 35698, - 'Vitens' => 10070, - 'Vodafone' => 24, - 'VoIPmobiel' => 34692, - 'Volgjezorg' => 37645, - 'VPSHosting.nl' => 33487, - 'VPSServer.nl' => 33484, - 'VVV Cadeaubon' => 35134, - 'Warface' => 38764, - 'Warframe' => 36490, - 'Waterbedrijf Groningen' => 10077, - 'Waterleiding Maatschappij Limburg' => 10076, - 'Waterleidingmaatschappij Drenthe' => 10079, - 'Waternet' => 10075, - 'Waze' => 33409, - 'Webex' => 34164, - 'Webreus' => 32778, - 'Weebly' => 34913, - 'Weeronline.nl' => 34014, - 'Weerplaza' => 35115, - 'Wehkamp' => 33980, - 'Westland Infra' => 10125, - 'WeTransfer' => 10190, - 'Whatsapp' => 10000, - 'Wikipedia' => 10092, - 'Wish' => 38152, - 'Wisper' => 10101, - 'Woningnet' => 36758, - 'Wordfeud' => 25, - 'WordOn' => 34036, - 'World of Tanks' => 35526, - 'World of Warcraft' => 34382, - 'World of Warships' => 38010, - 'Wrts' => 34215, - 'Xbox Live' => 10148, - 'XS4ALL' => 26, - 'XSyou' => 10106, - 'Yahoo Mail' => 34020, - 'Yellowbrick' => 10140, - 'Youfone' => 10144, - 'Yourhosting' => 34360, - 'YouTube' => 10005, - 'Z1 Battle Royale' => 35495, - 'Zalando' => 33981, - 'Ziggo' => 28, - 'Zilveren Kruis' => 34727, - 'Zwitserleven' => 34262, - ), - 'UK' => array( - '123 Reg' => 35870, - '1and1' => 34818, - '2k' => 36427, - '3 (Three)' => 32674, - '4chan' => 38596, - 'Abebooks' => 36536, - 'Adobe Creative Cloud' => 34552, - 'Airbnb' => 35424, - 'Alexa' => 35911, - 'AliExpress' => 39062, - 'Amazon' => 32613, - 'Amazon Prime Music' => 36426, - 'Amazon Prime Video' => 34494, - 'Amazon Web Services' => 36327, - 'American Express' => 37398, - 'Amino Apps' => 38196, - 'Ancestry' => 38680, - 'Anthem' => 38053, - 'Anydesk' => 37489, - 'Apex Legends' => 38114, - 'App Store' => 35578, - 'Apple Music' => 35242, - 'Apple Store' => 34454, - 'Argos' => 34018, - 'ARK: Survival Evolved' => 36551, - 'Arlo' => 37076, - 'Asana' => 38023, - 'Asda' => 37979, - 'Ask4' => 34411, - 'Assassin\'s Creed' => 35006, - 'Autotrader' => 34793, - 'Badoo' => 37501, - 'Bank of Ireland UK' => 38228, - 'Bank of Scotland' => 32692, - 'Barclaycard' => 37762, - 'Barclays' => 32685, - 'Battlefield' => 36585, - 'Bet365' => 35030, - 'Betfair' => 35031, - 'Binance' => 36877, - 'Bing' => 33615, - 'Bitbucket' => 36683, - 'Black Desert Online' => 38492, - 'Blizzard Battle.net' => 34487, - 'Blogger' => 32617, - 'Booking.com' => 38703, - 'Boom Beach' => 34783, - 'Brawl Stars' => 38819, - 'British Airways' => 37644, - 'British Gas' => 35653, - 'BT' => 32673, - 'Bumble' => 36401, - 'Bwin' => 34490, - 'Cablecom' => 34413, - 'Cahoot' => 34826, - 'Call of Duty' => 34151, - 'Candy Crush' => 35094, - 'Capital One' => 37223, - 'Car-Net' => 37030, - 'Cdkeys' => 37575, - 'Centurylink' => 35625, - 'CEX' => 34897, - 'Channel 4' => 34948, - 'Chelsea Building Society' => 38223, - 'Clash of Clans' => 34444, - 'Clash Royale' => 36856, - 'Cloudflare' => 34880, - 'CMC Markets' => 37629, - 'Coinbase' => 36797, - 'Colt Group ' => 35961, - 'Conan Exiles' => 38392, - 'ConnectWise' => 38469, - 'Conventry Building Society' => 38225, - 'Counter-strike' => 35057, - 'Craigslist' => 32619, - 'Crunchyroll' => 36715, - 'Daisy' => 34410, - 'Danske Bank' => 38227, - 'Dark Souls' => 36953, - 'Dauntless' => 38541, - 'DayZ' => 38781, - 'Dazn' => 39010, - 'Dead By Daylight' => 36425, - 'Deezer' => 33829, - 'DeGiro' => 38202, - 'Deliveroo' => 37548, - 'Demon' => 34412, - 'Destiny' => 34893, - 'DeviantArt' => 38491, - 'DFP' => 34999, - 'Discord' => 36020, - 'Dota 2' => 35403, - 'Driveclub' => 34927, - 'Dropbox' => 32620, - 'EA' => 34506, - 'EA Sports UFC' => 38468, - 'Easynet' => 33881, - 'eBay' => 32621, - 'Eclipse' => 32680, - 'EE' => 32661, - 'Elite: Dangerous' => 36684, - 'Epic Games Store' => 38828, - 'Escape from Tarkov' => 38108, - 'Etoro' => 36959, - 'Etsy' => 36422, - 'Eurosport Player' => 35819, - 'Evernote' => 34705, - 'Exchange Online' => 34536, - 'Expedia' => 36421, - 'Experian' => 38156, - 'Exponential-E' => 34409, - 'Facebook' => 32622, - 'Facebook Messenger' => 32623, - 'Faceit' => 37145, - 'Facetime' => 34604, - 'Fallout' => 35431, - 'Fandom' => 38690, - 'Far Cry' => 37176, - 'Fasthosts' => 35705, - 'Fifa' => 35471, - 'Find my iPhone' => 36123, - 'First Direct' => 33978, - 'First Trust Bank' => 38226, - 'Fitbit' => 36630, - 'Flickr' => 32624, - 'Fling' => 35165, - 'For Honor' => 35988, - 'Fortnite' => 36624, - 'Forza' => 37569, - 'Freedompop' => 35639, - 'Freesat' => 37594, - 'Freeview' => 35771, - 'Friday the 13th The Game' => 37563, - 'Funimation' => 37745, - 'Game of war' => 35229, - 'Gamma' => 34899, - 'Garmin' => 36990, - 'Garmin Connect' => 37192, - 'Gears of War' => 35939, - 'Ghost Recon' => 36007, - 'Giffgaff' => 33985, - 'Gigler' => 35790, - 'GitHub' => 32625, - 'Glide' => 38901, - 'Gmail' => 32626, - 'Go Daddy' => 34873, - 'Google' => 32627, - 'Google Calendar' => 38598, - 'Google Cloud' => 38536, - 'Google Drive' => 34273, - 'Google Hangouts' => 32629, - 'Google Home' => 36138, - 'Google Play' => 32628, - 'GoToMeeting' => 36505, - 'Gran Turismo' => 36680, - 'Great Western Railway' => 37808, - 'Grindr' => 35530, - 'GTA 5' => 34752, - 'Guild Wars 2' => 36407, - 'Gumtree' => 32756, - 'Halifax' => 32689, - 'Halo' => 35451, - 'Harry Potter: Wizards Unite' => 38688, - 'Hay Day' => 34788, - 'Hayu' => 38052, - 'Heart Internet' => 37623, - 'Hearthstone' => 38563, - 'Hermes' => 34949, - 'Hipchat' => 34866, - 'Hitman' => 36880, - 'Hive' => 39072, - 'HMRC' => 36961, - 'Home telecom' => 35959, - 'Hootsuite' => 32630, - 'HQ Trivia' => 37161, - 'HSBC' => 32686, - 'Hue' => 37811, - 'Hunt: showdown' => 38823, - 'Hyperoptic' => 36520, - 'iCloud' => 32663, - 'IG' => 37630, - 'iMessage' => 32664, - 'Imgur' => 32632, - 'IMVU' => 37177, - 'Indeed' => 38589, - 'Instagram' => 32633, - 'Internet Movie Database (IMDb)' => 36752, - 'Iomart' => 37180, - 'iPlayer' => 32755, - 'iTunes' => 32665, - 'ITV' => 34302, - 'Janet' => 35452, - 'Jira' => 34871, - 'John Lewis' => 36782, - 'Jurassic World Alive' => 37332, - 'Just Eat' => 36031, - 'KC' => 34130, - 'Kik' => 33352, - 'Kraken' => 36751, - 'Ladbrokes' => 35033, - 'LastPass' => 34778, - 'League of Legends' => 35048, - 'LinkedIn' => 32634, - 'Litebit' => 36837, - 'Lloyds Bank' => 32691, - 'M&S Bank' => 35457, - 'M24Seven' => 37150, - 'Madden' => 38458, - 'Mail.com' => 34800, - 'Mailbox' => 34836, - 'Mailchimp' => 38728, - 'Manx Telecom' => 35534, - 'Mastercard' => 37250, - 'McDonalds app' => 37374, - 'Meetup' => 34246, - 'Metro Bank' => 34970, - 'Microsoft Azure' => 32651, - 'Microsoft Teams' => 38184, - 'Minecraft' => 32635, - 'Mixer' => 38768, - 'Monzo' => 37591, - 'Moonfruit' => 35456, - 'MTV' => 34124, - 'Musical.ly' => 37140, - 'My Fitness Pal' => 36956, - 'My Vue' => 36329, - 'Namesco Ltd' => 35900, - 'National Lottery' => 34950, - 'Nationwide' => 32690, - 'NatWest' => 32684, - 'NBA 2k' => 37721, - 'Nest' => 35192, - 'Netflix' => 32636, - 'Neverwinter' => 35767, - 'NikePlus' => 38783, - 'Nintendo eShop' => 34100, - 'Nintendo Network' => 35516, - 'Nintendo Switch Online' => 37241, - 'No Man\'s Sky' => 37405, - 'NordVPN' => 38248, - 'Now TV' => 34406, - 'Nvidia' => 39068, - 'O2' => 32660, - 'Office 365' => 34538, - 'OkCupid' => 37331, - 'Omegle' => 39043, - 'OneDrive' => 32781, - 'Online.net' => 36174, - 'ooVoo' => 36586, - 'Orange' => 32676, - 'Origin' => 36419, - 'Origin Broadband' => 36052, - 'Outlook' => 32631, - 'Overwatch' => 35681, - 'OVH' => 36160, - 'Paddy Power' => 35032, - 'Paladins' => 36377, - 'Path of Exile' => 36487, - 'Paypal' => 35557, - 'PES' => 38590, - 'Photobucket' => 32638, - 'Pinterest' => 32639, - 'Player Unknown\'s Battlegrounds' => 36335, - 'Playstation Network' => 32668, - 'PlentyOfFish' => 36023, - 'Plusnet' => 32683, - 'Pokémon Go' => 35730, - 'Pokerstars' => 37440, - 'Post Office' => 32682, - 'Project Online' => 34539, - 'ProtonMail' => 38963, - 'Quickbooks Online' => 36583, - 'Quizup' => 34298, - 'Rackspace' => 34610, - 'Rainbow Six' => 35476, - 'Rakuten TV' => 37597, - 'RBS (Royal Bank of Scotland)' => 32688, - 'Realm Royale' => 37309, - 'Red Dead Redemption' => 37743, - 'Redbox' => 38791, - 'Reddit' => 34844, - 'Relish' => 34797, - 'Revolut' => 37365, - 'Roblox' => 35786, - 'Rocket League' => 35355, - 'Royal Mail' => 35776, - 'Runescape' => 35107, - 'Ruzzle' => 34479, - 'Ryanair' => 36003, - 'Sagepay' => 36781, - 'Sainsbury\'s Bank' => 35116, - 'Salesforce' => 38495, - 'Santander' => 32687, - 'Sarahah' => 36373, - 'Scottish Power' => 36041, - 'Sea of Thieves' => 37110, - 'Sharepoint Online' => 34540, - 'Shopify' => 38538, - 'Shutterstock' => 37448, - 'Signal' => 37070, - 'Sky' => 32782, - 'Sky Bet' => 35076, - 'Skype' => 32640, - 'Skype for Business' => 34537, - 'Slack' => 35934, - 'Slideshare' => 32641, - 'Smart Hub' => 35179, - 'Smartsheet' => 38723, - 'Smile' => 36661, - 'Smite' => 35407, - 'Snapchat' => 32642, - 'Soundcloud' => 36954, - 'Sourceforge' => 32643, - 'South Western Railway' => 37809, - 'Spotify' => 32644, - 'Squarespace' => 38784, - 'SSE' => 37058, - 'Star Citizen' => 39036, - 'Star Wars Battlefront' => 35551, - 'Starling Bank' => 38719, - 'Steam' => 32667, - 'Steep' => 37822, - 'Strava' => 37046, - 'Streamcenter' => 37016, - 'Summoners War' => 37419, - 'T-Mobile' => 32679, - 'Tado' => 36632, - 'Talkmobile' => 33988, - 'TalkTalk' => 32677, - 'Tango' => 33739, - 'Teamviewer' => 34615, - 'Telegram' => 34222, - 'Tesco Bank' => 34969, - 'Tesco Broadband' => 34086, - 'Tesco Mobile' => 35149, - 'The Co-operative Bank' => 34971, - 'The Crew 2' => 36555, - 'The Division' => 35561, - 'The elder scrolls online' => 36400, - 'The People\'s Operator' => 35150, - 'The Simpsons Tapped out' => 35077, - 'Thinkmoney' => 36331, - 'Ticketmaster' => 36786, - 'Tiktok' => 38726, - 'Tinder' => 34240, - 'TomTom Live' => 34918, - 'Transferwise' => 38565, - 'Trove' => 38826, - 'TSB Bank' => 33970, - 'Tumblr' => 32645, - 'TuneIn' => 37973, - 'Tweakbox' => 38342, - 'Twitch' => 35022, - 'Twitter' => 32646, - 'Uber' => 37498, - 'Uber Eats' => 38271, - 'Udemy' => 37717, - 'UKfast' => 36838, - 'Ulster Bank' => 36071, - 'Unibet' => 36055, - 'Uplay PC' => 34691, - 'UPS' => 38744, - 'Viber' => 33349, - 'Vimeo' => 32647, - 'Virgin Media' => 32675, - 'Visa' => 37237, - 'Vistaprint' => 35130, - 'Vodafone' => 32659, - 'Waitrose' => 36964, - 'Warface' => 38766, - 'Warframe' => 36367, - 'Wattpad' => 34696, - 'Waze' => 33407, - 'Webex' => 34161, - 'Weebly' => 38547, - 'Weight Watchers' => 35663, - 'WeTransfer' => 32648, - 'Whatsapp' => 32649, - 'Whisper' => 37028, - 'Wikipedia' => 32650, - 'William Hill' => 35029, - 'Wish' => 39040, - 'Wix' => 37233, - 'Wordpress.com' => 32652, - 'World of Tanks' => 35525, - 'World of Warcraft' => 35052, - 'World of Warships' => 36639, - 'WWE Network' => 38085, - 'Xbox Live' => 32666, - 'Xero' => 37500, - 'XLN Telecom' => 33989, - 'Yahoo' => 32653, - 'Yahoo Mail' => 32654, - 'Yahoo Messenger' => 32655, - 'Yammer' => 36955, - 'Yelp' => 32656, - 'Yorkshire Bank' => 34968, - 'Yorkshire Building Society' => 38224, - 'Youtube' => 32657, - 'Youtube Music' => 37587, - 'Yubo' => 37246, - 'Z1 Battle Royale' => 35490, - 'Zen Internet' => 33874, - 'ZoHo' => 36039, - 'Zone Broadband' => 36994, - 'Zoom' => 37946, - 'Zynga' => 32658, - ), - 'United States' => array( - '1and1' => 34817, - '1Password' => 36441, - '2600hertz' => 37692, - '2k' => 34768, - '4chan' => 37567, - '7 Days to Die' => 35947, - '8x8' => 34314, - 'ABC' => 38902, - 'Abebooks' => 36535, - 'Absolver' => 37366, - 'Access One' => 36063, - 'Acorns' => 37647, - 'Adams Networks' => 33556, - 'Adobe Connect' => 34134, - 'Adobe Creative Cloud' => 34551, - 'ADP' => 37657, - 'ADT' => 38502, - 'Adyen' => 37611, - 'Agar.io' => 38216, - 'Aio Wireless' => 34098, - 'Air Canada' => 20003, - 'Airbnb' => 34335, - 'AireSpring' => 35893, - 'Airnow.gov' => 37658, - 'Akamai' => 35447, - 'Alaska Airlines' => 20004, - 'Alaska Communications' => 36040, - 'Albion Online' => 38795, - 'Alexa' => 35910, - 'AliExpress' => 37395, - 'Allegiant Air' => 20005, - 'Ally' => 34791, - 'Amazon' => 20006, - 'Amazon Prime Music' => 34710, - 'Amazon Prime Video' => 33370, - 'Amazon Web Services' => 10147, - 'American Airlines' => 20007, - 'American Express' => 35958, - 'American Messaging' => 34128, - 'Amino Apps' => 37290, - 'Ammyy' => 36575, - 'Amtrak' => 20008, - 'Ancestry' => 38296, - 'Anno 1800' => 38413, - 'Anthem' => 37746, - 'Antietam' => 37051, - 'Anydesk' => 37406, - 'AOL' => 33419, - 'Apex Legends' => 38111, - 'App Store' => 35575, - 'Apple Maps' => 36125, - 'Apple Music' => 35241, - 'Apple News' => 36126, - 'Apple Store' => 34447, - 'Apple TV' => 36058, - 'Apple TV+' => 38318, - 'Appriver' => 37074, - 'AppValley' => 37749, - 'Arbuckle' => 34489, - 'ArcheAge' => 34876, - 'ARK: Survival Evolved' => 36350, - 'Arlo' => 36779, - 'Armstrong' => 34027, - 'Arvest Bank' => 37148, - 'Arvig' => 34919, - 'Asana' => 35828, - 'Assassin\'s Creed' => 35005, - 'AT&T' => 20010, - 'Atlantic Broadband' => 33437, - 'Authorize.net' => 34821, - 'Autotask' => 37579, - 'BabyTEL' => 35631, - 'Backblaze' => 35483, - 'Badoo' => 35993, - 'Bandwidth' => 35667, - 'Bank of America' => 20011, - 'Barclays' => 37639, - 'BART' => 20012, - 'Batman The Telltale Series' => 35896, - 'Battalion 1944' => 37002, - 'Battleborn' => 35718, - 'Battlefield' => 36091, - 'Battlerite' => 36820, - 'BB&T' => 35658, - 'Beats Music' => 34294, - 'beIN' => 37355, - 'Bejeweled' => 35904, - 'Bendbroad' => 34884, - 'Best Buy' => 34329, - 'BET' => 34125, - 'Bethesda' => 38316, - 'Binance' => 36818, - 'Bing' => 20014, - 'Birch Communications' => 33549, - 'Bitbucket' => 35414, - 'Bitfinex' => 36787, - 'Bitflyer' => 36789, - 'Bitstamp' => 34023, - 'Bittrex' => 36788, - 'Black Desert Online' => 37108, - 'BlackBerry' => 20015, - 'Blade and Soul' => 35511, - 'Blizzard Battle.net' => 34486, - 'Blogger' => 20016, - 'Blue Jay Wireless' => 35464, - 'Bluebird' => 37362, - 'Bluehost' => 33418, - 'BlueJeans' => 37509, - 'BMW ConnectedDrive' => 38471, - 'BNY Mellon' => 35674, - 'Boingo' => 37377, - 'Booking.com' => 34330, - 'Boom Beach' => 34364, - 'Box' => 32775, - 'Brawl Stars' => 38312, - 'BT' => 38338, - 'Buckeye Cablesystem' => 34029, - 'Buffer' => 36013, - 'Bullet Force' => 37109, - 'Bumble' => 35823, - 'C Spire' => 38219, - 'Cable One' => 20019, - 'Call of Duty' => 34148, - 'CallTower' => 35365, - 'Caltrain' => 20021, - 'Candy Crush' => 34024, - 'Candy Crush Soda Saga' => 35171, - 'Canva' => 36778, - 'Capital One' => 20022, - 'Cash App' => 38685, - 'CBSSports' => 35826, - 'Century National Bank' => 37690, - 'CenturyLink' => 20023, - 'Change.org' => 35616, - 'Charles Schwab' => 20078, - 'Chase' => 20025, - 'Chegg' => 36054, - 'Chicago Transit Authority' => 20026, - 'Chime' => 39050, - 'Chrome Web Store' => 34116, - 'Cincinnati Bell' => 33422, - 'Cirra Networks' => 36357, - 'Citi' => 20027, - 'Citizens Bank' => 37748, - 'Civilization' => 35869, - 'Clash of Clans' => 33550, - 'Clash Royale' => 35587, - 'Classlink' => 36821, - 'Clear' => 33423, - 'Cloudflare' => 32542, - 'Cloudsmith' => 35946, - 'Cloudtrax' => 37528, - 'CNET' => 33341, - 'CNN' => 34141, - 'Cogent' => 34026, - 'Coinbase' => 36175, - 'Comcast' => 20029, - 'Comedy Central' => 34126, - 'Common App' => 36726, - 'Comporium' => 36032, - 'Conan Exiles' => 37195, - 'Concur' => 36730, - 'Confluence' => 37540, - 'ConnectWise' => 37493, - 'Consolidated' => 34030, - 'Consolidated Edison' => 35679, - 'Consumer Cellular' => 37761, - 'Copy.com' => 34231, - 'Coredial' => 35454, - 'Costco' => 37061, - 'Counter-strike' => 34886, - 'Cox' => 20030, - 'Crackle' => 35349, - 'Craigslist' => 20031, - 'Credit One Bank' => 37760, - 'Cricket Wireless' => 20032, - 'Crown Castle' => 35812, - 'Crunchyroll' => 35114, - 'Cryptopia' => 36973, - 'Dailymotion' => 37598, - 'Dark Souls.' => 35710, - 'Dauntless' => 37204, - 'DayZ' => 37751, - 'Dazn' => 37497, - 'DC Universe Online' => 37059, - 'Dead By Daylight' => 35760, - 'Deezer' => 36424, - 'Defiance 2050' => 37364, - 'Delta Air Lines' => 20033, - 'Destiny' => 34761, - 'DeviantArt' => 34888, - 'Di.fm' => 35645, - 'Diablo' => 34385, - 'DirecTV' => 20034, - 'Directv Now' => 35933, - 'Discord' => 35795, - 'Dish Network' => 20035, - 'Disney World' => 35837, - 'Disney+' => 38800, - 'Disqus' => 34145, - 'Diversity Lottery' => 38472, - 'Dlive' => 38470, - 'Docker' => 37817, - 'Doom' => 35717, - 'Doordash' => 37581, - 'Dota 2' => 34304, - 'Doublelist' => 37580, - 'Downforeveryoneorjustme' => 34758, - 'Draftkings' => 37036, - 'Dragon Ball' => 35858, - 'Dramafever' => 36064, - 'DreamHost' => 35926, - 'Driveclub' => 35891, - 'Dropbox' => 20036, - 'Duckduckgo' => 35650, - 'Duke Energy' => 35871, - 'Duolingo' => 35942, - 'Dyl' => 37383, - 'Dyn' => 35707, - 'E-Trade' => 20037, - 'EA' => 34499, - 'EA Sports UFC' => 35715, - 'Eagle Communications ' => 36027, - 'Earthlink' => 32776, - 'Eatel' => 35448, - 'eBay' => 20038, - 'Ecobee' => 37163, - 'eFax' => 38664, - 'Electric Power Board' => 35778, - 'Elite: Dangerous' => 36478, - 'Ello' => 34906, - 'Endicia' => 36839, - 'Engine Yard' => 33966, - 'eNom' => 35901, - 'EPB Chattanooga' => 35513, - 'Epic Games Store' => 37756, - 'Escape from Tarkov' => 37424, - 'ESPN' => 33335, - 'ESPN Plus' => 38320, - 'Etherdelta' => 36858, - 'Etsy' => 33366, - 'Eve Online' => 35721, - 'Eve Valkyrie' => 35884, - 'Evernote' => 34706, - 'Everquest' => 38337, - 'Exchange Online' => 34534, - 'Exede' => 33557, - 'Expedia' => 34332, - 'Experian' => 38155, - 'ExpressVPN' => 37544, - 'Faceapp' => 38695, - 'Facebook' => 10198, - 'Facebook Messenger' => 20039, - 'Faceit' => 38543, - 'Facetime' => 34606, - 'FAFSA' => 37691, - 'FairPoint' => 33994, - 'Fallout' => 35430, - 'Family Search' => 33439, - 'Fandango' => 34481, - 'Fandom' => 34317, - 'Fanduel' => 37035, - 'Fanfiction' => 37101, - 'Far Cry' => 35713, - 'FastMail' => 35779, - 'Fatcow' => 34882, - 'Fax2mail' => 38665, - 'FedEx' => 35843, - 'Feedly' => 34704, - 'Fidelity' => 36738, - 'Fifa' => 35470, - 'Final Fantasy' => 35860, - 'Find my iPhone' => 38662, - 'First Communications ' => 36062, - 'FirstEnergy' => 35994, - 'FirstLight' => 34879, - 'Fitbit' => 35800, - 'Fite' => 38794, - 'Fiverr' => 37054, - 'Flickr' => 20040, - 'Fling' => 37353, - 'Flipboard' => 33491, - 'Florida Power & Light' => 35699, - 'Fonality' => 34312, - 'For Honor' => 35984, - 'Forge Of Empires' => 35886, - 'Fortnite' => 36375, - 'Forza' => 36817, - 'Fox News' => 35880, - 'Fox Sports Go' => 37164, - 'Frankfort PlantBoard' => 35466, - 'Freedompop' => 35657, - 'Freepik' => 36739, - 'Friday the 13th The Game' => 36348, - 'Frii' => 35668, - 'Frontier' => 20041, - 'Frontier Airlines' => 20042, - 'FuboTV' => 36658, - 'Funimation' => 36665, - 'Fuze' => 35847, - 'FXNOW' => 37504, - 'G-Portal' => 37154, - 'G2A.com' => 35898, - 'Game of Thrones Conquest' => 37669, - 'Game of war' => 35228, - 'Gamebattles' => 37753, - 'Gameloft' => 35964, - 'Gamestop' => 36780, - 'Gang Beasts' => 36926, - 'Garmin' => 35554, - 'Garmin Connect' => 35801, - 'Gatehub' => 36841, - 'GCI' => 35633, - 'GDAX' => 36791, - 'Gears of War' => 35848, - 'Geeking' => 37251, - 'Gemini' => 36811, - 'Gems of war' => 38801, - 'Geocaching' => 35770, - 'Gfycat' => 38504, - 'Ghost' => 38720, - 'Ghost Recon' => 36000, - 'Ghosttunes' => 34883, - 'GitHub' => 10004, - 'Gitlab' => 37146, - 'Glassdoor' => 35740, - 'Glide' => 34016, - 'Glitch' => 38721, - 'Gmail' => 20043, - 'Go Daddy' => 33416, - 'GOG.com' => 34287, - 'Google' => 10200, - 'Google Calendar' => 38597, - 'Google Cloud' => 37380, - 'Google Drive' => 34271, - 'Google Fiber' => 33421, - 'Google Hangouts' => 20046, - 'Google Home' => 36137, - 'Google Play' => 20045, - 'GoToMeeting' => 33489, - 'Gran Turismo' => 36347, - 'Grande' => 34404, - 'Greenlight' => 37628, - 'Grindr' => 35529, - 'Groove Music' => 33972, - 'Groupme' => 35739, - 'Groupon' => 35709, - 'Growtopia' => 36835, - 'Grubhub' => 37693, - 'GTA 5' => 33995, - 'Guild Wars 2' => 35060, - 'H&R Block' => 34328, - 'Halo' => 35418, - 'Halo Wars' => 36035, - 'Happn' => 37013, - 'Hargray' => 34142, - 'Harry Potter: Wizards Unite' => 38593, - 'Hashflare' => 37038, - 'Hawaiian Airlines' => 20047, - 'Hawaiian Telcom' => 33436, - 'Hay Day' => 34367, - 'Hayu' => 37198, - 'HBO Go' => 34340, - 'HBO Now' => 38697, - 'Healthcare.gov' => 34011, - 'Hearthstone' => 36834, - 'Heartland' => 38803, - 'Heroku' => 33965, - 'HiDive' => 37527, - 'HInge' => 37625, - 'Hipchat' => 34865, - 'HitBTC' => 36925, - 'Hitman' => 35585, - 'Hive' => 37571, - 'Honeywell' => 38110, - 'Hootsuite' => 20048, - 'Hostgator' => 33417, - 'Hostmonster' => 35931, - 'Hotels.com' => 34334, - 'Hotwire' => 34331, - 'HouseParty' => 37356, - 'HQ Trivia' => 36815, - 'HSBC' => 35673, - 'Hue' => 37514, - 'HughesNet' => 32787, - 'Hulu' => 33331, - 'Humanity' => 36735, - 'Hunt: showdown' => 37006, - 'Hurricane Electric' => 35811, - 'I3 Broadband' => 36286, - 'IBM Cloud' => 37649, - 'iCloud' => 20049, - 'ICQ' => 34766, - 'iFunny' => 38294, - 'IGTV' => 37317, - 'iHeartRadio' => 33369, - 'Illinois Century Network' => 36086, - 'IMDb Freedrive' => 37960, - 'iMessage' => 20050, - 'Imgur' => 20051, - 'IMVU' => 36523, - 'Inbox' => 34318, - 'inContact' => 35836, - 'Indeed' => 35864, - 'Ingress' => 35764, - 'Injustice 2' => 36526, - 'Inmotion' => 34759, - 'Instagram' => 20052, - 'Integra' => 33554, - 'Interactive Brokers' => 34115, - 'Intermedia' => 33973, - 'Internet Movie Database (IMDb)' => 33337, - 'Intralinks' => 38667, - 'Ipage' => 35659, - 'iRacing' => 35957, - 'Iridium' => 35652, - 'Ironsight' => 37142, - 'IRS' => 35542, - 'iTunes' => 20053, - 'iTunes Connect' => 36489, - 'iTunes Match' => 36059, - 'iWork' => 36124, - 'Jabber' => 36524, - 'Jackbox' => 38790, - 'JetBlue Airways' => 20054, - 'Jira' => 34870, - 'Jive' => 35453, - 'JP Morgan' => 37034, - 'Juno' => 32786, - 'Jurassic World Alive' => 37256, - 'Kayak' => 34338, - 'Keek' => 33770, - 'Kik' => 32770, - 'Kraken' => 36660, - 'Kucoin' => 36962, - 'last.fm' => 35634, - 'LastPass' => 35541, - 'LawBreakers' => 36527, - 'Layer3 TV' => 37378, - 'Leaco' => 35665, - 'League of Legends' => 34111, - 'Lifesize' => 37003, - 'Lightpath' => 35685, - 'Limebike' => 37205, - 'Limelight Networks' => 35462, - 'Line' => 33738, - 'LinkedIn' => 20055, - 'Linode' => 35907, - 'Liquid Web' => 34825, - 'LiveLeak' => 38273, - 'Liveperson' => 35417, - 'Logix' => 38295, - 'Logmein' => 33488, - 'Lola Wireless' => 35905, - 'Lowe\'s' => 38297, - 'LS Networks' => 35703, - 'Lumos Networks' => 34217, - 'Lyft' => 36571, - 'Lynda' => 35781, - 'Madden' => 37747, - 'Maguss' => 37047, - 'Mail.com' => 34798, - 'Mailbox' => 34831, - 'Mailchimp' => 36017, - 'MARTA' => 20056, - 'Marvel' => 38792, - 'Marvel Contest of Champions' => 37005, - 'Maryland Transit Administration (MTA)' => 20017, - 'Mass Effect ' => 36057, - 'Mastercard' => 37249, - 'MaxxSouth ' => 36095, - 'MBTA' => 20057, - 'McDonalds app' => 36369, - 'Media Temple' => 35950, - 'Mediacom' => 20058, - 'Meetme' => 37042, - 'Meetup' => 34245, - 'Megapath' => 33420, - 'Merrill Lynch' => 37032, - 'Metra' => 20059, - 'Metro PCS' => 20060, - 'MetroCast' => 34405, - 'Metrolink' => 20061, - 'Metronetinc' => 35890, - 'MHz Choice' => 37297, - 'Miami Dade Transit' => 20062, - 'Microsoft Azure' => 32547, - 'Microsoft Teams' => 38018, - 'Microsoft VLSC' => 38187, - 'Midcontinent Media' => 33552, - 'Mimecast' => 35783, - 'Minecraft' => 20063, - 'Mint' => 36589, - 'MintSim' => 36438, - 'Mitel' => 36005, - 'Mixer' => 36525, - 'MLB The Show' => 35711, - 'MLB TV' => 35712, - 'Mobile legends' => 37394, - 'Mobile Strike' => 36476, - 'Momentum Telecom' => 36733, - 'Moneylion' => 38303, - 'Monster Hunter' => 37007, - 'Mordhau' => 38497, - 'Moviepass' => 37004, - 'Movies Anywhere' => 38771, - 'MovieTickets' => 36178, - 'MSL Live' => 37107, - 'MTV' => 34133, - 'MU Legend' => 36792, - 'Music Unlimited' => 35909, - 'Musical.ly' => 36659, - 'My Fitness Pal' => 35803, - 'My Social Security' => 37627, - 'Naruto-Storm' => 35845, - 'National Grid NY' => 36024, - 'NBA 2k' => 35714, - 'NBC Sports Live Extra' => 34295, - 'NBCNews.com' => 33340, - 'NCTC' => 36344, - 'Need for Speed' => 35428, - 'NEMR' => 35514, - 'Nest' => 35190, - 'Net10 Wireless' => 34090, - 'netBlazr' => 35656, - 'Neteller' => 38321, - 'Netflix' => 20065, - 'Netsuite' => 35743, - 'Nettalk' => 36732, - 'Network Solutions' => 33548, - 'NetZero' => 32785, - 'Neverwinter' => 35603, - 'New Jersey Transit' => 20066, - 'New York MTA' => 20067, - 'New York Times' => 33339, - 'Newegg' => 35438, - 'NewWave Communications' => 36026, - 'Nextiva' => 34850, - 'NFL Network' => 36572, - 'NHL.tv' => 36816, - 'Nicehash' => 37037, - 'NikePlus' => 35805, - 'Nintendo eShop' => 34099, - 'Nintendo Network' => 35515, - 'Nintendo Switch Online' => 37240, - 'No Man\'s Sky' => 35793, - 'NordVPN' => 37545, - 'North State' => 36840, - 'Northland Communications' => 38035, - 'Norwood Light' => 35738, - 'NTT Communications' => 35856, - 'NuGet' => 36740, - 'Oculus' => 38253, - 'Office 365' => 34532, - 'OkCupid' => 35902, - 'Okta' => 35742, - 'Omegle' => 36522, - 'OneDrive' => 32780, - 'Onelogin' => 38439, - 'Ooma' => 35628, - 'ooVoo' => 34085, - 'Opera' => 38406, - 'Optimum / Cablevision' => 20020, - 'OptionsHouse' => 20068, - 'optionsXpress' => 20069, - 'Oracle Cloud' => 38033, - 'Orbitz' => 34337, - 'Origin' => 34113, - 'OS X Update' => 36127, - 'Outlook' => 10205, - 'Overdrive' => 35855, - 'Overwatch' => 35680, - 'Pacific Northern Gas ' => 35807, - 'Page Plus' => 34012, - 'Pagely' => 33974, - 'Paladins' => 35857, - 'Pandora' => 20070, - 'Paperspace' => 37200, - 'Paragon' => 36359, - 'PATCO' => 20071, - 'PATH' => 20072, - 'Path of Exile' => 34823, - 'Patreon' => 38503, - 'Paypal' => 20073, - 'pCloud' => 36588, - 'PenTeleData' => 34401, - 'Periscope' => 35595, - 'Personal Capital' => 36969, - 'PES' => 37951, - 'PG&E' => 35677, - 'PGA Tour Live' => 37235, - 'Phonepower' => 35361, - 'Photobucket' => 20074, - 'Pinterest' => 20075, - 'Planetside2' => 35787, - 'Player Unknown\'s Battlegrounds' => 36135, - 'Playstation Network' => 20076, - 'Playstation Vue' => 35188, - 'PlentyOfFish' => 35799, - 'Plex' => 35573, - 'PNC' => 33333, - 'Pokémon Duel' => 35966, - 'Pokémon Go' => 35725, - 'Pokerstars' => 38749, - 'Poloniex' => 36349, - 'Postmates' => 36774, - 'Powerschool' => 35867, - 'Prey' => 37295, - 'Priceline' => 34333, - 'Project Online' => 34535, - 'Proofpoint' => 38666, - 'ProtonMail' => 36136, - 'Quake Champions' => 36773, - 'Quickbooks Online' => 35761, - 'Quicken' => 36614, - 'Quizlet' => 34322, - 'Quizup' => 34296, - 'Quora' => 34341, - 'Rabb.it' => 36819, - 'Rackspace' => 34609, - 'Rain World Game' => 36042, - 'Rainbow Six' => 35463, - 'Razer' => 38698, - 'RCN' => 32784, - 'Realm Royale' => 37287, - 'Red Dead Redemption' => 37600, - 'Redbox' => 37213, - 'Reddit' => 33342, - 'Reflexion' => 38733, - 'RingCentral' => 34311, - 'Rise Broadband' => 35458, - 'Robinhood' => 35706, - 'Roblox' => 34820, - 'Rocket League' => 35251, - 'RocketRez' => 35784, - 'Roku' => 37194, - 'Royal Games' => 38218, - 'Runescape' => 34804, - 'Runkeeper' => 35802, - 'Runtastic' => 35804, - 'Rust' => 37755, - 'Ruzzle' => 33969, - 'Safelink Wireless' => 35467, - 'Safenet' => 37538, - 'Salesforce' => 32773, - 'Santander Bank' => 38656, - 'Sarahah' => 36365, - 'Schooldesk' => 35468, - 'Scottrade' => 20079, - 'ScreenConnect' => 35661, - 'Scum' => 37464, - 'Sea of Thieves' => 36995, - 'SEC Network' => 37757, - 'Secom' => 36972, - 'Second Life' => 37612, - 'SEPTA' => 20080, - 'Service Electric' => 33553, - 'Sharebuilder' => 20081, - 'Sharefile' => 37513, - 'Sharepoint Online' => 34533, - 'Shentel' => 35666, - 'Shopify' => 37555, - 'Shoretel' => 35846, - 'Showtime Anytime' => 35591, - 'Shutterstock' => 35704, - 'Signal' => 37071, - 'SignupGenius' => 35636, - 'Simple' => 34790, - 'Simple Mobile' => 33979, - 'Siri' => 38339, - 'SiriusXM' => 37048, - 'Skillshare' => 35775, - 'Skrill' => 37257, - 'Skype' => 20082, - 'Skype for Business' => 35350, - 'Skyscanner' => 38645, - 'SkySwitch' => 36664, - 'SkyWest' => 20084, - 'Slack' => 35437, - 'Slashdot' => 36615, - 'Sleeper' => 37455, - 'Slideshare' => 20085, - 'Sling' => 35155, - 'Smart Hub' => 35178, - 'Smartsheet' => 38310, - 'SmartThings' => 37102, - 'Smite' => 34802, - 'SNAP EBT' => 34006, - 'Snapchat' => 20086, - 'Socket' => 37411, - 'Sonic.net' => 34403, - 'Soundcloud' => 34351, - 'Sourceforge' => 20087, - 'South Central Communications' => 35759, - 'Southern California Edison' => 35700, - 'Southwest Airlines' => 20088, - 'Spectrum' => 20024, - 'Speedtest' => 37354, - 'Spirit Communications' => 35808, - 'SplashID' => 35594, - 'Spotify' => 20090, - 'Sprint' => 20091, - 'Square' => 37053, - 'Squarespace' => 36573, - 'Stack Exchange' => 34319, - 'Stackoverflow' => 34258, - 'Stadia' => 38302, - 'Staminus' => 34608, - 'Stamps.com' => 36662, - 'Star Citizen' => 36477, - 'Star Wars Battlefront' => 35459, - 'Starbucks' => 37210, - 'Starz' => 38291, - 'Steam' => 20092, - 'Steep' => 36011, - 'Straight Talk' => 34097, - 'Strava' => 36047, - 'Streamlabs' => 37206, - 'Strife' => 35853, - 'Stripe' => 38668, - 'Suddenlink' => 20093, - 'Suitebox' => 37211, - 'Summit Broadband' => 35617, - 'Summoners War' => 37409, - 'SunTrust Bank' => 37346, - 'Surfline' => 36341, - 'SurveyMonkey' => 35963, - 'T-Mobile' => 20097, - 'T. Rowe Price' => 37033, - 'Talkray' => 34750, - 'Tango' => 33728, - 'Target' => 34320, - 'Taxslayer' => 36018, - 'TD Ameritrade' => 20094, - 'TD Bank' => 35672, - 'TDS Telecom' => 33558, - 'Teamviewer' => 34343, - 'Tekken' => 37196, - 'Telecharge' => 37220, - 'Telegram' => 34227, - 'Tennis TV' => 37040, - 'TERA' => 37105, - 'Tesla' => 37152, - 'The Crew 2' => 35809, - 'The Culling' => 37752, - 'The Division' => 35558, - 'The elder scrolls online' => 35535, - 'The Huffington Post' => 33334, - 'The Simpsons Tapped out' => 34702, - 'The Weather Channel' => 33336, - 'Thingiverse' => 38732, - 'Threads' => 38983, - 'Threema' => 35892, - 'TIAA ' => 35597, - 'Ticketmaster' => 36534, - 'Tidal' => 37512, - 'Tiktok' => 38367, - 'Tinder' => 34237, - 'Titanfall' => 35897, - 'Tivo' => 37001, - 'Todoist' => 36461, - 'Toggl' => 36993, - 'TPx Communications' => 34402, - 'TracFone Wireless' => 20099, - 'Tradeking' => 33768, - 'TradeSatoshi' => 37106, - 'TradeStation' => 20100, - 'Transferwise' => 37199, - 'Travelocity' => 34336, - 'Trello' => 34796, - 'Trove' => 35363, - 'Trusted Id' => 36636, - 'TSYS' => 36517, - 'Tumblr' => 20101, - 'TuneIn' => 35221, - 'TurboTax' => 34327, - 'TV Time' => 37197, - 'TW Telecom' => 32783, - 'Tweakbox' => 37750, - 'Tweetdeck' => 39009, - 'Twitch' => 34308, - 'Twitter' => 10204, - 'Uber' => 36570, - 'Uber Eats' => 37648, - 'Udacity' => 35842, - 'Udemy' => 35644, - 'UFC' => 36736, - 'UMG Gaming' => 36528, - 'Uncharted' => 35716, - 'United Airlines' => 20104, - 'Untappd' => 37363, - 'Uplay PC' => 34381, - 'UPS' => 34321, - 'Upwork' => 35436, - 'US Bank' => 20106, - 'US Cellular' => 20103, - 'USA Mobility' => 34129, - 'USPS' => 34259, - 'Utah Broadband' => 36342, - 'Vainglory' => 37203, - 'Vanguard' => 37011, - 'Vectren' => 35872, - 'Venmo' => 37209, - 'Verizon' => 20107, - 'Vero' => 37062, - 'Viaero' => 37508, - 'Viasat' => 38298, - 'Viber' => 32769, - 'Viewster' => 34323, - 'Viki' => 36065, - 'Vimeo' => 20110, - 'Vine' => 33368, - 'Virgin Mobile' => 34724, - 'Visa' => 37239, - 'Visible' => 38605, - 'Visual Studio Team Services' => 37463, - 'Vlive' => 36574, - 'Vonage' => 34315, - 'Voxer' => 33492, - 'VRChat' => 36991, - 'VRV' => 36640, - 'Vudu' => 34109, - 'Vyve Broadband' => 36012, - 'W3Schools' => 35887, - 'Waiter.com' => 36776, - 'Walmart Family' => 35465, - 'Walmart.com' => 33338, - 'War Thunder' => 37318, - 'Warface' => 37510, - 'Warframe' => 35825, - 'Washington Metropolitan Area Transit Authority' => 20111, - 'Wattpad' => 34693, - 'Wave Broadband' => 34028, - 'Waze' => 33382, - 'Webassign' => 35873, - 'Webex' => 33490, - 'Webhosting.net' => 35435, - 'Webs' => 35895, - 'Weebly' => 34910, - 'Weight Watchers' => 35646, - 'Wells Fargo' => 20112, - 'Wemo' => 37593, - 'Westman' => 35359, - 'WeTransfer' => 20113, - 'Whatsapp' => 10136, - 'Whisper' => 36881, - 'Wikipedia' => 20115, - 'Wilcoinc' => 35696, - 'Wildstar' => 36475, - 'William Hill' => 38900, - 'Windstream' => 20117, - 'Wish' => 38154, - 'Wix' => 35596, - 'Wordpress.com' => 20118, - 'Workday' => 37314, - 'World of Tanks' => 35357, - 'World of Warcraft' => 34263, - 'World of Warships' => 36637, - 'World War 3' => 37599, - 'World War Z' => 38405, - 'WOW' => 20114, - 'WP Engine' => 33975, - 'Wunderground' => 35948, - 'Wunderlist' => 35839, - 'WWE Network' => 36056, - 'Xbox Live' => 20119, - 'Xfinity Flex' => 38412, - 'XO' => 32777, - 'Yahoo' => 10203, - 'Yahoo Mail' => 20120, - 'Yahoo Messenger' => 20121, - 'Yammer' => 35559, - 'Yelp' => 20122, - 'Youtube' => 34651, - 'Youtube Music' => 37461, - 'Youtube TV' => 37296, - 'Yubo' => 37720, - 'Z1 Battle Royale' => 35146, - 'Zayo' => 35866, - 'Zelle' => 38683, - 'Zendesk' => 35854, - 'Zillow' => 33367, - 'ZoHo' => 34144, - 'Zoom' => 37349, - 'Zwift' => 38167, - 'Zynga' => 20123, - ), - 'New Zealand' => array( - '2degrees' => 33886, - '4chan' => 38270, - 'Alexa' => 36920, - 'Amurit.net' => 37710, - 'Anthem' => 38210, - 'ANZ' => 33888, - 'Apex Legends' => 38134, - 'Apple Store' => 34457, - 'ASB' => 33905, - 'beIN' => 38493, - 'Bigpipe' => 37704, - 'Bing' => 33889, - 'Blizzard Battle.net' => 38465, - 'BNZ' => 33907, - 'Call of Duty' => 34150, - 'CallPlus' => 33885, - 'CCL' => 37705, - 'Counter-strike' => 36854, - 'Crunchyroll' => 38146, - 'Cryptopia' => 36963, - 'Dead By Daylight' => 37434, - 'Destiny' => 38942, - 'Discord' => 38609, - 'Dota 2' => 36415, - 'EA' => 34505, - 'eBay' => 33891, - 'Evernote' => 35914, - 'Facebook' => 33892, - 'Facebook Messenger' => 33893, - 'Facetime' => 34587, - 'Fifa' => 38400, - 'Flip' => 37706, - 'Fortnite' => 36655, - 'Gmail' => 33894, - 'Google' => 33895, - 'Google Drive' => 36759, - 'Google Hangouts' => 33896, - 'Google Play' => 38525, - 'Gran Turismo' => 36948, - 'Grindr' => 38402, - 'GTA 5' => 37277, - 'iCloud' => 33921, - 'iMessage' => 33922, - 'Inspire Net' => 35445, - 'Instagram' => 33915, - 'iTunes' => 33923, - 'Kik' => 33914, - 'Kiwibank' => 33906, - 'League of Legends' => 38692, - 'Lightbox' => 35504, - 'LinkedIn' => 33897, - 'Minecraft' => 38617, - 'My Fitness Pal' => 37635, - 'myob' => 38926, - 'MyRepublic' => 34931, - 'NBA 2k' => 38785, - 'Neon' => 37707, - 'Netflix' => 34857, - 'Nzdating' => 37708, - 'Office 365' => 37171, - 'OneDrive' => 36863, - 'Orcon' => 33883, - 'Origin' => 36887, - 'Outlook' => 33918, - 'Overwatch' => 36416, - 'Pandora' => 33919, - 'Pinterest' => 33898, - 'Player Unknown\'s Battlegrounds' => 36362, - 'Playstation Network' => 34858, - 'Pokémon Go' => 35733, - 'Rabodirect' => 34980, - 'Rainbow Six' => 36610, - 'Reddit' => 34860, - 'Roblox' => 37662, - 'Skinny' => 37709, - 'Sky TV' => 33887, - 'Skype' => 33912, - 'Slingshot' => 34737, - 'Snapchat' => 33908, - 'Spark' => 33884, - 'Spotify' => 33910, - 'Steam' => 34225, - 'Tinder' => 34859, - 'Trade Me' => 33916, - 'Trustpower' => 37703, - 'Twitch' => 36540, - 'Twitter' => 33899, - 'Viber' => 33909, - 'Vodafone' => 33882, - 'Voxer' => 33913, - 'Wattpad' => 34697, - 'WeChat' => 33920, - 'Westpac' => 33900, - 'Whatsapp' => 33917, - 'Wikipedia' => 33901, - 'World of Warcraft' => 38814, - 'Xbox Live' => 34284, - 'Xero' => 35929, - 'Yahoo' => 33902, - 'Yahoo Mail' => 33903, - 'Youtube' => 33904, - 'Z1 Battle Royale' => 37266, - ), - 'België' => array( - '2dehands.be' => 34257, - 'Apex Legends' => 38141, - 'App Store' => 38555, - 'Apple Store' => 38925, - 'Argenta' => 32491, - 'Bancontact Mister Cash' => 33976, - 'Base' => 10207, - 'Battlefield' => 38444, - 'Belfius' => 32545, - 'Binance' => 36934, - 'Binck' => 32388, - 'Blizzard Battle.net' => 38810, - 'BNP Paribas Fortis' => 32543, - 'Bolero' => 37619, - 'Bpost' => 37157, - 'Bwin' => 34872, - 'Call of Duty' => 35536, - 'Clash Royale' => 38948, - 'Counter-strike' => 38245, - 'Crelan' => 37614, - 'Dead By Daylight' => 37445, - 'Deezer' => 33828, - 'Destiny' => 38559, - 'Deutsche Bank' => 34140, - 'Discord' => 37391, - 'Dommel' => 33977, - 'Dota 2' => 38478, - 'Dropbox' => 32525, - 'EA' => 34841, - 'eBay' => 33858, - 'EDPnet' => 32855, - 'Engie Electrabel' => 37616, - 'Epic Games Store' => 39027, - 'Facebook' => 22339, - 'Facebook Messenger' => 34851, - 'Facetime' => 34605, - 'Fifa' => 36906, - 'Fintro' => 37617, - 'Fluvius' => 37615, - 'Fortnite' => 36696, - 'Ghost Recon' => 39001, - 'Gmail' => 32374, - 'Google' => 32343, - 'Google Hangouts' => 32395, - 'Google Play' => 32371, - 'Gran Turismo' => 36913, - 'Grindr' => 38743, - 'GTA 5' => 35500, - 'Guild Wars 2' => 36474, - 'iCloud' => 22363, - 'iMessage' => 32463, - 'ING' => 22342, - 'Instagram' => 34286, - 'Isabel' => 34004, - 'Itsme' => 37454, - 'iTunes' => 32484, - 'Jim Mobile' => 37618, - 'KBC' => 32544, - 'Kik' => 32524, - 'Kraken' => 36828, - 'League of Legends' => 38631, - 'Lebara' => 32461, - 'LinkedIn' => 32534, - 'Lycamobile' => 32462, - 'Mobile Vikings' => 32854, - 'Moneyou' => 32488, - 'Multisafepay' => 34878, - 'Nest' => 38517, - 'Netflix' => 34629, - 'Numéricable' => 32856, - 'Office 365' => 36409, - 'OneDrive' => 35410, - 'Orange' => 10208, - 'Origin' => 36888, - 'Outlook' => 32375, - 'Overwatch' => 36383, - 'OVH' => 36746, - 'Paypal' => 32354, - 'Paysafecard' => 32485, - 'Play Sports' => 37582, - 'Player Unknown\'s Battlegrounds' => 36568, - 'Playstation Network' => 32339, - 'Pokémon Go' => 35736, - 'Proximus' => 10206, - 'Rainbow Six' => 36180, - 'Reddit' => 37474, - 'Roblox' => 38757, - 'Rocket League' => 35978, - 'Runescape' => 38654, - 'Salesforce' => 32349, - 'Scarlet' => 32435, - 'Simyo' => 32387, - 'Skype' => 32357, - 'Smartschool' => 37620, - 'Snapchat' => 32520, - 'Sofort Banking' => 32482, - 'Spotify' => 32336, - 'Steam' => 34278, - 'Strava' => 38161, - 'Teamviewer' => 34907, - 'Telegram' => 35971, - 'Telenet' => 10196, - 'Tinder' => 35499, - 'TuneIn' => 38759, - 'TV Vlaanderen' => 22364, - 'Tweakbox' => 38786, - 'Twitch' => 36542, - 'Twitter' => 22355, - 'Unibet' => 38830, - 'Uplay PC' => 35614, - 'Viber' => 32505, - 'Voo' => 32853, - 'VTM' => 38394, - 'Waze' => 36657, - 'WeTransfer' => 32523, - 'Whatsapp' => 32333, - 'Wikipedia' => 32425, - 'Wordfeud' => 22358, - 'Xbox Live' => 32481, - 'Yahoo Mail' => 34840, - 'Yellowbrick' => 37609, - 'Yeloplay' => 37359, - 'YouTube' => 32338, - 'Z1 Battle Royale' => 37258, - ), - 'Australia' => array( - '2k' => 36402, - '4chan' => 38290, - 'AAPT telecommunications' => 35849, - 'ABC' => 37712, - 'Activ8me' => 36814, - 'Adam Internet' => 33877, - 'Adelaide Bank' => 36729, - 'Adobe Creative Cloud' => 34553, - 'Airbnb' => 36403, - 'Alexa' => 36922, - 'Amaysim' => 35654, - 'Amazon' => 36404, - 'Amazon Prime Video' => 36968, - 'Amazon Web Services' => 34093, - 'American Express' => 38173, - 'Amino Apps' => 39045, - 'Amnet' => 33878, - 'Anthem' => 38176, - 'ANZ' => 33364, - 'Apex Legends' => 38125, - 'App Store' => 35577, - 'Apple Music' => 35243, - 'Apple Store' => 34455, - 'ARK: Survival Evolved' => 38746, - 'Assassin\'s Creed' => 35008, - 'AusBBS' => 36812, - 'Aussie Broadband' => 37219, - 'Australia Post' => 37052, - 'Bank Australia' => 38682, - 'Bank of Melbourne' => 35356, - 'Bank of Queensland' => 37458, - 'Bank SA' => 35354, - 'Bankwest' => 34972, - 'Battlefield' => 37468, - 'Belong' => 37179, - 'Bendigo Bank' => 37715, - 'BigPond' => 33993, - 'Binance' => 36878, - 'Bing' => 32874, - 'Blizzard Battle.net' => 35053, - 'Blogger' => 32875, - 'Boom Beach' => 36871, - 'Box' => 32876, - 'Brawl Stars' => 38816, - 'Bumble' => 36809, - 'Call of Duty' => 36405, - 'Candy Crush' => 35092, - 'Centrelink' => 37549, - 'Centurylink' => 34131, - 'Citibank' => 36507, - 'Clash of Clans' => 37302, - 'Clash Royale' => 38953, - 'Cloudflare' => 32877, - 'ClubTelco' => 34792, - 'Coinbase' => 36802, - 'Coinspot' => 36927, - 'Commander' => 37714, - 'Commonwealth Bank' => 33362, - 'Counter-strike' => 35059, - 'Crackle' => 35112, - 'Craigslist' => 32878, - 'Crunchyroll' => 36717, - 'Dead By Daylight' => 37415, - 'Deezer' => 33833, - 'Deliveroo' => 38197, - 'Destiny' => 34894, - 'DFP' => 35002, - 'Discord' => 36022, - 'Dodo' => 34003, - 'Doordash' => 39006, - 'Dota 2' => 35405, - 'Dropbox' => 32879, - 'EA' => 34509, - 'eBay' => 32880, - 'Eftel' => 35553, - 'Elite: Dangerous' => 37104, - 'Escape from Tarkov' => 38626, - 'ESPN' => 38387, - 'Etsy' => 37481, - 'Exchange Online' => 34545, - 'Exetel' => 34478, - 'Facebook' => 32881, - 'Facebook Messenger' => 32882, - 'Facetime' => 34601, - 'Fallout' => 35432, - 'Far Cry' => 37190, - 'Fifa' => 35473, - 'Fitbit' => 38358, - 'Flickr' => 32883, - 'Fonality' => 34313, - 'For Honor' => 35989, - 'Fortnite' => 36622, - 'Foxtel' => 33355, - 'Funimation' => 38583, - 'Game of war' => 35416, - 'Gears of War' => 38894, - 'Ghost Recon' => 36008, - 'GitHub' => 36529, - 'Gmail' => 32885, - 'Go Daddy' => 36619, - 'Google' => 32886, - 'Google Drive' => 34274, - 'Google Hangouts' => 32887, - 'Google Home' => 37343, - 'Google Play' => 32888, - 'Gran Turismo' => 36875, - 'Grindr' => 35834, - 'GTA 5' => 34755, - 'Guild Wars 2' => 36408, - 'Gumtree' => 33360, - 'Halo' => 35449, - 'Hay Day' => 34786, - 'Hayu' => 38366, - 'Hearthstone' => 38660, - 'Heronet' => 35949, - 'Hipchat' => 34867, - 'Hootsuite' => 32889, - 'iCloud' => 32890, - 'iiNet' => 33555, - 'iMessage' => 32891, - 'Imgur' => 32892, - 'Instagram' => 32893, - 'Internode' => 34477, - 'iPrimus' => 33876, - 'iTunes' => 32894, - 'Kik' => 32895, - 'Kraken' => 36849, - 'Ladbrokes' => 37640, - 'LastPass' => 34776, - 'League of Legends' => 35049, - 'LinkedIn' => 32896, - 'Mail.com' => 34799, - 'Mailbox' => 34834, - 'Mate Communicate' => 36813, - 'McDonalds app' => 37525, - 'ME Bank' => 34973, - 'Microsoft Azure' => 32917, - 'Microsoft Teams' => 38694, - 'Minecraft' => 32897, - 'My Fitness Pal' => 37633, - 'My Republic' => 35982, - 'MyGov' => 37550, - 'MyNetFone' => 35481, - 'myob' => 36343, - 'NAB' => 33365, - 'National Broadband Network, NBN' => 35992, - 'NBA 2k' => 37736, - 'Netflix' => 35177, - 'Netregistry' => 36060, - 'Netspeed' => 34492, - 'Nine' => 37000, - 'Nintendo eShop' => 34102, - 'Nintendo Network' => 35517, - 'Nintendo Switch Online' => 37243, - 'No Man\'s Sky' => 37404, - 'Office 365' => 34543, - 'OkCupid' => 38489, - 'OneDrive' => 32904, - 'Optus' => 33356, - 'Origin' => 35091, - 'Outlook' => 32899, - 'Overwatch' => 35683, - 'Paladins' => 37656, - 'Pandora' => 33343, - 'Panthur' => 36346, - 'Path of Exile' => 36498, - 'Paypal' => 35607, - 'Photobucket' => 32901, - 'Pinterest' => 32902, - 'Player Unknown\'s Battlegrounds' => 36356, - 'Playstation Network' => 34842, - 'PlentyOfFish' => 37416, - 'Pokémon Go' => 35732, - 'Project Online' => 34542, - 'Quickbooks Online' => 38220, - 'Rabobank' => 34974, - 'Rabodirect' => 34975, - 'Rainbow Six' => 35478, - 'Red Dead Redemption' => 37740, - 'Reddit' => 34843, - 'Roblox' => 36832, - 'Rocket League' => 35497, - 'Runescape' => 35106, - 'Salesforce' => 32903, - 'Sarahah' => 36374, - 'SBS' => 34703, - 'Sea of Thieves' => 37115, - 'Sharepoint Online' => 34541, - 'Skype' => 32905, - 'Skype for Business' => 34544, - 'Slack' => 35937, - 'Slideshare' => 32906, - 'Smite' => 35406, - 'Snapchat' => 32907, - 'Soundcloud' => 37230, - 'Spintel' => 35962, - 'Sportsbet' => 37713, - 'Spotify' => 33225, - 'St. George Bank' => 35353, - 'Stan' => 35503, - 'Star Wars Battlefront' => 35552, - 'Steam' => 34114, - 'Steep' => 37922, - 'Summoners War' => 37418, - 'Suncorp Bank' => 34432, - 'Sure Telecom' => 34394, - 'Tango' => 33742, - 'Teamviewer' => 34617, - 'Telegram' => 34220, - 'Telstra' => 33354, - 'The Division' => 35572, - 'The elder scrolls online' => 36554, - 'The Simpsons Tapped out' => 38166, - 'Think Mobile' => 34527, - 'Tiktok' => 39061, - 'Tinder' => 34241, - 'TPG Telecom' => 33359, - 'Tumblr' => 32910, - 'TuneIn' => 38761, - 'Tweakbox' => 38346, - 'Twitch' => 35026, - 'Twitter' => 32911, - 'Uber' => 37766, - 'Uber Eats' => 38272, - 'Uberglobal' => 35669, - 'Udemy' => 37719, - 'Uplay PC' => 36211, - 'V4 Telecom' => 37357, - 'Vaya' => 34518, - 'Viber' => 32912, - 'Vimeo' => 32913, - 'Virgin Mobile' => 34516, - 'Vocus' => 37711, - 'Vodafone' => 33357, - 'War Thunder' => 38361, - 'Warframe' => 36495, - 'Wattpad' => 34694, - 'Waze' => 33405, - 'Webex' => 34162, - 'Weebly' => 34911, - 'Westnet' => 35163, - 'Westpac' => 33363, - 'WeTransfer' => 32914, - 'Whatsapp' => 32915, - 'Wikipedia' => 32916, - 'Wish' => 39042, - 'Wix' => 37255, - 'Wordpress.com' => 32918, - 'World of Tanks' => 37191, - 'World of Warcraft' => 35054, - 'WWE Network' => 38722, - 'Xbox Live' => 34283, - 'Xero' => 36177, - 'Yahoo' => 32919, - 'Yahoo Mail' => 32920, - 'Yahoo Messenger' => 32921, - 'Yelp' => 32922, - 'Youtube' => 32923, - 'Youtube Music' => 37590, - 'Z1 Battle Royale' => 35494, - 'Zettanet' => 35655, - 'ZoHo' => 37551, - ), - 'Canada' => array( - '2k' => 36723, - 'Acanac' => 34107, - 'Access' => 33069, - 'Aeroplan' => 35789, - 'Air Canada' => 37029, - 'Air Miles' => 35788, - 'Airbnb' => 37423, - 'Alaska Airlines' => 32926, - 'Alexa' => 37067, - 'Allegiant Air' => 32927, - 'Allstream' => 34001, - 'Altima Telecom' => 37247, - 'Amazon' => 32928, - 'Amazon Prime Music' => 38907, - 'Amazon Prime Video' => 37393, - 'Amazon Web Services' => 32929, - 'American Airlines' => 32930, - 'American Express' => 38172, - 'Amino Apps' => 38716, - 'Anthem' => 38056, - 'Anydesk' => 37488, - 'Apex Legends' => 38113, - 'App Store' => 35576, - 'Apple Music' => 35244, - 'Apple Store' => 34456, - 'ARK: Survival Evolved' => 38747, - 'Assassin\'s Creed' => 35007, - 'BabyTEL' => 35360, - 'Battlefield' => 36923, - 'BC Hydro' => 35806, - 'Beanfield' => 36663, - 'beIN' => 38788, - 'Bell' => 32936, - 'Bell Aliant' => 32937, - 'Bet365' => 39064, - 'Binance' => 36876, - 'Bing' => 32938, - 'Black Desert Online' => 38829, - 'Blizzard Battle.net' => 34488, - 'Blogger' => 32940, - 'BMO' => 34525, - 'Boom Beach' => 36558, - 'Box' => 32941, - 'Brama Telecom' => 34146, - 'Brawl Stars' => 38833, - 'Bumble' => 36330, - 'Call of Duty' => 34149, - 'Candy Crush' => 35093, - 'Capital One' => 36410, - 'Carry Telecom' => 37695, - 'CBC' => 34267, - 'Centurylink' => 35627, - 'Chatr' => 34005, - 'CIBC' => 34526, - 'CIK Telecom' => 34428, - 'Clash of Clans' => 34445, - 'Clash Royale' => 37523, - 'Cloudflare' => 32952, - 'CNN' => 37372, - 'Cogeco' => 32953, - 'Cogent' => 35556, - 'Coinbase' => 36800, - 'Colbanet' => 37542, - 'CommStream' => 36028, - 'Comwave' => 36641, - 'Counter-strike' => 35056, - 'Crackle' => 35111, - 'Craigslist' => 32956, - 'Crave TV' => 35621, - 'Crunchyroll' => 36716, - 'Dark Souls' => 36557, - 'DayZ' => 39007, - 'Dazn' => 36510, - 'Dead By Daylight' => 36595, - 'Delta Air Lines' => 32958, - 'Destiny' => 34892, - 'DFP' => 35001, - 'Diablo' => 39071, - 'Discord' => 36411, - 'Distributel' => 33870, - 'Doordash' => 38385, - 'Dota 2' => 35404, - 'Dropbox' => 32961, - 'EA' => 34504, - 'EA Sports UFC' => 36552, - 'Eastlink' => 33414, - 'eBay' => 32964, - 'Ebox' => 35555, - 'Ecobee' => 38021, - 'Elite: Dangerous' => 37103, - 'Epic Games Store' => 38767, - 'Equitable Bank' => 36731, - 'Escape from Tarkov' => 37975, - 'Etsy' => 38265, - 'Exchange Online' => 34546, - 'Execulink' => 34942, - 'Expedia' => 37401, - 'Facebook' => 32965, - 'Facebook Messenger' => 32966, - 'Facetime' => 34595, - 'Fallout' => 37663, - 'FedEx' => 36500, - 'Fido' => 34000, - 'Fifa' => 35472, - 'Fitbit' => 38359, - 'Flickr' => 32967, - 'For Honor' => 35986, - 'Fortnite' => 36623, - 'Forza' => 38836, - 'Fox News' => 37665, - 'Freedom Mobile' => 33869, - 'Friday the 13th The Game' => 37562, - 'Funimation' => 37744, - 'Game of war' => 35415, - 'Garmin Connect' => 38540, - 'Gears of War' => 35938, - 'Ghost Recon' => 37772, - 'GitHub' => 32970, - 'Gmail' => 32971, - 'Go Daddy' => 34943, - 'GO Transit' => 32972, - 'Google' => 32973, - 'Google Calendar' => 38599, - 'Google Cloud' => 38537, - 'Google Drive' => 34272, - 'Google Hangouts' => 32974, - 'Google Home' => 36141, - 'Google Play' => 32975, - 'GoToMeeting' => 36504, - 'Gran Turismo' => 36679, - 'Grindr' => 35835, - 'GTA 5' => 34756, - 'Guild Wars 2' => 36406, - 'Halo' => 35450, - 'Harry Potter: Wizards Unite' => 38687, - 'Hay Day' => 34789, - 'Hayu' => 38789, - 'Hearthstone' => 38289, - 'Hipchat' => 34868, - 'Hootsuite' => 32977, - 'HQ Trivia' => 37027, - 'HSBC' => 39048, - 'Hue' => 37814, - 'HughesNet' => 32978, - 'iCloud' => 32979, - 'iMessage' => 33249, - 'Imgur' => 32981, - 'IMVU' => 37472, - 'Indeed' => 38782, - 'Instagram' => 32982, - 'Interac' => 39049, - 'iTunes' => 32983, - 'JetBlue Airways' => 32984, - 'Jira' => 38545, - 'Juno' => 32985, - 'Kijiji' => 34303, - 'Kik' => 32986, - 'Koodo' => 34002, - 'Kraken' => 36750, - 'LastPass' => 36785, - 'League of Legends' => 35047, - 'Lightspeed' => 37066, - 'LinkedIn' => 32987, - 'Madden' => 38459, - 'Mail.com' => 34801, - 'Mailbox' => 34837, - 'McDonalds app' => 37642, - 'MCSNet' => 35889, - 'Metro Loop' => 37646, - 'Microsoft Azure' => 33057, - 'Minecraft' => 32996, - 'Mixer' => 38769, - 'MLB The Show' => 36989, - 'MLB TV' => 37189, - 'Montréal Metro' => 32997, - 'Mordhau' => 38734, - 'MTS' => 33868, - 'My Fitness Pal' => 37632, - 'NBA 2k' => 37722, - 'Nest' => 35191, - 'Netflix' => 32998, - 'NetZero' => 32999, - 'Neverwinter' => 35766, - 'Nintendo eShop' => 34101, - 'Nintendo Network' => 35518, - 'No Man\'s Sky' => 38779, - 'NorthernTel' => 34636, - 'Northwestel' => 35104, - 'Office 365' => 34548, - 'OkCupid' => 36678, - 'OneDrive' => 33020, - 'Oricom Internet' => 35829, - 'Origin' => 36584, - 'Outlook' => 33005, - 'Overwatch' => 35682, - 'Paladins' => 37664, - 'Path of Exile' => 36481, - 'Paypal' => 33010, - 'PC Optimum' => 37216, - 'Peer 1' => 34824, - 'Photobucket' => 33011, - 'Pinterest' => 33012, - 'Piper' => 38323, - 'Player Unknown\'s Battlegrounds' => 36337, - 'Playstation Network' => 33013, - 'PlentyOfFish' => 35798, - 'Pokémon Go' => 35734, - 'Pokerstars' => 38174, - 'Primus' => 33872, - 'Qtrade Financial Group' => 36025, - 'Quickbooks Online' => 37607, - 'Quizup' => 34297, - 'Rabb.it' => 37974, - 'Rainbow Six' => 35477, - 'RBC' => 34521, - 'Red Dead Redemption' => 37742, - 'Reddit' => 34845, - 'Roblox' => 35785, - 'Rocket League' => 35498, - 'Rogers' => 33015, - 'Runescape' => 35105, - 'Salesforce' => 33016, - 'Sarahah' => 36514, - 'Sasktel' => 33871, - 'Scotiabank' => 34523, - 'Sea of Thieves' => 37111, - 'Sharepoint Online' => 34550, - 'Shaw' => 33551, - 'Shopify' => 35863, - 'Signal' => 36328, - 'Simplii' => 37541, - 'SiriusXM' => 38561, - 'SkipTheDishes' => 38322, - 'Skype' => 33021, - 'Skype for Business' => 34547, - 'Slack' => 35935, - 'Slideshare' => 33023, - 'Smartsheet' => 38724, - 'Smite' => 37026, - 'Snapchat' => 33024, - 'Soundcloud' => 37229, - 'Sourceforge' => 33025, - 'Spotify' => 33028, - 'Star Wars Battlefront' => 35550, - 'Start Communications' => 34426, - 'Steam' => 33030, - 'Steep' => 37923, - 'Strava' => 38745, - 'Summoners War' => 37422, - 'Tangerine' => 37694, - 'Tango' => 33741, - 'Tbaytel' => 34612, - 'TD Canada Trust' => 34522, - 'Teamviewer' => 34618, - 'TekSavvy' => 33867, - 'Télébec' => 34110, - 'Telegram' => 34228, - 'Telnet' => 34158, - 'Telus' => 33034, - 'TeraGo' => 34427, - 'Tesla' => 38835, - 'The Division' => 35571, - 'The elder scrolls online' => 36399, - 'The Simpsons Tapped out' => 35109, - 'The Weather Channel' => 38837, - 'Ticketmaster' => 39008, - 'Tiktok' => 38727, - 'Tinder' => 34239, - 'Toronto Transit Commission' => 33036, - 'Translink' => 33039, - 'Trello' => 39066, - 'Trove' => 38825, - 'Tumblr' => 33040, - 'TuneIn' => 36037, - 'TurboTax' => 38213, - 'Tweakbox' => 38343, - 'Twitch' => 35023, - 'Twitter' => 33042, - 'Uber' => 37765, - 'Uber Eats' => 38022, - 'Udemy' => 37718, - 'United Airlines' => 33044, - 'Uplay PC' => 34688, - 'UPS' => 36831, - 'Vancity' => 37570, - 'Velcom' => 36978, - 'Viber' => 33348, - 'Vidéotron' => 33050, - 'Vimeo' => 33051, - 'Virgin Mobile' => 34096, - 'Visa' => 38693, - 'VMedia' => 34265, - 'Warface' => 38777, - 'Warframe' => 36370, - 'Wattpad' => 34695, - 'Waveapps' => 35960, - 'Waze' => 33406, - 'Webex' => 34160, - 'Weight Watchers' => 35664, - 'WestJet' => 35648, - 'WeTransfer' => 33054, - 'Whatsapp' => 33055, - 'Whisper' => 34625, - 'Wiband' => 34771, - 'Wikipedia' => 33056, - 'Wish' => 39041, - 'Wisp' => 37218, - 'Wordpress.com' => 33059, - 'World of Tanks' => 35527, - 'World of Warcraft' => 34264, - 'WWE Network' => 38717, - 'Xbox Live' => 33061, - 'Xplornet' => 34270, - 'Yahoo' => 33063, - 'Yahoo Mail' => 33064, - 'Yahoo Messenger' => 33065, - 'Yak' => 34269, - 'Yelp' => 33066, - 'Youtube' => 33067, - 'Youtube Music' => 37588, - 'Yubo' => 38961, - 'Z1 Battle Royale' => 35491, - 'ZoHo' => 36038, - 'Zoom' => 37947, - 'Zynga' => 33068, - ), - 'Hong Kong' => array( - '3 (Three)' => 36202, - 'Apex Legends' => 38178, - 'App Store' => 37734, - 'Binance' => 37023, - 'Blizzard Battle.net' => 36186, - 'China Mobile' => 36199, - 'Cloudflare' => 38641, - 'Counter-strike' => 38725, - 'CSL' => 36200, - 'Discord' => 38608, - 'EA' => 36700, - 'eBay' => 36207, - 'Facebook' => 36185, - 'Fifa' => 37604, - 'For Honor' => 36187, - 'Ghost Recon' => 38990, - 'Gmail' => 36188, - 'Google' => 36210, - 'Google Drive' => 38264, - 'Google Play' => 38526, - 'GTA 5' => 37279, - 'Hong Kong Broadband Network' => 36204, - 'iCloud' => 38842, - 'Instagram' => 36392, - 'Kraken' => 36984, - 'Netflix' => 36213, - 'Netvigator' => 36203, - 'Office 365' => 37172, - 'Origin' => 38396, - 'Outlook' => 36189, - 'Overwatch' => 36190, - 'PCCW' => 36205, - 'Player Unknown\'s Battlegrounds' => 36617, - 'Playstation Network' => 36191, - 'Pokémon Go' => 36192, - 'Rainbow Six' => 36193, - 'Reddit' => 37479, - 'Skype' => 36194, - 'Smartone' => 36198, - 'Snapchat' => 36208, - 'Steam' => 38650, - 'Telegram' => 36195, - 'Three Home Broadband' => 36206, - 'Tinder' => 36196, - 'TuneIn' => 36036, - 'Twitter' => 38671, - 'Uplay PC' => 36212, - 'Whatsapp' => 36197, - 'Yahoo Mail' => 36769, - 'Youtube' => 36209, - ), - 'Ireland' => array( - '3 (Three)' => 34214, - 'AIB (Allied Irish Banks)' => 34209, - 'Amazon' => 34165, - 'Apex Legends' => 38133, - 'App Store' => 38557, - 'Apple Store' => 34460, - 'Bank of Ireland' => 37754, - 'Battlefield' => 38451, - 'Binance' => 36931, - 'Bing' => 34166, - 'Boom Beach' => 35153, - 'Call of Duty' => 36709, - 'Counter-strike' => 38233, - 'Craigslist' => 34167, - 'Crunchyroll' => 38094, - 'Dead By Daylight' => 37444, - 'Deezer' => 34168, - 'Destiny' => 38615, - 'Digiweb' => 34875, - 'Discord' => 37390, - 'DoneDeal' => 34208, - 'EA' => 36413, - 'eBay' => 34169, - 'Eir' => 34210, - 'eMobile' => 34211, - 'Facebook' => 34170, - 'Facebook Messenger' => 34171, - 'Facetime' => 34584, - 'Fifa' => 36907, - 'Fortnite' => 36692, - 'Ghost Recon' => 38992, - 'Gmail' => 34172, - 'Google' => 34173, - 'Google Hangouts' => 34174, - 'Google Play' => 34175, - 'Gran Turismo' => 36949, - 'Grindr' => 36755, - 'GTA 5' => 37275, - 'Hootsuite' => 34176, - 'iCloud' => 34177, - 'Imagine' => 34425, - 'iMessage' => 34178, - 'Imgur' => 34179, - 'Instagram' => 34180, - 'iTunes' => 34181, - 'Kik' => 34182, - 'Kraken' => 36850, - 'Ladbrokes' => 37469, - 'LinkedIn' => 34183, - 'Magnet' => 34424, - 'Meteor' => 34213, - 'My Fitness Pal' => 37634, - 'Nest' => 35193, - 'Netflix' => 34184, - 'Now TV' => 38403, - 'O2' => 34206, - 'Office 365' => 35441, - 'Origin' => 36890, - 'Outlook' => 34185, - 'Overwatch' => 36484, - 'Paddy Power' => 38360, - 'Pinterest' => 34186, - 'Playstation Network' => 34187, - 'PlentyOfFish' => 37417, - 'Pokémon Go' => 35758, - 'Rabodirect' => 34979, - 'Rainbow Six' => 36414, - 'Red Dead Redemption' => 38288, - 'Reddit' => 36826, - 'Roblox' => 38756, - 'Rocket League' => 36670, - 'Ryanair' => 37253, - 'Sky' => 34212, - 'Skype' => 37018, - 'Sleepless' => 38314, - 'Snapchat' => 34188, - 'Spotify' => 34189, - 'Steam' => 34190, - 'Strava' => 38643, - 'Teamviewer' => 37631, - 'Telegram' => 37226, - 'Tinder' => 34864, - 'Tumblr' => 34191, - 'TuneIn' => 38760, - 'Twitch' => 36538, - 'Twitter' => 34192, - 'Ulster Bank' => 36070, - 'Viber' => 34193, - 'Vimeo' => 34194, - 'Virgin Media' => 34207, - 'Vodafone' => 34196, - 'Wattpad' => 34699, - 'Waze' => 34197, - 'Webex' => 34198, - 'Whatsapp' => 34199, - 'Wikipedia' => 34200, - 'Xbox Live' => 34201, - 'Yahoo' => 34202, - 'Yahoo Mail' => 34203, - 'Yahoo Messenger' => 34204, - 'Youtube' => 34205, - 'Youtube Music' => 37589, - 'Z1 Battle Royale' => 37261, - ), - 'Danmark' => array( - '3 (Tre)' => 33694, - 'Amazon' => 33651, - 'Anthem' => 38212, - 'Apex Legends' => 38128, - 'App Store' => 38554, - 'Apple Store' => 34452, - 'Battlefield' => 37097, - 'Bet365' => 38813, - 'Bibob' => 33841, - 'Binance' => 36930, - 'Blizzard Battle.net' => 36162, - 'Call of Duty' => 36707, - 'CBB Mobil' => 33840, - 'Counter-strike' => 36171, - 'Crunchyroll' => 36721, - 'Danske Bank' => 33687, - 'DBA' => 33700, - 'Dead By Daylight' => 37427, - 'Deezer' => 33835, - 'Destiny' => 36593, - 'Discord' => 36682, - 'DMI' => 36121, - 'Dota 2' => 36398, - 'EA' => 35496, - 'eBay' => 34862, - 'EnergiMidt' => 34435, - 'Epic Games Store' => 39025, - 'Ewii' => 34436, - 'Facebook' => 33652, - 'Facebook Messenger' => 33653, - 'Faceit' => 38542, - 'Facetime' => 34590, - 'Fifa' => 36621, - 'For Honor' => 36163, - 'Fortnite' => 36690, - 'Fullrate' => 34433, - 'Ghost Recon' => 39002, - 'Gmail' => 33654, - 'Google' => 33655, - 'Google Drive' => 36606, - 'Google Hangouts' => 33656, - 'Google Play' => 33657, - 'Gran Turismo' => 36915, - 'GTA 5' => 36170, - 'Guild Wars 2' => 36469, - 'HBO Nordic' => 35605, - 'iCloud' => 33658, - 'iMessage' => 33659, - 'Instagram' => 33660, - 'iTunes' => 33661, - 'Kik' => 33705, - 'Kraken' => 36851, - 'Kviknet' => 38016, - 'League of Legends' => 36166, - 'LinkedIn' => 33701, - 'Minecraft' => 37282, - 'Net 1' => 33725, - 'Netflix' => 33689, - 'Nordea' => 33688, - 'Office 365' => 35440, - 'OneDrive' => 36867, - 'Origin' => 36894, - 'Outlook' => 33662, - 'Overwatch' => 36165, - 'Path of Exile' => 36482, - 'Playerunknown\'s Battlegrounds' => 36334, - 'Playstation Network' => 33663, - 'Pokémon Go' => 35753, - 'Rainbow Six' => 36161, - 'Realm Royale' => 37306, - 'Red Dead Redemption' => 38306, - 'Reddit' => 36824, - 'Roblox' => 36612, - 'Rocket League' => 35977, - 'Sea of Thieves' => 37122, - 'Skype' => 33686, - 'Snapchat' => 33696, - 'Spotify' => 33697, - 'Steam' => 34277, - 'Stofa' => 34434, - 'TDC' => 33691, - 'Teamviewer' => 35693, - 'Telegram' => 37181, - 'Telenor' => 33692, - 'Telia' => 33693, - 'Tinder' => 34861, - 'Twitch' => 35508, - 'Twitter' => 33664, - 'Uplay PC' => 35610, - 'Viaplay' => 37292, - 'Viber' => 33698, - 'Warframe' => 36703, - 'Whatsapp' => 33703, - 'Wikipedia' => 33665, - 'Wordfeud' => 33702, - 'World of Warcraft' => 36168, - 'World of Warships' => 38013, - 'Xbox Live' => 33666, - 'Yahoo Mail' => 37043, - 'Youmusic' => 33699, - 'Yousee' => 33695, - 'Youtube' => 33667, - 'Z1 Battle Royale' => 37265, - ), - 'Sverige' => array( - '3 (Tre)' => 33722, - 'A3' => 38249, - 'Amazon' => 33668, - 'Anthem' => 38190, - 'Apex Legends' => 38115, - 'App Store' => 38101, - 'Apple Store' => 34450, - 'Bahnhof' => 34417, - 'BankID' => 33845, - 'Battlefield' => 37093, - 'Bet365' => 38812, - 'Binance' => 36933, - 'Blizzard Battle.net' => 34484, - 'Blocket' => 33716, - 'Boxer' => 37572, - 'Bredband2' => 34418, - 'Bredbandsbolaget' => 33843, - 'Bredbandsson' => 36049, - 'Call of Duty' => 35539, - 'Com Hem' => 34416, - 'Counter-strike' => 36172, - 'Crunchyroll' => 36719, - 'Dead By Daylight' => 36598, - 'Destiny' => 35510, - 'Discord' => 36764, - 'Dota 2' => 35876, - 'EA' => 35509, - 'eBay' => 34863, - 'Epic Games Store' => 39023, - 'Escape from Tarkov' => 38107, - 'Eurosport Player' => 36613, - 'Facebook' => 33669, - 'Facebook Messenger' => 33670, - 'Faceit' => 39039, - 'Facetime' => 34589, - 'Fifa' => 36909, - 'For Honor' => 35998, - 'Fortnite' => 36685, - 'Ghost Recon' => 39003, - 'Glocalnet' => 33842, - 'Gmail' => 33671, - 'Google' => 33672, - 'Google Drive' => 36604, - 'Google Hangouts' => 33673, - 'Google Play' => 33674, - 'Gran Turismo' => 36914, - 'Grindr' => 38739, - 'GTA 5' => 35528, - 'Guild Wars 2' => 36466, - 'Halebop' => 33726, - 'Handelsbanken' => 33718, - 'Hay Day' => 38214, - 'HBO Nordic' => 35606, - 'Hearthstone' => 38659, - 'Hue' => 37816, - 'iCloud' => 33675, - 'Ikano Bank' => 36578, - 'iMessage' => 33676, - 'Instagram' => 33677, - 'iTunes' => 33678, - 'Kik' => 33704, - 'Kraken' => 36829, - 'League of Legends' => 36167, - 'LinkedIn' => 33714, - 'Minecraft' => 37281, - 'Net 1' => 33724, - 'Netflix' => 33706, - 'Nordea' => 33717, - 'Office 365' => 35439, - 'OneDrive' => 36869, - 'Origin' => 36898, - 'Outlook' => 33679, - 'Overwatch' => 36150, - 'Path of Exile' => 36480, - 'Playerunknown\'s Battlegrounds' => 36333, - 'Playstation Network' => 33707, - 'Pokémon Go' => 35749, - 'Rainbow Six' => 36148, - 'Realm Royale' => 37307, - 'Red Dead Redemption' => 38285, - 'Reddit' => 35883, - 'Riksnet' => 37486, - 'Roblox' => 36611, - 'Rocket League' => 35487, - 'Sea of Thieves' => 37114, - 'SEB' => 33719, - 'SF Anytime' => 37573, - 'SkandiaBanken' => 34021, - 'Skype' => 33708, - 'Slack' => 37495, - 'Snapchat' => 33709, - 'Soundcloud' => 37228, - 'Sparbanken Öresund' => 34310, - 'Spotify' => 33838, - 'Steam' => 34135, - 'Swedbank' => 33713, - 'Swish' => 35777, - 'Teamviewer' => 35692, - 'Tele2' => 33721, - 'Telegram' => 35969, - 'Telenor' => 33723, - 'Telia' => 33720, - 'Telldus' => 36653, - 'The Elder Scrolls Online' => 36033, - 'Tinder' => 36458, - 'Tradera' => 33715, - 'Tumblr' => 36713, - 'Tweakbox' => 38905, - 'Twitch' => 35507, - 'Twitter' => 33710, - 'Uplay PC' => 35609, - 'Viaplay' => 37291, - 'Viber' => 37502, - 'Warframe' => 36701, - 'Whatsapp' => 33711, - 'Wikipedia' => 33682, - 'Wordfeud' => 33712, - 'World of Tanks' => 35488, - 'World of Warcraft' => 36169, - 'World of Warships' => 38011, - 'Xbox Live' => 33683, - 'Yahoo Mail' => 37044, - 'Youtube' => 33684, - 'Z1 Battle Royale' => 35493, - ), - 'Italia' => array( - '3 Italia' => 33154, - 'Airbnb' => 37345, - 'Aircomm' => 36015, - 'Alexa' => 38031, - 'AlternatYva' => 37556, - 'Amazon' => 33174, - 'Amazon Prime Video' => 36975, - 'Anthem' => 38189, - 'Anydesk' => 37491, - 'Apex Legends' => 38119, - 'App Store' => 37733, - 'Apple Store' => 34468, - 'Aruba' => 37217, - 'Banco di Napoli' => 33181, - 'Battlefield' => 37095, - 'Betclic' => 39047, - 'Binance' => 36937, - 'Blizzard Battle.net' => 37092, - 'BNL' => 33180, - 'Brawl Stars' => 38821, - 'Bwin' => 35072, - 'Call of Duty' => 35537, - 'Clash of Clans' => 34361, - 'Clash Royale' => 38956, - 'Cloudflare' => 38620, - 'Coinbase' => 36801, - 'Coopvoce' => 34921, - 'Counter-strike' => 36650, - 'Credito Emiliano - Credem' => 33182, - 'Crunchyroll' => 38095, - 'Dazn' => 37442, - 'Dead By Daylight' => 36627, - 'Deezer' => 33839, - 'Destiny' => 36590, - 'Discord' => 36767, - 'Dota 2' => 35878, - 'Dropbox' => 33175, - 'EA' => 34498, - 'EA Sports UFC' => 36553, - 'eBay' => 32816, - 'Elite: Dangerous' => 37774, - 'Eolo' => 37763, - 'Epic Games Store' => 39024, - 'Escape from Tarkov' => 39056, - 'Facebook' => 32812, - 'Facebook Messenger' => 32813, - 'Facetime' => 34598, - 'Fallout' => 37668, - 'Fastweb' => 33160, - 'Fifa' => 36910, - 'Fineco' => 33167, - 'Flickr' => 37949, - 'For Honor' => 35990, - 'Fortnite' => 36686, - 'Friday the 13th The Game' => 37564, - 'Ghost Recon' => 38999, - 'Gmail' => 32815, - 'Google' => 32805, - 'Google Drive' => 36607, - 'Google Hangouts' => 32806, - 'Google Play' => 32807, - 'Gran Turismo' => 36874, - 'Grindr' => 36757, - 'GTA5 ' => 35918, - 'Guild Wars 2' => 36472, - 'Ho' => 37408, - 'Hue' => 37813, - 'iCloud' => 33169, - 'Iliad' => 37214, - 'iMessage' => 33164, - 'Infinity' => 35972, - 'Infostrada' => 34711, - 'ING Direct' => 33168, - 'Instagram' => 33170, - 'Intesa Sanpaolo' => 33179, - 'iTunes' => 33159, - 'Kena Mobile' => 36364, - 'Kraken' => 36771, - 'League of Legends' => 37759, - 'Libero' => 33178, - 'LinkedIn' => 33177, - 'Linkem' => 34475, - 'Lottomatica' => 35068, - 'Lycamobile' => 38691, - 'Mc-link' => 34476, - 'Mediaset Premium' => 33162, - 'Mediaset TV Free' => 35973, - 'My Fitness Pal' => 37638, - 'Netflix' => 35568, - 'Neverwinter' => 36675, - 'NGI' => 34474, - 'Office 365' => 35443, - 'Onedrive' => 35411, - 'Origin' => 36897, - 'Outlook' => 33163, - 'Overwatch' => 36384, - 'Paddy Power' => 35069, - 'Paladins' => 36379, - 'Path of Exile' => 37726, - 'Paypal' => 33166, - 'Player Unknown\'s Battlegrounds' => 36567, - 'Playstation Network' => 32818, - 'Pokémon Go' => 35737, - 'Poste Italiane' => 37361, - 'PosteMobile' => 37360, - 'Quizduello' => 34299, - 'Rainbow Six' => 36045, - 'Red Dead Redemption' => 38284, - 'Reddit' => 37367, - 'Roblox' => 38916, - 'Rocket League' => 35979, - 'Ruzzle' => 33967, - 'Ryanair' => 37519, - 'Sea of Thieves' => 37121, - 'Sisal' => 35066, - 'Sky' => 33155, - 'Skype' => 32821, - 'Slack' => 37341, - 'SNAI' => 35067, - 'Snapchat' => 36446, - 'Spotify' => 33176, - 'Steam' => 34136, - 'Tango' => 33743, - 'Teamviewer' => 35455, - 'Telegram' => 34904, - 'The elder scrolls online' => 37327, - 'TIM' => 33158, - 'TimMusic' => 35913, - 'TimVision' => 35908, - 'Tinder' => 36549, - 'Tiscali' => 33161, - 'Tumblr' => 36712, - 'Tweakbox' => 38344, - 'Twitch' => 35604, - 'Twitter' => 32814, - 'UniCredit' => 33171, - 'Uplay PC' => 35613, - 'Viber' => 33403, - 'Virgilio' => 36459, - 'Vodafone' => 33156, - 'Warface' => 38765, - 'Warframe' => 36492, - 'WeBank' => 33173, - 'WeTransfer' => 36445, - 'Whatsapp' => 32820, - 'Wifi Trenitalia' => 35974, - 'Wikipedia' => 33172, - 'William Hill' => 35070, - 'Wind' => 33157, - 'World of Warships' => 38012, - 'Xbox Live' => 32819, - 'Yahoo' => 32809, - 'Yahoo Mail' => 32810, - 'Yahoo Messenger' => 32811, - 'Youtube' => 32808, - 'Z1 Battle Royale' => 37254, - 'ZoHo' => 37554, - ), - 'South Africa' => array( - 'ABSA' => 33599, - 'Afrihost' => 35660, - 'Amazon' => 33578, - 'Apex Legends' => 38157, - 'Apple Store' => 34458, - 'Axxess' => 35618, - 'Bidorbuy' => 33596, - 'Binance' => 36941, - 'Bing' => 33579, - 'Blizzard Battle.net' => 37661, - 'Call of Duty' => 34152, - 'Capitec' => 36966, - 'Cell C' => 33602, - 'Clash Royale' => 38946, - 'Cool Ideas' => 38326, - 'Counter-strike' => 36512, - 'Cybersmart' => 34619, - 'Dead By Daylight' => 37435, - 'Deezer' => 33834, - 'Destiny' => 38981, - 'DirecTV Now' => 35915, - 'Discord' => 38611, - 'Discovery' => 38334, - 'Dota 2' => 36428, - 'DSTv' => 33607, - 'EA' => 34507, - 'eBay' => 33581, - 'Eskom' => 38333, - 'Facebook' => 33582, - 'Facebook Messenger' => 37737, - 'Fifa' => 38436, - 'First National Bank (FNB)' => 33597, - 'Fortnite' => 36710, - 'Ghost Recon' => 38994, - 'Gmail' => 33583, - 'Google' => 33584, - 'Google Hangouts' => 33585, - 'Google Play' => 33586, - 'Gov.za' => 38328, - 'GTA 5' => 37273, - 'Guild Wars 2' => 36501, - 'Gumtree' => 33592, - 'iBurst' => 34620, - 'iCloud' => 33587, - 'Imaginet' => 38325, - 'Instagram' => 33588, - 'Internet Solutions' => 38332, - 'iTunes' => 33589, - 'Kik' => 33590, - 'Kraken' => 36985, - 'League of Legends' => 38633, - 'LinkedIn' => 33591, - 'Luno' => 37012, - 'Metrofibre' => 38335, - 'MTN' => 33601, - 'MWEB' => 33606, - 'Nedbank' => 35461, - 'Neotel' => 35426, - 'Netflix' => 36390, - 'Octotel' => 38327, - 'Office 365' => 35444, - 'OLX' => 33255, - 'Openserve' => 38331, - 'Origin' => 36886, - 'Outlook' => 33566, - 'Overwatch' => 36149, - 'Paypal' => 33567, - 'Pinterest' => 33568, - 'Player Unknown\'s Battlegrounds' => 36642, - 'Playstation Network' => 33569, - 'Pokémon Go' => 35756, - 'Rain' => 37397, - 'Rainbow Six' => 37738, - 'Reddit' => 38362, - 'Safricom' => 35139, - 'SARS' => 38336, - 'Seacom' => 38329, - 'Showmax' => 37149, - 'Skype' => 33570, - 'Snapchat' => 33571, - 'Standard Bank' => 33598, - 'Steam' => 34223, - 'Takealot' => 36777, - 'Teamviewer' => 37480, - 'Telegram' => 37184, - 'Telkom' => 33603, - 'The elder scrolls online' => 37325, - 'The Simpsons Tapped out' => 35903, - 'Tinder' => 37166, - 'Twitch' => 38702, - 'Twitter' => 33572, - 'Viber' => 33595, - 'Virgin Mobile' => 38324, - 'Vodacom' => 33600, - 'Vox' => 35368, - 'Vumatel' => 37660, - 'Warframe' => 37125, - 'Webafrica' => 38330, - 'WeChat' => 33594, - 'Whatsapp' => 33573, - 'Wikipedia' => 33574, - 'Xbox Live' => 34285, - 'Yahoo' => 33575, - 'Yahoo Mail' => 33576, - 'Youtube' => 33577, - ), - 'India' => array( - 'ACT' => 34764, - 'Aircel' => 33257, - 'Airtel' => 33233, - 'Alexa' => 36921, - 'AliExpress' => 37824, - 'Amazon' => 35118, - 'Amazon Prime Music' => 37583, - 'Amazon Prime Video' => 36974, - 'Amazon Web Services' => 38411, - 'Apex Legends' => 38180, - 'App Store' => 38556, - 'Apple Store' => 34459, - 'Bank of Baroda' => 37676, - 'Bank of India' => 33250, - 'Bharat Sanchar Nigam Limited (BSNL)' => 33232, - 'Binance' => 36932, - 'Bitbucket' => 36988, - 'Boom Beach' => 34782, - 'Brawl Stars' => 38822, - 'Call of Duty' => 34153, - 'Cherrinet' => 37677, - 'Clash of Clans' => 36872, - 'Clash Royale' => 38955, - 'Cloudflare' => 38652, - 'Counter-strike' => 38240, - 'Destiny' => 38989, - 'Discord' => 38106, - 'Dota 2' => 36393, - 'Dropbox' => 33237, - 'EA' => 34508, - 'eBay' => 32863, - 'Facebook' => 32860, - 'Facebook Messenger' => 33238, - 'Facetime' => 34596, - 'Fifa' => 38399, - 'Flipkart' => 33252, - 'Fortnite' => 36843, - 'GitHub' => 37606, - 'Gmail' => 32862, - 'Go Daddy' => 37499, - 'Google' => 32857, - 'Google Drive' => 38261, - 'Google Hangouts' => 32869, - 'Google Home' => 37344, - 'Google Play' => 33239, - 'GTA 5' => 37272, - 'Haptik' => 34762, - 'Hathway' => 37673, - 'Hay Day' => 34787, - 'HDFC Bank' => 33231, - 'ICICI Bank' => 37674, - 'iCloud' => 33240, - 'IDBI Bank' => 37678, - 'Idea Cellular' => 33234, - 'Idian Bank' => 37670, - 'iMessage' => 33248, - 'Instagram' => 33241, - 'IRCTC' => 37671, - 'iTunes' => 33230, - 'Jio' => 37460, - 'Kik' => 33351, - 'Line' => 33400, - 'LinkedIn' => 33242, - 'Mahanagar Telephone Nigam Limited (MTNL)' => 33258, - 'Microsoft Azure' => 36112, - 'MTS' => 33260, - 'Naukri' => 33253, - 'Netflix' => 37299, - 'Office 365' => 37174, - 'OLX' => 33593, - 'ooVoo' => 34084, - 'Origin' => 38378, - 'Outlook' => 33243, - 'Paypal' => 33244, - 'Paytm' => 33401, - 'Pinterest' => 37231, - 'Player Unknown\'s Battlegrounds' => 36643, - 'Playstation Network' => 32865, - 'Pokémon Go' => 35755, - 'Quora' => 37473, - 'Rainbow Six' => 36412, - 'Reddit' => 38061, - 'Reliance' => 33236, - 'Roblox' => 38921, - 'Salesforce' => 34734, - 'Sarahah' => 36513, - 'Skype' => 32868, - 'Snapchat' => 36741, - 'Spotify' => 37079, - 'Standard Chartered' => 33235, - 'State Bank of India (SBI)' => 33251, - 'Steam' => 35930, - 'Swiggy' => 37675, - 'Tango' => 33744, - 'Tata Docomo' => 33256, - 'Teamviewer' => 34346, - 'Telegram' => 36582, - 'Tiktok' => 38661, - 'Tinder' => 37167, - 'Tumblr' => 37641, - 'Twitch' => 38355, - 'Twitter' => 32861, - 'Uber Eats' => 38898, - 'Udemy' => 38389, - 'Uninor' => 33259, - 'Uplay PC' => 36628, - 'Viber' => 33049, - 'Videocon' => 33261, - 'Vimeo' => 37232, - 'Vodafone' => 33254, - 'Warframe' => 37124, - 'Wattpad' => 34698, - 'Waze' => 33408, - 'Webex' => 34163, - 'WeChat' => 33396, - 'Whatsapp' => 32867, - 'Wikipedia' => 33245, - 'Xbox Live' => 32866, - 'Yahoo' => 32859, - 'Yahoo Mail' => 33246, - 'Yahoo Messenger' => 33247, - 'You Broadband' => 34763, - 'Youtube' => 32858, - 'Zee5' => 38647, - 'Zerodha' => 37207, - 'ZoHo' => 37552, - 'Zomato' => 37672, - ), - 'Portugal' => array( - 'ActivoBank' => 33959, - 'Apex Legends' => 38127, - 'Apple Store' => 34464, - 'Banco Espírito Santo' => 33961, - 'Banco Santander Totta' => 33960, - 'Binance' => 36945, - 'Bing' => 33925, - 'Blizzard Battle.net' => 37225, - 'Cabovisão' => 33956, - 'Caixa Geral de Depósitos' => 33957, - 'Call of Duty' => 38982, - 'Clash Royale' => 38957, - 'Counter-Strike' => 36651, - 'Dead By Daylight' => 37429, - 'Deezer' => 33927, - 'Destiny' => 38964, - 'Discord' => 37388, - 'Dota 2' => 38479, - 'EA' => 36452, - 'eBay' => 33928, - 'Facebook' => 33929, - 'Facebook Messenger' => 34847, - 'Facetime' => 34586, - 'Fifa' => 36905, - 'Fortnite' => 36695, - 'Gmail' => 33930, - 'Go Daddy' => 34849, - 'Google' => 33931, - 'Google Play' => 33932, - 'GTA 5' => 36857, - 'HBO' => 38379, - 'iCloud' => 33933, - 'iMessage' => 33934, - 'Instagram' => 33963, - 'iTunes' => 33935, - 'Kik' => 33943, - 'Kraken' => 36980, - 'League of Legends' => 38635, - 'LinkedIn' => 33936, - 'MEO' => 33950, - 'Millennium Bcp' => 33958, - 'Montepio' => 33962, - 'Netflix' => 37301, - 'NOS' => 33952, - 'Office 365' => 37175, - 'OLX' => 33949, - 'OneDrive' => 36870, - 'Optimus' => 33951, - 'Origin' => 36892, - 'Outlook' => 33937, - 'Overwatch' => 37082, - 'Paypal' => 33938, - 'Pinterest' => 37373, - 'Player Unknown\'s Battlegrounds' => 36566, - 'Playstation Network' => 33939, - 'Pokémon Go' => 35754, - 'Rainbow Six' => 36454, - 'Reddit' => 38068, - 'Roblox' => 38920, - 'Rocket League' => 36668, - 'Skype' => 33940, - 'Snapchat' => 36742, - 'Spotify' => 33964, - 'Steam' => 34138, - 'Teamviewer' => 35691, - 'Telegram' => 37186, - 'Tinder' => 37921, - 'Twitch' => 36453, - 'Twitter' => 33941, - 'Uplay PC' => 38651, - 'Uzo' => 33954, - 'Viber' => 33942, - 'Vodafone' => 33953, - 'Warframe' => 37135, - 'Whatsapp' => 34855, - 'Wikipedia' => 33944, - 'Xbox Live' => 33945, - 'Yahoo' => 33946, - 'Yahoo Mail' => 33947, - 'Youtube' => 33948, - 'Z1 Battle Royale' => 37264, - ), - 'Schweiz' => array( - 'Airbnb' => 37666, - 'Anthem' => 38205, - 'Apex Legends' => 38130, - 'App Store' => 35583, - 'Apple Store' => 34462, - 'Battlefield' => 38050, - 'Bet365' => 38831, - 'Binance' => 36935, - 'Blizzard Battle.net' => 36569, - 'Call of Duty' => 34157, - 'Clash Royale' => 38958, - 'Coinbase' => 36805, - 'Counter-strike' => 36646, - 'Crunchyroll' => 38144, - 'Dazn' => 36509, - 'Dead By Daylight' => 37413, - 'Deezer' => 34038, - 'Destiny' => 36591, - 'Discord' => 37389, - 'Dota 2' => 35402, - 'Dropbox' => 34039, - 'EA' => 34503, - 'eBay' => 34040, - 'Epic Games Store' => 39026, - 'Evard' => 35642, - 'Facebook' => 34041, - 'Facebook Messenger' => 34885, - 'Facetime' => 34585, - 'Fifa' => 37089, - 'flashcable' => 37202, - 'Flickr' => 34042, - 'For Honor' => 35997, - 'Fortnite' => 36688, - 'Ghost Recon' => 36006, - 'Gmail' => 34043, - 'GMX' => 34044, - 'Google' => 34045, - 'Google Hangouts' => 34046, - 'Google Play' => 34047, - 'Green' => 36971, - 'Grindr' => 38742, - 'GTA 5' => 35502, - 'Guild Wars 2' => 36465, - 'Hay Day' => 34853, - 'iCloud' => 34048, - 'ICQ' => 34049, - 'iMessage' => 34050, - 'Instagram' => 34051, - 'iTunes' => 34052, - 'Kik' => 34054, - 'Kraken' => 36807, - 'League of Legends' => 35574, - 'LinkedIn' => 34055, - 'Lovoo' => 35401, - 'Mail.de' => 38629, - 'Netatmo' => 38467, - 'Netflix' => 34632, - 'Nitrado' => 35547, - 'Office 365' => 36581, - 'OneDrive' => 35413, - 'Origin' => 36891, - 'Outlook' => 34057, - 'Overwatch' => 36152, - 'Peoplefone' => 36602, - 'Playerunknown\'s Battlegrounds' => 36560, - 'Playstation Network' => 34087, - 'Pokémon Go' => 35744, - 'PostFinance' => 36048, - 'Protonmail' => 35643, - 'Quickline' => 36970, - 'Quizduell' => 34529, - 'Rainbow Six' => 36147, - 'Red Dead Redemption' => 37741, - 'Reddit' => 38067, - 'Ricardo' => 34079, - 'Rocket League' => 35486, - 'Salt' => 35602, - 'Search.ch' => 34080, - 'Sky Sport' => 37069, - 'Skype' => 34059, - 'Snapchat' => 34060, - 'Spotify' => 34061, - 'SRF' => 34081, - 'Steam' => 34226, - 'Sunrise' => 34078, - 'Swisscom' => 34077, - 'Tango' => 34062, - 'Teamviewer' => 34616, - 'Telegram' => 35968, - 'The elder scrolls online' => 35763, - 'Threema' => 34256, - 'Tinder' => 34244, - 'Tumblr' => 34063, - 'TuneIn' => 38775, - 'Tutti' => 36014, - 'Tweakbox' => 38904, - 'Twint' => 38183, - 'Twitch' => 35505, - 'Twitter' => 34064, - 'UBS' => 34082, - 'UPC' => 34075, - 'Uplay PC' => 34690, - 'Viber' => 34065, - 'Viewster' => 34325, - 'Vimeo' => 34066, - 'Waze' => 34067, - 'Web.de' => 34068, - 'Whatsapp' => 34069, - 'Wikipedia' => 34070, - 'World of Warships' => 38015, - 'Xbox Live' => 34088, - 'Yahoo Mail' => 34072, - 'Yahoo Messenger' => 34073, - 'Yallo' => 37298, - 'Youtube' => 34074, - 'Z1 Battle Royale' => 35492, - 'Zattoo' => 35366, - ), - 'Singapore' => array( - 'Airbnb' => 37608, - 'Apex Legends' => 38182, - 'App Store' => 37735, - 'Apple Store' => 34682, - 'Binance' => 36944, - 'Bing' => 34652, - 'Blizzard Battle.net' => 36431, - 'Brawl Stars' => 38820, - 'Call of Duty' => 39051, - 'Clash of Clans' => 34667, - 'Clash Royale' => 38952, - 'Counter-strike' => 36556, - 'DBS' => 34658, - 'Destiny' => 38986, - 'Discord' => 37100, - 'Dota 2' => 35875, - 'Dropbox' => 34671, - 'EA' => 36429, - 'Facebook' => 34645, - 'Facebook Messenger' => 34646, - 'Facetime' => 34681, - 'Fifa' => 38398, - 'Fortnite' => 37321, - 'Ghost Recon' => 38995, - 'Gmail' => 34647, - 'Google' => 34648, - 'Google Drive' => 34649, - 'Google Hangouts' => 38364, - 'Google Play' => 38519, - 'Grindr' => 36754, - 'GTA 5' => 37278, - 'HSBC' => 34672, - 'iCloud' => 34685, - 'iMessage' => 34684, - 'Instagram' => 34665, - 'iTunes' => 34683, - 'League of Legends' => 38748, - 'Line' => 34740, - 'LinkedIn' => 34656, - 'M1' => 34662, - 'Microsoft Azure' => 36113, - 'Minecraft' => 38738, - 'My Republic' => 36463, - 'Netflix' => 36391, - 'Office 365' => 37173, - 'OkCupid' => 38490, - 'Origin' => 38377, - 'Outlook' => 34673, - 'Overwatch' => 36389, - 'Path of Exile' => 37723, - 'Player Unknown\'s Battlegrounds' => 36361, - 'Playstation Network' => 34668, - 'Pokémon Go' => 35757, - 'POSB' => 34738, - 'Rainbow Six' => 36382, - 'Reddit' => 37475, - 'Roblox' => 37286, - 'SingTel' => 34661, - 'Skype' => 36418, - 'Snapchat' => 34666, - 'Spotify' => 34739, - 'Standard Chartered' => 34743, - 'Starhub' => 34663, - 'Steam' => 36430, - 'Summoners War' => 37421, - 'Taobao' => 34660, - 'Telegram' => 36381, - 'Tinder' => 37137, - 'Tumblr' => 36711, - 'Tweakbox' => 38345, - 'Twitch' => 36539, - 'Twitter' => 34659, - 'Uplay PC' => 37521, - 'Viber' => 34670, - 'Viewqwest' => 37506, - 'Warframe' => 36494, - 'WeChat' => 34674, - 'Whatsapp' => 34664, - 'Whisper' => 34624, - 'Wikipedia' => 34655, - 'Xbox Live' => 34669, - 'Yahoo' => 34654, - 'Yahoo Mail' => 34653, - 'Youtube' => 34650, - ), - 'Türkiye' => array( - 'Akbank' => 33783, - 'Anthem' => 38195, - 'Apex Legends' => 38121, - 'Apple Store' => 34451, - 'Battlefield' => 38449, - 'Binance' => 36952, - 'Blizzard Battle.net' => 37224, - 'Call of Duty' => 38940, - 'Counter-strike' => 36652, - 'D-Smart' => 34422, - 'Dead By Daylight' => 37433, - 'Denizbank' => 33788, - 'Destiny' => 38984, - 'Discord' => 36766, - 'Dota 2' => 36396, - 'EA' => 36157, - 'Facebook' => 33748, - 'Facebook Messenger' => 33749, - 'Facetime' => 34588, - 'Fifa' => 37087, - 'Finansbank' => 33786, - 'For Honor' => 37369, - 'Fortnite' => 36698, - 'Garanti' => 33780, - 'GittiGidiyor' => 33779, - 'Gmail' => 33750, - 'Google' => 33751, - 'Google Hangouts' => 33752, - 'Google Play' => 33753, - 'Gran Turismo' => 36947, - 'GTA 5' => 37276, - 'HalkBank' => 33784, - 'HSBC' => 33790, - 'iCloud' => 33754, - 'iMessage' => 33755, - 'ING Bank' => 33789, - 'Instagram' => 33732, - 'iTunes' => 33756, - 'Kik' => 33757, - 'Kraken' => 36986, - 'LinkedIn' => 33758, - 'Messageme' => 33727, - 'Netflix' => 37300, - 'Office 365' => 38058, - 'Origin' => 36901, - 'Outlook' => 33759, - 'Overwatch' => 36386, - 'Player Unknown\'s Battlegrounds' => 36456, - 'Playstation Network' => 33760, - 'Rainbow Six' => 36154, - 'Reddit' => 38064, - 'Roblox' => 38915, - 'Rocket League' => 36669, - 'Sahibinden' => 33778, - 'Skype' => 33761, - 'Snapchat' => 33762, - 'Steam' => 37322, - 'Tango' => 33730, - 'Telegram' => 37090, - 'Tinder' => 37911, - 'Ttnet' => 33776, - 'Türk Ekonomi Bankası (TEB)' => 33791, - 'Türk Telekom' => 33771, - 'Turkcell' => 33774, - 'Türkiye İş Bankası' => 33781, - 'Türksat Kablo' => 33772, - 'Twitch' => 35506, - 'Twitter' => 33613, - 'Uplay PC' => 36156, - 'VakıfBank' => 33785, - 'Viber' => 33733, - 'Vodafone' => 33775, - 'Warframe' => 37128, - 'Whatsapp' => 33764, - 'Xbox Live' => 33766, - 'Yahoo Mail' => 38885, - 'Yandex' => 33777, - 'Yapı Kredi' => 33782, - 'Youtube' => 33767, - 'Ziraat Bankası' => 33787, - ), - 'Suomi' => array( - 'Aktia' => 36308, - 'Apex Legends' => 38123, - 'Battlefield' => 37096, - 'Binance' => 36929, - 'Blizzard Battle.net' => 36299, - 'Call of Duty' => 36706, - 'Clash Royale' => 38947, - 'Counter-strike' => 36645, - 'Crunchyroll' => 38097, - 'Dankse Bank' => 36307, - 'Dead By Daylight' => 37428, - 'Destiny' => 38531, - 'Discord' => 36681, - 'DNA' => 36304, - 'Dota 2' => 36434, - 'EA' => 36295, - 'Elisa' => 36303, - 'Epic Games Store' => 39030, - 'Escape from Tarkov' => 39057, - 'Facebook' => 36237, - 'Facebook Messenger' => 36240, - 'Faceit' => 39038, - 'Fifa' => 37603, - 'Fortnite' => 36691, - 'Ghost Recon' => 38998, - 'Gmail' => 36254, - 'Google' => 36253, - 'Google Play' => 38522, - 'Gran Turismo' => 36912, - 'GTA 5' => 36844, - 'Guild Wars 2' => 36471, - 'HBO Nordic' => 36302, - 'iCloud' => 38841, - 'Instagram' => 36435, - 'Kraken' => 36852, - 'League of Legends' => 38632, - 'Minecraft' => 38616, - 'Netflix' => 36252, - 'Nordea' => 36306, - 'Office 365' => 36297, - 'OP' => 36305, - 'Origin' => 36904, - 'Outlook' => 36251, - 'Overwatch' => 36250, - 'Path of Exile' => 36530, - 'Paypal' => 37347, - 'Playerunknown\'s Battlegrounds' => 36339, - 'Playstation Network' => 36249, - 'Pokémon Go' => 36248, - 'Rainbow Six' => 36247, - 'Reddit' => 36823, - 'Roblox' => 38913, - 'Rocket League' => 36300, - 'Runescape' => 38655, - 'Sea of Thieves' => 37118, - 'Skype' => 36246, - 'Slack' => 37496, - 'Snapchat' => 36245, - 'Spotify' => 37078, - 'Steam' => 36296, - 'Telegram' => 37091, - 'Telia' => 36287, - 'Tinder' => 36298, - 'Twitch' => 36436, - 'Twitter' => 36677, - 'Uplay PC' => 36244, - 'Viaplay' => 37294, - 'Viber' => 37494, - 'Warframe' => 36704, - 'Whatsapp' => 36243, - 'Wikipedia' => 38871, - 'World of Tanks' => 36673, - 'Xbox Live' => 36301, - 'Yahoo Mail' => 38883, - 'Youtube' => 36242, - 'Z1 Battle Royale' => 37259, - ), - 'France' => array( - 'Albion Online' => 38796, - 'Alexa' => 38030, - 'Alice' => 34009, - 'Amazon' => 32693, - 'Amazon Web Services' => 38109, - 'Ameli' => 38705, - 'Amen' => 35888, - 'Anthem' => 38207, - 'Apex Legends' => 38116, - 'App Store' => 35582, - 'Apple Store' => 34469, - 'AXA Banque' => 33427, - 'Bankin\'' => 37592, - 'Banque Populaire' => 33432, - 'Battlefield' => 38057, - 'Betclic' => 39046, - 'Binance' => 36939, - 'Bing' => 32696, - 'BlaBlaCar' => 36089, - 'Blizzard Battle.net' => 36437, - 'Blogger' => 32697, - 'BNP Paribas' => 32749, - 'Boom Beach' => 35154, - 'Bouygues Télécom' => 32747, - 'Brawl Stars' => 38815, - 'Caisse d\'allocations familiales' => 38708, - 'Caisse d\'Epargne' => 33428, - 'Call of Duty' => 35037, - 'CanalSat' => 34034, - 'Candy Crush' => 35881, - 'Cdiscount' => 35038, - 'CIC' => 33431, - 'Clash Royale' => 38151, - 'Cloudflare' => 38622, - 'Coinbase' => 36830, - 'Completel' => 33982, - 'Compte Nickel' => 35983, - 'Counter-Strike' => 36649, - 'Crédit Agricole' => 32753, - 'Crédit Mutuel' => 33429, - 'Crunchyroll' => 38096, - 'Dartybox' => 32848, - 'Dead By Daylight' => 37425, - 'Deezer' => 32843, - 'Destiny' => 34951, - 'Discord' => 36763, - 'Dofus Touch' => 38711, - 'Dota 2' => 35877, - 'Dropbox' => 32700, - 'Duel Quiz' => 34530, - 'EA' => 32844, - 'eBay' => 32701, - 'Elite: Dangerous' => 37776, - 'Epic Games Store' => 39029, - 'Escape from Tarkov' => 39058, - 'Facebook' => 32702, - 'Facebook Messenger' => 32703, - 'Facetime' => 34602, - 'Fallout' => 37667, - 'Fifa' => 35475, - 'Flickr' => 32704, - 'Fnac' => 35065, - 'For Honor' => 35995, - 'Fortnite' => 36656, - 'Free' => 32744, - 'Gandi' => 38712, - 'Ghost Recon' => 38959, - 'GitHub' => 32705, - 'Gmail' => 32706, - 'Google' => 32707, - 'Google Agenda' => 38601, - 'Google Drive' => 36608, - 'Google Hangouts' => 32709, - 'Google Play' => 32708, - 'Gran Turismo' => 36911, - 'Grindr' => 36756, - 'GTA 5' => 35039, - 'Guild Wars 2' => 36468, - 'Hearthstone' => 38562, - 'Hootsuite' => 32710, - 'Hue' => 37815, - 'Hunt: showdown' => 38824, - 'iCloud' => 32712, - 'Idealo' => 34890, - 'iMessage' => 32713, - 'ING Direct' => 33426, - 'Instagram' => 32715, - 'iTunes' => 32716, - 'JeuxVidéo' => 32847, - 'K-net' => 37790, - 'Kraken' => 36753, - 'La Banque Postale' => 32754, - 'La Poste' => 37215, - 'La Poste Mobile' => 32752, - 'LCL (Crédit Lyonnais)' => 33424, - 'Le Bon Coin' => 32840, - 'League of Legends' => 35369, - 'LinkedIn' => 32717, - 'LycaMobile' => 32849, - 'M6 Mobile' => 32845, - 'Magic' => 37456, - 'Météo France' => 38706, - 'Microsoft Azure' => 36115, - 'Minecraft' => 32718, - 'Molotov.TV' => 35965, - 'myCanal' => 38710, - 'Netatmo' => 37957, - 'Netflix' => 34633, - 'Nintendo eShop' => 34103, - 'Nordnet' => 38709, - 'NRJ Mobile' => 32846, - 'Numéricable' => 32748, - 'OCS' => 38707, - 'Office 365' => 35126, - 'Onedrive' => 35409, - 'Online.net' => 36159, - 'Orange' => 32745, - 'Orange Bank' => 37511, - 'Origin' => 36896, - 'Outlook' => 32711, - 'Overwatch' => 36151, - 'OVH' => 34744, - 'Paladins' => 36378, - 'Path of Exile' => 36855, - 'Paypal' => 32842, - 'Photobucket' => 32721, - 'Pinterest' => 32722, - 'Player Unknown\'s Battlegrounds' => 36363, - 'Playstation Network' => 32741, - 'Pokémon Go' => 35735, - 'Prime Video' => 38457, - 'Rainbow Six' => 35562, - 'Rakuten TV' => 37596, - 'Realm Royale' => 37308, - 'Red Dead Redemption' => 38287, - 'Reddit' => 37368, - 'Roblox' => 38755, - 'Rocket League' => 35253, - 'Salesforce' => 38501, - 'Sea of Thieves' => 37117, - 'SFR' => 32746, - 'Shadow' => 37212, - 'Skype' => 32723, - 'Skyrock.com' => 33430, - 'Slack' => 37337, - 'Slideshare' => 32724, - 'Snapchat' => 33378, - 'Société Générale' => 33425, - 'Sosh' => 34830, - 'Spotify' => 32727, - 'Star Citizen' => 39037, - 'Steam' => 32742, - 'Streamlabs' => 38927, - 'Syma' => 37449, - 'Tango' => 34482, - 'Teamviewer' => 34908, - 'Telegram' => 34230, - 'The elder scrolls online' => 37326, - 'Tinder' => 34808, - 'Tumblr' => 32728, - 'TuneIn' => 38758, - 'Tweakbox' => 38347, - 'Twitch' => 35027, - 'Twitter' => 32729, - 'Uber Eats' => 38897, - 'Unibet' => 38274, - 'Uplay PC' => 35544, - 'Viber' => 33373, - 'Vimeo' => 32730, - 'Warface' => 38776, - 'Warframe' => 36368, - 'Waze' => 33410, - 'WeTransfer' => 32731, - 'Whatsapp' => 32575, - 'Wibox' => 37055, - 'Wikipedia' => 32732, - 'Wordpress.com' => 32734, - 'World of Tanks' => 36030, - 'Xbox Live' => 32743, - 'Yahoo' => 32735, - 'Yahoo Mail' => 32736, - 'Yahoo Messenger' => 32737, - 'Youtube' => 32739, - 'Z1 Battle Royale' => 37260, - 'Zynga' => 32740, - ), - 'Brasil' => array( - 'Albion Online' => 38797, - 'Alelo' => 37410, - 'Algar' => 37507, - 'Alog' => 34746, - 'Amazon' => 33206, - 'Amazon Prime Video' => 38754, - 'Amazon Web Services' => 34091, - 'America Net' => 35127, - 'Anthem' => 38211, - 'Anydesk' => 37492, - 'Apex Legends' => 38126, - 'App Store' => 39054, - 'Apple Store' => 34449, - 'Avianca' => 36519, - 'Azul' => 36358, - 'Banco Central do Brasil' => 36002, - 'Banco do Brasil' => 34037, - 'Banco Inter' => 37559, - 'Banco Itaú' => 33205, - 'Banco Safra' => 37151, - 'Banco Santander' => 33381, - 'Banestes' => 37065, - 'Banrisul' => 35425, - 'Battlefield' => 38447, - 'Betfair' => 38188, - 'Binance' => 36940, - 'Bing' => 33215, - 'Blizzard Battle.net' => 36671, - 'Bradesco' => 33197, - 'Brisanet' => 36967, - 'Buscapé' => 35167, - 'C6 Bank' => 38773, - 'Cabo Telecom' => 33991, - 'Cabonnet' => 35919, - 'Caixa Econômica Federal' => 33191, - 'Call of Duty' => 38163, - 'Claro' => 33199, - 'Clash of Clans' => 37303, - 'Clash Royale' => 36053, - 'Clear' => 37557, - 'Cloudflare' => 38621, - 'Clusterweb' => 35967, - 'Copel Telecom' => 35671, - 'Correios' => 34611, - 'Counter-Strike' => 38162, - 'Credit Suisse' => 37978, - 'Crunchyroll' => 37138, - 'Dataprev' => 35137, - 'Dead By Daylight' => 36596, - 'Deezer' => 33385, - 'Destiny' => 38943, - 'Discord' => 37392, - 'Dota 2' => 35879, - 'Dropbox' => 33214, - 'EA' => 34500, - 'eBay' => 33207, - 'eCAC' => 34961, - 'Embratel' => 33999, - 'Enem' => 35136, - 'Epic Games Store' => 39031, - 'eSocial' => 35427, - 'Faceapp' => 38696, - 'Facebook' => 33184, - 'Facebook Messenger' => 34846, - 'Facetime' => 34597, - 'Feedly' => 34723, - 'Fifa' => 37769, - 'For Honor' => 37371, - 'Fortnite' => 36699, - 'Free Fire' => 38804, - 'Garena' => 38301, - 'Getnet' => 36745, - 'GitHub' => 38591, - 'Globo' => 34721, - 'Gmail' => 33194, - 'Go Daddy' => 34848, - 'Gol' => 38600, - 'Google' => 33186, - 'Google Cloud' => 38535, - 'Google Play' => 33211, - 'GTA 5' => 34757, - 'GVT' => 33562, - 'HBO' => 36354, - 'Hostgator' => 34745, - 'Hostnet' => 35850, - 'HSBC' => 34644, - 'iCloud' => 33219, - 'ICQ' => 34765, - 'iFood ' => 36960, - 'iMessage' => 33165, - 'Instagram' => 33204, - 'ITMNetworks' => 36094, - 'iTunes' => 33208, - 'Jurassic World Alive' => 37335, - 'Kik' => 34856, - 'KingHost' => 34957, - 'Kraken' => 36979, - 'League of Legends' => 39018, - 'Ligue Telecom' => 37526, - 'Line' => 33736, - 'LinkedIn' => 33210, - 'Locaweb' => 34722, - 'Mandic' => 36090, - 'Mercado Bitcoin' => 36790, - 'Mercado Livre' => 34234, - 'Microsoft Azure' => 36111, - 'Multiplay' => 37221, - 'NET' => 33190, - 'Netflix' => 33222, - 'Neverwinter' => 36158, - 'Nextel' => 33202, - 'Nota fiscal eletrônica' => 34718, - 'Nubank' => 37063, - 'Office 365' => 37170, - 'Oi' => 33196, - 'OLX' => 34235, - 'OneDrive' => 36866, - 'Origin' => 36903, - 'Outlook' => 33221, - 'Overwatch' => 36502, - 'PagSeguro' => 37313, - 'Path of Exile' => 37725, - 'Paypal' => 33213, - 'Pinterest' => 38164, - 'Player Unknown\'s Battlegrounds' => 36565, - 'Playstation Network' => 33217, - 'Pokémon Go' => 35751, - 'Polícia Federal' => 35135, - 'Porto Seguro Conecta' => 35981, - 'QConcursos' => 36601, - 'Rainbow Six' => 36451, - 'Receita Federal' => 34960, - 'Red Dead Redemption' => 38909, - 'Reddit' => 37477, - 'Roblox' => 37399, - 'Rocket League' => 38558, - 'Salesforce' => 38494, - 'Sefaz' => 37626, - 'Sercomtel' => 33990, - 'Sicoob' => 37624, - 'Sicredi' => 37505, - 'SiSU' => 38029, - 'SKY' => 33875, - 'Skype' => 33203, - 'Slack' => 37339, - 'Snapchat' => 33379, - 'Spotify' => 36594, - 'Steam' => 34137, - 'Submarino' => 35074, - 'SuperDigital' => 36744, - 'Teamviewer' => 35690, - 'Telegram' => 34937, - 'Terra' => 33193, - 'TIM' => 34686, - 'Tinder' => 36550, - 'Tribunal Superior Eleitoral' => 37578, - 'Twitch' => 36372, - 'Twitter' => 33195, - 'Uber' => 37767, - 'Udemy' => 38313, - 'Umbler' => 37577, - 'UOL' => 33189, - 'UOLHost' => 34748, - 'Uplay PC' => 38636, - 'Viber' => 33386, - 'Vimeo' => 38533, - 'Vivo' => 33192, - 'Vono' => 35138, - 'Warframe' => 36702, - 'Waze' => 33383, - 'WeChat' => 33735, - 'Whatsapp' => 32837, - 'Wikipedia' => 33198, - 'World of Warcraft' => 38879, - 'Xbox Live' => 33216, - 'Yahoo' => 33187, - 'Yahoo Mail' => 33201, - 'Youtube' => 33185, - 'Zello' => 33387, - ), - 'Россия' => array( - 'Albion Online' => 38798, - 'Anthem' => 38191, - 'Apex Legends' => 38138, - 'Apple Music' => 39012, - 'Apple Store' => 34470, - 'avito' => 37696, - 'Battlefield' => 38448, - 'Binance' => 37021, - 'Blizzard Battle.net' => 36784, - 'Call of Duty' => 39052, - 'Citilink' => 37700, - 'Cloudflare' => 38619, - 'Counter-strike' => 38234, - 'Dead By Daylight' => 37430, - 'Destiny' => 38941, - 'DNS Shop' => 37701, - 'Dota 2' => 36395, - 'EA' => 36924, - 'eBay' => 32799, - 'Elite: Dangerous' => 37775, - 'Facetime' => 34599, - 'Fifa' => 38428, - 'For Honor' => 37370, - 'Fortnite' => 37143, - 'GitHub' => 38713, - 'Gmail' => 32798, - 'GTA 5' => 36845, - 'iCloud' => 38840, - 'ICQ' => 33122, - 'iMessage' => 33115, - 'Interzet' => 33563, - 'iTunes' => 33114, - 'Ivi' => 34807, - 'Kraken' => 36982, - 'Last FM' => 36533, - 'Mail.Ru' => 33113, - 'Megogo' => 38198, - 'NetByNet' => 35927, - 'Okko' => 38793, - 'Origin' => 36900, - 'Outlook' => 33116, - 'Overwatch' => 36385, - 'Ozon' => 37702, - 'Path of Exile' => 37724, - 'Paypal' => 33118, - 'Player Unknown\'s Battlegrounds' => 36455, - 'Playstation Network' => 32801, - 'Qip' => 37697, - 'Qiwi' => 33504, - 'Rainbow Six' => 36457, - 'Reddit' => 38066, - 'Roblox' => 38922, - 'SkyNet' => 35103, - 'Slack' => 37340, - 'Snapchat' => 37144, - 'Teamviewer' => 34347, - 'Telegram' => 36734, - 'The elder scrolls online' => 37323, - 'Tumblr' => 37284, - 'Twitter' => 32797, - 'Uplay PC' => 35611, - 'Viber ' => 33402, - 'Warframe' => 37126, - 'WebMoney' => 33119, - 'Wildberries' => 37699, - 'World of Tanks' => 35051, - 'Xbox Live' => 32802, - 'Yahoo' => 32792, - 'Yahoo Mail' => 32793, - 'Yahoo Messenger' => 32794, - 'Yota' => 34356, - 'Акадо' => 34429, - 'Альфа-Банк' => 33110, - 'Банк ДОМ.РФ' => 38704, - 'Билайн' => 32850, - 'Ватсап' => 32803, - 'Википедия' => 33120, - 'ВКонтакте' => 32851, - 'ВТБ 24' => 33109, - 'ГИС ЖКХ' => 35822, - 'Гугл' => 32788, - 'Гугл Hangouts' => 32789, - 'Гугл Плей' => 32790, - 'Дискорд ' => 37386, - 'Дом.ru' => 34395, - 'Инстаграм' => 36069, - 'Кинопаб' => 38199, - 'Летай' => 34430, - 'МГТС' => 33108, - 'МегаФон' => 33104, - 'Мотив' => 33864, - 'МТС' => 33121, - 'НСПК ' => 35608, - 'Одноклассники' => 33112, - 'Почта России' => 36587, - 'Рамблер' => 37441, - 'Релком' => 34357, - 'Росреестр' => 38311, - 'Ростелеком' => 33105, - 'Сбербанк' => 33106, - 'СДЭК' => 38729, - 'Скай Линк' => 34358, - 'Скайп' => 32804, - 'Стим' => 34266, - 'Твич' => 36543, - 'Теле2' => 33818, - 'Тиндер' => 37919, - 'Тинькофф Банк ' => 37698, - 'ТТК' => 34359, - 'Уфанет' => 34431, - 'Фейсбук' => 32795, - 'Фейсбук Мессенджер' => 32796, - 'ФНС' => 38648, - 'Хоум Кредит' => 38627, - 'ЭлЖур ' => 37515, - 'Ютуб' => 32791, - 'Яндекс' => 33111, - 'Яндекс.Музыка' => 39013, - 'Яндекс.Навигатор' => 39014, - ), - 'España' => array( - 'Alexa' => 38032, - 'Amazon' => 33140, - 'Amazon Prime Video' => 38753, - 'Anthem' => 38201, - 'Anydesk' => 37490, - 'Apex Legends' => 38122, - 'App Store' => 38553, - 'Apple Store' => 34465, - 'Banco Popular' => 33135, - 'Banco Sabadell' => 33151, - 'Banco Santander' => 33132, - 'Bankia' => 33134, - 'Bankinter' => 33143, - 'Battlefield' => 39017, - 'BBVA' => 33133, - 'Binance' => 36936, - 'Blizzard Battle.net' => 36105, - 'Brawl Stars' => 38832, - 'Cableworld' => 37457, - 'Call of Duty' => 35538, - 'Clash of Clans' => 34362, - 'Clash Royale' => 38951, - 'Coinbase' => 36803, - 'Counter Strike' => 38239, - 'Crunchyroll' => 38147, - 'Dazn' => 39005, - 'Dead By Daylight' => 36597, - 'Deezer' => 33830, - 'Destiny' => 38944, - 'Discord' => 37387, - 'Dota 2' => 37188, - 'Dropbox' => 33142, - 'EA' => 36449, - 'eBay' => 33267, - 'Epic Games Store' => 39032, - 'Euskaltel' => 34442, - 'Facebook' => 32829, - 'Facebook Messenger' => 32830, - 'Facetime' => 34600, - 'Fifa' => 37088, - 'For Honor' => 36182, - 'Fortnite' => 36694, - 'Ghost Recon' => 38996, - 'Github' => 36521, - 'Gmail' => 32832, - 'Google' => 32822, - 'Google Drive' => 36605, - 'Google Hangouts' => 32823, - 'Google Play' => 33226, - 'Gran Turismo' => 36950, - 'Grindr' => 38401, - 'GTA 5' => 37274, - 'Guild Wars 2' => 36485, - 'HBO' => 36497, - 'Ibercaja' => 33153, - 'iCloud' => 33227, - 'iMessage' => 32980, - 'ING Direct' => 33148, - 'Instagram' => 33147, - 'iTunes' => 33144, - 'Jazztel' => 33129, - 'Kraken' => 36806, - 'League of Legends' => 38634, - 'Line' => 33394, - 'LinkedIn' => 33150, - 'Llamaya' => 38315, - 'Lowi' => 35832, - 'MásMóvil' => 36068, - 'Mil Anuncios' => 33141, - 'Movistar' => 33124, - 'Nest' => 38551, - 'Netflix' => 35565, - 'Office 365' => 35442, - 'OneDrive' => 36864, - 'ONO' => 33128, - 'Orange' => 33126, - 'Origin' => 36895, - 'Outlook' => 33220, - 'Overwatch' => 36387, - 'Pasion.com' => 37141, - 'Path of Exile' => 37770, - 'Paypal' => 33145, - 'Pepephone' => 33130, - 'Player Unknown\'s Battlegrounds' => 36561, - 'Playstation Network' => 32835, - 'Pokémon Go' => 35750, - 'Quantis' => 37050, - 'R' => 34441, - 'Rainbow Six' => 36145, - 'Rakuten TV' => 37595, - 'Reddit' => 37476, - 'Roblox' => 38928, - 'Rocket League' => 36181, - 'Sea of Thieves' => 37119, - 'Segunda Mano' => 33138, - 'Simyo' => 33131, - 'Sky' => 37520, - 'Skype' => 32838, - 'Slack' => 37342, - 'Snapchat' => 33380, - 'Spotify' => 33146, - 'Steam' => 34280, - 'Tango' => 33746, - 'Teamviewer' => 35688, - 'Telecable' => 34443, - 'Telegram' => 35352, - 'Tinder' => 36548, - 'Tuenti' => 33137, - 'Tweakbox' => 38350, - 'Twitch' => 36545, - 'Twitter' => 32831, - 'Unicaja' => 33149, - 'Uplay PC' => 35615, - 'Vibbo' => 36183, - 'Viber' => 33374, - 'Vodafone' => 33125, - 'Warframe' => 36705, - 'Whatsapp' => 33263, - 'Wikipedia' => 33139, - 'Xbox Live' => 32836, - 'Yahoo' => 32826, - 'Yahoo Mail' => 32827, - 'Yahoo Messenger' => 32828, - 'Yoigo' => 33127, - 'Youtube' => 32825, - 'Z1 Battle Royale' => 37263, - ), - 'Polska' => array( - 'Allegro' => 33799, - 'Amazon' => 33633, - 'Apex Legends' => 38124, - 'Apple Store' => 34453, - 'Bank Millennium' => 37535, - 'Bank Pekao' => 37534, - 'Battlefield' => 37471, - 'BGŻ BNP Paribas' => 37530, - 'Binance' => 36946, - 'Blizzard Battle.net' => 36783, - 'Call of Duty' => 38923, - 'Chomikuj' => 33803, - 'Clash Royale' => 38950, - 'Cloudflare' => 38624, - 'Counter-Strike' => 36648, - 'Cyfrowy Polsat' => 33866, - 'Dead By Daylight' => 36599, - 'Deezer' => 33837, - 'Destiny' => 38945, - 'Deutsche bank Polska' => 36176, - 'Discord' => 36761, - 'Dota 2' => 36447, - 'Dropbox' => 33796, - 'EA' => 36448, - 'East&West' => 35641, - 'eBay' => 38579, - 'Elite: Dangerous' => 37778, - 'Epic Games Store' => 39022, - 'Escape from Tarkov' => 39060, - 'Eurobank' => 37008, - 'Facebook' => 33634, - 'Facebook Messenger' => 33635, - 'Facetime' => 34591, - 'Fifa' => 37086, - 'For Honor' => 37305, - 'Fortnite' => 36693, - 'Get in Bank' => 37532, - 'GG (Gadu Gadu)' => 33809, - 'Ghost Recon' => 38997, - 'GitHub' => 38466, - 'Gmail' => 33636, - 'Google' => 33637, - 'Google Hangouts' => 33638, - 'Google Play' => 33639, - 'Gran Turismo' => 36916, - 'Grindr' => 38741, - 'GTA 5' => 36848, - 'Guild Wars 2' => 36467, - 'Gumtree' => 33805, - 'HBO' => 36355, - 'Heyah' => 33812, - 'Home.pl' => 37537, - 'iCloud' => 33640, - 'Idea Bank' => 37533, - 'iMessage' => 33641, - 'Inea' => 34415, - 'ING Bank' => 33807, - 'Instagram' => 33642, - 'iTunes' => 33643, - 'Kraken' => 36983, - 'League of Legends' => 36629, - 'LinkedIn' => 33806, - 'Mbank' => 33800, - 'Multimedia Polska' => 33879, - 'Nazwa' => 37320, - 'Nest Bank' => 37529, - 'Netflix' => 35728, - 'Netia' => 33815, - 'NK.pl (Nasza-klasa)' => 33808, - 'O2' => 33801, - 'Office 365' => 35125, - 'OLX' => 33802, - 'OneDrive' => 36868, - 'Orange' => 33810, - 'Origin' => 36899, - 'Outlook' => 33644, - 'Overwatch' => 36388, - 'Path of Exile' => 37728, - 'PKO Bank Polski' => 36044, - 'Play' => 33813, - 'Player Unknown\'s Battlegrounds' => 36580, - 'Playstation Network' => 33645, - 'Plus' => 33814, - 'Pokémon Go' => 35752, - 'Rainbow Six' => 36394, - 'Reddit' => 37485, - 'Roblox' => 37400, - 'Rocket League' => 36667, - 'Santander' => 37531, - 'Sea of Thieves' => 37120, - 'Skype' => 33793, - 'Slack' => 37338, - 'Snapchat' => 33797, - 'Spotify' => 33836, - 'Steam' => 34279, - 'T-Mobile' => 33811, - 'Teamviewer' => 35694, - 'Telegram' => 35970, - 'The elder scrolls online' => 37328, - 'Tinder' => 36547, - 'Tumblr' => 37285, - 'Twitch' => 36450, - 'Twitter' => 33646, - 'UPC' => 33816, - 'Uplay PC' => 35612, - 'Vectra' => 33817, - 'Viber' => 33794, - 'Warframe' => 36493, - 'Whatsapp' => 33792, - 'Wikipedia' => 33647, - 'World of Tanks' => 36029, - 'World of Warcraft' => 37446, - 'World of Warships' => 38008, - 'Xbox Live' => 33648, - 'Yahoo Mail' => 38887, - 'Youtube' => 33649, - 'Z1 Battle Royale' => 37267, - ), - 'Norge' => array( - 'Altibox' => 36318, - 'Anthem' => 38206, - 'Apex Legends' => 38120, - 'App Store' => 38100, - 'Battlefield' => 37098, - 'Binance' => 36928, - 'Blizzard Battle.net' => 36309, - 'Call of Duty' => 36708, - 'Coinbase' => 36799, - 'Counter-strike' => 36647, - 'Crunchyroll' => 36720, - 'Danske Bank' => 36321, - 'Dead By Daylight' => 37426, - 'Destiny' => 36592, - 'Discord' => 36762, - 'Dota 2' => 36442, - 'EA' => 36310, - 'Epic Games Store' => 39028, - 'Facebook' => 36238, - 'Facebook Messenger' => 36269, - 'Fifa' => 36908, - 'Fortnite' => 36687, - 'Get' => 36319, - 'Ghost Recon' => 39000, - 'Gmail' => 36255, - 'Google' => 36256, - 'Google Drive' => 36760, - 'Google Play' => 38528, - 'GTA 5' => 36847, - 'Guild Wars 2' => 36470, - 'HBO Nordic' => 36316, - 'iCloud' => 38844, - 'Ikano Bank' => 36577, - 'Instagram' => 36443, - 'Kraken' => 36853, - 'League of Legends' => 38630, - 'Minecraft' => 37280, - 'Netflix' => 36257, - 'NexGenTel' => 36320, - 'Nordea' => 36325, - 'Norges Bank' => 36324, - 'Office 365' => 36311, - 'OneDrive' => 36865, - 'Origin' => 36893, - 'Outlook' => 36258, - 'Overwatch' => 36259, - 'Path of Exile' => 37727, - 'Playerunknown\'s Battlegrounds' => 36338, - 'Playstation Network' => 36260, - 'Pokémon Go' => 36261, - 'Rainbow Six' => 36262, - 'Realm Royale' => 37316, - 'Red Dead Redemption' => 38283, - 'Reddit' => 36825, - 'Roblox' => 38919, - 'Rocket League' => 36312, - 'Sea of Thieves' => 37116, - 'Skype' => 36263, - 'Snapchat' => 36264, - 'Sparebanken 1' => 36323, - 'Spotify' => 36796, - 'Steam' => 36313, - 'Strava' => 38730, - 'Tele2' => 36317, - 'Telegram' => 37183, - 'Telenor' => 36288, - 'The elder scrolls online' => 37324, - 'Tinder' => 36314, - 'Twitch' => 36444, - 'Twitter' => 37268, - 'Uplay PC' => 36265, - 'Viaplay' => 37293, - 'Warframe' => 37129, - 'Whatsapp' => 36266, - 'Wikipedia' => 38872, - 'World of Warcraft' => 37447, - 'Xbox Live' => 36315, - 'Yahoo' => 36268, - 'Yahoo Mail' => 38890, - 'Youtube' => 36267, - 'Z1 Battle Royale' => 37262, - ), - 'México' => array( - 'Amazon' => 33292, - 'Amazon Prime Video' => 38752, - 'Anthem' => 38192, - 'Apex Legends' => 38139, - 'App Store' => 37732, - 'Apple Store' => 34466, - 'AT&T' => 35861, - 'Axtel' => 34439, - 'Banamex' => 33322, - 'Banco Santander' => 36772, - 'Banorte' => 33326, - 'Battlefield' => 38445, - 'BBVA Bancomer' => 33321, - 'Binance' => 37019, - 'Blizzard Battle.net' => 36106, - 'Cablecom' => 34035, - 'Cablemás' => 34438, - 'Call of Duty' => 35540, - 'Clash of Clans' => 37099, - 'Crunchyroll' => 37139, - 'Dead By Daylight' => 37432, - 'Deezer' => 33397, - 'Destiny' => 38966, - 'Discord' => 38606, - 'Dropbox' => 33294, - 'EA' => 36609, - 'eBay' => 33295, - 'Facebook' => 33296, - 'Facebook Messenger' => 33297, - 'Facetime' => 34594, - 'Fifa' => 38307, - 'Fortnite' => 36842, - 'Gears of War' => 38893, - 'Gmail' => 33298, - 'Go Daddy' => 36620, - 'Google' => 33299, - 'Google Drive' => 38260, - 'Google Hangouts' => 33300, - 'Google Play' => 33301, - 'Gran Turismo' => 36951, - 'Grindr' => 38740, - 'GTA 5' => 38275, - 'HBO' => 36351, - 'iCloud' => 33302, - 'iMessage' => 33303, - 'Instagram' => 33304, - 'iTunes' => 33305, - 'Izzi' => 37685, - 'League of Legends' => 39019, - 'Line' => 33395, - 'LinkedIn' => 33306, - 'Megacable' => 34437, - 'Mercado Libre' => 33320, - 'Microsoft Azure' => 37466, - 'Minecraft' => 38737, - 'Movistar' => 33307, - 'Netflix' => 33325, - 'Origin' => 38103, - 'Outlook' => 33308, - 'Overwatch' => 36483, - 'Paypal' => 33309, - 'Player Unknown\'s Battlegrounds' => 36562, - 'Playstation Network' => 33310, - 'Pokémon Go' => 36360, - 'Rainbow Six' => 36146, - 'Reddit' => 37482, - 'Roblox' => 38917, - 'Salesforce' => 38496, - 'Scotiabank' => 37686, - 'Segunda Mano' => 33327, - 'SKY México' => 33328, - 'Skype' => 33311, - 'Snapchat' => 34288, - 'SPEI' => 37689, - 'Spotify' => 33398, - 'Steam' => 34281, - 'Tango' => 33745, - 'Teamviewer' => 35689, - 'Telcel' => 33319, - 'Telegram' => 35852, - 'Telmex' => 33324, - 'Telnor' => 34440, - 'Tinder' => 37165, - 'Totalplay' => 37687, - 'Tweakbox' => 38903, - 'Twitch' => 36546, - 'Twitter' => 33312, - 'Unefon' => 37688, - 'WeChat' => 33399, - 'Whatsapp' => 33313, - 'Wikipedia' => 33314, - 'World of Warcraft' => 38880, - 'Xbox Live' => 33315, - 'Yahoo' => 33316, - 'Yahoo Mail' => 33317, - 'Yahoo Messenger' => 33318, - 'Youtube' => 33361, - 'Zello' => 33388, - ), - '日本' => array( - 'Amazon' => 33464, - 'Amazon Web Services' => 34094, - 'Amazon インスタント・ビデオ' => 34496, - 'Apex Legends' => 38177, - 'App Store' => 35581, - 'Apple Store' => 34467, - 'ASAHI ネット' => 33859, - 'Au' => 33465, - 'Biglobe' => 33479, - 'Blizzard Battle.net' => 36672, - 'bmobile' => 33466, - 'Call of Duty' => 39053, - 'Dazn' => 36511, - 'Discord' => 38613, - 'Disney Mobile' => 33467, - 'DMM' => 35818, - 'Dropbox' => 33441, - 'EA' => 37080, - 'eBay' => 33442, - 'EO Net' => 33863, - 'Facebook' => 33443, - 'Facebook Messenger' => 33444, - 'Facetime' => 34593, - 'FC2' => 34510, - 'Fortnite' => 37162, - 'Freetel' => 35620, - 'Ghost Recon' => 38993, - 'Github' => 37375, - 'Gmail' => 33445, - 'Google' => 33446, - 'Google Hangouts' => 33447, - 'Google Play' => 33448, - 'Hulu' => 33480, - 'iCloud' => 33449, - 'IIJ' => 34511, - 'iMessage' => 33450, - 'Instagram' => 33451, - 'iTunes' => 33452, - 'Jcom' => 33478, - 'League of Legends' => 38899, - 'Line' => 33453, - 'LinkedIn' => 33454, - 'Livedoor' => 33472, - 'Microsoft Azure' => 36110, - 'Netflix' => 36728, - 'Nifty' => 33473, - 'Nintendo Network' => 35521, - 'NTT Docomo' => 33474, - 'NTT東日本' => 33475, - 'NTT西日本' => 33476, - 'OCN' => 33564, - 'Office 365' => 35928, - 'OneDrive' => 36496, - 'Outlook' => 33455, - 'Paypal' => 33456, - 'Player Unknown\'s Battlegrounds' => 36616, - 'Playstation Network' => 33457, - 'Rakuten' => 34513, - 'Reddit' => 37478, - 'Skype' => 33458, - 'Slack' => 37358, - 'Snapchat' => 38511, - 'So-net' => 33862, - 'Steam' => 38649, - 'Telegram' => 37227, - 'Tinder' => 37916, - 'Twitter' => 33459, - 'UQ Wimax' => 33482, - 'Whatsapp' => 38255, - 'Xbox Live' => 33471, - 'Yahoo' => 33460, - 'Yahoo BB' => 33860, - 'Yahoo Mail' => 33461, - 'Yammer' => 35560, - 'Youtube' => 33462, - 'じぶん銀行' => 33500, - 'ぷらら' => 33861, - 'みずほ銀行' => 33493, - 'りそな銀行' => 33496, - 'アメーバブログ' => 34514, - 'カカオトーク' => 33469, - 'ジャパンネット銀行' => 33497, - 'スカパー' => 33477, - 'スポナビLive' => 36516, - 'ソニー銀行' => 33498, - 'ソフトバンク' => 33470, - 'ポケモン go' => 35746, - 'ワイモバイル' => 36503, - '三井住友銀行' => 33495, - '三菱東京UFJ銀行' => 33494, - '埼玉りそな銀行' => 36532, - '楽天銀行' => 33499, - '近畿大阪銀行' => 36531, - ), - 'Pilipinas' => array( - 'Anthem' => 38193, - 'Apex Legends' => 38158, - 'Battlefield' => 37925, - 'Blizzard Battle.net' => 37834, - 'Converge' => 38714, - 'Counter-strike' => 38241, - 'Destiny' => 38987, - 'Discord' => 38453, - 'Dota 2' => 37835, - 'EA' => 37931, - 'Facebook' => 37836, - 'Facebook Messenger' => 37837, - 'Fortnite' => 37930, - 'Globe' => 37941, - 'Gmail' => 38080, - 'Google' => 37838, - 'Google Drive' => 38259, - 'Google Hangouts' => 38365, - 'Google Play' => 38521, - 'GTA 5' => 38718, - 'iCloud' => 38853, - 'Instagram' => 37839, - 'League of Legends' => 38911, - 'Mobile legends' => 38175, - 'Netflix' => 37840, - 'Office 365' => 37936, - 'Origin' => 38397, - 'Outlook' => 37935, - 'Overwatch' => 37932, - 'Paypal' => 38463, - 'Player Unknown\'s Battlegrounds' => 37841, - 'Playstation Network' => 38736, - 'PLDT' => 37938, - 'Rainbow Six' => 37933, - 'Reddit' => 38089, - 'Roblox' => 37927, - 'Sky Cable' => 37942, - 'Skype' => 37929, - 'Smart' => 37940, - 'Spotify' => 38644, - 'Steam' => 37924, - 'Telegram' => 38019, - 'Tinder' => 37912, - 'TNT' => 37943, - 'Twitter' => 37928, - 'Warframe' => 37934, - 'Wattpad' => 38962, - 'Waze' => 38267, - 'Whatsapp' => 38254, - 'Yahoo' => 37926, - 'Yahoo Mail' => 38456, - 'Youtube' => 37842, - ), - 'Indonesia' => array( - 'Anthem' => 38194, - 'Apex Legends' => 38181, - 'Biznet' => 38975, - 'Blizzard Battle.net' => 37825, - 'Clash of Clans' => 37961, - 'Counter-strike' => 38410, - 'Discord' => 38084, - 'Dota 2' => 37826, - 'EA' => 38419, - 'Facebook' => 37827, - 'Fifa' => 38427, - 'First Media' => 38974, - 'Fortnite' => 38532, - 'Gmail' => 38250, - 'Google' => 37828, - 'Google Drive' => 38262, - 'Google Play' => 38518, - 'GTA 5' => 38700, - 'iCloud' => 38851, - 'Indosat Ooredoo' => 38978, - 'Instagram' => 37829, - 'Myrepublic' => 38972, - 'Netflix' => 37830, - 'Origin' => 38376, - 'Pinterest' => 37950, - 'Player Unknown\'s Battlegrounds' => 37831, - 'Playstation Network' => 38735, - 'Reddit' => 38498, - 'Roblox' => 38914, - 'Steam' => 38098, - 'Telegram' => 38020, - 'Telkom' => 38973, - 'Telkomsel' => 38976, - 'Tinder' => 37915, - 'Twitter' => 38160, - 'Uplay PC' => 37962, - 'Whatsapp' => 37832, - 'XL' => 38977, - 'Yahoo Mail' => 38889, - 'Youtube' => 37833, - ), - 'Pakistan' => array( - 'Apex Legends' => 38414, - 'App Store' => 38960, - 'Battlefield' => 38002, - 'Blizzard Battle.net' => 37992, - 'Call of Duty' => 38939, - 'Discord' => 37993, - 'Dota 2' => 38476, - 'EA' => 38418, - 'Facebook' => 37994, - 'Facebook Messenger' => 37995, - 'Fiberlink' => 38006, - 'Fifa' => 38425, - 'Fortnite' => 38252, - 'Google' => 37996, - 'iCloud' => 38715, - 'Instagram' => 37997, - 'Kik' => 38539, - 'Netflix' => 37998, - 'Pinterest' => 38679, - 'Player Unknown\'s Battlegrounds' => 37999, - 'Playstation Network' => 38571, - 'PTLC' => 38004, - 'Quora' => 38908, - 'Snapchat' => 38508, - 'Steam' => 38341, - 'Tinder' => 38000, - 'Tumblr' => 38546, - 'Twitter' => 38678, - 'Wateen' => 38007, - 'Wattpad' => 38544, - 'Whatsapp' => 38003, - 'Yahoo Mail' => 38882, - 'Youtube' => 38001, - 'Zong' => 38005, - ), - 'UAE' => array( - 'Apex Legends' => 38159, - 'beIN' => 39073, - 'Binance' => 37022, - 'Blizzard Battle.net' => 36214, - 'Botim' => 38393, - 'Call of Duty' => 38935, - 'Cloudflare' => 38642, - 'Counter-strike' => 38269, - 'Dead By Daylight' => 37437, - 'Discord' => 37773, - 'Dota 2' => 36423, - 'Du' => 36235, - 'EA' => 36883, - 'Etisalat' => 36234, - 'Facebook' => 36215, - 'Facebook Messenger' => 36233, - 'Fifa' => 38433, - 'For Honor' => 36216, - 'Fortnite' => 36996, - 'Gmail' => 36217, - 'Google' => 36218, - 'iCloud' => 38843, - 'Instagram' => 36420, - 'Netflix' => 36219, - 'Office 365' => 38041, - 'Origin' => 38422, - 'Outlook' => 36220, - 'Overwatch' => 36221, - 'Player Unknown\'s Battlegrounds' => 36618, - 'Playstation Network' => 36222, - 'Pokémon Go' => 36223, - 'Rainbow Six' => 36224, - 'Reddit' => 38499, - 'Roblox' => 37222, - 'Rocket League' => 36231, - 'Skype' => 36225, - 'Snapchat' => 36226, - 'Steam' => 38340, - 'Telegram' => 37185, - 'Tinder' => 36227, - 'Twitter' => 38677, - 'Uplay PC' => 36228, - 'Whatsapp' => 36229, - 'Wikipedia' => 38878, - 'Xbox Live' => 38404, - 'Yahoo' => 36232, - 'Yahoo Mail' => 38888, - 'Youtube' => 36230, - 'ZoHo' => 37553, - ), - 'Malaysia' => array( - 'Apex Legends' => 38179, - 'Blizzard Battle.net' => 37891, - 'Counter-strike' => 38409, - 'Destiny' => 38985, - 'Discord' => 37976, - 'Dota 2' => 38017, - 'EA' => 38420, - 'Facebook' => 37892, - 'Facebook Messenger' => 37893, - 'Fifa' => 38429, - 'Gmail' => 38251, - 'Google' => 37894, - 'Google Drive' => 38263, - 'Google Play' => 38529, - 'GTA 5' => 38488, - 'iCloud' => 38850, - 'Instagram' => 37895, - 'League of Legends' => 38910, - 'Line' => 38386, - 'LinkedIn' => 38150, - 'Maxis' => 37964, - 'Netflix' => 37896, - 'Origin' => 38395, - 'Overwatch' => 38895, - 'Paypal' => 38461, - 'Player Unknown\'s Battlegrounds' => 37897, - 'Playstation Network' => 38570, - 'Reddit' => 38363, - 'Roblox' => 38912, - 'Snapchat' => 38548, - 'Steam' => 38081, - 'TIME' => 37965, - 'Tinder' => 37917, - 'Twitter' => 38481, - 'Unifi' => 37963, - 'Uplay PC' => 38896, - 'Waze' => 38268, - 'Whatsapp' => 38028, - 'Yahoo Mail' => 38891, - 'Youtube' => 37898, - ), - 'Perú' => array( - 'Apex Legends' => 39015, - 'Bitel' => 37982, - 'Claro' => 37981, - 'Crunchyroll' => 38550, - 'Dota 2' => 38353, - 'EA' => 38415, - 'Econocable' => 37983, - 'Entel' => 37980, - 'Facebook' => 37791, - 'Facebook Messenger' => 38258, - 'Fortnite' => 37792, - 'Gmail' => 37944, - 'Google' => 37945, - 'HBO' => 38369, - 'iCloud' => 38855, - 'Instagram' => 37793, - 'Movistar' => 37794, - 'Netflix' => 38594, - 'Origin' => 38423, - 'Outlook' => 38581, - 'Playstation Network' => 38568, - 'Spotify' => 38580, - 'Steam' => 38099, - 'Telegram' => 38483, - 'Tinder' => 37909, - 'Twitter' => 38105, - 'Whatsapp' => 37795, - 'Youtube' => 37796, - ), - 'Argentina' => array( - 'Apex Legends' => 38129, - 'Arnet' => 37969, - 'Banco Galicia' => 36132, - 'Banco Nación' => 36131, - 'Banco Provincia' => 36133, - 'Banco Santander Río ' => 36130, - 'BBVA Francés' => 36134, - 'Binance' => 37020, - 'Blizzard Battle.net' => 36104, - 'Call of Duty' => 36743, - 'Claro' => 36100, - 'Clash of Clans' => 36128, - 'Correo Argentino' => 36999, - 'Counter-Strike' => 38247, - 'Crunchyroll' => 38091, - 'Dead By Daylight' => 37431, - 'Destiny' => 38965, - 'Discord' => 38614, - 'Dota 2' => 37187, - 'EA' => 36885, - 'Facebook' => 36072, - 'Facebook Messenger' => 36073, - 'Fibertel' => 36635, - 'Fifa' => 38435, - 'Flow' => 38309, - 'Fortnite' => 37017, - 'Free Fire' => 38806, - 'Gigared' => 38215, - 'Gmail' => 37716, - 'Google' => 36074, - 'Google Play' => 38520, - 'GTA 5' => 38276, - 'HBO' => 36352, - 'ICBC' => 38646, - 'iCloud' => 38858, - 'Instagram' => 36075, - 'Mercado Libre' => 36076, - 'Microsoft Azure' => 37467, - 'Movistar' => 36077, - 'Netflix' => 36078, - 'Nextel' => 36103, - 'OLX' => 37045, - 'Origin' => 36902, - 'Outlook' => 36079, - 'Overwatch' => 38774, - 'Path of Exile' => 37771, - 'Personal' => 37970, - 'Player Unknown\'s Battlegrounds' => 36563, - 'Playstation Network' => 36080, - 'Pokémon Go' => 36294, - 'Rainbow Six' => 36144, - 'Reddit' => 37483, - 'Roblox' => 38929, - 'Skype' => 36081, - 'Snapchat' => 36082, - 'Spotify' => 36107, - 'Steam' => 36129, - 'Teamviewer' => 36142, - 'Telecentro' => 36810, - 'Telecom' => 36097, - 'Telefónica' => 36099, - 'Telegram' => 38485, - 'Telered' => 37971, - 'Tinder' => 37169, - 'Tuenti' => 38628, - 'Twitch' => 36544, - 'Twitter' => 36109, - 'Uplay PC' => 36143, - 'Waze' => 37470, - 'Whatsapp' => 36083, - 'Wikipedia' => 37352, - 'World of Warcraft' => 38778, - 'Xbox Live' => 37443, - 'Yahoo Mail' => 36084, - 'Youtube' => 36085, - ), - 'Slovensko' => array( - 'Apex Legends' => 38131, - 'Blizzard Battle.net' => 37875, - 'Counter-strike' => 38243, - 'Discord' => 38612, - 'Dota 2' => 38480, - 'EA' => 38417, - 'Facebook' => 37876, - 'Facebook Messenger' => 37877, - 'Fifa' => 38426, - 'Fortnite' => 38807, - 'Google' => 37878, - 'GTA 5' => 38279, - 'HBO Go' => 38381, - 'iCloud' => 38848, - 'Instagram' => 37879, - 'Netflix' => 37880, - 'Origin' => 38374, - 'Player Unknown\'s Battlegrounds' => 37881, - 'Playstation Network' => 38572, - 'Reddit' => 38809, - 'Snapchat' => 38513, - 'Steam' => 38230, - 'Twitter' => 38669, - 'Whatsapp' => 38371, - 'Wikipedia' => 38877, - 'Xbox Live' => 38088, - 'Yahoo Mail' => 38881, - 'Youtube' => 37882, - ), - 'Ελλάς' => array( - 'Apex Legends' => 38132, - 'Bet365' => 38811, - 'Blizzard Battle.net' => 37843, - 'Clash Royale' => 38949, - 'Counter-strike' => 38235, - 'Destiny' => 38980, - 'Discord' => 38607, - 'Dota 2' => 38352, - 'EA' => 38416, - 'eBay' => 38578, - 'Facebook' => 37844, - 'Facebook Messenger' => 37845, - 'Fifa' => 38434, - 'Fortnite' => 38087, - 'Gmail' => 38079, - 'Google' => 37846, - 'Google Play' => 38527, - 'GTA 5' => 38701, - 'iCloud' => 38846, - 'Instagram' => 37847, - 'League of Legends' => 38618, - 'Netflix' => 37848, - 'Office 365' => 38076, - 'Origin' => 38375, - 'Paypal' => 38462, - 'Player Unknown\'s Battlegrounds' => 37849, - 'Playstation Network' => 38575, - 'Pokémon Go' => 38639, - 'Reddit' => 38516, - 'Snapchat' => 38505, - 'Steam' => 38231, - 'Twitch' => 38582, - 'Twitter' => 38676, - 'Viber' => 38588, - 'Whatsapp' => 38373, - 'Yahoo Mail' => 38892, - 'Youtube' => 37850, - 'Βικιπαίδεια' => 38873, - ), - 'Hrvatska' => array( - 'Apex Legends' => 38135, - 'Battlefield' => 38442, - 'Blizzard Battle.net' => 37859, - 'Clash Royale' => 38356, - 'Counter-strike' => 38244, - 'Destiny' => 39011, - 'Discord' => 38610, - 'Dota 2' => 38351, - 'EA' => 38036, - 'Facebook' => 37860, - 'Facebook Messenger' => 37861, - 'Fifa' => 38438, - 'Fortnite' => 38069, - 'Google' => 37862, - 'GTA 5' => 38278, - 'HBO Go' => 38384, - 'iCloud' => 38847, - 'Instagram' => 37863, - 'Netflix' => 37864, - 'Office 365' => 38075, - 'Origin' => 38149, - 'Player Unknown\'s Battlegrounds' => 37865, - 'Playstation Network' => 38574, - 'Reddit' => 38090, - 'Snapchat' => 38509, - 'Steam' => 38229, - 'Tinder' => 37920, - 'Twitter' => 38672, - 'Whatsapp' => 38026, - 'Wikipedia' => 38874, - 'Yahoo Mail' => 38884, - 'Youtube' => 37866, - ), - 'Česko' => array( - 'Apex Legends' => 38136, - 'Battlefield' => 38446, - 'Blizzard Battle.net' => 37851, - 'Counter-strike' => 38236, - 'Discord' => 38452, - 'Dota 2' => 38221, - 'EA' => 38037, - 'Facebook' => 37852, - 'Facebook Messenger' => 37853, - 'Fifa' => 38430, - 'Fortnite' => 38086, - 'Gmail' => 38515, - 'Google' => 37854, - 'Google Play' => 38523, - 'GTA 5' => 38304, - 'HBO Go' => 38380, - 'iCloud' => 38845, - 'Instagram' => 37855, - 'Netflix' => 37856, - 'Office 365' => 38042, - 'Origin' => 38043, - 'Player Unknown\'s Battlegrounds' => 37857, - 'Playstation Network' => 38573, - 'Reddit' => 38062, - 'Snapchat' => 38512, - 'Steam' => 38082, - 'Tinder' => 37914, - 'Twitch' => 38530, - 'Twitter' => 38675, - 'Uplay PC' => 38638, - 'Whatsapp' => 38025, - 'Xbox Live' => 38072, - 'Youtube' => 37858, - ), - 'Chile' => array( - 'Apex Legends' => 38137, - 'Banco Estado' => 37682, - 'Banco Santander' => 37351, - 'Blizzard Battle.net' => 38104, - 'Call of Duty' => 38938, - 'Claro' => 36290, - 'Clash of Clans' => 37304, - 'Correos' => 37025, - 'Counter-Strike' => 38246, - 'Crunchyroll' => 38093, - 'Dead By Daylight' => 37436, - 'Discord' => 37758, - 'Dota 2' => 38477, - 'EA' => 36884, - 'Entel' => 37680, - 'Facebook' => 36239, - 'Facebook Messenger' => 36285, - 'Falabella' => 37681, - 'Fifa' => 38432, - 'Fortnite' => 36997, - 'Gmail' => 36499, - 'Google' => 36284, - 'Google Drive' => 38560, - 'GTA 5' => 38305, - 'Gtd Manquehue' => 37683, - 'HBO' => 36353, - 'iCloud' => 38854, - 'Instagram' => 36283, - 'Itaú' => 38473, - 'LinkedIn' => 38625, - 'Movistar' => 36289, - 'Mundo Pacifico' => 37684, - 'Netflix' => 36282, - 'Office 365' => 38077, - 'Origin' => 38102, - 'Outlook' => 36281, - 'Player Unknown\'s Battlegrounds' => 36564, - 'Playstation Network' => 36280, - 'Pokémon Go' => 36293, - 'Rainbow Six' => 36270, - 'Reddit' => 37484, - 'Roblox' => 38930, - 'SII' => 37679, - 'Skype' => 36279, - 'Snapchat' => 36278, - 'Spotify' => 36274, - 'Steam' => 36272, - 'Teamviewer' => 37068, - 'Telegram' => 38170, - 'Telsur' => 36292, - 'Tinder' => 37907, - 'Twitch' => 36541, - 'Twitter' => 36273, - 'Uplay PC' => 36271, - 'VTR' => 36291, - 'Warframe' => 37123, - 'Whatsapp' => 36277, - 'Wom' => 38308, - 'Xbox Live' => 37024, - 'Yahoo Mail' => 36276, - 'Youtube' => 36275, - ), - 'Colombia' => array( - 'Apex Legends' => 38140, - 'BBVA' => 37781, - 'Blizzard Battle.net' => 38148, - 'Call of Duty' => 38937, - 'Claro' => 37788, - 'Directv' => 37968, - 'Discord' => 38552, - 'EA' => 38282, - 'ETB' => 37967, - 'Facebook' => 37782, - 'Facebook Messenger' => 38257, - 'Fifa' => 38431, - 'Fortnite' => 37783, - 'Free Fire' => 38805, - 'Gmail' => 37779, - 'Google' => 37780, - 'Google Drive' => 39067, - 'GTA 5' => 38277, - 'HBO' => 38370, - 'iCloud' => 38857, - 'Instagram' => 37784, - 'Movistar' => 37789, - 'Netflix' => 37820, - 'Origin' => 38424, - 'Outlook' => 38464, - 'Player Unknown\'s Battlegrounds' => 37785, - 'Playstation Network' => 38391, - 'Snapchat' => 38506, - 'Spotify' => 38168, - 'Steam' => 38933, - 'Teamviewer' => 38460, - 'Telegram' => 38484, - 'Tigo' => 37966, - 'Tinder' => 37908, - 'Twitter' => 38169, - 'Waze' => 39070, - 'Whatsapp' => 37786, - 'Xbox Live' => 38071, - 'Yahoo Mail' => 38924, - 'Youtube' => 37787, - ), - 'România' => array( - 'Apex Legends' => 38142, - 'Battlefield' => 38441, - 'Blizzard Battle.net' => 37867, - 'Brawl Stars' => 38817, - 'Call of Duty' => 38934, - 'Counter-strike' => 38237, - 'Destiny' => 38988, - 'Digi' => 38971, - 'Discord' => 37953, - 'Dota 2' => 38222, - 'EA' => 38039, - 'Facebook' => 37868, - 'Facebook Messenger' => 37869, - 'Fifa' => 38046, - 'Fortnite' => 38070, - 'Gmail' => 38549, - 'Google' => 37870, - 'Google Play' => 38524, - 'GTA 5' => 38281, - 'HBO Go' => 38383, - 'iCloud' => 38849, - 'Instagram' => 37871, - 'Netflix' => 37872, - 'Orange' => 38970, - 'Origin' => 38045, - 'Player Unknown\'s Battlegrounds' => 37873, - 'Playstation Network' => 38407, - 'Reddit' => 38063, - 'Roblox' => 38918, - 'Snapchat' => 38510, - 'Steam' => 38083, - 'Telekom' => 38931, - 'Tinder' => 37913, - 'Twitter' => 38674, - 'UPC' => 38968, - 'Uplay PC' => 38637, - 'Vodafone' => 38967, - 'Whatsapp' => 38027, - 'Wikipedia' => 38875, - 'Xbox Live' => 38073, - 'Yahoo Mail' => 38455, - 'Youtube' => 37874, - ), - 'Magyarország' => array( - 'Apex Legends' => 38143, - 'Battlefield' => 38450, - 'Blizzard Battle.net' => 37883, - 'Call of Duty' => 39004, - 'Counter-strike' => 38242, - 'Destiny' => 38979, - 'Discord' => 38454, - 'Dota 2' => 38475, - 'EA' => 38038, - 'Escape from Tarkov' => 39055, - 'Facebook' => 37884, - 'Facebook Messenger' => 37885, - 'Fifa' => 38437, - 'Fortnite' => 38808, - 'Ghost Recon' => 38991, - 'Gmail' => 38514, - 'Google' => 37886, - 'GTA 5' => 38280, - 'HBO Go' => 38382, - 'iCloud' => 38852, - 'Instagram' => 37887, - 'Mastercard' => 38592, - 'Netflix' => 37888, - 'Office 365' => 38040, - 'Origin' => 38044, - 'Outlook' => 38078, - 'Pinterest' => 37954, - 'Player Unknown\'s Battlegrounds' => 37889, - 'Playstation Network' => 38408, - 'Rainbow Six' => 38486, - 'Reddit' => 38065, - 'Snapchat' => 38507, - 'Steam' => 38232, - 'Tinder' => 37910, - 'Twitch' => 39016, - 'Twitter' => 38673, - 'Uplay PC' => 38354, - 'Whatsapp' => 38372, - 'Wikipedia' => 38876, - 'Xbox Live' => 38074, - 'Yahoo Mail' => 38886, - 'Youtube' => 37890, - ), - 'Ecuador' => array( - 'Call of Duty' => 38936, - 'Claro' => 37985, - 'CNT' => 37990, - 'EA' => 38421, - 'Facebook' => 37797, - 'Facebook Messenger' => 38256, - 'Fortnite' => 37798, - 'HBO' => 38368, - 'iCloud' => 38856, - 'Instagram' => 37799, - 'iPlanet' => 37986, - 'Movistar' => 37984, - 'Nedetel' => 37987, - 'Netflix' => 37819, - 'Netlife' => 37991, - 'Playstation Network' => 38577, - 'Puntonet' => 37988, - 'Steam' => 38932, - 'Telegram' => 38482, - 'TVCable' => 37989, - 'Twitter' => 38670, - 'Whatsapp' => 37800, - 'Youtube' => 37801, - ), - ) - ) - ), - ); - - const API_TOKEN = 'YW5kcm9pZF9hcGlfdXNlcl92MTpxTkRyenZSczY1bW1ESlk0ZVNIWmtobFY='; - - public function collectData(){ - - if($this->queriedContext == 'All Websites') { - $html = getSimpleHTMLDOM($this->getURI() . '/archive/') - or returnClientError('Impossible to query website !.'); - - $table = $html->find('table.table-striped', 0); - - $maxCount = 10; - foreach ($table->find('tr') as $downEvent) { - $downLink = $downEvent->find('td', 1)->find('a', 1); - $item = $this->collectArticleData($downLink->getAttribute('href')); - $this->items[] = $item; - if($maxCount == 0) break; - $maxCount -= 1; - } - } else { - $this->items = $this->collectCompanyEvents($this->getInput('website')); - } - } - - protected function collectArticleData($link) { - - preg_match('/\/([0-9]{3,})/', $link, $matches); - $eventId = $matches[1]; - - $header = array( - 'Authorization: Basic ' . self::API_TOKEN - ); - - $article = getContents('https://downdetectorapi.com/v1/events/' . $eventId, $header) - or returnServerError('Could not request DownDetector API.'); - $article_json = json_decode($article); - - $item = array(); - $item['uri'] = $this->getURI() . $link; - $item['id'] = $article_json->id; - $item['title'] = $article_json->title; - $item['content'] = $article_json->body; - $item['timestamp'] = (new DateTime($article_json->started_at))->getTimestamp(); - return $item; - - } - - protected function collectCompanyEvents($companyId) { - - $header = array( - 'Authorization: Basic ' . self::API_TOKEN - ); - - $events = getContents('https://downdetectorapi.com/v1/companies/' . $companyId . '/events/', $header) - or returnServerError('Could not request DownDetector API.'); - $events_json = json_decode($events); - - $items = array(); - - foreach($events_json as $event) { - $item = array(); - $item['id'] = $event->id; - $item['title'] = $event->title; - $item['content'] = $event->body; - $item['timestamp'] = (new DateTime($event->started_at))->getTimestamp(); - $items[] = $item; - } - - return $items; - - } - - public function getURI() { - if($this->getInput('country') !== null) { - return $this->getInput('country'); - } else { - return self::URI; - } - } -} diff --git a/bridges/DribbbleBridge.php b/bridges/DribbbleBridge.php index b1193c90b72..539127b36ba 100644 --- a/bridges/DribbbleBridge.php +++ b/bridges/DribbbleBridge.php @@ -1,96 +1,114 @@ loadEmbeddedJsonData($html); - - foreach($html->find('li[id^="screenshot-"]') as $shot) { - $item = array(); - - $additional_data = $this->findJsonForShot($shot, $json); - if ($additional_data === null) { - $item['uri'] = self::URI . $shot->find('a', 0)->href; - $item['title'] = $shot->find('.dribbble-over strong', 0)->plaintext; - } else { - $item['timestamp'] = strtotime($additional_data['published_at']); - $item['uri'] = self::URI . $additional_data['path']; - $item['title'] = $additional_data['title']; - } - - $item['author'] = trim($shot->find('.attribution-user a', 0)->plaintext); - - $description = $shot->find('.comment', 0); - $item['content'] = $description === null ? '' : $description->plaintext; - - $preview_path = $shot->find('picture source', 0)->attr['srcset']; - $item['content'] .= $this->getImageTag($preview_path, $item['title']); - $item['enclosures'] = array($this->getFullSizeImagePath($preview_path)); - - $this->items[] = $item; - } - } - - private function loadEmbeddedJsonData($html){ - $json = array(); - $scripts = $html->find('script'); - - foreach($scripts as $script) { - if(strpos($script->innertext, 'newestShots') !== false) { - // fix single quotes - $script->innertext = str_replace('\'', '"', $script->innertext); - - // fix JavaScript JSON (why do they not adhere to the standard?) - $script->innertext = preg_replace('/(\w+):/i', '"\1":', $script->innertext); - - // find beginning of JSON array - $start = strpos($script->innertext, '['); - - // find end of JSON array, compensate for missing character! - $end = strpos($script->innertext, '];') + 1; - - // convert JSON to PHP array - $json = json_decode(substr($script->innertext, $start, $end - $start), true); - break; - } - } - - return $json; - } - - private function findJsonForShot($shot, $json){ - foreach($json as $element) { - if(strpos($shot->getAttribute('id'), (string)$element['id']) !== false) { - return $element; - } - } - - return null; - } - - private function getImageTag($preview_path, $title){ - return sprintf( - '
', - $this->getFullSizeImagePath($preview_path), - $preview_path, - $title - ); - } - - private function getFullSizeImagePath($preview_path){ - return str_replace('_1x', '', $preview_path); - } + } + + public function collectData() + { + $html = getSimpleHTMLDOM(self::URI); + + $data = $this->fetchData($html); + + foreach ($html->find('li[id^="screenshot-"]') as $shot) { + $item = []; + + $additional_data = $this->findJsonForShot($shot, $data); + if ($additional_data === null) { + $item['uri'] = self::URI . $shot->find('a', 0)->href; + $item['title'] = $shot->find('.shot-title', 0)->plaintext; + } else { + $item['timestamp'] = strtotime($additional_data['published_at']); + $item['uri'] = self::URI . $additional_data['path']; + $item['title'] = $additional_data['title']; + } + + $item['author'] = trim($shot->find('.user-information .display-name', 0)->plaintext); + + $description = $shot->find('.comment', 0); + $item['content'] = $description === null ? '' : $description->plaintext; + + $preview_path = $shot->find('figure img', 1)->attr['data-srcset']; + $item['content'] .= $this->getImageTag($preview_path, $item['title']); + $item['enclosures'] = [$this->getFullSizeImagePath($preview_path)]; + + $this->items[] = $item; + } + } + + private function fetchData($html) + { + $scripts = $html->find('script'); + + foreach ($scripts as $script) { + if (strpos($script->innertext, 'newestShots') !== false) { + // fix single quotes + $script->innertext = preg_replace('/\'(.*)\'(,?)$/im', '"\1"\2', $script->innertext); + + // fix JavaScript JSON (why do they not adhere to the standard?) + $script->innertext = preg_replace('/^(\s*)(\w+):/im', '\1"\2":', $script->innertext); + + // fix relative dates, so they are recognized by strtotime + $script->innertext = preg_replace('/"about ([0-9]+ hours? ago)"(,?)$/im', '"\1"\2', $script->innertext); + + // find beginning of JSON array + $start = strpos($script->innertext, '['); + + // find end of JSON array, compensate for missing character! + $end = strpos($script->innertext, '];') + 1; + + // convert JSON to PHP array + $json = substr($script->innertext, $start, $end - $start); + + try { + // TODO: fix broken json + return Json::decode($json); + } catch (\JsonException $e) { + return []; + } + } + } + return []; + } + + private function findJsonForShot($shot, $json) + { + foreach ($json as $element) { + if (strpos($shot->getAttribute('id'), (string)$element['id']) !== false) { + return $element; + } + } + + return null; + } + + private function getImageTag($preview_path, $title) + { + return sprintf( + '
', + $this->getFullSizeImagePath($preview_path), + $preview_path, + $title + ); + } + + private function getFullSizeImagePath($preview_path) + { + // Get last image from srcset + $src_set_urls = explode(',', $preview_path); + $url = end($src_set_urls); + $url = explode(' ', $url)[1]; + + return htmlspecialchars_decode($url); + } } diff --git a/bridges/Drive2ruBridge.php b/bridges/Drive2ruBridge.php new file mode 100644 index 00000000000..b3bd73c3fde --- /dev/null +++ b/bridges/Drive2ruBridge.php @@ -0,0 +1,232 @@ + [], + 'Бортжурналы (По модели или марке)' => [ + 'url' => [ + 'name' => 'Ссылка на страницу с бортжурналом', + 'type' => 'text', + 'required' => true, + 'title' => 'Например: https://www.drive2.ru/experience/suzuki/g4895/', + 'exampleValue' => 'https://www.drive2.ru/experience/suzuki/g4895/' + ], + ], + 'Личные блоги' => [ + 'username' => [ + 'name' => 'Никнейм пользователя на сайте', + 'type' => 'text', + 'required' => true, + 'title' => 'Например: Mickey', + 'exampleValue' => 'Mickey' + ] + ], + 'Публикации по темам (Стоит почитать)' => [ + 'topic' => [ + 'name' => 'Темы', + 'type' => 'list', + 'values' => [ + 'Автозвук' => '16', + 'Автомобильный дизайн' => '10', + 'Автоспорт' => '11', + 'Автошоу, музеи, выставки' => '12', + 'Безопасность' => '18', + 'Беспилотные автомобили' => '15', + 'Видеосюжеты' => '20', + 'Вне дорог' => '21', + 'Встречи' => '22', + 'Выбор и покупка машины' => '23', + 'Гаджеты' => '30', + 'Гибридные машины' => '32', + 'Грузовики, автобусы, спецтехника' => '31', + 'Доработка интерьера' => '35', + 'Законодательство' => '40', + 'История автомобилестроения' => '50', + 'Мототехника' => '60', + 'Новые модели и концепты' => '85', + 'Обучение вождению' => '70', + 'Путешествия' => '80', + 'Ремонт и обслуживание' => '90', + 'Реставрация ретро-авто' => '91', + 'Сделай сам' => '104', + 'Смешное' => '103', + 'Спорткары' => '102', + 'Стайлинг' => '101', + 'Тест-драйвы' => '110', + 'Тюнинг' => '111', + 'Фотосессии' => '120', + 'Шины и диски' => '140', + 'Электрика' => '130', + 'Электромобили' => '131' + ], + 'defaultValue' => '16', + ] + ], + 'global' => [ + 'full_articles' => [ + 'name' => 'Загружать в ленту полный текст', + 'type' => 'checkbox' + ] + ] + ]; + + private $title; + + private function getUserContent($url) + { + $html = getSimpleHTMLDOM($url); + $this->title = $html->find('title', 0)->innertext; + $articles = $html->find('div.js-entity'); + foreach ($articles as $article) { + $item = []; + $item['title'] = $article->find('a.c-link--text', 0)->plaintext; + $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 0)->href); + if ($this->getInput('full_articles')) { + $item['content'] = $this->addCommentsLink( + $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.c-post__body', 0))->innertext, + $item['uri'] + ); + } else { + $item['content'] = $this->addReadMoreLink($article->find('div.c-post-preview__lead', 0), $item['uri']); + } + $item['author'] = $article->find('a.c-username--wrap', 0)->plaintext; + if (!is_null($article->find('img', 1))) { + $item['enclosures'][] = $article->find('img', 1)->src; + } + $this->items[] = $item; + } + } + + private function getLogbooksContent($url) + { + $html = getSimpleHTMLDOM($url); + $this->title = $html->find('title', 0)->innertext; + $articles = $html->find('div.js-entity'); + foreach ($articles as $article) { + $item = []; + $item['title'] = $article->find('a.c-link--text', 1)->plaintext; + $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 1)->href); + if ($this->getInput('full_articles')) { + $item['content'] = $this->addCommentsLink( + $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.c-post__body', 0))->innertext, + $item['uri'] + ); + } else { + $item['content'] = $this->addReadMoreLink($article->find('div.c-post-preview__lead', 0), $item['uri']); + } + $item['author'] = $article->find('a.c-username--wrap', 0)->plaintext; + if (!is_null($article->find('img', 1))) { + $item['enclosures'][] = $article->find('img', 1)->src; + } + $this->items[] = $item; + } + } + + private function getNews() + { + $html = getSimpleHTMLDOM('https://www.drive2.ru/editorial/'); + $this->title = $html->find('title', 0)->innertext; + $articles = $html->find('div.c-article-card'); + foreach ($articles as $article) { + $item = []; + $item['title'] = $article->find('a.c-link--text', 0)->plaintext; + $item['uri'] = urljoin(self::URI, $article->find('a.c-link--text', 0)->href); + if ($this->getInput('full_articles')) { + $item['content'] = $this->addCommentsLink( + $this->adjustContent(getSimpleHTMLDomCached($item['uri'])->find('div.article', 0))->innertext, + $item['uri'] + ); + } else { + $item['content'] = $this->addReadMoreLink($article->find('div.c-article-card__lead', 0), $item['uri']); + } + $item['author'] = 'Новости и тест-драйвы на Drive2.ru'; + if (!is_null($article->find('img', 0))) { + $item['enclosures'][] = $article->find('img', 0)->src; + } + $this->items[] = $item; + } + } + + private function adjustContent($content) + { + foreach ($content->find('div.o-group') as $node) { + $node->outertext = ''; + } + foreach ($content->find('div, span') as $attrs) { + foreach ($attrs->getAllAttributes() as $attr => $val) { + $attrs->removeAttribute($attr); + } + } + foreach ($content->getElementsByTagName('figcaption') as $attrs) { + $attrs->setAttribute( + 'style', + 'font-style: italic; font-size: small; margin: 0 100px 75px;' + ); + } + foreach ($content->find('script') as $node) { + $node->outertext = ''; + } + foreach ($content->find('iframe') as $node) { + preg_match('/embed\/(.*?)\?/', $node->src, $match); + $node->outertext = 'https://www.youtube.com/watch?v=' . $match[1] . ''; + } + return $content; + } + + private function addCommentsLink($content, $url) + { + return $content . '
Перейти к комментариям'; + } + + private function addReadMoreLink($content, $url) + { + if (!is_null($content)) { + return preg_replace('!\s+!', ' ', str_replace('Читать дальше', '', $content->plaintext)) . + '
Читать далее'; + } else { + return ''; + } + } + + public function collectData() + { + switch ($this->queriedContext) { + default: + case 'Новости и тест-драйвы': + $this->getNews(); + break; + case 'Бортжурналы (По модели или марке)': + if (!preg_match('/^https:\/\/www.drive2.ru\/experience/', $this->getInput('url'))) { + returnServerError('Invalid url'); + } + $this->getLogbooksContent($this->getInput('url')); + break; + case 'Личные блоги': + if (!preg_match('/^[a-zA-Z0-9-]{3,16}$/', $this->getInput('username'))) { + returnServerError('Invalid username'); + } + $this->getUserContent('https://www.drive2.ru/users/' . $this->getInput('username')); + break; + case 'Публикации по темам (Стоит почитать)': + $this->getUserContent('https://www.drive2.ru/topics/' . $this->getInput('topic')); + break; + } + } + + public function getName() + { + return $this->title ?: parent::getName(); + } + + public function getIcon() + { + return 'https://www.drive2.ru/favicon.ico'; + } +} diff --git a/bridges/DuckDuckGoBridge.php b/bridges/DuckDuckGoBridge.php index 8533be5ecfd..6f9069fa345 100644 --- a/bridges/DuckDuckGoBridge.php +++ b/bridges/DuckDuckGoBridge.php @@ -1,42 +1,53 @@ array( - 'name' => 'keyword', - 'required' => true - ), - 'sort' => array( - 'name' => 'sort by', - 'type' => 'list', - 'required' => false, - 'values' => array( - 'date' => self::SORT_DATE, - 'relevance' => self::SORT_RELEVANCE - ), - 'defaultValue' => self::SORT_DATE - ) - )); + const PARAMETERS = [ [ + 'u' => [ + 'name' => 'keyword', + 'exampleValue' => 'duck', + 'required' => true + ], + 'sort' => [ + 'name' => 'sort by', + 'type' => 'list', + 'required' => false, + 'values' => [ + 'date' => self::SORT_DATE, + 'relevance' => self::SORT_RELEVANCE + ], + 'defaultValue' => self::SORT_DATE + ] + ]]; - public function collectData(){ - $html = getSimpleHTMLDOM(self::URI . 'html/?kd=-1&q=' . $this->getInput('u') . $this->getInput('sort')) - or returnServerError('Could not request DuckDuckGo.'); + public function collectData() + { + $query = [ + 'kd' => '-1', + 'q' => $this->getInput('u') . $this->getInput('sort'), + ]; + $url = 'https://duckduckgo.com/html/?' . http_build_query($query); + $html = getSimpleHTMLDOM($url); - foreach($html->find('div.results_links') as $element) { - $item = array(); - $item['uri'] = $element->find('a', 0)->href; - $item['title'] = $element->find('a', 1)->innertext; - $item['content'] = $element->find('div.snippet', 0)->plaintext; - $this->items[] = $item; - } - } + foreach ($html->find('div.result') as $element) { + $item = []; + $item['uri'] = $element->find('a.result__a', 0)->href; + $item['title'] = $element->find('h2.result__title', 0)->plaintext; + + $snippet = $element->find('a.result__snippet', 0); + if ($snippet) { + $item['content'] = $snippet->plaintext; + } + $this->items[] = $item; + } + } } diff --git a/bridges/DuvarOrgBridge.php b/bridges/DuvarOrgBridge.php new file mode 100644 index 00000000000..f5f01063775 --- /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 00000000000..bb5fa41d11c --- /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 new file mode 100644 index 00000000000..463f73d6321 --- /dev/null +++ b/bridges/EBayBridge.php @@ -0,0 +1,185 @@ + [ + 'name' => 'Search URL', + 'title' => 'Copy the URL from your browser\'s address bar after searching for your items and paste it here', + 'pattern' => '^(https:\/\/)?(www\.)?(befr\.|benl\.)?ebay\.(com|com\.au|at|be|ca|ch|cn|es|fr|de|com\.hk|ie|it|com\.my|nl|ph|pl|com\.sg|co\.uk)\/.*$', + 'exampleValue' => 'https://www.ebay.com/sch/i.html?_nkw=atom+rss', + 'required' => true, + ], + 'includesSearchLink' => [ + 'name' => 'Include Original Search Link', + 'title' => 'Whether or not each feed item should include the original search query link to eBay which was used to find the given listing.', + 'type' => 'checkbox', + 'defaultValue' => false, + ], + ]]; + + public function getURI() + { + if ($this->getInput('url')) { + # make sure we order by the most recently listed offers + $uri = trim(preg_replace('/([?&])_sop=[^&]+(&|$)/', '$1', $this->getInput('url')), '?&/'); + $uri .= (parse_url($uri, PHP_URL_QUERY) ? '&' : '?') . '_sop=10'; + + // Ensure the List View is used instead of the Gallery View. + $uri = trim(preg_replace('/[?&]_dmd=[^&]+(&|$)/i', '$1', $uri), '?&/'); + $uri .= '&_dmd=1'; + + return $uri; + } else { + return parent::getURI(); + } + } + + public function getName() + { + $url = $this->getInput('url'); + if (!$url) { + return parent::getName(); + } + $urlQueries = explode('&', parse_url($url, PHP_URL_QUERY)); + + $searchQuery = array_reduce($urlQueries, function ($q, $p) { + if (preg_match('/^_nkw=(.+)$/i', $p, $matches)) { + $q[] = str_replace('+', ' ', urldecode($matches[1])); + } + + return $q; + }); + + if ($searchQuery) { + return 'eBay - ' . $searchQuery[0]; + } + + return parent::getName(); + } + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + + // Remove any unsolicited results, e.g. "Results matching fewer words" + foreach ($html->find('ul.srp-results > li.srp-river-answer--REWRITE_START ~ li') as $inexactMatches) { + $inexactMatches->remove(); + } + + // Remove "NEW LISTING" labels: we sort by the newest, so this is redundant. + foreach ($html->find('.LIGHT_HIGHLIGHT') as $new_listing_label) { + $new_listing_label->remove(); + } + + $results = $html->find('ul.srp-results > li.s-item'); + foreach ($results as $listing) { + $item = []; + + // Define a closure to shorten the ugliness of querying the current listing. + $find = function ($query, $altText = '') use ($listing) { + return $listing->find($query, 0)->plaintext ?? $altText; + }; + + $item['title'] = $find('.s-item__title'); + if (!$item['title']) { + // Skip entries where the title cannot be found (for w/e reason). + continue; + } + + // It appears there may be more than a single 'subtitle' subclass in the listing. Collate them. + $subtitles = $listing->find('.s-item__subtitle'); + if (is_array($subtitles)) { + $subtitle = trim(implode(' ', array_column($subtitles, 'plaintext'))); + } else { + $subtitle = trim($subtitles->plaintext ?? ''); + } + + // Get the listing's link and uid. + $itemUri = $listing->find('.s-item__link', 0); + if ($itemUri) { + $item['uri'] = $itemUri->href; + } + if (preg_match('/.*\/itm\/(\d+).*/i', $item['uri'], $matches)) { + $item['uid'] = $matches[1]; + } + + // Price should be fetched on its own so we can provide the alt text without complication. + $price = $find('.s-item__price', '[NO PRICE]'); + + // Map a list of dynamic variable names to their subclasses within the listing. + // This is just a bit of sugar to make this cleaner and more maintainable. + $propertyMappings = [ + 'additionalPrice' => '.s-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', + ]; + + foreach ($propertyMappings as $k => $v) { + $$k = $find($v); + } + + // When an additional price detail or discount is defined, create the 'discountLine'. + if ($additionalPrice || $discount) { + $discountLine = '
(' + . trim($additionalPrice ?? '') + . '; ' . trim($discount ?? '') + . ')'; + } else { + $discountLine = ''; + } + + // Prepend the time-left info with a comma if the right details were found. + $timeInfo = trim($timeLeft . ' ' . $timeEnd); + if ($timeInfo) { + $timeInfo = ', ' . $timeInfo; + } + + // Set the listing type. + if ($bids) { + $listingTypeDetails = "Auction: {$bids}{$timeInfo}"; + } else { + $listingTypeDetails = 'Buy It Now'; + } + + // Acquire the listing's primary image and atach it. + $image = $listing->find('.s-item__image-wrapper > img', 0); + if ($image) { + // Not quite sure why append fragment here + $imageUrl = $image->src . '#.image'; + $item['enclosures'] = [$imageUrl]; + } + + // Include the original search link, if specified. + if ($this->getInput('includesSearchLink')) { + $searchLink = ''; + } else { + $searchLink = ''; + } + + // Build the final item's content to display and add the item onto the list. + $item['content'] = <<$sellerInfo $location + $price $obo ($listingTypeDetails) + $discountLine +
+
$shippingFree $localDelivery $logisticsCost{$subtitle}
+$searchLink +CONTENT; + + $this->items[] = $item; + } + } +} diff --git a/bridges/EDDHPiRepsBridge.php b/bridges/EDDHPiRepsBridge.php new file mode 100644 index 00000000000..7d5e6a0bd04 --- /dev/null +++ b/bridges/EDDHPiRepsBridge.php @@ -0,0 +1,85 @@ +find('table table table td') as $itemnode) { + $texts = $this->extractTexts($itemnode->find('text, br')); + $timestamp = $itemnode->find('.su_dat', 0)->innertext(); + $uri = $itemnode->find('.pir_hd a', 0)->href; + $this->items[] = [ + 'timestamp' => $this->formatItemTimestamp($timestamp), + 'title' => $this->formatItemTitle($texts), + 'uri' => $this->formatItemUri($uri), + 'author' => $this->formatItemAuthor($texts), + 'content' => $this->formatItemContent($texts) + ]; + } + } + + public function getIcon() + { + return 'https://eddh.de/favicon.ico'; + } + + private function extractTexts($nodes) + { + $texts = []; + $i = 0; + foreach ($nodes as $node) { + $text = trim($node->outertext()); + if ($node->tag == 'br') { + $texts[$i++] = "\n"; + } elseif (($node->tag == 'text') && ($text != '')) { + $text = iconv('Windows-1252', 'UTF-8', $text); + $text = str_replace(' ', '', $text); + $texts[$i++] = $text; + } + } + return $texts; + } + + protected function formatItemAuthor($texts) + { + $pos = array_search('Name:', $texts); + return $texts[$pos + 1]; + } + + protected function formatItemContent($texts) + { + $pos1 = array_search('Bemerkungen:', $texts); + $pos2 = array_search('Bewertung:', $texts); + $content = ''; + for ($i = $pos1 + 1; $i < $pos2; $i++) { + $content .= $texts[$i]; + } + return trim($content); + } + + protected function formatItemTitle($texts) + { + $texts[5] = ltrim($texts[5], '('); + return implode(' ', [$texts[1], $texts[2], $texts[3], $texts[5]]); + } + + protected function formatItemTimestamp($value) + { + $value = str_replace('Eintrag vom', '', $value); + $value = trim($value); + return strtotime($value); + } + + protected function formatItemUri($value) + { + return 'https://eddh.de/info/' . $value; + } +} diff --git a/bridges/EDDHPresseschauBridge.php b/bridges/EDDHPresseschauBridge.php new file mode 100644 index 00000000000..935856847af --- /dev/null +++ b/bridges/EDDHPresseschauBridge.php @@ -0,0 +1,42 @@ +setTime(0, 0, 0); + return $dti->getTimestamp(); + } +} diff --git a/bridges/ETTVBridge.php b/bridges/ETTVBridge.php deleted file mode 100644 index c348ca01c8f..00000000000 --- a/bridges/ETTVBridge.php +++ /dev/null @@ -1,161 +0,0 @@ - array( - 'name' => 'Keywords', - 'required' => true - ), - 'cat' => array( - 'type' => 'list', - 'name' => 'Category', - 'values' => array( - '(ALL TYPES)' => '0', - 'Anime: Movies' => '73', - 'Anime: Dubbed/Subbed' => '74', - 'Anime: Others' => '75', - 'Books: Ebooks' => '53', - 'Books: Magazines' => '54', - 'Books: Comics' => '55', - 'Books: Audio' => '56', - 'Books: Others' => '68', - 'Games: Windows' => '57', - 'Games: Android' => '58', - 'Games: Others' => '71', - 'Movies: HD 1080p' => '1', - 'Movies: HD 720p' => '2', - 'Movies: UltraHD/4K' => '3', - 'Movies: XviD' => '42', - 'Movies: X264/H264' => '47', - 'Movies: 3D' => '49', - 'Movies: Dubs/Dual Audio' => '51', - 'Movies: CAM/TS' => '65', - 'Movies: BluRay Disc/Remux' => '66', - 'Movies: DVDR' => '67', - 'Movies: HEVC/x265' => '76', - 'Music: MP3' => '59', - 'Music: FLAC' => '60', - 'Music: Music Videos' => '61', - 'Music: Others' => '69', - 'Software: Windows' => '62', - 'Software: Android' => '63', - 'Software: Mac' => '64', - 'Software: Others' => '70', - 'TV: HD/X264/H264' => '41', - 'TV: SD/X264/H264' => '5', - 'TV: TV Packs' => '7', - 'TV: SD/XVID' => '50', - 'TV: Sport' => '72', - 'TV: HEVC/x265' => '77', - 'Unsorted: Unsorted' => '78' - ), - 'defaultValue' => '(ALL TYPES)' - ), - 'status' => array( - 'type' => 'list', - 'name' => 'Status', - 'values' => array( - 'Active Transfers' => '0', - 'Included Dead' => '1', - 'Only Dead' => '2' - ), - 'defaultValue' => 'Included Dead' - ), - 'lang' => array( - 'type' => 'list', - 'name' => 'Lang', - 'values' => array( - '(ALL)' => '0', - 'Arabic' => '17', - 'Chinese ' => '10', - 'Danish' => '13', - 'Dutch' => '11', - 'English' => '1', - 'Finnish' => '18', - 'French' => '2', - 'German' => '3', - 'Greek' => '15', - 'Hindi' => '8', - 'Italian' => '4', - 'Japanese' => '5', - 'Korean' => '9', - 'Polish' => '14', - 'Russian' => '7', - 'Spanish' => '6', - 'Turkish' => '16' - ), - 'defaultValue' => '(ALL)' - ) - )); - - protected $results_link; - - public function collectData(){ - // No control on inputs, because all defaultValue are set - $query_str = 'torrents-search.php'; - $query_str .= '?search=' . urlencode('+' . str_replace(' ', ' +', $this->getInput('query'))); - $query_str .= '&cat=' . $this->getInput('cat'); - $query_str .= '&incldead=' . $this->getInput('status'); - $query_str .= '&lang=' . $this->getInput('lang'); - $query_str .= '&sort=id&order=desc'; - - // Get results page - $this->results_link = self::URI . $query_str; - $html = getSimpleHTMLDOM($this->results_link) - or returnServerError('Could not request ' . $this->getName()); - - // Loop on each entry - foreach($html->find('table.table tr') as $element) { - if($element->parent->tag == 'thead') continue; - $entry = $element->find('td', 1)->find('a', 0); - - // retrieve result page to get more details - $link = rtrim(self::URI, '/') . $entry->href; - $page = getSimpleHTMLDOM($link) - or returnServerError('Could not request page ' . $link); - - // get details & download links - $details = $page->find('fieldset.download table', 0); // WHAT?? It should be the second one… - $dllinks = $page->find('div#downloadbox table', 0); - - // fill item - $item = array(); - $item['author'] = $details->children(6)->children(1)->plaintext; - $item['title'] = $entry->title; - $item['uri'] = $link; - $item['timestamp'] = strtotime($details->children(7)->children(1)->plaintext); - $item['content'] = ''; - $item['content'] .= '
Name: ' . $details->children(0)->children(1)->innertext; - $item['content'] .= '
Lang: ' . $details->children(3)->children(1)->innertext; - $item['content'] .= '
Size: ' . $details->children(4)->children(1)->innertext; - $item['content'] .= '
Hash: ' . $details->children(5)->children(1)->innertext; - foreach($dllinks->children(0)->children(1)->find('a') as $dl) { - $item['content'] .= '
' . $dl->outertext; - } - $item['content'] .= '
' . $details->children(1)->children(0)->innertext; - $this->items[] = $item; - } - } - - public function getName(){ - if($this->getInput('query')) { - return '[' . self::NAME . '] ' . $this->getInput('query'); - } - - return self::NAME; - } - - public function getURI(){ - if(isset($this->results_link) && !empty($this->results_link)) { - return $this->results_link; - } - - return self::URI; - } -} diff --git a/bridges/EZTVBridge.php b/bridges/EZTVBridge.php index c016ff33081..556bd39ec38 100644 --- a/bridges/EZTVBridge.php +++ b/bridges/EZTVBridge.php @@ -1,67 +1,117 @@ array( - 'name' => 'Show ids', - 'exampleValue' => 'showID1,showID2,…', - 'required' => true - ) - )); +class EZTVBridge extends BridgeAbstract +{ + const MAINTAINER = 'alexAubin'; + const NAME = 'EZTV'; + const URI = 'https://eztvstatus.com'; + const DESCRIPTION = 'Search for torrents by IMDB id. You can find IMDB id in the url of a tv show.'; - public function collectData(){ + const PARAMETERS = [ + [ + 'ids' => [ + 'name' => 'IMDB ids', + 'exampleValue' => '8740790,1733785', + 'required' => true, + 'title' => 'One or more IMDB ids' + ], + 'no480' => [ + 'name' => 'No 480p', + 'type' => 'checkbox', + 'title' => 'Activate to exclude 480p torrents' + ], + 'no720' => [ + 'name' => 'No 720p', + 'type' => 'checkbox', + 'title' => 'Activate to exclude 720p torrents' + ], + 'no1080' => [ + 'name' => 'No 1080p', + 'type' => 'checkbox', + 'title' => 'Activate to exclude 1080p torrents' + ], + 'no2160' => [ + 'name' => 'No 2160p', + 'type' => 'checkbox', + 'title' => 'Activate to exclude 2160p torrents' + ], + 'noUnknownRes' => [ + 'name' => 'No Unknown resolution', + 'type' => 'checkbox', + 'title' => 'Activate to exclude unknown resolution torrents' + ], + ] + ]; - // Make timestamp from relative released time in table - function makeTimestamp($relativeReleaseTime){ + public function collectData() + { + $eztv_uri = $this->getEztvUri(); + $ids = explode(',', trim($this->getInput('ids'))); + foreach ($ids as $id) { + $url = sprintf('%s/api/get-torrents?imdb_id=%s', $eztv_uri, $id); + $json = getContents($url); + $data = json_decode($json); + if (!isset($data->torrents)) { + // No results + continue; + } + foreach ($data->torrents as $torrent) { + $title = $torrent->title; + $regex480 = '/480p/'; + $regex720 = '/720p/'; + $regex1080 = '/1080p/'; + $regex2160 = '/2160p/'; + $regexUnknown = '/(480p|720p|1080p|2160p)/'; + // Skip unwanted resolution torrents + if ( + (preg_match($regex480, $title) === 1 && $this->getInput('no480')) + || (preg_match($regex720, $title) === 1 && $this->getInput('no720')) + || (preg_match($regex1080, $title) === 1 && $this->getInput('no1080')) + || (preg_match($regex2160, $title) === 1 && $this->getInput('no2160')) + || (preg_match($regexUnknown, $title) !== 1 && $this->getInput('noUnknownRes')) + ) { + continue; + } + $this->items[] = $this->getItemFromTorrent($torrent); + } + } + usort($this->items, function ($torrent1, $torrent2) { + return $torrent2['timestamp'] <=> $torrent1['timestamp']; + }); + } - $relativeDays = 0; - $relativeHours = 0; + protected function getEztvUri() + { + $html = getSimpleHTMLDom(self::URI); + $urls = $html->find('a.domainLink'); + foreach ($urls as $url) { + $headers = get_headers($url->href); + if (substr($headers[0], 9, 3) === '200') { + return $url->href; + } + } + throw new Exception('No valid EZTV URI available'); + } - foreach(explode(' ', $relativeReleaseTime) as $relativeTimeElement) { - if(substr($relativeTimeElement, -1) == 'd') $relativeDays = substr($relativeTimeElement, 0, -1); - if(substr($relativeTimeElement, -1) == 'h') $relativeHours = substr($relativeTimeElement, 0, -1); - } - return mktime(date('h') - $relativeHours, 0, 0, date('m'), date('d') - $relativeDays, date('Y')); - } + protected function getItemFromTorrent($torrent) + { + $item = []; + $item['uri'] = $torrent->episode_url ?? $torrent->torrent_url; + $item['author'] = $torrent->imdb_id; + $item['timestamp'] = $torrent->date_released_unix; + $item['title'] = $torrent->title; + $item['enclosures'][] = $torrent->torrent_url; - // Loop on show ids - $showList = explode(',', $this->getInput('i')); - foreach($showList as $showID) { + $thumbnailUri = 'https:' . $torrent->small_screenshot; + $torrentSize = format_bytes((int) $torrent->size_bytes); - // Get show page - $html = getSimpleHTMLDOM(self::URI . 'shows/' . rawurlencode($showID) . '/') - or returnServerError('Could not request EZTV for id "' . $showID . '"'); + $item['content'] = $torrent->filename . '
File size: ' + . $torrentSize . '
magnet link
torrent link
'; - // Loop on each element that look like an episode entry... - foreach($html->find('.forum_header_border') as $element) { - - // Filter entries that are not episode entries - $ep = $element->find('td', 1); - if(empty($ep)) continue; - $epinfo = $ep->find('.epinfo', 0); - $released = $element->find('td', 3); - if(empty($epinfo)) continue; - if(empty($released->plaintext)) continue; - - // Filter entries that are older than 1 week - if($released->plaintext == '>1 week') continue; - - // Fill item - $item = array(); - $item['uri'] = self::URI . $epinfo->href; - $item['id'] = $item['uri']; - $item['timestamp'] = makeTimestamp($released->plaintext); - $item['title'] = $epinfo->plaintext; - $item['content'] = $epinfo->alt; - if(isset($item['title'])) - $this->items[] = $item; - } - } - } + return $item; + } } diff --git a/bridges/EconomistBridge.php b/bridges/EconomistBridge.php index 94121ac3aaf..eaa50ba1d16 100644 --- a/bridges/EconomistBridge.php +++ b/bridges/EconomistBridge.php @@ -1,63 +1,259 @@ find('article') as $element) { - - $a = $element->find('a', 0); - $href = self::URI . $a->href; - $full = getSimpleHTMLDOMCached($href); - $article = $full->find('article', 0); - - $header = $article->find('h1', 0); - $author = $article->find('span[itemprop="author"]', 0); - $time = $article->find('time[itemprop="dateCreated"]', 0); - $content = $article->find('div[itemprop="description"]', 0); - - // Remove newsletter subscription box - $newsletter = $content->find('div[class="newsletter-form__message"]', 0); - if ($newsletter) - $newsletter->outertext = ''; - - $newsletterForm = $content->find('form', 0); - if ($newsletterForm) - $newsletterForm->outertext = ''; - - // Remove next and previous article URLs at the bottom - $nextprev = $content->find('div[class="blog-post__next-previous-wrapper"]', 0); - if ($nextprev) - $nextprev->outertext = ''; - - $section = array( $article->find('h3[itemprop="articleSection"]', 0)->plaintext ); - - $item = array(); - $item['title'] = $header->find('span', 0)->innertext . ': ' - . $header->find('span', 1)->innertext; - - $item['uri'] = $href; - $item['timestamp'] = strtotime($time->datetime); - $item['author'] = $author->innertext; - $item['categories'] = $section; - - $item['content'] = '' . $content->innertext; - - $this->items[] = $item; - - if (count($this->items) >= 10) - break; - } - } + +class EconomistBridge extends FeedExpander +{ + const MAINTAINER = 'bockiii, sqrtminusone'; + const NAME = 'Economist Bridge'; + const URI = 'https://www.economist.com/'; + const CACHE_TIMEOUT = 3600; //1hour + const DESCRIPTION = 'Returns the latest articles for the selected category'; + + const CONFIGURATION = [ + 'cookie' => [ + 'required' => false, + ] + ]; + + const PARAMETERS = [ + 'global' => [ + 'limit' => [ + 'name' => 'Feed Item Limit', + 'required' => true, + 'type' => 'number', + 'defaultValue' => 10, + 'title' => 'Maximum number of returned feed items. Maximum 30, default 10' + ] + ], + 'Topics' => [ + 'topic' => [ + 'name' => 'Topics', + 'type' => 'list', + 'title' => 'Select a Topic', + 'defaultValue' => 'latest', + 'values' => [ + 'Latest' => 'latest', + 'The world this week' => 'the-world-this-week', + 'Letters' => 'letters', + 'Leaders' => 'leaders', + 'Briefings' => 'briefing', + 'Special reports' => 'special-report', + 'Britain' => 'britain', + 'Europe' => 'europe', + 'United States' => 'united-states', + 'The Americas' => 'the-americas', + 'Middle East and Africa' => 'middle-east-and-africa', + 'Asia' => 'asia', + 'China' => 'china', + 'International' => 'international', + 'Business' => 'business', + 'Finance and economics' => 'finance-and-economics', + 'Science and technology' => 'science-and-technology', + 'Books and arts' => 'books-and-arts', + 'Obituaries' => 'obituary', + 'Graphic detail' => 'graphic-detail', + 'Indicators' => 'economic-and-financial-indicators', + 'The Economist Reads' => 'the-economist-reads', + ] + ] + ], + 'Blogs' => [ + 'blog' => [ + 'name' => 'Blogs', + 'type' => 'list', + 'title' => 'Select a Blog', + 'values' => [ + 'Bagehots notebook' => 'bagehots-notebook', + 'Bartleby' => 'bartleby', + 'Buttonwoods notebook' => 'buttonwoods-notebook', + 'Charlemagnes notebook' => 'charlemagnes-notebook', + 'Democracy in America' => 'democracy-in-america', + 'Erasmus' => 'erasmus', + 'Free exchange' => 'free-exchange', + 'Game theory' => 'game-theory', + 'Gulliver' => 'gulliver', + 'Kaffeeklatsch' => 'kaffeeklatsch', + 'Prospero' => 'prospero', + 'The Economist Explains' => 'the-economist-explains', + ] + ] + ] + ]; + + public function collectData() + { + // get if topics or blogs were selected and store the selected category + switch ($this->queriedContext) { + case 'Topics': + $category = $this->getInput('topic'); + break; + case 'Blogs': + $category = $this->getInput('blog'); + break; + default: + $category = 'latest'; + } + // limit the returned articles to 30 at max + if ((int)$this->getInput('limit') <= 30) { + $limit = (int)$this->getInput('limit'); + } else { + $limit = 30; + } + + $url = 'https://www.economist.com/' . $category . '/rss.xml'; + $this->collectExpandableDatas($url, $limit); + } + + protected function parseItem(array $item) + { + $headers = []; + if ($this->getOption('cookie')) { + $headers = [ + 'Authority: www.economist.com', + 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-language: en-US,en;q=0.9', + 'Cache-control: max-age=0', + 'Cookie: ' . $this->getOption('cookie'), + 'Upgrade-insecure-requests: 1', + 'User-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36' + ]; + } + try { + $dom = getSimpleHTMLDOM($item['uri'], $headers); + } catch (Exception $e) { + $item['content'] = $e->getMessage(); + return $item; + } + + $article = $dom->find('#new-article-template', 0); + if ($article == null) { + $article = $dom->find('main', 0); + } + if ($article) { + $elem = $article->find('div', 0); + list($content, $audio_url) = $this->processContent($dom, $elem); + $item['content'] = $content; + if ($audio_url != null) { + $item['enclosures'] = [$audio_url]; + } + } + return $item; + } + + private function processContent($html, $elem) + { + // Remove extra styles + $styles = $elem->find('style'); + foreach ($styles as $style) { + $style->parent->removeChild($style); + } + + // Remove the section with remaining articles + $more_elem = $elem->find('h2.ds-section-headline.ds-section-headline--rule-emphasised', 0); + if ($more_elem != null) { + if ($more_elem->parent && $more_elem->parent->parent) { + $more_elem->parent->parent->removeChild($more_elem->parent); + } + } + + // Remove 'capitalization' with tags + foreach ($elem->find('small') as $small) { + $small->outertext = strtoupper($small->innertext); + } + + // Extract audio + $audio_url = null; + $audio_elem = $elem->find('#audio-player', 0); + if ($audio_elem != null) { + $audio_url = $audio_elem->src; + $audio_elem->parent->parent->removeChild($audio_elem->parent); + } + + // No idea how this works on the original site + foreach ($elem->find('img') as $img) { + $img->removeAttribute('width'); + $img->removeAttribute('height'); + } + + // Some hacks for 'interactive' sections to make them a bit + // more readable. Here's one example: + // https://www.economist.com/interactive/briefing/2022/09/24/war-in-ukraine-has-reshaped-worlds-fuel-markets + $svelte = $elem->find('svelte-scroller-outer', 0); + if ($svelte != null) { + $svelte->parent->removeChild($svelte); + } + foreach ($elem->find('img') as $strange_img) { + if (!str_contains($strange_img->src, 'economist.com')) { + $strange_img->src = 'https://economist.com' . $strange_img->src; + } + } + // Trying to fix interactive infographics. This doesn't look + // quite as well, but fortunately, such elements are rare + // (~95% of infographics are plain images) + foreach ($elem->find('div.ds-image') as $ds_img) { + $ds_img->style = 'max-width: min(100%, 700px); overflow: hidden; margin: 2rem auto;'; + $g_artboard = null; + foreach ($ds_img->find('div.g-artboard') as $g_artboard_cand) { + if (!str_contains($g_artboard_cand->style, 'display: none')) { + $g_artboard = $g_artboard_cand; + } + } + if ($g_artboard != null) { + $g_artboard->style = $g_artboard->style . 'position: relative;'; + $img = $g_artboard->find('img', 0); + if ($img != null) { + $img->style = 'top: 0; display: block; width: 100% !important;'; + foreach ($g_artboard->find('div') as $div) { + if ($div->style == null) { + $div->style = 'position: absolute;'; + } else { + $div->style = $div->style . 'position: absolute'; + } + } + } + } + } + + $vertical = $elem->find('div[data-test-id=vertical]', 0); + if ($vertical != null) { + $vertical->parent->removeChild($vertical); + } + + // Section with 'Save', 'Share' and 'Give buttons' + foreach ($elem->find('div[data-test-id=sharing-modal]') as $sharing) { + $sharing->parent->removeChild($sharing); + } + // These links become HUGE without '); - $article_content = stripWithDelimiters($article_content, ''); - - $item['content'] = $article_content; - - return $item; - } - - public function collectData(){ - $feedUrl = self::URI . '9emeart.rss'; - $this->collectExpandableDatas($feedUrl); - } -} diff --git a/bridges/NewOnNetflixBridge.php b/bridges/NewOnNetflixBridge.php new file mode 100644 index 00000000000..43278fd9eb7 --- /dev/null +++ b/bridges/NewOnNetflixBridge.php @@ -0,0 +1,60 @@ + [ + 'name' => 'Country', + 'type' => 'list', + 'values' => [ + 'Australia/New Zealand' => 'anz', + 'Canada' => 'can', + 'United Kingdom' => 'uk', + 'United States' => 'usa', + ], + 'defaultValue' => 'uk', + ] + ]]; + const CACHE_TIMEOUT = 3600 * 24; + + public function collectData() + { + $baseURI = 'https://' . $this->getInput('country') . '.newonnetflix.info'; + $html = getSimpleHTMLDOMCached($baseURI . '/lastchance', self::CACHE_TIMEOUT); + + foreach ($html->find('article.oldpost') as $element) { + $title = $element->find('a.infopop[title]', 0); + $img = $element->find('img[lazy_src]', 0); + $date = $element->find('span[title]', 0); + + // format sholud be 'dd/mm/yy - dd/mm/yy' + // (the added date might be "unknown") + $fromTo = []; + if (preg_match('/^\s*(.*?)\s*-\s*(.*?)\s*$/', $date->title, $fromTo)) { + $from = $fromTo[1]; + $to = $fromTo[2]; + } else { + $from = 'unknown'; + $to = 'unknown'; + } + $summary = <<+ {$title->title}+Added on:$from+Removed on:$to+EOD; + + $item = []; + $item['uri'] = $baseURI . $title->href; + $item['title'] = $to . ' - ' . $title->plaintext; + $item['content'] = $summary; + // some movies are added and removed multiple times + $item['uid'] = $title->href . '-' . $to; + $this->items[] = $item; + } + } +} diff --git a/bridges/NewgroundsBridge.php b/bridges/NewgroundsBridge.php new file mode 100644 index 00000000000..fe956573c0b --- /dev/null +++ b/bridges/NewgroundsBridge.php @@ -0,0 +1,81 @@ + [ + 'username' => [ + 'name' => 'Username', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'TomFulp' + ] + ] + ]; + + public function collectData() + { + $username = $this->getInput('username'); + if (!preg_match('/^\w+$/', $username)) { + throw new \Exception('Illegal username'); + } + + $html = getSimpleHTMLDOM($this->getURI()); + + $posts = $html->find('.item-portalitem-art-medium'); + + foreach ($posts as $post) { + $item = []; + + $item['author'] = $username; + $item['uri'] = $post->href; + + $titleOrRestricted = $post->find('h4')[0]->innertext; + + // Newgrounds doesn't show public previews for NSFW content. + if ($titleOrRestricted === 'Restricted Content: Sign in to view!') { + $item['title'] = 'NSFW: ' . $item['uri']; + $item['content'] = <<+{$item['title']} + +EOD; + } else { + $item['title'] = $titleOrRestricted; + $item['content'] = << + + +EOD; + } + + $this->items[] = $item; + } + } + + public function getName() + { + if ($this->getInput('username')) { + return sprintf('%s - %s', $this->getInput('username'), self::NAME); + } + return parent::getName(); + } + + public function getURI() + { + if ($this->getInput('username')) { + return sprintf('https://%s.newgrounds.com/art', $this->getInput('username')); + } + return parent::getURI(); + } +} diff --git a/bridges/NextInkBridge.php b/bridges/NextInkBridge.php new file mode 100644 index 00000000000..d9410d514ce --- /dev/null +++ b/bridges/NextInkBridge.php @@ -0,0 +1,188 @@ + [ + 'name' => 'Feed', + 'type' => 'list', + 'values' => [ + 'Publications' => [ + 'Toutes nos publications' => 'news', + 'Droit' => 'news:3', + 'Économie' => 'news:4', + 'Flock' => 'news:13', + 'Hardware' => 'news:9', + 'IA et algorithmes' => 'news:6', + 'Internet' => 'news:7', + 'Logiciel' => 'news:8', + 'Next' => 'news:14', + 'Réseaux sociaux' => 'news:5', + 'Sciences et escpace' => 'news:10', + 'Sécurité' => 'news:12', + 'Société numérique' => 'news:11', + ], + 'Flux Gratuit' => [ + 'Publications en accès libre' => 'free', + ], + ], + 'title' => << [ + 'name' => 'Premium', + 'type' => 'list', + 'values' => [ + 'No filter' => '0', + 'Hide Premium' => '1', + 'Only Premium' => '2' + ], + 'title' => 'Note: "Flux Gratuit" already excludes Premium articles.', + ], + 'filter_brief' => [ + 'name' => 'Brief', + 'type' => 'list', + 'values' => [ + 'No filter' => '0', + 'Hide Brief' => '1', + 'Only Brief' => '2' + ], + 'title' => 'Note: "Publications" has only one #LeBrief entry each day.', + ], + 'limit' => self::LIMIT, + ]]; + + public function collectData() + { + $limit = $this->getInput('limit') ?? 10; + + $feed = explode(':', $this->getInput('feed')); + $category = ''; + if (count($feed) > 1) { + $category = $feed[1]; + } + $feed = $feed[0]; + + if ($feed === 'news') { + // Scrap HTML listing to build list of articles + $url = self::URI; + if ($category !== '') { + $url = $url . '?category=' . $category; + } + $this->collectArticlesFromHtmlListing($url, $limit); + } else if ($feed === 'free') { + // Expand Free RSS feed + $url = self::URI . 'feed/free'; + $this->collectExpandableDatas($url, $limit); + } + } + + protected function collectArticlesFromHtmlListing($url, $limit) + { + $html = getSimpleHTMLDOM($url); + $html = convertLazyLoading($html); + foreach ($html->find('.block-article') as $article) { + $author = $article->find('.author', 0); + $subtitle = $article->find('h3', 0); + $item = [ + 'uri' => trim($article->find('a', 0)->href), + 'title' => trim($article->find('h2', 0)->plaintext), + 'author' => is_object($author) ? trim($author->plaintext) : '', + 'enclosures' => [ $article->find('img', 0)->src ], + 'content' => is_object($subtitle) ? trim($subtitle->plaintext) : '', + ]; + $item = $this->parseItem($item); + if ($item !== null) { + $this->items[] = $item; + if (--$limit == 0) { + break; + } + } + } + } + + protected function parseItem(array $item) + { + $html = getSimpleHTMLDOMCached($item['uri']); + $html = convertLazyLoading($html); + + if (!is_object($html)) { + $item['content'] = $item['content'] + . ' Failed to request Next.ink: ' . $item['uri'] . '
'; + return $item; + } + + // Filter premium and brief articles? + $paywall_selector = 'div#paywall'; + $brief_selector = 'div.brief-article'; + foreach ( + [ + 'filter_premium' => $paywall_selector, + 'filter_brief' => $brief_selector, + ] as $param_name => $selector + ) { + $param_val = intval($this->getInput($param_name)); + if ($param_val != 0) { + $element_present = is_object($html->find($selector, 0)); + $element_wanted = ($param_val == 2); + if ($element_present != $element_wanted) { + return null; //Filter article + } + } + } + + $article_content = $html->find('div.article-contenu, ' . $brief_selector, 0); + if (is_object($article_content)) { + // Clean article content + foreach ( + [ + 'h1', + 'div.author', + 'p.brief-categories', + 'div.thumbnail-mobile', + 'div#share-bottom', + 'div.author-info', + 'div.other-article', + 'script', + ] as $item_to_remove + ) { + foreach ($article_content->find($item_to_remove) as $dom_node) { + $dom_node->outertext = ''; + } + } + // Image + $postimg = $article_content->find('div.thumbnail', 0); + if (empty($item['enclosures']) && is_object($postimg)) { + $postimg = $postimg->find('img', 0); + if (!empty($postimg->src)) { + $item['enclosures'] = [ $postimg->src ]; + } + } + // Timestamp + $published_time = $html->find('meta[property=article:published_time]', 0); + if (!isset($item['timestamp']) && is_object($published_time)) { + $item['timestamp'] = strtotime($published_time->content); + } + // Paywall + $paywall = $article_content->find($paywall_selector, 0); + if (is_object($paywall) && is_object($paywall->find('h3', 0))) { + $paywall->outertext = '' . $paywall->find('h3', 0)->innertext . '
'; + } + // Content + $item['content'] = $article_content->outertext; + } else { + $item['content'] = $item['content'] . 'Failed to retrieve full article content
'; + } + + return $item; + } +} diff --git a/bridges/NextInpactBridge.php b/bridges/NextInpactBridge.php deleted file mode 100644 index c6bf2f53787..00000000000 --- a/bridges/NextInpactBridge.php +++ /dev/null @@ -1,110 +0,0 @@ - array( - 'name' => 'Feed', - 'type' => 'list', - 'values' => array( - 'Tous nos articles' => 'news', - 'Nos contenus en accès libre' => 'acces-libre', - 'Blog' => 'blog', - 'Bons plans' => 'bonsplans' - ) - ), - 'filter_premium' => array( - 'name' => 'Premium', - 'type' => 'list', - 'values' => array( - 'No filter' => '0', - 'Hide Premium' => '1', - 'Only Premium' => '2' - ) - ), - 'filter_brief' => array( - 'name' => 'Brief', - 'type' => 'list', - 'values' => array( - 'No filter' => '0', - 'Hide Brief' => '1', - 'Only Brief' => '2' - ) - ) - )); - - public function collectData(){ - $feed = $this->getInput('feed'); - if (empty($feed)) - $feed = 'news'; - $this->collectExpandableDatas(self::URI . 'rss/' . $feed . '.xml'); - } - - protected function parseItem($newsItem){ - $item = parent::parseItem($newsItem); - $item['content'] = $this->extractContent($item, $item['uri']); - if (is_null($item['content'])) - return null; //Filtered article - return $item; - } - - private function extractContent($item, $url){ - $html = getSimpleHTMLDOMCached($url); - if (!is_object($html)) - return 'Failed to request NextInpact: ' . $url; - - foreach(array( - 'filter_premium' => 'h2.title_reserve_article', - 'filter_brief' => 'div.brief-inner-content' - ) as $param_name => $selector) { - $param_val = intval($this->getInput($param_name)); - if ($param_val != 0) { - $element_present = is_object($html->find($selector, 0)); - $element_wanted = ($param_val == 2); - if ($element_present != $element_wanted) { - return null; //Filter article - } - } - } - - if (is_object($html->find('div[itemprop=articleBody], div.brief-inner-content', 0))) { - - $subtitle = trim($html->find('span.sub_title, div.brief-head', 0)); - if(is_object($subtitle) && $subtitle->plaintext !== $item['title']) { - $subtitle = '' . $subtitle->plaintext . '
'; - } else { - $subtitle = ''; - } - - $postimg = $html->find( - 'div.container_main_image_article, div.image-brief-container, div.image-brief-side-container', 0 - ); - if(is_object($postimg)) { - $postimg = ''; - } else { - $postimg = ''; - } - - $text = $subtitle - . $postimg - . $html->find('div[itemprop=articleBody], div.brief-inner-content', 0)->outertext; - - } else { - $text = $item['content'] - . 'Failed retrieve full article content
'; - } - - $premium_article = $html->find('h2.title_reserve_article', 0); - if (is_object($premium_article)) { - $text .= '' . $premium_article->innertext . '
'; - } - - return $text; - } -} diff --git a/bridges/NextgovBridge.php b/bridges/NextgovBridge.php index 74bfc54a06b..7fe7130a8bb 100644 --- a/bridges/NextgovBridge.php +++ b/bridges/NextgovBridge.php @@ -1,70 +1,72 @@ array( - 'name' => 'Category', - 'type' => 'list', - 'values' => array( - 'All' => 'all', - 'Technology News' => 'technology-news', - 'CIO Briefing' => 'cio-briefing', - 'Emerging Tech' => 'emerging-tech', - 'Cloud' => 'cloud-computing', - 'Cybersecurity' => 'cybersecurity', - 'Mobile' => 'mobile', - 'Health' => 'health', - 'Defense' => 'defense', - 'Big Data' => 'big-data' - ) - ) - )); + const PARAMETERS = [ [ + 'category' => [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'All' => 'all', + 'Technology News' => 'technology-news', + 'CIO Briefing' => 'cio-briefing', + 'Emerging Tech' => 'emerging-tech', + 'Cybersecurity' => 'cybersecurity', + 'IT Modernization' => 'it-modernization', + 'Policy' => 'policy', + 'Ideas' => 'ideas', + ] + ] + ]]; - public function collectData(){ - $this->collectExpandableDatas(self::URI . 'rss/' . $this->getInput('category') . '/', 10); - } + public function collectData() + { + $url = self::URI . 'rss/' . $this->getInput('category') . '/'; + $limit = 10; + $this->collectExpandableDatas($url, $limit); + } - protected function parseItem($newsItem){ - $item = parent::parseItem($newsItem); + protected function parseItem(array $item) + { + $article_thumbnail = 'https://cdn.nextgov.com/nextgov/images/logo.png'; + $item['content'] = '' . $item['content'] . '
'; - $article_thumbnail = 'https://cdn.nextgov.com/nextgov/images/logo.png'; - $item['content'] = '' . $item['content'] . '
'; +// $namespaces = $newsItem->getNamespaces(true); +// if (isset($namespaces['media'])) { +// $media = $newsItem->children($namespaces['media']); +// if (isset($media->content)) { +// $attributes = $media->content->attributes(); +// $item['content'] = '' . $item['content']; +// $article_thumbnail = str_replace( +// 'large.jpg', +// 'small.jpg', +// strval($attributes['url']) +// ); +// } +// } - $namespaces = $newsItem->getNamespaces(true); - if(isset($namespaces['media'])) { - $media = $newsItem->children($namespaces['media']); - if(isset($media->content)) { - $attributes = $media->content->attributes(); - $item['content'] = '' . $item['content']; - $article_thumbnail = str_replace( - 'large.jpg', - 'small.jpg', - strval($attributes['url']) - ); - } - } + $item['enclosures'] = [$article_thumbnail]; + $item['content'] .= $this->extractContent($item['uri']); + return $item; + } - $item['enclosures'] = array($article_thumbnail); - $item['content'] .= $this->extractContent($item['uri']); - return $item; - } + private function extractContent($url) + { + $article = getSimpleHTMLDOMCached($url); - private function extractContent($url){ - $article = getSimpleHTMLDOMCached($url); + if (!is_object($article)) { + return 'Could not request Nextgov: ' . $url; + } - if (!is_object($article)) - return 'Could not request Nextgov: ' . $url; - - $contents = $article->find('div.wysiwyg', 0); - $contents->find('svg.content-tombstone', 0)->outertext = ''; - $contents = $contents->innertext; - $contents = stripWithDelimiters($contents, '', ''); - $contents = stripWithDelimiters($contents, ''); //ad outer div - return trim(stripWithDelimiters($contents, '')); + } } diff --git a/bridges/NiceMatinBridge.php b/bridges/NiceMatinBridge.php index b0af7608ca7..dd90dbfe187 100644 --- a/bridges/NiceMatinBridge.php +++ b/bridges/NiceMatinBridge.php @@ -1,32 +1,37 @@ collectExpandableDatas(self::URI . 'derniere-minute/rss', 10); - } + public function collectData() + { + $this->collectExpandableDatas(self::URI . 'derniere-minute/rss', 10); + } - protected function parseItem($newsItem){ - $item = parent::parseItem($newsItem); - $item['content'] = $this->extractContent($item['uri']); - return $item; - } + protected function parseItem(array $item) + { + $item['content'] = $this->extractContent($item['uri']); + return $item; + } - private function extractContent($url){ - $html = getSimpleHTMLDOMCached($url); - if(!$html) - return 'Could not acquire content from url: ' . $url . '!'; + private function extractContent($url) + { + $html = getSimpleHTMLDOMCached($url); + if (!$html) { + return 'Could not acquire content from url: ' . $url . '!'; + } - $content = $html->find('article', 0); - if(!$content) - return 'Could not find \'section\'!'; + $content = $html->find('article', 0); + if (!$content) { + return 'Could not find \'section\'!'; + } - $text = preg_replace('##is', '', $content->innertext); - $text = strip_tags($text, ''); - return $text; - } + $text = preg_replace('##is', '', $content->innertext); + $text = strip_tags($text, '
'); + return $text; + } } diff --git a/bridges/NikonDownloadCenterBridge.php b/bridges/NikonDownloadCenterBridge.php new file mode 100644 index 00000000000..143d40f5b09 --- /dev/null +++ b/bridges/NikonDownloadCenterBridge.php @@ -0,0 +1,49 @@ +getURI()); + + foreach ($html->find('dd>ul>li') as $element) { + $date = $element->find('.date', 0)->plaintext; + $productType = $element->find('.icon>img', 0)->alt; + $desc = $element->find('p>a', 0)->plaintext; + $link = urljoin(self::URI, $element->find('p>a', 0)->href); + + $item = [ + 'title' => $desc, + 'uri' => $link, + 'timestamp' => strtotime($date), + 'content' => <<
++ New/updated {$productType}:
+ {$desc} ++ {$date} +
+EOD + ]; + $this->items[] = $item; + } + } +} diff --git a/bridges/NineGagBridge.php b/bridges/NineGagBridge.php index 939ff387ec4..6da84d0632c 100644 --- a/bridges/NineGagBridge.php +++ b/bridges/NineGagBridge.php @@ -1,356 +1,371 @@ array( - 'd' => array( - 'name' => 'Section', - 'type' => 'list', - 'values' => array( - 'Hot' => 'hot', - 'Trending' => 'trending', - 'Fresh' => 'fresh', - ), - ), - 'video' => array( - 'name' => 'Filter Video', - 'type' => 'list', - 'values' => array( - 'NotFiltred' => 'none', - 'VideoFiltred' => 'without', - 'VideoOnly' => 'only', - ), - ), - 'p' => array( - 'name' => 'Pages', - 'type' => 'number', - 'defaultValue' => 3, - ), - ), - 'Sections' => array( - 'g' => array( - 'name' => 'Section', - 'type' => 'list', - 'values' => array( - 'Animals' => 'cute', - 'Anime & Manga' => 'anime-manga', - 'Ask 9GAG' => 'ask9gag', - 'Awesome' => 'awesome', - 'Basketball' => 'basketball', - 'Car' => 'car', - 'Classical Art Memes' => 'classicalartmemes', - 'Comic' => 'comic', - 'Cosplay' => 'cosplay', - 'Countryballs' => 'country', - 'DIY & Crafts' => 'imadedis', - 'Drawing & Illustration' => 'drawing', - 'Fan Art' => 'animefanart', - 'Food & Drinks' => 'food', - 'Football' => 'football', - 'Fortnite' => 'fortnite', - 'Funny' => 'funny', - 'GIF' => 'gif', - 'Gaming' => 'gaming', - 'Girl' => 'girl', - 'Girly Things' => 'girly', - 'Guy' => 'guy', - 'History' => 'history', - 'Home Design' => 'home', - 'Horror' => 'horror', - 'K-Pop' => 'kpop', - 'LEGO' => 'lego', - 'League of Legends' => 'leagueoflegends', - 'Movie & TV' => 'movie-tv', - 'Music' => 'music', - 'NFK - Not For Kids' => 'nsfw', - 'Overwatch' => 'overwatch', - 'PC Master Race' => 'pcmr', - 'PUBG' => 'pubg', - 'Pic Of The Day' => 'photography', - 'Pokémon' => 'pokemon', - 'Politics' => 'politics', - 'Relationship' => 'relationship', - 'Roast Me' => 'roastme', - 'Satisfying' => 'satisfying', - 'Savage' => 'savage', - 'School' => 'school', - 'Sci-Tech' => 'science', - 'Sport' => 'sport', - 'Star Wars' => 'starwars', - 'Superhero' => 'superhero', - 'Surreal Memes' => 'surrealmemes', - 'Timely' => 'timely', - 'Travel' => 'travel', - 'Video' => 'video', - 'WTF' => 'wtf', - 'Wallpaper' => 'wallpaper', - 'Warhammer' => 'warhammer', - ), - ), - 't' => array( - 'name' => 'Type', - 'type' => 'list', - 'values' => array( - 'Hot' => 'hot', - 'Fresh' => 'fresh', - ), - ), - 'p' => array( - 'name' => 'Pages', - 'type' => 'number', - 'defaultValue' => 3, - ), - ), - ); - - const MIN_NBR_PAGE = 1; - const MAX_NBR_PAGE = 6; - - protected $p = null; - - public function collectData() { - $url = sprintf( - '%sv1/group-posts/group/%s/type/%s?', - self::URI, - $this->getGroup(), - $this->getType() - ); - $cursor = 'c=10'; - $posts = array(); - for ($i = 0; $i < $this->getPages(); ++$i) { - $content = getContents($url . $cursor); - $json = json_decode($content, true); - $posts = array_merge($posts, $json['data']['posts']); - $cursor = $json['data']['nextCursor']; - } - - foreach ($posts as $post) { - $AvoidElement = false; - switch ($this->getInput('video')) { - case 'without': - if ($post['type'] === 'Animated') { - $AvoidElement = true; - } - break; - case 'only': - echo $post['type']; - if ($post['type'] !== 'Animated') { - $AvoidElement = true; - } - break; - case 'none': default: - break; - } - - if (!$AvoidElement) { - $item['uri'] = $post['url']; - $item['title'] = $post['title']; - $item['content'] = self::getContent($post); - $item['categories'] = self::getCategories($post); - $item['timestamp'] = self::getTimestamp($post); - - $this->items[] = $item; - } - } - } - - public function getName() { - if ($this->getInput('d')) { - $name = sprintf('%s - %s', '9GAG', $this->getParameterKey('d')); - } elseif ($this->getInput('g')) { - $name = sprintf('%s - %s', '9GAG', $this->getParameterKey('g')); - if ($this->getInput('t')) { - $name = sprintf('%s [%s]', $name, $this->getParameterKey('t')); - } - } - if (!empty($name)) { - return $name; - } - - return self::NAME; - } - - public function getURI() { - $uri = $this->getInput('g'); - if ($uri === 'default') { - $uri = $this->getInput('t'); - } - - return self::URI . $uri; - } - - protected function getGroup() { - if ($this->getInput('d')) { - return 'default'; - } - - return $this->getInput('g'); - } - - protected function getType() { - if ($this->getInput('d')) { - return $this->getInput('d'); - } - - return $this->getInput('t'); - } - - protected function getPages() { - if ($this->p === null) { - $value = (int) $this->getInput('p'); - $value = ($value < self::MIN_NBR_PAGE) ? self::MIN_NBR_PAGE : $value; - $value = ($value > self::MAX_NBR_PAGE) ? self::MAX_NBR_PAGE : $value; - - $this->p = $value; - } - - return $this->p; - } - - protected function getParameterKey($input = '') { - $params = $this->getParameters(); - $tab = 'Sections'; - if ($input === 'd') { - $tab = 'Popular'; - } - if (!isset($params[$tab][$input])) { - return ''; - } - - return array_search( - $this->getInput($input), - $params[$tab][$input]['values'] - ); - } - - protected static function getContent($post) { - if ($post['type'] === 'Animated') { - $content = self::getAnimated($post); - } elseif ($post['type'] === 'Article') { - $content = self::getArticle($post); - } else { - $content = self::getPhoto($post); - } - - return $content; - } - - protected static function getPhoto($post) { - $image = $post['images']['image460']; - $photo = ''; - - return $photo; - } - - protected static function getAnimated($post) { - $poster = $post['images']['image460']['url']; - $sources = $post['images']; - $video = sprintf( - ''; - - return $video; - } - - protected static function getArticle($post) { - $blocks = $post['article']['blocks']; - $medias = $post['article']['medias']; - $contents = array(); - foreach ($blocks as $block) { - if ('Media' === $block['type']) { - $mediaId = $block['mediaId']; - $contents[] = self::getContent($medias[$mediaId]); - } elseif ('RichText' === $block['type']) { - $contents[] = self::getRichText($block['content']); - } - } - - $content = join('', $contents); - $content = sprintf( - '<%1$s>%2$s%1$s>', - 'div', - $content - ); - - return $content; - } - - protected static function getRichText($text = '') { - $text = trim($text); - - if (preg_match('/^>\s(?.*)/', $text, $matches)) { - $text = sprintf( - '<%1$s>%2$s%1$s>', - 'blockquote', - $matches['text'] - ); - } else { - $text = sprintf( - '<%1$s>%2$s%1$s>', - 'p', - $text - ); - } - - return $text; - } - - protected static function getCategories($post) { - $params = self::PARAMETERS; - $sections = $params['Sections']['g']['values']; - - if(isset($post['sections'])) { - $postSections = $post['sections']; - } elseif (isset($post['postSection'])) { - $postSections = array($post['postSection']); - } else { - $postSections = array(); - } - - foreach ($postSections as $key => $section) { - $postSections[$key] = array_search($section, $sections); - } - - return $postSections; - } - - protected static function getTimestamp($post) { - $url = $post['images']['image460']['url']; - $headers = get_headers($url, true); - $date = $headers['Date']; - $time = strtotime($date); - - return $time; - } +class NineGagBridge extends BridgeAbstract +{ + const NAME = '9gag Bridge'; + const URI = 'https://9gag.com/'; + const DESCRIPTION = 'Returns latest quotes from 9gag.'; + const MAINTAINER = 'ZeNairolf'; + const CACHE_TIMEOUT = 3600; + const PARAMETERS = [ + 'Popular' => [ + 'd' => [ + 'name' => 'Section', + 'type' => 'list', + 'values' => [ + 'Hot' => 'hot', + 'Trending' => 'trending', + 'Fresh' => 'fresh', + ], + ], + 'video' => [ + 'name' => 'Filter Video', + 'type' => 'list', + 'values' => [ + 'NotFiltred' => 'none', + 'VideoFiltred' => 'without', + 'VideoOnly' => 'only', + ], + ], + 'p' => [ + 'name' => 'Pages', + 'type' => 'number', + 'defaultValue' => 3, + ], + ], + 'Sections' => [ + 'g' => [ + 'name' => 'Section', + 'type' => 'list', + 'values' => [ + 'Among Us' => 'among-us', + 'Animals' => 'animals', + 'Anime & Manga' => 'anime-manga', + 'Anime Waifu' => 'animewaifu', + 'Anime Wallpaper' => 'animewallpaper', + 'Apex Legends' => 'apexlegends', + 'Ask 9GAG' => 'ask9gag', + 'Awesome' => 'awesome', + 'Car' => 'car', + 'Comic & Webtoon' => 'comic-webtoon', + 'Coronavirus ' => 'coronavirus', + 'Cosplay' => 'cosplay', + 'Countryballs' => 'countryballs', + 'Cozy & Comfy' => 'home-living', + 'Crappy Design' => 'crappydesign', + 'Cryptocurrency ' => 'cryptocurrency', + 'Cyberpunk 2077' => 'cyberpunk2077', + 'Dark Humor' => 'darkhumor', + 'Drawing, DIY & Crafts' => 'drawing-diy-crafts', + 'Fashion & Beauty' => 'rate-my-outfit', + 'Food & Drinks' => 'food-drinks', + 'Football' => 'football', + 'Fortnite' => 'fortnite', + 'Funny' => 'funny', + 'Game of Thrones' => 'got', + 'Gaming' => 'gaming', + 'GIF' => 'gif', + 'Girl' => 'girl', + 'Girl Celebrity' => 'girlcelebrity', + 'Guy' => 'guy', + 'History' => 'history', + 'Horror' => 'horror', + 'K-Pop' => 'kpop', + 'Latest News' => 'timely', + 'League of Legends' => 'leagueoflegends', + 'LEGO' => 'lego', + 'Marvel & DC' => 'superhero', + 'Meme' => 'meme', + 'Movie & TV' => 'movie-tv', + 'Music' => 'music', + 'NBA' => 'basketball', + 'Overwatch' => 'overwatch', + 'PC Master Race' => 'pcmr', + 'Pokémon' => 'pokemon', + 'Politics ' => 'politics', + 'PUBG' => 'pubg', + 'Random ' => 'random', + 'Relationship' => 'relationship', + 'Satisfying' => 'satisfying', + 'Savage' => 'savage', + 'Science & Tech' => 'science-tech', + 'Sport ' => 'sport', + 'Star Wars' => 'starwars', + 'Teens Can Relate' => 'school', + 'Travel & Photography' => 'travel-photography', + 'Video' => 'video', + 'Wallpaper' => 'wallpaper', + 'Warhammer' => 'warhammer', + 'Wholesome' => 'wholesome', + 'WTF' => 'wtf', + ], + ], + 't' => [ + 'name' => 'Type', + 'type' => 'list', + 'values' => [ + 'Hot' => 'hot', + 'Fresh' => 'fresh', + ], + ], + 'video' => [ + 'name' => 'Filter Video', + 'type' => 'list', + 'values' => [ + 'NotFiltred' => 'none', + 'VideoFiltred' => 'without', + 'VideoOnly' => 'only', + ], + ], + 'p' => [ + 'name' => 'Pages', + 'type' => 'number', + 'defaultValue' => 3, + ], + ], + ]; + + const MIN_NBR_PAGE = 1; + const MAX_NBR_PAGE = 6; + + protected $p = null; + + public function collectData() + { + $url = sprintf( + '%sv1/group-posts/group/%s/type/%s?', + self::URI, + $this->getGroup(), + $this->getType() + ); + $cursor = 'c=10'; + $posts = []; + for ($i = 0; $i < $this->getPages(); ++$i) { + $content = getContents($url . $cursor); + $json = json_decode($content, true); + $posts = array_merge($posts, $json['data']['posts']); + $cursor = $json['data']['nextCursor']; + } + + foreach ($posts as $post) { + $AvoidElement = false; + switch ($this->getInput('video')) { + case 'without': + if ($post['type'] === 'Animated') { + $AvoidElement = true; + } + break; + case 'only': + echo $post['type']; + if ($post['type'] !== 'Animated') { + $AvoidElement = true; + } + break; + case 'none': + default: + break; + } + + if (!$AvoidElement) { + $item['uri'] = preg_replace('/^http:/i', 'https:', $post['url']); + $item['title'] = $post['title']; + $item['content'] = self::getContent($post); + $item['categories'] = self::getCategories($post); + $item['timestamp'] = self::getTimestamp($post); + + $this->items[] = $item; + } + } + } + + public function getName() + { + if ($this->getInput('d')) { + $name = sprintf('%s - %s', '9GAG', $this->getKey('d')); + } elseif ($this->getInput('g')) { + $name = sprintf('%s - %s', '9GAG', $this->getKey('g')); + if ($this->getInput('t')) { + $name = sprintf('%s [%s]', $name, $this->getKey('t')); + } + } + if (!empty($name)) { + return $name; + } + + return self::NAME; + } + + public function getURI() + { + $uri = $this->getInput('g'); + if ($uri === 'default') { + $uri = $this->getInput('t'); + } + + return self::URI . $uri; + } + + protected function getGroup() + { + if ($this->getInput('d')) { + return 'default'; + } + + return $this->getInput('g'); + } + + protected function getType() + { + if ($this->getInput('d')) { + return $this->getInput('d'); + } + + return $this->getInput('t'); + } + + protected function getPages() + { + if ($this->p === null) { + $value = (int) $this->getInput('p'); + $value = ($value < self::MIN_NBR_PAGE) ? self::MIN_NBR_PAGE : $value; + $value = ($value > self::MAX_NBR_PAGE) ? self::MAX_NBR_PAGE : $value; + + $this->p = $value; + } + + return $this->p; + } + + protected static function getContent($post) + { + if ($post['type'] === 'Animated') { + $content = self::getAnimated($post); + } elseif ($post['type'] === 'Article') { + $content = self::getArticle($post); + } else { + $content = self::getPhoto($post); + } + + return $content; + } + + protected static function getPhoto($post) + { + $image = $post['images']['image460']; + $photo = ''; + + return $photo; + } + + protected static function getAnimated($post) + { + $poster = $post['images']['image460']['url']; + $sources = $post['images']; + $video = sprintf( + ''; + + return $video; + } + + protected static function getArticle($post) + { + $blocks = $post['article']['blocks']; + $medias = $post['article']['medias']; + $contents = []; + foreach ($blocks as $block) { + if ('Media' === $block['type']) { + $mediaId = $block['mediaId']; + $contents[] = self::getContent($medias[$mediaId]); + } elseif ('RichText' === $block['type']) { + $contents[] = self::getRichText($block['content']); + } + } + + $content = join(' ', $contents); + $content = sprintf( + '<%1$s>%2$s%1$s>', + 'div', + $content + ); + + return $content; + } + + protected static function getRichText($text = '') + { + $text = trim($text); + + if (preg_match('/^>\s(?.*)/', $text, $matches)) { + $text = sprintf( + '<%1$s>%2$s%1$s>', + 'blockquote', + $matches['text'] + ); + } else { + $text = sprintf( + '<%1$s>%2$s%1$s>', + 'p', + $text + ); + } + + return $text; + } + + protected static function getCategories($post) + { + $params = self::PARAMETERS; + $sections = $params['Sections']['g']['values']; + + if (isset($post['sections'])) { + $postSections = $post['sections']; + } elseif (isset($post['postSection'])) { + $postSections = [$post['postSection']]; + } else { + $postSections = []; + } + + foreach ($postSections as $key => $section) { + $postSections[$key] = array_search($section, $sections); + } + + return $postSections; + } + + protected static function getTimestamp($post) + { + $url = $post['images']['image460']['url']; + $headers = get_headers($url, true); + $date = $headers['Date']; + $time = strtotime($date); + + return $time; + } } diff --git a/bridges/NintendoBridge.php b/bridges/NintendoBridge.php new file mode 100644 index 00000000000..2f6113b2fbe --- /dev/null +++ b/bridges/NintendoBridge.php @@ -0,0 +1,489 @@ + [ + 'category' => [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'All' => 'all', + 'Mario Kart 8 Deluxe' => 'mk8d', + 'Splatoon 2' => 's2', + 'Super Mario 3D All-Stars' => 'sm3as', + 'Super Mario 3D World + Bowser’s Fury' => 'sm3wbf', + 'Super Mario Bros. Wonder' => 'smbw', + 'Super Mario Maker 2' => 'smm2', + 'Super Mario Odyssey' => 'smo', + 'Super Smash Bros. Ultimate' => 'ssbu', + 'Switch Firmware' => 'sf', + 'The Legend of Zelda: Link’s Awakening' => 'tlozla', + 'The Legend of Zelda: Skyward Sword HD' => 'tlozss', + 'The Legend of Zelda: Tears of the Kingdom' => 'tloztotk', + 'Xenoblade Chronicles 2' => 'xc2', + ], + 'defaultValue' => 'mk8d', + 'title' => 'Select category' + ], + 'country' => [ + 'name' => 'Country', + 'type' => 'list', + 'values' => [ + 'België' => 'be/nl', + 'Belgique' => 'be/fr', + 'Deutschland' => 'de', + 'España' => 'es', + 'France' => 'fr', + 'Italia' => 'it', + 'Nederland' => 'nl', + 'Österreich' => 'at', + 'Portugal' => 'pt', + 'Schweiz' => 'ch/de', + 'Suisse' => 'ch/fr', + 'Svizzera' => 'ch/it', + 'UK & Ireland' => 'co.uk', + 'South Africa' => 'co.za' + ], + 'defaultValue' => 'co.uk', + 'title' => 'Select your country' + ] + ] + ]; + + const CACHE_TIMEOUT = 3600; + + const FEED_SOURCE_URL = [ + 'mk8d' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Mario-Kart-8-Deluxe-1482895.html', + 's2' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Splatoon-2-1482897.html', + 'sm3as' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-3D-All-Stars-1844226.html', + 'sm3wbf' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-3D-World-Bowser-s-Fury-1920668.html', + 'smbw' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-Bros-Wonder-2485410.html', + 'smm2' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-Maker-2-1586745.html', + 'smo' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Mario-Odyssey-1482901.html', + 'ssbu' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-Super-Smash-Bros-Ultimate-1484130.html', + 'sf' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/System-Updates/Nintendo-Switch-System-Updates-and-Change-History-1445507.html', + 'tlozla' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-The-Legend-of-Zelda-Link-s-Awakening-1666739.html', + 'tlozss' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-The-Legend-of-Zelda-Skyward-Sword-HD-2022801.html', + 'tloztotk' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/How-to-Update-The-Legend-of-Zelda-Tears-of-the-Kingdom-2388231.html', + 'xc2' => 'https://www.nintendo.co.uk/Support/Nintendo-Switch/Game-Updates/Xenoblade-Chronicles-2-Update-History-1482911.html', + ]; + const XPATH_EXPRESSION_ITEM = '//div[@class="col-xs-12 content"]/div[starts-with(@id,"v") and @class="collapse"]'; + const XPATH_EXPRESSION_ITEM_FIRMWARE = '//div[@id="latest" and @class="collapse" and @rel="1"]'; + const XPATH_EXPRESSION_ITEM_TITLE = '(.//h2[1] | .//strong[1])[1]/node()'; + const XPATH_EXPRESSION_ITEM_CONTENT = '.'; + const XPATH_EXPRESSION_ITEM_URI = '//link[@rel="canonical"]/@href'; + + //const XPATH_EXPRESSION_ITEM_AUTHOR = ''; + const XPATH_EXPRESSION_ITEM_TIMESTAMP_PART = 'substring-after(//a[@class="collapse_link collapsed" and @data-target="#{{id_here}}"]/text(), "{{label_here}}")'; + const XPATH_EXPRESSION_ITEM_TIMESTAMP = 'substring(' . self::XPATH_EXPRESSION_ITEM_TIMESTAMP_PART . ', 1, string-length(' + . self::XPATH_EXPRESSION_ITEM_TIMESTAMP_PART . ') - 1)'; + + //const XPATH_EXPRESSION_ITEM_ENCLOSURES = ''; + //const XPATH_EXPRESSION_ITEM_CATEGORIES = ''; + const SETTING_FIX_ENCODING = false; + const SETTING_USE_RAW_ITEM_CONTENT = true; + + private const GAME_COUNTRY_DATE_SUBSTRING_PART = [ + 'mk8d' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'ubblicata il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada no dia ', + 'en' => 'eleased ', + ], + 's2' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'ubblicata il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + 'sm3as' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'ubliée le ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + 'sm3wbf' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada no dia ', + 'en' => 'eleased ', + ], + 'smbw' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + 'smm2' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'ubliée le ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada no dia ', + 'en' => 'eleased ', + ], + 'smo' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada no dia ', + 'en' => 'eleased ', + ], + 'ssbu' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada no dia ', + 'en' => 'eleased ', + ], + 'sf' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'ise en ligne le ', + 'it' => 'ubblicata il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada no dia ', + 'en' => 'istributed ', + ], + 'tlozla' => [ + 'de' => 'eröffentlicht ', + 'es' => 'ublicada el ', + 'fr' => 'atée du ', + 'it' => 'istribuita il ', + 'nl' => 'itgegeven op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + 'tlozss' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'ubblicata l\'', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + 'tloztotk' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'ubliée le ', + 'it' => 'ubblicata il ', + 'nl' => 'erschenen op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + 'xc2' => [ + 'de' => 'eröffentlicht am ', + 'es' => 'isponible desde el ', + 'fr' => 'atée du ', + 'it' => 'istribuita il ', + 'nl' => 'itgebracht op ', + 'pt' => 'ançada a ', + 'en' => 'eleased ', + ], + ]; + + private const GAME_COUNTRY_DATE_FORMAT = [ + 'mk8d' => [ + 'de' => 'd.m.y', + 'es' => 'd-m-y', + 'fr' => 'd/m/Y', + 'it' => 'd/m/y', + 'nl' => 'd m Y', + 'pt' => 'd/m/y', + 'en' => 'd/m/y', + ], + 's2' => [ + 'de' => 'd.m.Y', + 'es' => 'd-m-Y', + 'fr' => 'd/m/y', + 'it' => 'd/m/y', + 'nl' => 'd/m/y', + 'pt' => 'd/m/y', + 'en' => 'd F Y', + ], + 'sm3as' => [ + 'de' => 'j. m Y', + 'es' => 'j \d\e m \d\e Y', + 'fr' => 'j m Y', + 'it' => 'j m Y', + 'nl' => 'j m Y', + 'pt' => 'j \d\e m \d\e Y', + 'en' => 'j F Y', + ], + 'sm3wbf' => [ + 'de' => 'd.m.y', + 'es' => 'd-m-y', + 'fr' => 'd/m/y', + 'it' => 'd/m/y', + 'nl' => 'd m Y', + 'pt' => 'd/m/y', + 'en' => 'F j, Y', + ], + 'smbw' => [ + 'de' => 'd. m Y', + 'es' => 'j \d\e m \d\e Y', + 'fr' => 'd/m/Y', + 'it' => 'j m Y', + 'nl' => 'd m Y', + 'pt' => 'j \d\e m \d\e Y', + 'en' => 'j F Y', + ], + 'smm2' => [ + 'de' => 'd.m.Y', + 'es' => 'd-m-Y', + 'fr' => 'd/m/Y', + 'it' => 'd/m/Y', + 'nl' => 'd m Y', + 'pt' => 'd/m/y', + 'en' => 'd/m/y', + ], + 'smo' => [ + 'de' => 'd.m.Y', + 'es' => 'd-m-Y', + 'fr' => 'd/m/Y', + 'it' => 'd/m/y', + 'nl' => 'd m Y', + 'pt' => 'd/m/y', + 'en' => 'd/m/y', + ], + 'ssbu' => [ + 'de' => 'd. m Y', + 'es' => 'j \d\e m \d\e Y', + 'fr' => 'j m Y', + 'it' => 'j m Y', + 'nl' => 'd m Y', + 'pt' => 'd/m/Y', + 'en' => 'j F Y', + ], + 'sf' => [ + 'de' => 'd.m.Y', + 'es' => 'd-m-y', + 'fr' => 'd/m/Y', + 'it' => 'd/m/Y', + 'nl' => 'd m Y', + 'pt' => 'd/m/Y', + 'en' => 'd/m/Y', + ], + 'tlozla' => [ + 'de' => 'd. m Y', + 'es' => 'j m \d\e Y', + 'fr' => 'd/m/y', + 'it' => 'j m Y', + 'nl' => 'd m Y', + 'pt' => 'j \d\e m \d\e Y', + 'en' => 'j F y', + ], + 'tlozss' => [ + 'de' => 'd. m Y', + 'es' => 'j \d\e m \d\e Y', + 'fr' => 'd/m/y', + 'it' => 'j m Y', + 'nl' => 'd m Y', + 'pt' => 'j \d\e m \d\e Y', + 'en' => 'j F Y', + ], + 'tloztotk' => [ + 'de' => 'd. m Y', + 'es' => 'j \d\e m \d\e Y', + 'fr' => 'j m Y', + 'it' => 'j m Y', + 'nl' => 'd m Y', + 'pt' => 'j \d\e m \d\e Y', + 'en' => 'j F Y', + ], + 'xc2' => [ + 'de' => 'd.m.y', + 'es' => 'd-m-y', + 'fr' => 'd/m/Y', + 'it' => 'd/m/y', + 'nl' => 'd m Y', + 'pt' => 'd/m/y', + 'en' => 'd/m/y', + ], + ]; + + private const FOREIGN_MONTH_NAMES = [ + 'nl' => ['01' => 'januari', '02' => 'februari', '03' => 'maart', '04' => 'april', '05' => 'mei', '06' => 'juni', '07' => 'juli', '08' => 'augustus', + '09' => 'september', '10' => 'oktober', '11' => 'november', '12' => 'december'], + 'fr' => ['01' => 'janvier', '02' => 'février', '03' => 'mars', '04' => 'avril', '05' => 'mai', '06' => 'juin', '07' => 'juillet', '08' => 'août', + '09' => 'septembre', '10' => 'octobre', '11' => 'novembre', '12' => 'décembre'], + 'de' => ['01' => 'Januar', '02' => 'Februar', '03' => 'März', '04' => 'April', '05' => 'Mai', '06' => 'Juni', '07' => 'Juli', '08' => 'August', + '09' => 'September', '10' => 'Oktober', '11' => 'November', '12' => 'Dezember'], + 'es' => ['01' => 'enero', '02' => 'febrero', '03' => 'marzo', '04' => 'abril', '05' => 'mayo', '06' => 'junio', '07' => 'julio', '08' => 'agosto', + '09' => 'septiembre', '10' => 'octubre', '11' => 'noviembre', '12' => 'diciembre'], + 'it' => ['01' => 'gennaio', '02' => 'febbraio', '03' => 'marzo', '04' => 'aprile', '05' => 'maggio', '06' => 'giugno', '07' => 'luglio', '08' => 'agosto', + '09' => 'settembre', '10' => 'ottobre', '11' => 'novembre', '12' => 'dicembre'], + 'pt' => ['01' => 'janeiro', '02' => 'fevereiro', '03' => 'março', '04' => 'abril', '05' => 'maio', '06' => 'junho', '07' => 'julho', '08' => 'agosto', + '09' => 'setembro', '10' => 'outubro', '11' => 'novembro', '12' => 'dezembro'], + ]; + const LANGUAGE_REWRITE = ['co.uk' => 'en', 'co.za' => 'en', 'at' => 'de']; + + private string $lastId = ''; + private ?string $currentCategory = ''; + + private function getCurrentCategory() + { + if (empty($this->currentCategory)) { + $category = $this->getInput('category'); + $this->currentCategory = empty($category) ? self::PARAMETERS['']['category']['defaultValue'] : $category; + } + return $this->currentCategory; + } + + public function getIcon() + { + return 'https://www.nintendo.co.uk/favicon.ico'; + } + + public function getURI() + { + $category = $this->getInput('category'); + if ('all' === $category) { + return self::URI; + } else { + return $this->getSourceUrl(); + } + } + + protected function provideFeedTitle(\DOMXPath $xpath) + { + $category = $this->getInput('category'); + $categoryName = array_search($category, self::PARAMETERS['']['category']['values']); + return 'all' === $category ? self::NAME : $categoryName . ' Software-Updates'; + } + + protected function getSourceUrl() + { + $country = $this->getInput('country') ?? ''; + $category = $this->getCurrentCategory(); + return str_replace(self::PARAMETERS['']['country']['defaultValue'], $country, self::FEED_SOURCE_URL[$category]); + } + + protected function getExpressionItem() + { + $category = $this->getCurrentCategory(); + return 'sf' === $category ? self::XPATH_EXPRESSION_ITEM_FIRMWARE : self::XPATH_EXPRESSION_ITEM; + } + + protected function getExpressionItemTimestamp() + { + if (empty($this->lastId)) { + return null; + } + $country = $this->getInput('country'); + $category = $this->getCurrentCategory(); + $language = $this->getLanguageFromCountry($country); + return str_replace( + ['{{id_here}}', '{{label_here}}'], + [$this->lastId, static::GAME_COUNTRY_DATE_SUBSTRING_PART[$category][$language]], + static::XPATH_EXPRESSION_ITEM_TIMESTAMP + ); + } + + protected function getExpressionItemCategories() + { + $category = $this->getCurrentCategory(); + $categoryName = array_search($category, self::PARAMETERS['']['category']['values']); + return 'string("' . $categoryName . '")'; + } + + public function collectData() + { + $category = $this->getCurrentCategory(); + if ('all' === $category) { + $allItems = []; + foreach (self::PARAMETERS['']['category']['values'] as $catKey) { + if ('all' === $catKey) { + continue; + } + $this->currentCategory = $catKey; + $this->items = []; + parent::collectData(); + $allItems = [...$allItems, ...$this->items]; + } + $this->currentCategory = 'all'; + $this->items = $allItems; + } else { + parent::collectData(); + } + } + + protected function formatItemTitle($value) + { + if (false !== strpos($value, ' (')) { + $value = substr($value, 0, strpos($value, ' (')); + } + if ('all' === $this->getInput('category')) { + $category = $this->getCurrentCategory(); + $categoryName = array_search($category, self::PARAMETERS['']['category']['values']); + return $categoryName . ' ' . $value; + } + return $value; + } + + protected function formatItemContent($value) + { + $result = preg_match('~ (.*)~', $value, $matches); + if (1 === $result) { + $this->lastId = $matches[1]; + return trim($matches[2]); + } + return $value; + } + + protected function formatItemTimestamp($value) + { + $country = $this->getInput('country'); + $category = $this->getCurrentCategory(); + $language = $this->getLanguageFromCountry($country); + + $aMonthNames = self::FOREIGN_MONTH_NAMES[$language] ?? null; + if (null !== $aMonthNames) { + $value = str_replace(array_values($aMonthNames), array_keys($aMonthNames), $value); + } + $value = str_replace('', '-', $value); + $value = str_replace('--', '-', $value); + + $date = \DateTime::createFromFormat(self::GAME_COUNTRY_DATE_FORMAT[$category][$language], $value); + if (false === $date) { + $date = new \DateTime('now'); + } + return $date->getTimestamp(); + } + + protected function generateItemId(array $item) + { + return $this->getCurrentCategory() . '-' . $this->lastId; + } + + private function getLanguageFromCountry($country) + { + return (strpos($country, '/') !== false) ? substr($country, strpos($country, '/') + 1) : (self::LANGUAGE_REWRITE[$country] ?? $country); + } +} diff --git a/bridges/NordbayernBridge.php b/bridges/NordbayernBridge.php new file mode 100644 index 00000000000..94319e2bd4b --- /dev/null +++ b/bridges/NordbayernBridge.php @@ -0,0 +1,213 @@ + [ + 'name' => 'region', + 'type' => 'list', + 'exampleValue' => 'Nürnberg', + 'title' => 'Select a region', + 'values' => [ + 'Ansbach' => 'ansbach', + 'Bamberg' => 'bamberg', + 'Bayreuth' => 'bayreuth', + 'Erlangen' => 'erlangen', + 'Forchheim' => 'forchheim', + 'Fürth' => 'fuerth', + 'Gunzenhausen' => 'gunzenhausen', + 'Herzogenaurach' => 'herzogenaurach', + 'Höchstadt' => 'hoechstadt', + 'Neumarkt' => 'neumarkt', + 'Neustadt/Aisch-Bad Windsheim' => 'neustadt-aisch-bad-windsheim', + 'Nürnberg' => 'nuernberg', + 'Nürnberger Land' => 'nuernberger-land', + 'Regensburg' => 'regensburg', + 'Roth' => 'roth', + 'Schwabach' => 'schwabach', + 'Weißenburg' => 'weissenburg' + ] + ], + 'policeReports' => [ + 'name' => 'Police Reports', + 'type' => 'checkbox', + 'exampleValue' => 'checked', + 'title' => 'Include Police Reports', + ], + 'hideNNPlus' => [ + 'name' => 'Hide NN+ articles', + 'type' => 'checkbox', + 'exampleValue' => 'unchecked', + 'title' => 'Hide all paywall articles on NN' + ], + 'hideDPA' => [ + 'name' => 'Hide dpa articles', + 'type' => 'checkbox', + 'exampleValue' => 'unchecked', + 'title' => 'Hide external articles from dpa' + ] + ]]; + + public function collectData() + { + $region = $this->getInput('region'); + if ($region === 'rothenburg-o-d-t') { + $region = 'rothenburg-ob-der-tauber'; + } + $url = self::URI . '/region/' . $region; + $listSite = getSimpleHTMLDOM($url); + + $this->handleNewsblock($listSite); + } + + + private function getValidImage($picture) + { + $img = $picture->find('img', 0); + if ($img) { + $imgUrl = $img->src; + if (!preg_match('#/logo-.*\.png#', $imgUrl)) { + return '
'; + } + } + return ''; + } + + private function getUseFullContent($rawContent) + { + $content = ''; + foreach ($rawContent->children as $element) { + if ( + ($element->tag === 'p' || $element->tag === 'h3') && + $element->class !== 'article__teaser' + ) { + $content .= $element; + } elseif ($element->tag === 'main') { + $content .= $this->getUseFullContent($element->find('article', 0)); + } elseif ($element->tag === 'header') { + $content .= $this->getUseFullContent($element); + } elseif ( + $element->tag === 'div' && + !str_contains($element->class, 'article__infobox') && + !str_contains($element->class, 'authorinfo') + ) { + $content .= $this->getUseFullContent($element); + } elseif ( + $element->tag === 'section' && + (str_contains($element->class, 'article__richtext') || + str_contains($element->class, 'article__context')) + ) { + $content .= $this->getUseFullContent($element); + } elseif ($element->tag === 'picture') { + $content .= $this->getValidImage($element); + } elseif ($element->tag === 'ul') { + $content .= $element; + } + } + return $content; + } + + private function getTeaser($content) + { + $teaser = $content->find('p[class=article__teaser]', 0); + if ($teaser === null) { + return ''; + } + $teaser = $teaser->plaintext; + $teaser = preg_replace('/[ ]{2,}/', ' ', $teaser); + $teaser = ' '; + return $teaser; + } + + private function getArticle($link) + { + $item = []; + $article = getSimpleHTMLDOM($link); + defaultLinkTo($article, self::URI); + $content = $article->find('article[id=article]', 0); + $item['uri'] = $link; + + $author = $article->find('.article__author', 1); + if ($author !== null) { + $item['author'] = trim($author->plaintext); + } + + $createdAt = $article->find('[class=article__release]', 0); + if ($createdAt) { + $item['timestamp'] = strtotime(str_replace('Uhr', '', $createdAt->plaintext)); + } + + if ($article->find('h2', 0) === null) { + $item['title'] = $article->find('h3', 0)->innertext; + } else { + $item['title'] = $article->find('h2', 0)->innertext; + } + $item['content'] = ''; + + if ($article->find('section[class*=article__richtext]', 0) === null) { + $content = $article->find('div[class*=modul__teaser]', 0) + ->find('p', 0); + $item['content'] .= $content; + } else { + $content = $article->find('article', 0); + // change order of article teaser in order to show it on top + // of the title image. If we didn't do this some rss programs + // would show the subtitle of the title image as teaser instead + // of the actuall article teaser. + $item['content'] .= $this->getTeaser($content); + $item['content'] .= $this->getUseFullContent($content); + } + + $categories = $article->find('[class=themen]', 0); + if ($categories) { + $item['categories'] = []; + foreach ($categories->find('a') as $category) { + $item['categories'][] = $category->innertext; + } + } + + $article->clear(); + return $item; + } + + private function handleNewsblock($listSite) + { + $main = $listSite->find('main', 0); + foreach ($main->find('article') as $article) { + $url = $article->find('a', 0)->href; + $url = urljoin(self::URI, $url); + // exclude nn+ articles if desired + if ( + $this->getInput('hideNNPlus') && + str_contains($url, 'www.nn.de') + ) { + continue; + } + + $item = $this->getArticle($url); + + // exclude police reports if desired + if ( + !$this->getInput('policeReports') && + str_contains($item['content'], 'Hier geht es zu allen aktuellen Polizeimeldungen.') + ) { + continue; + } + + // exclude dpa articles + if ( + $this->getInput('hideDPA') && + str_contains($item['author'], 'dpa') + ) { + continue; + } + + $this->items[] = $item; + } + } +} diff --git a/bridges/NotAlwaysBridge.php b/bridges/NotAlwaysBridge.php index c7758c32d23..6f1a8c008a3 100644 --- a/bridges/NotAlwaysBridge.php +++ b/bridges/NotAlwaysBridge.php @@ -1,60 +1,68 @@ array( - 'type' => 'list', - 'name' => 'Filter', - 'values' => array( - 'All' => 'all', - 'Right' => 'right', - 'Working' => 'working', - 'Romantic' => 'romantic', - 'Related' => 'related', - 'Learning' => 'learning', - 'Friendly' => 'friendly', - 'Hopeless' => 'hopeless', - 'Unfiltered' => 'unfiltered' - ) - ) - )); - - public function getIcon() { - return self::URI . 'favicon_nar.png'; - } - - public function collectData(){ - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request NotAlways.'); - foreach($html->find('.post') as $post) { - #print_r($post); - $item = array(); - $item['uri'] = $post->find('h1', 0)->find('a', 0)->href; - $item['content'] = $post; - $item['title'] = $post->find('h1', 0)->find('a', 0)->innertext; - $this->items[] = $item; - } - } - - public function getName(){ - if(!is_null($this->getInput('filter'))) { - return $this->getInput('filter') . ' - NotAlways Bridge'; - } - - return parent::getName(); - } - - public function getURI(){ - if(!is_null($this->getInput('filter'))) { - return self::URI . $this->getInput('filter') . '/'; - } - - return parent::getURI(); - } + +class NotAlwaysBridge extends BridgeAbstract +{ + const MAINTAINER = 'mozes'; + const NAME = 'Not Always family Bridge'; + const URI = 'https://notalwaysright.com/'; + const DESCRIPTION = 'Returns the latest stories'; + const CACHE_TIMEOUT = 1800; // 30 minutes + + const PARAMETERS = [ [ + 'filter' => [ + 'type' => 'list', + 'name' => 'Filter', + 'values' => [ + 'All' => '', + 'Right' => 'right', + 'Working' => 'working', + 'Romantic' => 'romantic', + 'Related' => 'related', + 'Learning' => 'learning', + 'Hopeless' => 'hopeless', + 'Healthy' => 'healthy', + 'Legal' => 'legal', + 'Friendly' => 'friendly', + 'Unfiltered' => 'unfiltered' + ] + ] + ]]; + + public function getIcon() + { + return self::URI . 'favicon_nar.png'; + } + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + foreach ($html->find('.post') as $post) { + #print_r($post); + $item = []; + $item['uri'] = $post->find('h1', 0)->find('a', 0)->href; + $postHeader = $post->find('.post_header', 0); + $storyContent = $post->find('.storycontent', 0); + $item['content'] = $postHeader . '
' . $storyContent; + $item['title'] = $post->find('h1', 0)->find('a', 0)->innertext; + $this->items[] = $item; + } + } + + public function getName() + { + if (!is_null($this->getInput('filter'))) { + return $this->getInput('filter') . ' - NotAlways Bridge'; + } + + return parent::getName(); + } + + public function getURI() + { + if (!is_null($this->getInput('filter'))) { + return self::URI . $this->getInput('filter') . '/'; + } + + return parent::getURI(); + } } diff --git a/bridges/NovayaGazetaEuropeBridge.php b/bridges/NovayaGazetaEuropeBridge.php new file mode 100644 index 00000000000..89d31a944e0 --- /dev/null +++ b/bridges/NovayaGazetaEuropeBridge.php @@ -0,0 +1,151 @@ + [ + 'language' => [ + 'name' => 'Language', + 'type' => 'list', + 'defaultValue' => 'ru', + 'values' => [ + 'Russian' => 'ru', + 'English' => 'en', + ] + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => false, + 'title' => 'Maximum number of items to return', + 'defaultValue' => 20 + ] + ] + ]; + + public function collectData() + { + $url = 'https://novayagazeta.eu/api/v1/get/main'; + if ($this->getInput('language') != 'ru') { + $url .= '?lang=' . $this->getInput('language'); + } + + $json = getContents($url); + $data = json_decode($json); + + foreach ($data->records as $record) { + if (!isset($record->blocks)) { + continue; + } + foreach ($record->blocks as $block) { + if (!property_exists($block, 'date')) { + continue; + } + $title = strip_tags($block->title); + if (!empty($block->subtitle)) { + $title .= '. ' . strip_tags($block->subtitle); + } + $item = [ + 'uri' => self::URI . '/articles/' . $block->slug, + 'block' => $block, + 'title' => $title, + 'author' => join(', ', array_map(function ($author) { + return $author->name; + }, $block->authors)), + 'timestamp' => $block->date / 1000, + 'categories' => $block->tags + ]; + $this->items[] = $item; + } + } + usort($this->items, function ($item1, $item2) { + return $item2['timestamp'] <=> $item1['timestamp']; + }); + if ($this->getInput('limit') !== null) { + $this->items = array_slice($this->items, 0, $this->getInput('limit')); + } + foreach ($this->items as &$item) { + $block = $item['block']; + $body = ''; + if (property_exists($block, 'body') && $block->body !== null) { + $body = self::convertBody($block); + } else { + $record_json = getContents("https://novayagazeta.eu/api/v1/get/record?slug={$block->slug}"); + $record_data = json_decode($record_json); + $body = self::convertBody($record_data->record); + } + $item['content'] = $body; + unset($item['block']); + } + } + + private static function convertBody($data) + { + $body = ''; + if ($data->previewUrl !== null && !$data->isPreviewHidden) { + $body .= ''; + } + if ($data->lead !== null) { + $body .= "{$data->lead}
"; + } + if (!empty($data->body)) { + foreach ($data->body as $datum) { + $body .= self::convertElement($datum); + } + } + return $body; + } + + private static function convertElement($datum) + { + switch ($datum->type) { + case 'text': + return $datum->data; + case 'image/single': + $alt = strip_tags($datum->data); + $res = "'; + return $res; + case 'text/quote': + return "
"; + case 'embed/native': + if (isset($datum->link)) { + $desc = $datum->link; + if (isset($datum->caption)) { + $desc = $datum->caption; + } + return sprintf('', $datum->link, $desc); + } + return ''; + case 'text/framed': + $res = ''; + if (property_exists($datum, 'typeDisplay')) { + $res .= "{$datum->typeDisplay}
"; + } + $res .= "{$datum->data}
"; + if ( + property_exists($datum, 'attachment') + && property_exists($datum->attachment, 'type') + ) { + $res .= self::convertElement($datum->attachment); + } + return $res; + default: + return ''; + } + } +} diff --git a/bridges/NovelUpdatesBridge.php b/bridges/NovelUpdatesBridge.php index 05acd8ef999..79969ea7dde 100644 --- a/bridges/NovelUpdatesBridge.php +++ b/bridges/NovelUpdatesBridge.php @@ -1,69 +1,71 @@ array( - 'name' => 'Novel name as found in the url', - 'exampleValue' => 'spirit-realm', - 'required' => true - ) - )); +class NovelUpdatesBridge extends BridgeAbstract +{ + const MAINTAINER = 'albirew'; + const NAME = 'Novel Updates'; + const URI = 'https://www.novelupdates.com/'; + const CACHE_TIMEOUT = 21600; // 6h + const DESCRIPTION = 'Returns releases from Novel Updates'; + const PARAMETERS = [ [ + 'n' => [ + 'name' => 'Novel name as found in the url', + 'exampleValue' => 'spirit-realm', + 'required' => true + ] + ]]; - private $seriesTitle = ''; + private $seriesTitle = ''; - public function getURI(){ - if(!is_null($this->getInput('n'))) { - return static::URI . '/series/' . $this->getInput('n') . '/'; - } + public function getURI() + { + if (!is_null($this->getInput('n'))) { + return static::URI . '/series/' . $this->getInput('n') . '/'; + } - return parent::getURI(); - } + return parent::getURI(); + } - public function collectData(){ - $fullhtml = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request NovelUpdates, novel "' . $this->getInput('n') . '" not found'); + public function collectData() + { + $fullhtml = getSimpleHTMLDOM($this->getURI()); - $this->seriesTitle = $fullhtml->find('h4.seriestitle', 0)->plaintext; - // dirty fix for nasty simpledom bug: https://github.com/sebsauvage/rss-bridge/issues/259 - // forcefully removes tbody - $html = $fullhtml->find('table#myTable', 0)->innertext; - $html = stristr($html, ''); //strip thead - $html = stristr($html, ''); //remove tbody - $html = str_get_html(stristr($html, ' ', true)); //remove last tbody and get back as an array - foreach($html->find('tr') as $element) { - $item = array(); - $item['uri'] = $element->find('td', 2)->find('a', 0)->href; - $item['title'] = $element->find('td', 2)->find('a', 0)->plaintext; - $item['team'] = $element->find('td', 1)->innertext; - $item['timestamp'] = strtotime($element->find('td', 0)->plaintext); - $item['content'] = '' - . $this->seriesTitle - . ' - ' - . $item['title'] - . ' by ' - . $item['team'] - . '
' - . $fullhtml->find('div.seriesimg', 0)->innertext - . ''; + $this->seriesTitle = $fullhtml->find('div.seriestitlenu', 0)->plaintext; + // dirty fix for nasty simpledom bug: https://github.com/sebsauvage/rss-bridge/issues/259 + // forcefully removes tbody + $html = $fullhtml->find('table#myTable', 0)->innertext; + $html = stristr($html, ''); //strip thead + $html = stristr($html, ''); //remove tbody + $html = str_get_html(stristr($html, ' ', true)); //remove last tbody and get back as an array + foreach ($html->find('tr') as $element) { + $item = []; + $item['uri'] = $element->find('td', 2)->find('a', 0)->href; + $item['title'] = $element->find('td', 2)->find('a', 0)->plaintext; + $item['team'] = $element->find('td', 1)->innertext; + $item['timestamp'] = strtotime($element->find('td', 0)->plaintext); + $item['content'] = '' + . $this->seriesTitle + . ' - ' + . $item['title'] + . ' by ' + . $item['team'] + . '
' + . $fullhtml->find('div.seriesimg', 0)->innertext + . ''; - $this->items[] = $item; - } - } + $this->items[] = $item; + } + } - public function getName(){ - if(!empty($this->seriesTitle)) { - return $this->seriesTitle . ' - ' . static::NAME; - } - - return parent::getName(); - } + public function getName() + { + if (!empty($this->seriesTitle)) { + return $this->seriesTitle . ' - ' . static::NAME; + } + return parent::getName(); + } } diff --git a/bridges/NpciBridge.php b/bridges/NpciBridge.php new file mode 100644 index 00000000000..f9fa63af038 --- /dev/null +++ b/bridges/NpciBridge.php @@ -0,0 +1,95 @@ + 'circulars', + 'upi' => 'circular', + 'rupay' => 'circulars', + 'nach' => 'circulars', + 'imps' => 'circular', + 'netc-fastag' => 'circulars', + '99' => 'circular', + 'nfs' => 'circulars', + 'aeps' => 'circulars', + 'bhim-aadhaar' => 'circular', + 'e-rupi' => 'circular', + 'Bharat QR' => 'circulars', + 'bharat-billpay' => 'circulars', + ]; + + const PARAMETERS = [[ + 'product' => [ + 'name' => 'product', + 'type' => 'list', + 'values' => [ + 'CTS' => 'cts', + 'UPI' => 'upi', + 'RuPay' => 'rupay', + 'NACH' => 'nach', + 'IMPS' => 'imps', + 'NETC FASTag' => 'netc-fastag', + '*99#' => '99', + 'NFS' => 'nfs', + 'AePS' => 'aeps', + 'BHIM Aadhaar' => 'bhim-aadhaar', + 'e-RUPI' => 'e-rupi', + 'Bharat BillPay' => 'bharat-billpay' + ] + ] + ]]; + + public function getName() + { + if ($this->getInput('product')) { + return 'NPCI Circulars: ' . $this->getKey('product'); + } + return 'NPCI Circulars'; + } + + public function getURI() + { + $product = $this->getInput('product'); + return $product ? sprintf('%s/what-we-do/%s/%s', self::URI, $product, self::URL_SUFFIX[$product]) : self::URI; + } + + public function collectData() + { + $html = getSimpleHTMLDOMCached($this->getURI()); + $year = date('Y'); + $elements = $html->find("div[id=year$year] .pdf-item"); + + foreach ($elements as $element) { + $title = $element->find('p', 0)->innertext; + + $link = $element->find('a', 0); + + $uri = null; + + if ($link) { + $pdfLink = $link->getAttribute('href'); + $uri = self::URI . str_replace(' ', '+', $pdfLink); + } + + $item = [ + 'uri' => $uri, + 'title' => $title, + 'content' => $title, + 'uid' => sha1($pdfLink), + 'enclosures' => [ + $uri + ] + ]; + + $this->items[] = $item; + } + + $this->items = array_slice($this->items, 0, 15); + } +} diff --git a/bridges/NurembergerNachrichtenBridge.php b/bridges/NurembergerNachrichtenBridge.php new file mode 100644 index 00000000000..10644212525 --- /dev/null +++ b/bridges/NurembergerNachrichtenBridge.php @@ -0,0 +1,178 @@ + [ + 'name' => 'region', + 'type' => 'list', + 'exampleValue' => 'Nürnberg', + 'title' => 'Select a region', + 'values' => [ + 'Ansbach' => 'ansbach', + 'Erlangen' => 'erlangen', + 'Erlangen-Höchstadt' => 'erlangen-hoechstadt', + 'Forchheim' => 'forchheim', + 'Fürth' => 'fuerth', + 'Gunzenhausen' => 'gunzenhausen', + 'Neumarkt' => 'neumarkt', + 'Neustadt/Aisch-Bad Windsheim' => 'neustadt-aisch-bad-windsheim', + 'Nürnberg' => 'nuernberg', + 'Nürnberger Land' => 'nuernberger-land', + 'Pegnitz' => 'pegnitz', + 'Roth' => 'roth', + 'Schwabach' => 'schwabach', + 'Weißenburg' => 'weissenburg' + ] + ], + 'hideNNPlus' => [ + 'name' => 'Hide NN+ articles', + 'type' => 'checkbox', + 'exampleValue' => 'unchecked', + 'title' => 'Hide all paywall articles on NN' + ], + ]]; + + public function collectData() + { + $region = $this->getInput('region'); + if ( + $region === 'neustadt-aisch-bad-windsheim' || + $region === 'erlangen-hoechstadt' || + $region === '' + ) { + $region = 'region/' . $region; + } + $url = self::URI . '/' . $region; + $listSite = getSimpleHTMLDOM($url); + + $this->handleNewsblock($listSite); + } + + private function handleNewsblock($listSite) + { + $main = $listSite->find('main', 0); + foreach ($main->find('article') as $article) { + $url = $article->find('a', 0)->href; + $url = urljoin(self::URI, $url); + + $articleContent = getSimpleHTMLDOMCached($url, 86400 * 7); + + // exclude nn+ articles if desired + if ( + $this->getInput('hideNNPlus') && + str_contains($articleContent->find('article[id=article]', 0)->find('header', 0), 'icon-nnplus') + ) { + continue; + } + + $item = $this->parseArticle($articleContent, $url); + $articleContent->clear(); + + $this->items[] = $item; + } + } + + private function parseArticle($article, $link) + { + $item = []; + defaultLinkTo($article, self::URI); + + $item['uri'] = $link; + + $author = $article->find('.article__author', 1); + if ($author !== null) { + $item['author'] = trim($author->plaintext); + } + + $createdAt = $article->find('[class=article__release]', 0); + if ($createdAt) { + $item['timestamp'] = strtotime(str_replace('Uhr', '', $createdAt->plaintext)); + } + + if ($article->find('h2', 0) === null) { + $item['title'] = $article->find('h3', 0)->innertext; + } else { + $item['title'] = $article->find('h2', 0)->innertext; + } + $item['content'] = ''; + + if ($article->find('section[class*=article__richtext]', 0) === null) { + $content = $article->find('div[class*=modul__teaser]', 0)->find('p', 0); + $item['content'] .= $content; + } else { + $content = $article->find('article', 0); + // change order of article teaser in order to show it on top + // of the title image. If we didn't do this some rss programs + // would show the subtitle of the title image as teaser instead + // of the actuall article teaser. + $item['content'] .= $this->getTeaser($content); + $item['content'] .= $this->getUseFullContent($content); + } + + return $item; + } + + private function getTeaser($content) + { + $teaser = $content->find('p[class=article__teaser]', 0); + if ($teaser === null) { + return ''; + } + $teaser = $teaser->plaintext; + $teaser = preg_replace('/[ ]{2,}/', ' ', $teaser); + $teaser = ' '; + return $teaser; + } + + private function getUseFullContent($rawContent) + { + $content = ''; + foreach ($rawContent->children as $element) { + if ( + ($element->tag === 'p' || $element->tag === 'h3') && + $element->class !== 'article__teaser' + ) { + $content .= $element; + } elseif ($element->tag === 'main') { + $content .= $this->getUseFullContent($element->find('article', 0)); + } elseif ($element->tag === 'header') { + $content .= $this->getUseFullContent($element); + } elseif ( + $element->tag === 'div' && + !str_contains($element->class, 'article__infobox') && + !str_contains($element->class, 'authorinfo') + ) { + $content .= $this->getUseFullContent($element); + } elseif ( + $element->tag === 'section' && + (str_contains($element->class, 'article__richtext') || + str_contains($element->class, 'article__context')) + ) { + $content .= $this->getUseFullContent($element); + } elseif ($element->tag === 'picture') { + $content .= $this->getValidImage($element); + } elseif ($element->tag === 'ul') { + $content .= $element; + } + } + return $content; + } + + private function getValidImage($picture) + { + $img = $picture->find('img', 0); + if ($img) { + $imgUrl = $img->src; + if (!preg_match('#/logo-.*\.png#', $imgUrl)) { + return '
'; + } + } + return ''; + } +} diff --git a/bridges/NvidiaDriverBridge.php b/bridges/NvidiaDriverBridge.php new file mode 100644 index 00000000000..3c47869745c --- /dev/null +++ b/bridges/NvidiaDriverBridge.php @@ -0,0 +1,107 @@ + [ + 'wwhql' => [ + 'name' => 'Driver Type', + 'type' => 'list', + 'values' => [ + 'All' => '', + 'Certified' => '1', + 'Studio' => '4', + ], + 'defaultValue' => '1', + ], + ], + 'Linux' => [ + 'lwhql' => [ + 'name' => 'Driver Type', + 'type' => 'list', + 'values' => [ + 'All' => '', + 'Beta' => '0', + 'Branch' => '5', + 'Certified' => '1', + ], + 'defaultValue' => '1', + ], + ], + 'FreeBSD' => [ + 'fwhql' => [ + 'name' => 'Driver Type', + 'type' => 'list', + 'values' => [ + 'All' => '', + 'Beta' => '0', + 'Branch' => '5', + 'Certified' => '1', + ], + 'defaultValue' => '1', + ], + ], + ]; + + private $operatingSystem = ''; + + public function collectData() + { + $parameters = [ + 'lid' => 1, // en-us + 'psid' => 129, // GeForce + ]; + + switch ($this->queriedContext) { + case 'Windows': + $whql = $this->getInput('wwhql'); + $parameters['osid'] = 57; + $parameters['dtcid'] = 1; // Windows Driver DCH + $parameters['whql'] = $whql; + $this->operatingSystem = 'Windows'; + break; + case 'Linux': + $whql = $this->getInput('lwhql'); + $parameters['osid'] = 12; + $parameters['whql'] = $whql; + $this->operatingSystem = 'Linux'; + break; + case 'FreeBSD': + $whql = $this->getInput('fwhql'); + $parameters['osid'] = 22; + $parameters['whql'] = $whql; + $this->operatingSystem = 'FreeBSD'; + break; + } + + $url = 'https://www.nvidia.com/Download/processFind.aspx?' . http_build_query($parameters); + $dom = getSimpleHTMLDOM($url); + + foreach ($dom->find('tr#driverList') as $element) { + $id = str_replace('img_', '', $element->find('img', 0)->id); + + $this->items[] = [ + 'timestamp' => $element->find('td.gridItem', 3)->plaintext, + 'title' => sprintf('NVIDIA Driver %s', $element->find('td.gridItem', 2)->plaintext), + 'uri' => 'https://www.nvidia.com/Download/driverResults.aspx/' . $id, + 'content' => $dom->find('tr#tr_' . $id . ' span', 0)->innertext, + ]; + } + } + + public function getIcon() + { + return 'https://www.nvidia.com/favicon.ico'; + } + + public function getName() + { + $version = $this->getKey('whql') ?? ''; + return sprintf('NVIDIA %s %s Driver Releases', $this->operatingSystem, $version); + } +} diff --git a/bridges/NyaaTorrentsBridge.php b/bridges/NyaaTorrentsBridge.php index b40b0f95291..36708411410 100644 --- a/bridges/NyaaTorrentsBridge.php +++ b/bridges/NyaaTorrentsBridge.php @@ -1,131 +1,115 @@ array( - 'name' => 'Filter', - 'type' => 'list', - 'values' => array( - 'No filter' => '0', - 'No remakes' => '1', - 'Trusted only' => '2' - ) - ), - 'c' => array( - 'name' => 'Category', - 'type' => 'list', - 'values' => array( - 'All categories' => '0_0', - 'Anime' => '1_0', - 'Anime - AMV' => '1_1', - 'Anime - English' => '1_2', - 'Anime - Non-English' => '1_3', - 'Anime - Raw' => '1_4', - 'Audio' => '2_0', - 'Audio - Lossless' => '2_1', - 'Audio - Lossy' => '2_2', - 'Literature' => '3_0', - 'Literature - English' => '3_1', - 'Literature - Non-English' => '3_2', - 'Literature - Raw' => '3_3', - 'Live Action' => '4_0', - 'Live Action - English' => '4_1', - 'Live Action - Idol/PV' => '4_2', - 'Live Action - Non-English' => '4_3', - 'Live Action - Raw' => '4_4', - 'Pictures' => '5_0', - 'Pictures - Graphics' => '5_1', - 'Pictures - Photos' => '5_2', - 'Software' => '6_0', - 'Software - Apps' => '6_1', - 'Software - Games' => '6_2', - ) - ), - 'q' => array( - 'name' => 'Keyword', - 'description' => 'Keyword(s)', - 'type' => 'text' - ) - ) - ); +class NyaaTorrentsBridge extends BridgeAbstract +{ + const MAINTAINER = 'ORelio & Jisagi'; + const NAME = 'NyaaTorrents'; + const URI = 'https://nyaa.si/'; + const DESCRIPTION = 'Returns the newest torrents, with optional search criteria.'; + const PARAMETERS = [ + [ + 'f' => [ + 'name' => 'Filter', + 'type' => 'list', + 'values' => [ + 'No filter' => '0', + 'No remakes' => '1', + 'Trusted only' => '2' + ] + ], + 'c' => [ + 'name' => 'Category', + 'type' => 'list', + 'values' => [ + 'All categories' => '0_0', + 'Anime' => '1_0', + 'Anime - AMV' => '1_1', + 'Anime - English' => '1_2', + 'Anime - Non-English' => '1_3', + 'Anime - Raw' => '1_4', + 'Audio' => '2_0', + 'Audio - Lossless' => '2_1', + 'Audio - Lossy' => '2_2', + 'Literature' => '3_0', + 'Literature - English' => '3_1', + 'Literature - Non-English' => '3_2', + 'Literature - Raw' => '3_3', + 'Live Action' => '4_0', + 'Live Action - English' => '4_1', + 'Live Action - Idol/PV' => '4_2', + 'Live Action - Non-English' => '4_3', + 'Live Action - Raw' => '4_4', + 'Pictures' => '5_0', + 'Pictures - Graphics' => '5_1', + 'Pictures - Photos' => '5_2', + 'Software' => '6_0', + 'Software - Apps' => '6_1', + 'Software - Games' => '6_2', + ] + ], + 'q' => [ + 'name' => 'Keyword', + 'description' => 'Keyword(s)', + 'type' => 'text' + ], + 'u' => [ + 'name' => 'User', + 'description' => 'User', + 'type' => 'text' + ] + ] + ]; - public function getIcon() { - return self::URI . 'static/favicon.png'; - } + public function collectData() + { + $feedParser = new FeedParser(); + $feed = $feedParser->parseFeed(getContents($this->getURI())); - public function collectData() { + foreach ($feed['items'] as $item) { + $item['enclosures'] = [$item['uri']]; + $item['uri'] = str_replace('.torrent', '', $item['uri']); + $item['uri'] = str_replace('/download/', '/view/', $item['uri']); + $item['id'] = str_replace('https://nyaa.si/view/', '', $item['uri']); + $dom = getSimpleHTMLDOMCached($item['uri']); + if ($dom) { + $description = $dom->find('#torrent-description', 0)->innertext ?? ''; + $item['content'] = markdownToHtml(html_entity_decode($description)); - // Build Search URL from user-provided parameters - $search_url = self::URI . '?s=id&o=desc&' - . http_build_query(array( - 'f' => $this->getInput('f'), - 'c' => $this->getInput('c'), - 'q' => $this->getInput('q') - )); + $magnet = $dom->find('div.panel-footer.clearfix > a', 1)->href; + // can't put raw magnet link in enclosure, this gives information on + // magnet contents and works a way to sent magnet value + $magnet = 'https://torrent.parts/#' . html_entity_decode($magnet); + array_push($item['enclosures'], $magnet); + } + $this->items[] = $item; + if (count($this->items) >= 10) { + break; + } + } + } - // Retrieve torrent listing from search results, which does not contain torrent description - $html = getSimpleHTMLDOM($search_url) - or returnServerError('Could not request Nyaa: ' . $search_url); - $links = $html->find('a'); - $results = array(); - foreach ($links as $link) - if (strpos($link->href, '/view/') === 0 && !in_array($link->href, $results)) - $results[] = $link->href; - if (empty($results) && empty($this->getInput('q'))) - returnServerError('No results from Nyaa: ' . $url, 500); + public function getName() + { + $name = parent::getName(); + $name .= $this->getInput('u') ? ' - ' . $this->getInput('u') : ''; + $name .= $this->getInput('q') ? ' - ' . $this->getInput('q') : ''; + $name .= $this->getInput('c') ? ' (' . $this->getKey('c') . ')' : ''; + return $name; + } - //Process each item individually - foreach ($results as $element) { + public function getIcon() + { + return self::URI . 'static/favicon.png'; + } - //Limit total amount of requests - if(count($this->items) >= 20) { - break; - } - - $torrent_id = str_replace('/view/', '', $element); - - //Ignore entries without valid torrent ID - if ($torrent_id != 0 && ctype_digit($torrent_id)) { - - //Retrieve data for this torrent ID - $item_uri = self::URI . 'view/' . $torrent_id; - - //Retrieve full description from torrent page - if ($item_html = getSimpleHTMLDOMCached($item_uri)) { - - //Retrieve data from page contents - $item_title = str_replace(' :: Nyaa', '', $item_html->find('title', 0)->plaintext); - $item_desc = str_get_html(markdownToHtml($item_html->find('#torrent-description', 0)->innertext)); - $item_author = extractFromDelimiters($item_html->outertext, 'href="/user/', '"'); - $item_date = intval(extractFromDelimiters($item_html->outertext, 'data-timestamp="', '"')); - - //Retrieve image for thumbnail or generic logo fallback - $item_image = $this->getURI() . 'static/img/avatar/default.png'; - foreach ($item_desc->find('img') as $img) { - if (strpos($img->src, 'prez') === false) { - $item_image = $img->src; - break; - } - } - - //Build and add final item - $item = array(); - $item['uri'] = $item_uri; - $item['title'] = $item_title; - $item['author'] = $item_author; - $item['timestamp'] = $item_date; - $item['enclosures'] = array($item_image); - $item['content'] = $item_desc; - $this->items[] = $item; - } - } - $element = null; - } - $results = null; - } + public function getURI() + { + $params = [ + 'f' => $this->getInput('f'), + 'c' => $this->getInput('c'), + 'q' => $this->getInput('q'), + 'u' => $this->getInput('u'), + ]; + return self::URI . '?page=rss&s=id&o=desc&' . http_build_query($params); + } } diff --git a/bridges/OLXBridge.php b/bridges/OLXBridge.php new file mode 100644 index 00000000000..31f05eaaa7c --- /dev/null +++ b/bridges/OLXBridge.php @@ -0,0 +1,211 @@ + [ + '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.)?olx\.(bg|kz|pl|pt|ro|ua|uz).*$', + 'exampleValue' => 'https://www.olx.pl/d/oferty/q-cebula/', + 'required' => true, + ], + 'includePostsWithoutPricetag' => [ + 'type' => 'checkbox', + 'name' => 'Include posts without price tag' + ], + 'includeFeaturedPosts' => [ + 'type' => 'checkbox', + 'name' => 'Include featured posts' + ], + 'shippingOfferedOnly' => [ + 'type' => 'checkbox', + 'name' => 'Only posts with shipping offered' + ] + ]]; + + private function getHostname() + { + $scheme = parse_url($this->getInput('url'), PHP_URL_SCHEME); + $host = parse_url($this->getInput('url'), PHP_URL_HOST); + + return $scheme . '://' . $host; + } + + public function getURI() + { + if ($this->getInput('url')) { + # make sure we order by the most recently listed offers + $uri = trim(preg_replace('/([?&])search%5Border%5D=[^&]+(&|$)/', '$1', $this->getInput('url')), '?&/'); + $uri = preg_replace('/([?&])view=[^&]+(&|$)/', '', $uri); + $uri .= (parse_url($uri, PHP_URL_QUERY) ? '&' : '?') . 'search%5Border%5D=created_at:desc'; + + return $uri; + } else { + return parent::getURI(); + } + } + + public function getName() + { + $url = $this->getInput('url'); + if (!$url) { + return parent::getName(); + } + + $parsedUrl = Url::fromString($url); + $paths = explode('/', $parsedUrl->getPath()); + + $query = array_reduce($paths, function ($q, $p) { + if (preg_match('/^q-(.+)$/i', $p, $matches)) { + $q[] = str_replace('-', ' ', urldecode($matches[1])); + } + + return $q; + }); + + if ($query) { + return $query[0]; + } + + return parent::getName(); + } + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + $html = defaultLinkTo($html, $this->getHostname()); + + $isoLang = $html->find('meta[http-equiv=Content-Language]', 0)->content; + + # the second grid, if any, has extended results from additional categories, outside of original search scope + $listing_grid = $html->find("div[data-testid='listing-grid']", 0); + + $results = $listing_grid->find("div[data-cy='l-card']"); + + foreach ($results as $post) { + $item = []; + + if (!$this->getInput('includeFeaturedPosts') && $post->find('div[data-testid="adCard-featured"]', 0)) { + continue; + } + + $price = $post->find('p[data-testid="ad-price"]', 0)->plaintext ?? ''; + if (!$this->getInput('includePostsWithoutPricetag') && !$price) { + continue; + } + + $negotiable = $post->find('p[data-testid="ad-price"] span.css-e2218f', 0)->plaintext ?? false; + if ($negotiable) { + $price = trim(str_replace($negotiable, '', $price)); + $negotiable = '(' . $negotiable . ')'; + } + + if ($post->find('h4', 0)->plaintext != '') { + $item['uri'] = $post->find('a', 0)->href; + $item['title'] = $post->find('h4', 0)->plaintext; + } + + # ignore the date component, as it is too convoluted — use the deep-crawled one; see below + $locationAndDate = $post->find('p[data-testid="location-date"]', 0)->plaintext; + $locationAndDateArray = explode(' - ', $locationAndDate, 2); + $location = trim($locationAndDateArray[0]); + + # OLX only shows 5 results before images get lazy-loaded, so we have to deep-crawl *almost* all the results. + # Given that, do deep-crawl *all* the results, which allows to aso obtain the ID, the simplified location + # and date strings, as well as the detailed description. + $articleHTMLContent = getSimpleHTMLDOMCached($item['uri']); + $articleHTMLContent = defaultLinkTo($articleHTMLContent, $this->getHostname()); + + $shippingOffered = $articleHTMLContent->find('img[alt="Safety Badge"]', 0)->src ?? false; + if ($this->getInput('shippingOfferedOnly') && !$shippingOffered) { + continue; + } + + # Extract a clean ID without resorting to the convoluted CSS class or sibling selectors. Should be always present. + $refreshLink = $articleHTMLContent->find('a[data-testid=refresh-link]', 0)->href ?? false; + if ($refreshLink) { + parse_str(parse_url($refreshLink, PHP_URL_QUERY), $refreshQuery); + $item['uid'] = $refreshQuery['ad-id']; + } else { + # may be an imported offer from a sibling auto-moto classifieds platform + $item['uid'] = $articleHTMLContent->find('span[id=ad_id]', 0)->plaintext; + } + + $img = $articleHTMLContent->find('meta[property="og:image"]', 0)->content ?? false; + if ($img) { + $item['enclosures'] = [$img . '#.image']; + } + + $isoDate = $articleHTMLContent->find('meta[property="og:updated_time"]', 0)->content ?? false; + if ($isoDate) { + $item['timestamp'] = strtotime($isoDate); + } else { + $date = $articleHTMLContent->find('span[data-cy="ad-posted-at"]', 0)->plaintext; + # Relative, today + if (preg_match('/^.*\s(\d\d:\d\d)$/i', $date, $matches)) { + $item['timestamp'] = strtotime($matches[1]); + } else { + # full, localized date + $formatter = new IntlDateFormatter($isoLang, IntlDateFormatter::SHORT, IntlDateFormatter::NONE); + $item['timestamp'] = $formatter->parse($date); + } + } + + $descriptionHtml = $articleHTMLContent->find('div[data-cy="ad_description"] div', 0)->innertext ?? false; + if (!$descriptionHtml) { + $descriptionHtml = $articleHTMLContent->find('div[id="description"] div[data-read-more]', 0)->innertext ?? false; + } + + $item['categories'] = []; + $breadcrumbs = $articleHTMLContent->find('li[data-testid="breadcrumb-item"]'); + foreach ($breadcrumbs as $breadcrumb) { + $category = $breadcrumb->find('a[href!="/"]', 0) ?? false; + + if ($category) { + $item['categories'][] = $category->plaintext; + } + } + + $parameters = $articleHTMLContent->find('div.parametersArea li'); + foreach ($parameters as $parameter) { + $category = $parameter->find('a', 0)->plaintext ?? false; + + if ($category = empty($category) ? false : trim($category)) { + if ($category == 'Tak') { + $category = $parameter->find('span', 0)->plaintext ?? ''; + } elseif ($category == 'Nie') { + continue; + } + + $item['categories'][] = $category; + } + } + + $item['content'] = <<+ + + ++ +$location
+$price $negotiable
++ + +$descriptionHtml +blocks + ); + $contents = $html->find('main > article > section.prose', 0); + $contents = defaultLinkTo($contents, self::URI); + return $contents->innertext; + } +} diff --git a/bridges/OnVaSortirBridge.php b/bridges/OnVaSortirBridge.php index ed1dcb65cab..f8c395c1da7 100644 --- a/bridges/OnVaSortirBridge.php +++ b/bridges/OnVaSortirBridge.php @@ -1,130 +1,133 @@ array( - 'name' => 'City', - 'type' => 'list', - 'values' => array( - 'Agen' => 'Agen', - 'Ajaccio' => 'Ajaccio', - 'Albi' => 'Albi', - 'Amiens' => 'Amiens', - 'Angers' => 'Angers', - 'Angoulême' => 'Angouleme', - 'Annecy' => 'annecy', - 'Aurillac' => 'aurillac', - 'Auxerre' => 'auxerre', - 'Avignon' => 'avignon', - 'Béziers' => 'Beziers', - 'Bastia' => 'Bastia', - 'Beauvais' => 'Beauvais', - 'Belfort' => 'Belfort', - 'Bergerac' => 'bergerac', - 'Besançon' => 'Besancon', - 'Biarritz' => 'Biarritz', - 'Blois' => 'Blois', - 'Bordeaux' => 'bordeaux', - 'Bourg-en-Bresse' => 'bourg-en-bresse', - 'Bourges' => 'Bourges', - 'Brest' => 'Brest', - 'Brive' => 'brive-la-gaillarde', - 'Bruxelles' => 'bruxelles', - 'Caen' => 'Caen', - 'Calais' => 'Calais', - 'Carcassonne' => 'Carcassonne', - 'Châteauroux' => 'Chateauroux', - 'Chalon-sur-saone' => 'chalon-sur-saone', - 'Chambéry' => 'chambery', - 'Chantilly' => 'chantilly', - 'Charleroi' => 'charleroi', - 'Charleville-Mézières' => 'Charleville-Mezieres', - 'Chartres' => 'Chartres', - 'Cherbourg' => 'Cherbourg', - 'Cholet' => 'cholet', - 'Clermont-Ferrand' => 'Clermont-Ferrand', - 'Compiègne' => 'compiegne', - 'Dieppe' => 'dieppe', - 'Dijon' => 'Dijon', - 'Dunkerque' => 'Dunkerque', - 'Evreux' => 'evreux', - 'Fréjus' => 'frejus', - 'Gap' => 'gap', - 'Genève' => 'geneve', - 'Grenoble' => 'Grenoble', - 'La Roche sur Yon' => 'La-Roche-sur-Yon', - 'La Rochelle' => 'La-Rochelle', - 'Lausanne' => 'lausanne', - 'Laval' => 'Laval', - 'Le Havre' => 'le-havre', - 'Le Mans' => 'le-mans', - 'Liège' => 'liege', - 'Lille' => 'lille', - 'Limoges' => 'Limoges', - 'Lorient' => 'Lorient', - 'Luxembourg' => 'Luxembourg', - 'Lyon' => 'lyon', - 'Marseille' => 'marseille', - 'Metz' => 'Metz', - 'Mons' => 'Mons', - 'Mont de Marsan' => 'mont-de-marsan', - 'Montauban' => 'Montauban', - 'Montluçon' => 'montlucon', - 'Montpellier' => 'montpellier', - 'Mulhouse' => 'Mulhouse', - 'Nîmes' => 'nimes', - 'Namur' => 'Namur', - 'Nancy' => 'Nancy', - 'Nantes' => 'nantes', - 'Nevers' => 'nevers', - 'Nice' => 'nice', - 'Niort' => 'niort', - 'Orléans' => 'orleans', - 'Périgueux' => 'perigueux', - 'Paris' => 'paris', - 'Pau' => 'Pau', - 'Perpignan' => 'Perpignan', - 'Poitiers' => 'Poitiers', - 'Quimper' => 'Quimper', - 'Reims' => 'Reims', - 'Rennes' => 'Rennes', - 'Roanne' => 'roanne', - 'Rodez' => 'rodez', - 'Rouen' => 'Rouen', - 'Saint-Brieuc' => 'Saint-Brieuc', - 'Saint-Etienne' => 'saint-etienne', - 'Saint-Malo' => 'saint-malo', - 'Saint-Nazaire' => 'saint-nazaire', - 'Saint-Quentin' => 'saint-quentin', - 'Saintes' => 'saintes', - 'Strasbourg' => 'Strasbourg', - 'Tarbes' => 'Tarbes', - 'Toulon' => 'Toulon', - 'Toulouse' => 'Toulouse', - 'Tours' => 'Tours', - 'Troyes' => 'troyes', - 'Valence' => 'valence', - 'Vannes' => 'vannes', - 'Zurich' => 'zurich', - ) - ) - ) - ); - protected function parseItem($item){ - $item = parent::parseItem($item); - $html = getSimpleHTMLDOMCached($item['uri']); - $text = $html->find('div.corpsMax', 0)->innertext; - $item['content'] = utf8_encode($text); - return $item; - } +class OnVaSortirBridge extends FeedExpander +{ + const MAINTAINER = 'AntoineTurmel'; + const NAME = 'OnVaSortir'; + const URI = 'https://www.onvasortir.com'; + const DESCRIPTION = 'Returns the newest events from OnVaSortir (full text)'; + const PARAMETERS = [ + [ + 'city' => [ + 'name' => 'City', + 'type' => 'list', + 'values' => [ + 'Agen' => 'Agen', + 'Ajaccio' => 'Ajaccio', + 'Albi' => 'Albi', + 'Amiens' => 'Amiens', + 'Angers' => 'Angers', + 'Angoulême' => 'Angouleme', + 'Annecy' => 'annecy', + 'Aurillac' => 'aurillac', + 'Auxerre' => 'auxerre', + 'Avignon' => 'avignon', + 'Béziers' => 'Beziers', + 'Bastia' => 'Bastia', + 'Beauvais' => 'Beauvais', + 'Belfort' => 'Belfort', + 'Bergerac' => 'bergerac', + 'Besançon' => 'Besancon', + 'Biarritz' => 'Biarritz', + 'Blois' => 'Blois', + 'Bordeaux' => 'bordeaux', + 'Bourg-en-Bresse' => 'bourg-en-bresse', + 'Bourges' => 'Bourges', + 'Brest' => 'Brest', + 'Brive' => 'brive-la-gaillarde', + 'Bruxelles' => 'bruxelles', + 'Caen' => 'Caen', + 'Calais' => 'Calais', + 'Carcassonne' => 'Carcassonne', + 'Châteauroux' => 'Chateauroux', + 'Chalon-sur-saone' => 'chalon-sur-saone', + 'Chambéry' => 'chambery', + 'Chantilly' => 'chantilly', + 'Charleroi' => 'charleroi', + 'Charleville-Mézières' => 'Charleville-Mezieres', + 'Chartres' => 'Chartres', + 'Cherbourg' => 'Cherbourg', + 'Cholet' => 'cholet', + 'Clermont-Ferrand' => 'Clermont-Ferrand', + 'Compiègne' => 'compiegne', + 'Dieppe' => 'dieppe', + 'Dijon' => 'Dijon', + 'Dunkerque' => 'Dunkerque', + 'Evreux' => 'evreux', + 'Fréjus' => 'frejus', + 'Gap' => 'gap', + 'Genève' => 'geneve', + 'Grenoble' => 'Grenoble', + 'La Roche sur Yon' => 'La-Roche-sur-Yon', + 'La Rochelle' => 'La-Rochelle', + 'Lausanne' => 'lausanne', + 'Laval' => 'Laval', + 'Le Havre' => 'le-havre', + 'Le Mans' => 'le-mans', + 'Liège' => 'liege', + 'Lille' => 'lille', + 'Limoges' => 'Limoges', + 'Lorient' => 'Lorient', + 'Luxembourg' => 'Luxembourg', + 'Lyon' => 'lyon', + 'Marseille' => 'marseille', + 'Metz' => 'Metz', + 'Mons' => 'Mons', + 'Mont de Marsan' => 'mont-de-marsan', + 'Montauban' => 'Montauban', + 'Montluçon' => 'montlucon', + 'Montpellier' => 'montpellier', + 'Mulhouse' => 'Mulhouse', + 'Nîmes' => 'nimes', + 'Namur' => 'Namur', + 'Nancy' => 'Nancy', + 'Nantes' => 'nantes', + 'Nevers' => 'nevers', + 'Nice' => 'nice', + 'Niort' => 'niort', + 'Orléans' => 'orleans', + 'Périgueux' => 'perigueux', + 'Paris' => 'paris', + 'Pau' => 'Pau', + 'Perpignan' => 'Perpignan', + 'Poitiers' => 'Poitiers', + 'Quimper' => 'Quimper', + 'Reims' => 'Reims', + 'Rennes' => 'Rennes', + 'Roanne' => 'roanne', + 'Rodez' => 'rodez', + 'Rouen' => 'Rouen', + 'Saint-Brieuc' => 'Saint-Brieuc', + 'Saint-Etienne' => 'saint-etienne', + 'Saint-Malo' => 'saint-malo', + 'Saint-Nazaire' => 'saint-nazaire', + 'Saint-Quentin' => 'saint-quentin', + 'Saintes' => 'saintes', + 'Strasbourg' => 'Strasbourg', + 'Tarbes' => 'Tarbes', + 'Toulon' => 'Toulon', + 'Toulouse' => 'Toulouse', + 'Tours' => 'Tours', + 'Troyes' => 'troyes', + 'Valence' => 'valence', + 'Vannes' => 'vannes', + 'Zurich' => 'zurich', + ] + ] + ] + ]; - public function collectData(){ - $this->collectExpandableDatas('https://' . - $this->getInput('city') . '.onvasortir.com/rss.php'); - } + public function collectData() + { + $url = 'https://' . $this->getInput('city') . '.onvasortir.com/rss.php'; + $this->collectExpandableDatas($url); + } + + protected function parseItem(array $item) + { + $dom = getSimpleHTMLDOMCached($item['uri']); + $text = $dom->find('div.corpsMax', 0)->innertext; + $item['content'] = utf8_encode($text); + return $item; + } } diff --git a/bridges/OneFortuneADayBridge.php b/bridges/OneFortuneADayBridge.php index 62fe767d9b9..de91a546a07 100644 --- a/bridges/OneFortuneADayBridge.php +++ b/bridges/OneFortuneADayBridge.php @@ -1,76 +1,84 @@ array( - 'name' => 'Time in UTC', - 'type' => 'list', - 'values' => array( - '0:00' => 0, - '1:00' => 1, - '2:00' => 2, - '3:00' => 3, - '4:00' => 4, - '5:00' => 5, - '6:00' => 6, - '7:00' => 7, - '8:00' => 8, - '9:00' => 9, - '10:00' => 10, - '11:00' => 11, - '12:00' => 12, - '13:00' => 13, - '14:00' => 14, - '15:00' => 15, - '16:00' => 16, - '17:00' => 17, - '18:00' => 18, - '19:00' => 19, - '20:00' => 20, - '21:00' => 21, - '22:00' => 22, - '23:00' => 23, - ), - 'defaultValue' => 5 - ), - 'lucky' => array( - 'name' => 'Lucky number (optional)', - 'type' => 'text' - ) - )); - const LIMIT_ITEMS = 7; - const DAY_SECS = 86400; +class OneFortuneADayBridge extends BridgeAbstract +{ + const NAME = 'One Fortune a Day'; + const URI = 'https://github.com/fulmeek'; + const DESCRIPTION = 'Get a fortune quote every single day.'; + const MAINTAINER = 'fulmeek'; + const PARAMETERS = [[ + 'time' => [ + 'name' => 'Time in UTC', + 'type' => 'list', + 'values' => [ + '0:00' => 0, + '1:00' => 1, + '2:00' => 2, + '3:00' => 3, + '4:00' => 4, + '5:00' => 5, + '6:00' => 6, + '7:00' => 7, + '8:00' => 8, + '9:00' => 9, + '10:00' => 10, + '11:00' => 11, + '12:00' => 12, + '13:00' => 13, + '14:00' => 14, + '15:00' => 15, + '16:00' => 16, + '17:00' => 17, + '18:00' => 18, + '19:00' => 19, + '20:00' => 20, + '21:00' => 21, + '22:00' => 22, + '23:00' => 23, + ], + 'defaultValue' => 5 + ], + 'lucky' => [ + 'name' => 'Lucky number (optional)', + 'type' => 'text' + ] + ]]; - public function getDescription(){ - return self::DESCRIPTION . '
Set a lucky number to get your personal quotes, like ' . mt_rand(); - } + const LIMIT_ITEMS = 7; + const DAY_SECS = 86400; - public function collectData() { - $time = gmmktime((int)$this->getInput('time'), 0, 0); - if ($time > time()) - $time -= self::DAY_SECS; + public function getDescription() + { + return self::DESCRIPTION . '
Set a lucky number to get your personal quotes, like ' . mt_rand(); + } - for ($i = self::LIMIT_ITEMS; $i > 0; --$i) { - $seed = gmdate('Ymd', $time) . $this->getInput('lucky'); - $quote = $this->getQuote($seed); + public function collectData() + { + $time = gmmktime((int)$this->getInput('time'), 0, 0); + if ($time > time()) { + $time -= self::DAY_SECS; + } - $item['title'] = strftime('%A, %x', $time); - $item['content'] = htmlentities($quote, ENT_QUOTES, 'UTF-8'); - $item['timestamp'] = $time; - $item['uid'] = hash('sha1', $seed); + for ($i = self::LIMIT_ITEMS; $i > 0; --$i) { + $seed = gmdate('Ymd', $time) . $this->getInput('lucky'); + $quote = $this->getQuote($seed); - $this->items[] = $item; + $item['title'] = strftime('%A, %x', $time); + $item['content'] = htmlentities($quote, ENT_QUOTES, 'UTF-8'); + $item['timestamp'] = $time; + $item['uid'] = hash('sha1', $seed); - $time -= self::DAY_SECS; - } - } + $this->items[] = $item; - private function getQuote($seed) { - $quotes = explode('//', <<[ + 'instance' => [ + 'name' => 'OpenCVE Instance', + 'required' => true, + 'defaultValue' => 'https://www.opencve.io', + 'exampleValue' => 'https://www.opencve.io' + ], + 'login' => [ + 'name' => 'Login', + 'type' => 'text', + 'required' => true + ], + 'password' => [ + 'name' => 'Password', + 'type' => 'text', + 'required' => true + ], + 'pages' => [ + 'name' => 'Number of pages', + 'type' => 'number', + 'required' => false, + 'exampleValue' => 1, + 'defaultValue' => 1 + ], + 'filter' => [ + 'name' => 'Filter', + 'type' => 'text', + 'required' => false, + 'exampleValue' => 'search:jenkins;product:gitlab,cvss:critical', + 'title' => 'Syntax: param1:value1,param2:value2;param1query2:param2query2. See https://docs.opencve.io/api/cve/ for parameters' + ], + 'upd_timestamp' => [ + 'name' => 'Use updated_at instead of created_at as timestamp', + 'type' => 'checkbox' + ], + 'trunc_summary' => [ + 'name' => 'Truncate summary for header', + 'type' => 'number', + 'defaultValue' => 100 + ], + 'fetch_contents' => [ + 'name' => 'Fetch detailed contents for CVEs', + 'defaultValue' => 'checked', + 'type' => 'checkbox' + ] + ] + ]; + + const CSS = ' + '; + + public function collectData() + { + $creds = $this->getInput('login') . ':' . $this->getInput('password'); + $authHeader = 'Authorization: Basic ' . base64_encode($creds); + $instance = $this->getInput('instance'); + + $queries = []; + $filter = $this->getInput('filter'); + $filterValues = []; + if ($filter && mb_strlen($filter) > 0) { + $filterValues = explode(';', $filter); + } else { + $queries[''] = []; + } + foreach ($filterValues as $filterValue) { + $params = explode(',', $filterValue); + $queryName = $filterValue; + $query = []; + foreach ($params as $param) { + [$key, $value] = explode(':', $param); + if ($key == 'title') { + $queryName = $value; + } else { + $query[$key] = $value; + } + } + $queries[$queryName] = $query; + } + + $fetchedIds = []; + + foreach ($queries as $queryName => $query) { + for ($i = 1; $i <= $this->getInput('pages'); $i++) { + $queryPaginated = array_merge($query, ['page' => $i]); + $url = $instance . '/api/cve?' . http_build_query($queryPaginated); + + $response = getContents($url, [$authHeader]); + + $titlePrefix = ''; + if (count($queries) > 1) { + $titlePrefix = '[' . $queryName . '] '; + } + + foreach (json_decode($response) as $cveItem) { + if (array_key_exists($cveItem->id, $fetchedIds)) { + continue; + } + $fetchedIds[$cveItem->id] = true; + $item = [ + 'uri' => $instance . '/cve/' . $cveItem->id, + 'uid' => $cveItem->id, + ]; + if ($this->getInput('upd_timestamp') == 1) { + $item['timestamp'] = strtotime($cveItem->updated_at); + } else { + $item['timestamp'] = strtotime($cveItem->created_at); + } + if ($this->getInput('fetch_contents')) { + [$content, $title] = $this->fetchContents( + $cveItem, + $titlePrefix, + $instance, + $authHeader + ); + $item['content'] = $content; + $item['title'] = $title; + } else { + $item['content'] = $cveItem->summary . $this->getLinks($cveItem->id); + $item['title'] = $this->getTitle($titlePrefix, $cveItem); + } + $this->items[] = $item; + } + } + } + usort($this->items, function ($a, $b) { + return $b['timestamp'] - $a['timestamp']; + }); + } + + private function getTitle($titlePrefix, $cveItem) + { + $summary = $cveItem->summary; + $limit = $this->getInput('limit'); + if ($limit && mb_strlen($summary) > 100) { + $summary = mb_substr($summary, 0, $limit) + '...'; + } + return $titlePrefix . $cveItem->id . '. ' . $summary; + } + + private function fetchContents($cveItem, $titlePrefix, $instance, $authHeader) + { + $url = $instance . '/api/cve/' . $cveItem->id; + + $response = getContents($url, [$authHeader]); + $datum = json_decode($response); + + $title = $this->getTitleFromDatum($datum, $titlePrefix); + + $result = self::CSS; + $result .= ' ' . $cveItem->id . '
'; + $result .= $this->getCVSSLabels($datum); + $result .= '' . $datum->summary . '
'; + $result .= <<Information: + +
+
+ + EOD; + + $result .= $this->getV3Table($datum); + $result .= $this->getV2Table($datum); + + $result .= $this->getLinks($datum->id); + $result .= $this->getReferences($datum); + + $result .= $this->getVendors($datum); + + return [$result, $title]; + } + + private function getTitleFromDatum($datum, $titlePrefix) + { + $title = $titlePrefix; + if ($datum->cvss->v3) { + $title .= "[v3: {$datum->cvss->v3}] "; + } + if ($datum->cvss->v2) { + $title .= "[v2: {$datum->cvss->v2}] "; + } + $title .= $datum->id . '. '; + $titlePostfix = $datum->summary; + $limit = $this->getInput('limit'); + if ($limit && mb_strlen($titlePostfix) > 100) { + $titlePostfix = mb_substr($titlePostfix, 0, $limit) + '...'; + } + $title .= $titlePostfix; + return $title; + } + + private function getCVSSLabels($datum) + { + $CVSSv2Text = 'n/a'; + $CVSSv2Class = 'cvss-na-color'; + if ($datum->cvss->v2) { + $importance = ''; + if ($datum->cvss->v2 >= 7) { + $importance = 'HIGH'; + $CVSSv2Class = 'cvss-high-color'; + } else if ($datum->cvss->v2 >= 4) { + $importance = 'MEDIUM'; + $CVSSv2Class = 'cvss-medium-color'; + } else { + $importance = 'LOW'; + $CVSSv2Class = 'cvss-low-color'; + } + $CVSSv2Text = sprintf('[%s] %.1f', $importance, $datum->cvss->v2); + } + $CVSSv2Item = "- Publication date: {$datum->raw_nvd_data->published} +
- Last modified: {$datum->raw_nvd_data->lastModified} +
- Last modified: {$datum->raw_nvd_data->lastModified} +
CVSS v2:{$CVSSv2Text}"; + + $CVSSv3Text = 'n/a'; + $CVSSv3Class = 'cvss-na-color'; + if ($datum->cvss->v3) { + $importance = ''; + if ($datum->cvss->v3 >= 9) { + $importance = 'CRITICAL'; + $CVSSv3Class = 'cvss-crit-color'; + } else if ($datum->cvss->v3 >= 7) { + $importance = 'HIGH'; + $CVSSv3Class = 'cvss-high-color'; + } else if ($datum->cvss->v3 >= 4) { + $importance = 'MEDIUM'; + $CVSSv3Class = 'cvss-medium-color'; + } else { + $importance = 'LOW'; + $CVSSv3Class = 'cvss-low-color'; + } + $CVSSv3Text = sprintf('[%s] %.1f', $importance, $datum->cvss->v3); + } + $CVSSv3Item = "CVSS v3:{$CVSSv3Text}"; + return '' . $CVSSv3Item . $CVSSv2Item . ''; + } + + private function getReferences($datum) + { + if (count($datum->raw_nvd_data->references) == 0) { + return ''; + } + $res = 'References:
'; + foreach ($datum->raw_nvd_data->references as $ref) { + $item = '
'; + return $res; + } + + private function getLinks($id) + { + return <<- '; + if (isset($ref->tags) && count($ref->tags) > 0) { + $item .= '[' . implode(', ', $ref->tags) . '] '; + } + $item .= "url}\">{$ref->url}"; + $item .= '
- '; + $res .= $item; + } + $res .= '
Links + +
+ + EOD; + } + + private function getV3Table($datum) + { + $metrics = $datum->raw_nvd_data->metrics; + if (!isset($metrics->cvssMetricV31) || count($metrics->cvssMetricV31) == 0) { + return ''; + } + $v3 = $metrics->cvssMetricV31[0]; + $data = $v3->cvssData; + return <<+ CVSS v3 details
++
+ + EOD; + } + + private function getV2Table($datum) + { + $metrics = $datum->raw_nvd_data->metrics; + if (!isset($metrics->cvssMetricV2) || count($metrics->cvssMetricV2) == 0) { + return ''; + } + $v2 = $metrics->cvssMetricV2[0]; + $data = $v2->cvssData; + return <<+ +Impact score {$v3->impactScore} +Exploitability score {$v3->exploitabilityScore} ++ +Attack vector {$data->attackVector} +Confidentiality Impact {$data->confidentialityImpact} ++ +Attack complexity {$data->attackComplexity} +Integrity Impact {$data->integrityImpact} ++ +Privileges Required {$data->privilegesRequired} +Availability Impact {$data->availabilityImpact} ++ +User Interaction {$data->userInteraction} +Scope {$data->scope} ++ CVSS v2 details
++
+ + EOD; + } + + private function getVendors($datum) + { + if (count((array)$datum->vendors) == 0) { + return ''; + } + $res = '+ +Impact score {$v2->impactScore} +Exploitability score {$v2->exploitabilityScore} ++ +Access Vector {$data->accessVector} +Confidentiality Impact {$data->confidentialityImpact} ++ +Access Complexity {$data->accessComplexity} +Integrity Impact {$data->integrityImpact} ++ +Authentication {$data->authentication} +Availability Impact {$data->availabilityImpact} ++ Affected products
'; + foreach ($datum->vendors as $vendor => $products) { + $res .= "
'; + } +} diff --git a/bridges/OpenClassroomsBridge.php b/bridges/OpenClassroomsBridge.php deleted file mode 100644 index 4db7bc19329..00000000000 --- a/bridges/OpenClassroomsBridge.php +++ /dev/null @@ -1,48 +0,0 @@ - array( - 'name' => 'Catégorie', - 'type' => 'list', - 'values' => array( - 'Arts & Culture' => 'arts', - 'Code' => 'code', - 'Design' => 'design', - 'Entreprise' => 'business', - 'Numérique' => 'digital', - 'Sciences' => 'sciences', - 'Sciences Humaines' => 'humainities', - 'Systèmes d\'information' => 'it', - 'Autres' => 'others' - ) - ) - )); - - public function getURI(){ - if(!is_null($this->getInput('u'))) { - return self::URI . '/courses?categories=' . $this->getInput('u') . '&title=&sort=updatedAt+desc'; - } - - return parent::getURI(); - } - - public function collectData(){ - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request OpenClassrooms.'); - - foreach($html->find('.courseListItem') as $element) { - $item = array(); - $item['uri'] = self::URI . $element->find('a', 0)->href; - $item['title'] = $element->find('h3', 0)->plaintext; - $item['content'] = $element->find('slidingItem__descriptionContent', 0)->plaintext; - $this->items[] = $item; - } - } -} diff --git a/bridges/OpenlyBridge.php b/bridges/OpenlyBridge.php new file mode 100644 index 00000000000..9f54e22a3e8 --- /dev/null +++ b/bridges/OpenlyBridge.php @@ -0,0 +1,255 @@ + [], + 'All Opinion' => [], + 'By Region' => [ + 'region' => [ + 'name' => 'Region', + 'type' => 'list', + 'values' => [ + 'Africa' => 'africa', + 'Asia Pacific' => 'asia-pacific', + 'Europe' => 'europe', + 'Latin America' => 'latin-america', + 'Middle Easta' => 'middle-east', + 'North America' => 'north-america' + ] + ], + 'content' => [ + 'name' => 'Content', + 'type' => 'list', + 'values' => [ + 'News' => 'news', + 'Opinion' => 'people' + ], + 'defaultValue' => 'news' + ] + ], + 'By Tag' => [ + 'tag' => [ + 'name' => 'Tag', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'lgbt-law', + ], + 'content' => [ + 'name' => 'Content', + 'type' => 'list', + 'values' => [ + 'News' => 'news', + 'Opinion' => 'people' + ], + 'defaultValue' => 'news' + ] + ], + 'By Author' => [ + 'profileId' => [ + 'name' => 'Profile ID', + 'type' => 'text', + 'required' => true, + 'exampleValue' => '003D000002WZGYRIA5', + ] + ] + ]; + + const TEST_DETECT_PARAMETERS = [ + 'https://www.openlynews.com/profile/?id=0033z00002XUTepAAH' => [ + 'context' => 'By Author', 'profileId' => '0033z00002XUTepAAH' + ], + 'https://www.openlynews.com/news/?page=1&theme=lgbt-law' => [ + 'context' => 'By Tag', 'content' => 'news', 'tag' => 'lgbt-law' + ], + 'https://www.openlynews.com/news/?page=1®ion=north-america' => [ + 'context' => 'By Region', 'content' => 'news', 'region' => 'north-america' + ], + 'https://www.openlynews.com/news/?theme=lgbt-law' => [ + 'context' => 'By Tag', 'content' => 'news', 'tag' => 'lgbt-law' + ], + 'https://www.openlynews.com/news/?region=north-america' => [ + 'context' => 'By Region', 'content' => 'news', 'region' => 'north-america' + ] + ]; + + const CACHE_TIMEOUT = 900; // 15 mins + const ARTICLE_CACHE_TIMEOUT = 3600; // 1 hour + + private $feedTitle = ''; + private $itemLimit = 10; + + private $profileUrlRegex = '/openlynews\.com\/profile\/\?id=([a-zA-Z0-9]+)/'; + private $tagUrlRegex = '/openlynews\.com\/([a-z]+)\/\?(?:page=(?:[0-9]+)&)?theme=([\w-]+)/'; + private $regionUrlRegex = '/openlynews\.com\/([a-z]+)\/\?(?:page=(?:[0-9]+)&)?region=([\w-]+)/'; + + public function detectParameters($url) + { + $params = []; + + if (preg_match($this->profileUrlRegex, $url, $matches) > 0) { + $params['context'] = 'By Author'; + $params['profileId'] = $matches[1]; + return $params; + } + + if (preg_match($this->tagUrlRegex, $url, $matches) > 0) { + $params['context'] = 'By Tag'; + $params['content'] = $matches[1]; + $params['tag'] = $matches[2]; + return $params; + } + + if (preg_match($this->regionUrlRegex, $url, $matches) > 0) { + $params['context'] = 'By Region'; + $params['content'] = $matches[1]; + $params['region'] = $matches[2]; + return $params; + } + + return null; + } + + public function collectData() + { + $url = $this->getAjaxURI(); + + if ($this->queriedContext === 'By Author') { + $url = $this->getURI(); + } + + $html = getSimpleHTMLDOM($url); + $html = defaultLinkTo($html, $this->getURI()); + + if ($html->find('h1', 0)) { + $this->feedTitle = $html->find('h1', 0)->plaintext; + } + + if ($html->find('h2.title-v4', 0)) { + $html->find('span.tooltiptext', 0)->innertext = ''; + $this->feedTitle = $html->find('a.tooltipitem', 0)->plaintext; + } + + $items = $html->find('div.item'); + $limit = 5; + foreach (array_slice($items, 0, $limit) as $div) { + $this->items[] = $this->getArticle($div->find('a', 0)->href); + + if (count($this->items) >= $this->itemLimit) { + break; + } + } + } + + public function getURI() + { + switch ($this->queriedContext) { + case 'All News': + return self::URI . 'news'; + break; + case 'All Opinion': + return self::URI . 'people'; + break; + case 'By Tag': + return self::URI . $this->getInput('content') . '/?theme=' . $this->getInput('tag'); + case 'By Region': + return self::URI . $this->getInput('content') . '/?region=' . $this->getInput('region'); + break; + case 'By Author': + return self::URI . 'profile/?id=' . $this->getInput('profileId'); + break; + default: + return parent::getURI(); + } + } + + public function getName() + { + switch ($this->queriedContext) { + case 'All News': + return 'News - Openly'; + break; + case 'All Opinion': + return 'Opinion - Openly'; + break; + case 'By Tag': + if (empty($this->feedTitle)) { + $this->feedTitle = $this->getInput('tag'); + } + + if ($this->getInput('content') === 'people') { + return $this->feedTitle . ' - Opinion - Openly'; + } + + return $this->feedTitle . ' - Openly'; + break; + case 'By Region': + if (empty($this->feedTitle)) { + $this->feedTitle = $this->getInput('region'); + } + + if ($this->getInput('content') === 'people') { + return $this->feedTitle . ' - Opinion - Openly'; + } + + return $this->feedTitle . ' - Openly'; + break; + case 'By Author': + if (empty($this->feedTitle)) { + $this->feedTitle = $this->getInput('profileId'); + } + + return $this->feedTitle . ' - Author - Openly'; + break; + default: + return parent::getName(); + } + } + + private function getAjaxURI() + { + $part = '/ajax.html?'; + + switch ($this->queriedContext) { + case 'All News': + return self::URI . 'news' . $part; + break; + case 'All Opinion': + return self::URI . 'people' . $part; + break; + case 'By Tag': + return self::URI . $this->getInput('content') . $part . 'theme=' . $this->getInput('tag'); + break; + case 'By Region': + return self::URI . $this->getInput('content') . $part . 'region=' . $this->getInput('region'); + break; + } + } + + private function getArticle($url) + { + $article = getSimpleHTMLDOMCached($url, self::ARTICLE_CACHE_TIMEOUT); + $article = defaultLinkTo($article, $this->getURI()); + + $item = []; + $item['title'] = $article->find('h1', 0)->plaintext; + $item['uri'] = $url; + $item['content'] = $article->find('div.body-text', 0); + $item['enclosures'][] = $article->find('meta[name="twitter:image"]', 0)->content; + $item['timestamp'] = $article->find('div.meta.small', 0)->plaintext; + + if ($article->find('div.meta a', 0)) { + $item['author'] = $article->find('div.meta a', 0)->plaintext; + } + + foreach ($article->find('div.themes li') as $li) { + $item['categories'][] = trim(htmlspecialchars($li->plaintext, ENT_QUOTES)); + } + + return $item; + } +} diff --git a/bridges/OpenwhydBridge.php b/bridges/OpenwhydBridge.php index f80cb066374..431173bc865 100644 --- a/bridges/OpenwhydBridge.php +++ b/bridges/OpenwhydBridge.php @@ -1,61 +1,66 @@ array( - 'name' => 'username/id', - 'required' => true - ) - )); - - private $userName = ''; - - public function getIcon() { - return self::URI . '/images/favicon.ico'; - } - - public function collectData(){ - $html = ''; - if(strlen(preg_replace('/[^0-9a-f]/', '', $this->getInput('u'))) == 24) { - // is input the userid ? - $html = getSimpleHTMLDOM( - self::URI . '/u/' . preg_replace('/[^0-9a-f]/', '', $this->getInput('u')) - ) or returnServerError('No results for this query.'); - } else { // input may be the username - $html = getSimpleHTMLDOM( - self::URI . '/search?q=' . urlencode($this->getInput('u')) - ) or returnServerError('No results for this query.'); - - for($j = 0; $j < 5; $j++) { - if(strtolower($html->find('div.user', $j)->find('a', 0)->plaintext) == strtolower($this->getInput('u'))) { - $html = getSimpleHTMLDOM( - self::URI . $html->find('div.user', $j)->find('a', 0)->getAttribute('href') - ) or returnServerError('No results for this query'); - break; - } - } - } - $this->userName = $html->find('div#profileTop', 0)->find('h1', 0)->plaintext; - - for($i = 0; $i < 10; $i++) { - $track = $html->find('div.post', $i); - $item = array(); - $item['author'] = $track->find('h2', 0)->plaintext; - $item['title'] = $track->find('h2', 0)->plaintext; - $item['content'] = $track->find('a.thumb', 0) . '- {$vendor}"; + if (count($products) > 0) { + $res .= '
'; + } + $res .= ''; + foreach ($products as $product) { + $res .= '
'; + } + $res .= '- ' . $product . '
'; + } + $res .= '
' . $track->find('h2', 0)->plaintext; - $item['id'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href'); - $item['uri'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href'); - $this->items[] = $item; - } - } - - public function getName(){ - return (!empty($this->userName) ? $this->userName . ' - ' : '') . 'Openwhyd Bridge'; - } + +class OpenwhydBridge extends BridgeAbstract +{ + const MAINTAINER = 'kranack'; + const NAME = 'Openwhyd Bridge'; + const URI = 'https://openwhyd.org'; + const CACHE_TIMEOUT = 600; // 10min + const DESCRIPTION = 'Returns 10 newest music from user profile'; + + const PARAMETERS = [ [ + 'u' => [ + 'name' => 'username/id', + 'exampleValue' => '5247f0267e91c862b2b052d0', + 'required' => true + ] + ]]; + + private $userName = ''; + + public function getIcon() + { + return self::URI . '/images/favicon.ico'; + } + + public function collectData() + { + $html = ''; + if (strlen(preg_replace('/[^0-9a-f]/', '', $this->getInput('u'))) == 24) { + // is input the userid ? + $html = getSimpleHTMLDOM( + self::URI . '/u/' . preg_replace('/[^0-9a-f]/', '', $this->getInput('u')) + ); + } else { // input may be the username + $html = getSimpleHTMLDOM( + self::URI . '/search?q=' . urlencode($this->getInput('u')) + ); + + for ($j = 0; $j < 5; $j++) { + if (strtolower($html->find('div.user', $j)->find('a', 0)->plaintext) == strtolower($this->getInput('u'))) { + $html = getSimpleHTMLDOM( + self::URI . $html->find('div.user', $j)->find('a', 0)->getAttribute('href') + ); + break; + } + } + } + $this->userName = $html->find('div#profileTop', 0)->find('h1', 0)->plaintext; + + for ($i = 0; $i < 10; $i++) { + $track = $html->find('div.post', $i); + $item = []; + $item['author'] = $track->find('h2', 0)->plaintext; + $item['title'] = $track->find('h2', 0)->plaintext; + $item['content'] = $track->find('a.thumb', 0) . '
' . $track->find('h2', 0)->plaintext; + $item['id'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href'); + $item['uri'] = self::URI . $track->find('a.no-ajaxy', 0)->getAttribute('href'); + $this->items[] = $item; + } + } + + public function getName() + { + return (!empty($this->userName) ? $this->userName . ' - ' : '') . 'Openwhyd Bridge'; + } } diff --git a/bridges/OpenwrtSecurityBridge.php b/bridges/OpenwrtSecurityBridge.php new file mode 100644 index 00000000000..bfe4e9dde50 --- /dev/null +++ b/bridges/OpenwrtSecurityBridge.php @@ -0,0 +1,40 @@ +find('div[class=plugin_nspages]', 0); + + foreach ($advisories->find('a[class=wikilink1]') as $element) { + $item = []; + + $row = $element->innertext; + + $item['title'] = substr($row, 0, strpos($row, ' - ')); + $item['timestamp'] = $this->getDate($element->href); + $item['uri'] = self::WEBROOT . $element->href; + $item['uid'] = self::WEBROOT . $element->href; + $item['content'] = substr($row, strpos($row, ' - ') + 3); + $item['author'] = 'OpenWrt Project'; + + $this->items[] = $item; + } + } + + private function getDate($href) + { + $date = substr($href, -12); + return $date; + } +} diff --git a/bridges/OsmAndBlogBridge.php b/bridges/OsmAndBlogBridge.php deleted file mode 100644 index 402c0301b1d..00000000000 --- a/bridges/OsmAndBlogBridge.php +++ /dev/null @@ -1,64 +0,0 @@ -find('div.article') as $element) { - $item = array(); - - $objTitle = $element->find('h1', 0); - if (!$objTitle) - $objTitle = $element->find('h2', 0); - if (!$objTitle) - $objTitle = $element->find('h3', 0); - if ($objTitle) - $item['title'] = $objTitle->plaintext; - - $objDate = $element->find('meta[pubdate]', 0); - if ($objDate) { - $item['timestamp'] = strtotime($objDate->pubdate); - } else { - $objDate = $element->find('.date', 0); - if ($objDate) - $item['timestamp'] = strtotime($objDate->plaintext); - } - - $this->cleanupContent($element, $objTitle, $objDate, $element->find('.date', 0)); - $item['content'] = $element->innertext; - - $objLink = $html->find('.articlelinklist a', 0); - if ($objLink) { - $item['uri'] = $this->filterURL($objLink->href); - } else { - $item['uri'] = 'urn:sha1:' . hash('sha1', $item['content']); - } - - $this->items[] = $item; - } - } - - private function filterURL($url) { - if (strpos($url, '://') === false) - return self::URI . ltrim($url, '/'); - return $url; - } - - private function cleanupContent($content, ...$removeItems) { - foreach ($removeItems as $obj) { - if ($obj) $obj->outertext = ''; - } - foreach ($content->find('img') as $obj) { - $obj->src = $this->filterURL($obj->src); - } - foreach ($content->find('a') as $obj) { - $obj->href = $this->filterURL($obj->href); - $obj->target = '_blank'; - } - } -} diff --git a/bridges/OtrkeyFinderBridge.php b/bridges/OtrkeyFinderBridge.php new file mode 100644 index 00000000000..7920ff9a430 --- /dev/null +++ b/bridges/OtrkeyFinderBridge.php @@ -0,0 +1,197 @@ + [ + 'name' => 'Search term', + 'exampleValue' => 'Tatort', + 'title' => 'The search term is case-insensitive', + ], + 'station' => [ + 'name' => 'Station name', + 'exampleValue' => 'ARD', + ], + 'type' => [ + 'name' => 'Media type', + 'type' => 'list', + 'values' => [ + 'any' => '', + 'Detail' => [ + 'HD' => 'HD.avi', + 'AC3' => 'HD.ac3', + 'HD & AC3' => 'HD.', + 'HQ' => 'HQ.avi', + 'AVI' => 'g.avi', // 'g.' to exclude HD.avi and HQ.avi (filename always contains 'mpg.') + 'MP4' => '.mp4', + ], + ], + ], + 'minTime' => [ + 'name' => 'Min. running time', + 'type' => 'number', + 'title' => 'The minimum running time in minutes. The resolution is 5 minutes.', + 'exampleValue' => '90', + 'defaultValue' => '0', + ], + 'maxTime' => [ + 'name' => 'Max. running time', + 'type' => 'number', + 'title' => 'The maximum running time in minutes. The resolution is 5 minutes.', + 'exampleValue' => '120', + 'defaultValue' => '0', + ], + 'pages' => [ + 'name' => 'Number of pages', + 'type' => 'number', + 'title' => 'Specifies the number of pages to fetch. Increase this value if you get an empty feed.', + 'exampleValue' => '5', + 'defaultValue' => '5', + ], + ] + ]; + // Example: Terminator_20.04.13_02-25_sf2_100_TVOON_DE.mpg.avi.otrkey + // The first group is the running time in minutes + const FILENAME_REGEX = '/_(\d+)_TVOON_DE\.mpg\..+\.otrkey/'; + // year.month.day_hour-minute with leading zeros + const TIME_REGEX = '/\d{2}\.\d{2}\.\d{2}_\d{2}-\d{2}/'; + const CONTENT_TEMPLATE = '%s
'; + const MIRROR_TEMPLATE = '%s '; + + public function collectData() + { + $pages = $this->getInput('pages'); + + for ($page = 1; $page <= $pages; $page++) { + $uri = $this->buildUri($page); + + $html = getSimpleHTMLDOMCached($uri, self::CACHE_TIMEOUT); + + $keys = $html->find('div.otrkey'); + + foreach ($keys as $key) { + $temp = $this->buildItem($key); + + if ($temp != null) { + $this->items[] = $temp; + } + } + + // Sleep for 0.5 seconds to don't hammer the server. + usleep(500000); + } + } + + private function buildUri($page) + { + $searchterm = $this->getInput('searchterm'); + $station = $this->getInput('station'); + $type = $this->getInput('type'); + + // Combine all three parts to a search query by separating them with white space + $search = implode(' ', [$searchterm, $station, $type]); + $search = trim($search); + $search = urlencode($search); + + return sprintf(self::URI_TEMPLATE, $search, $page); + } + + private function buildItem(simple_html_dom_node $node) + { + $file = $this->getFilename($node); + + if ($file == null) { + return null; + } + + $minTime = $this->getInput('minTime'); + $maxTime = $this->getInput('maxTime'); + + // Do we need to check the running time? + if ($minTime != 0 || $maxTime != 0) { + if ($maxTime > 0 && $maxTime < $minTime) { + returnClientError('The minimum running time must be less than the maximum running time.'); + } + + preg_match(self::FILENAME_REGEX, $file, $matches); + + if (!isset($matches[1])) { + return null; + } + + $time = (int)$matches[1]; + + // Check for minimum running time + if ($minTime > 0 && $minTime > $time) { + return null; + } + + // Check for maximum running time + if ($maxTime > 0 && $maxTime < $time) { + return null; + } + } + + $item = []; + $item['title'] = $file; + + // The URI_TEMPLATE for querying the site can be reused here + $item['uri'] = sprintf(self::URI_TEMPLATE, $file, 1); + + $content = $this->buildContent($node); + + if ($content != null) { + $item['content'] = $content; + } + + if (preg_match(self::TIME_REGEX, $file, $matches) === 1) { + $item['timestamp'] = DateTime::createFromFormat( + 'y.m.d_H-i', + $matches[0], + new DateTimeZone('Europe/Berlin') + )->getTimestamp(); + } + + return $item; + } + + private function getFilename(simple_html_dom_node $node) + { + $file = $node->find('.file', 0); + + if ($file == null) { + return null; + } + + // Sometimes there is HTML in the filename - we don't want that. + // To filter that out, enumerate to the node which contains the text only. + foreach ($file->nodes as $node) { + if ($node->nodetype == HDOM_TYPE_TEXT) { + return trim($node->innertext); + } + } + + return null; + } + + private function buildContent(simple_html_dom_node $node) + { + $mirrors = $node->find('div.mirror'); + $list = ''; + + // Build list of available mirrors + foreach ($mirrors as $mirror) { + $anchor = $mirror->find('a', 0); + $list .= sprintf(self::MIRROR_TEMPLATE, $anchor->href, $anchor->innertext); + } + + return sprintf(self::CONTENT_TEMPLATE, $list); + } +} diff --git a/bridges/OvertakeBridge.php b/bridges/OvertakeBridge.php new file mode 100644 index 00000000000..6de152761bd --- /dev/null +++ b/bridges/OvertakeBridge.php @@ -0,0 +1,42 @@ +collectExpandableDatas('https://www.overtake.gg/ams/index.rss', 10); + } + + protected function parseItem(array $item) + { + $articlePage = getSimpleHTMLDOMCached($item['uri']); + + $coverImage = $articlePage->find('img.js-articleCoverImage', 0); + #relative url -> absolute url + $coverImage = str_replace('src="/', 'src="' . $this->getURI() . '/', $coverImage); + $article = $articlePage->find('article.articleBody-main > div.bbWrapper', 0); + $item['content'] = str_get_html($coverImage . $article); + + //convert iframes to links. meant for embedded videos. + foreach ($item['content']->find('iframe') as $found) { + $iframeUrl = $found->getAttribute('src'); + + if ($iframeUrl) { + $found->outertext = '' . $iframeUrl . ''; + } + } + + $item['categories'] = []; + foreach ($articlePage->find('a.tagItem') as $tag) { + array_push($item['categories'], $tag->innertext); + } + + return $item; + } +} diff --git a/bridges/PanneauPocketBridge.php b/bridges/PanneauPocketBridge.php new file mode 100644 index 00000000000..464d56c5d92 --- /dev/null +++ b/bridges/PanneauPocketBridge.php @@ -0,0 +1,183 @@ + [ + 'name' => 'Choisir une ville', + 'type' => 'list', + 'values' => self::CITIES, + ], + 'cityName' => [ + 'name' => 'Ville', + ], + 'cityId' => [ + 'name' => 'Identifiant', + ] + ] + ]; + const CACHE_TIMEOUT = 7200; // 2h + + private const CITIES = [ + 'Andouillé-Neuville-35250' => '1455789521', + 'Aubigné-35250' => '1814317005', + 'Availles-sur-Seiche-35130' => '1892893207', + 'Baulon-35580' => '605833540', + 'Beaucé-35133' => '560906842', + 'Boisgervilly-35360' => '1993806123', + 'Bonnemain-35270' => '1099773691', + 'Bonnemain - Ecole Privée Saint-Joseph-35270' => '538925534', + 'Bonnemain - Ecole Publique Henri Matisse-35270' => '1820283844', + 'Bourg-des-Comptes-35890' => '957084809', + 'Breteil-35160' => '1206807553', + 'Chanteloup-35150' => '65528978', + 'Chavagne-35310' => '1825825704', + 'Cintré-35310' => '857744989', + 'Clayes-35590' => '1176604734', + 'Comblessac-35330' => '799252614', + 'Compagnie de Gendarmerie de Montfort-sur-Meu-35160' => '1310467096', + 'Compagnie de Gendarmerie de Redon-35600' => '772555117', + 'Compagnie de Gendarmerie de Saint-Malo-35400' => '212942271', + 'Compagnie de Gendarmerie de Vitré-35500' => '2117121991', + 'Dingé-35440' => '1146475327', + 'Feins-35440' => '762081007', + 'Gahard-35490' => '858141102', + 'Gendarmerie BTA de Bain-de-Bretagne-35470' => '2125697119', + 'Gendarmerie BTA de Mordelles-35310' => '1915843207', + 'Gendarmerie BTA de Saint-Aubin-du-Cormier-35140' => '1325843950', + 'Gendarmerie BTA de Vitré-35500' => '898672661', + 'Gendarmerie BTA Maen-Roch-35460' => '1096873908', + 'Gendarmerie COB Cancale-35260' => '1992410402', + 'Gendarmerie COB de Chateaugiron-35410' => '1867528169', + 'Gendarmerie COB de Combourg-35270' => '1045617593', + 'Gendarmerie COB de Fougères-35300' => '177248581', + 'Gendarmerie COB de Guichen-35580' => '557627842', + 'Gendarmerie COB de Hédé-Bazouges-35630' => '519881302', + 'Gendarmerie COB de Janzé-35150' => '533620097', + 'Gendarmerie COB de La Guerche-de-Bretagne-35130' => '1282120307', + 'Gendarmerie COB de Montauban de Bretagne-35360' => '137692263', + 'Gendarmerie COB de Redon-35600' => '1027850906', + 'Gendarmerie de Betton-35830' => '307605625', + 'Gosné-35140' => '1261503624', + 'Grand-Fougeray-35390' => '1687416796', + 'Guignen-35580' => '75195882', + 'L\'Hermitage-35590' => '1954292633', + 'La Boussac-35120' => '162444335', + 'La Chapelle-Bouëxic-35330' => '869117325', + 'La Couyère-35320' => '2075958825', + 'La Dominelais-35390' => '2065081911', + 'La Fresnais-35111' => '2010636370', + 'La Gouesnière-35350' => '1925923421', + 'La Noé-Blanche-35470' => '224305391', + 'La Nouaye-35137' => '1000733211', + 'Lalleu-35320' => '1460101917', + 'Landavran-35450' => '133549915', + 'Langouet-35630' => '1523560503', + 'Le Ferré-35420' => '1432943983', + 'Le Verger-35160' => '1266074746', + 'Les Brulais-35330' => '1854147921', + 'Les Portes du Coglais-35460' => '413267621', + 'Livré-sur-Changeon-35450' => '1850101087', + 'Louvigné-de-Bais-35680' => '1676392257', + 'Louvigné-de-Bais - Ecole Charles Perrault-35680' => '1180505145', + 'Louvigné-de-Bais - Ecole Saint-Patern-35680' => '919443746', + 'Maen Roch-35460' => '1112477040', + 'Maison de Quartier Francisco Ferrer-35200' => '944171353', + 'Marcillé-Raoul-35560' => '991970696', + 'Maxent-35380' => '209041860', + 'Meillac-35270' => '1841968856', + 'Mernel-35330' => '1311137811', + 'Monterfil-35160' => '873169651', + 'Montreuil-sur-Ille-35440' => '550764994', + 'Mouazé-35250' => '1931390548', + 'Moutiers-35130' => '443526227', + 'Parigné-35133' => '2013041755', + 'Pleugueneuc-35720' => '748287926', + 'Princé-35210' => '1765498088', + 'Rives-du-Couesnon-35140' => '1609662849', + 'Saint-Aubin-des-Landes-35500' => '1483721395', + 'Saint-Germain-du-Pinel-35370' => '1357547548', + 'Saint-Gonlay-35750' => '711639882', + 'Saint-Péran-35380' => '1484951371', + 'Saint-Séglin-35330' => '292665012', + 'Saint-Thual-35190' => '427165321', + 'Saint-Thurial-35310' => '940529156', + 'Sens-de-Bretagne-35490' => '1055647650', + 'Thourie-35134' => '1250885948', + 'Torcé-35370' => '1927215543', + 'Treffendel-35380' => '738532467', + 'Val d\'Anast-35330' => '225564233', + 'Vallons de Haute Bretagne Communauté-35580' => '1319050928', + 'Vergéal-35680' => '389815752', + 'Vieux-Vy-sur-Couesnon-35490' => '2016313694' + ]; + + public function collectData() + { + $cityId = $this->getInput('cityId'); + if ($cityId != null) { + $cityName = $this->getInput('cityName'); + $city = strtolower($cityId . '-' . $cityName); + } else { + $matchedCity = array_search($this->getInput('cities'), self::CITIES); + $city = strtolower($this->getInput('cities') . '-' . $matchedCity); + } + $url = sprintf('https://app.panneaupocket.com/ville/%s', urlencode($city)); + + $html = getSimpleHTMLDOM($url); + + foreach ($html->find('.sign-carousel--item') as $itemDom) { + $item = []; + + $item['uri'] = $itemDom->find('button[type=button]', 0)->href; + $item['title'] = $itemDom->find('.sign-preview__content .title', 0)->innertext; + $item['author'] = 'floviolleau'; + $item['content'] = $itemDom->find('.sign-preview__content .content', 0)->innertext; + + $timestamp = $itemDom->find('span.date', 0)->plaintext; + if (preg_match('#(?[0-9]+)/(? [0-9]+)/(? [0-9]+)#', $timestamp, $match)) { + $item['timestamp'] = "{$match['y']}-{$match['m']}-{$match['d']}"; + } + + $this->items[] = $item; + } + } + + public function detectParameters($url) + { + $params = []; + $regex = '/\/ville\/(\d+)-([a-z0-9-]+)/'; + if (preg_match($regex, $url, $matches)) { + $params['cityId'] = $matches[1]; + $params['cityName'] = $matches[2]; + return $params; + } + return null; + } + + /** + * Produce self::CITIES array + */ + private static function getCities($zipcodeStartWith) + { + $cities = json_decode(getContents(self::URI . '/public-api/city'), true); + + $formattedCities = null; + $citiesString = '[
'; + foreach ($cities as $city) { + if (str_starts_with($city['postCode'], $zipcodeStartWith)) { + $formattedCities[$city['name'] . ' - ' . $city['postCode']] = $city['id']; + $citiesString .= ' "' . $city['name'] . '-' . $city['postCode'] . '" => "' . $city['id'] . '",'; + $citiesString .= '
'; + } + } + $citiesString .= ']'; + echo '' . $citiesString . ''; + die(); + } +} diff --git a/bridges/ParksOnTheAirBridge.php b/bridges/ParksOnTheAirBridge.php new file mode 100644 index 00000000000..67910f6ef1b --- /dev/null +++ b/bridges/ParksOnTheAirBridge.php @@ -0,0 +1,42 @@ + 1]; + $json = getContents(self::API_URI, $header, $opts); + + $spots = json_decode($json, true); + + foreach ($spots as $spot) { + $title = $spot['activator'] . ' @ ' . $spot['reference'] . ' ' . + $spot['frequency'] . ' kHz'; + $park_link = self::URI . '/park/' . $spot['reference']; + + $content = <<+{$spot['reference']}, {$spot['name']}
+Location: {$spot['locationDesc']}
+Frequency: {$spot['frequency']} kHz
+Spotter: {$spot['spotter']}
+Comments: {$spot['comments']} +EOL; + + $this->items[] = [ + 'uri' => $park_link, + 'title' => $title, + 'content' => $content, + 'timestamp' => $spot['spotTime'] + ]; + } + } +} diff --git a/bridges/ParlerBridge.php b/bridges/ParlerBridge.php new file mode 100644 index 00000000000..9f481d13ca3 --- /dev/null +++ b/bridges/ParlerBridge.php @@ -0,0 +1,53 @@ + [ + 'name' => 'User', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'NigelFarage', + ], + 'limit' => self::LIMIT, + ] + ]; + + public function collectData() + { + $user = trim($this->getInput('user')); + if (preg_match('#^https?://parler\.com/(\w+)#i', $user, $m)) { + $user = $m[1]; + } + $json = getContents(sprintf('https://api.parler.com/v0/public/user/%s/feed/?page=1&limit=20&media_only=0', $user)); + $response = Json::decode($json, false); + $data = $response->data ?? null; + if (!$data) { + throw new \Exception('The returned data is empty'); + } + foreach ($data as $post) { + $item = [ + 'title' => $post->body, + 'uri' => sprintf('https://parler.com/feed/%s', $post->postuuid), + 'author' => $post->user->username, + 'uid' => $post->postuuid, + 'content' => $post->body, + ]; + $date = $post->date_created; + $createdAt = date_create($date); + if ($createdAt) { + $item['timestamp'] = $createdAt->getTimestamp(); + } + if (isset($post->image)) { + $item['content'] .= sprintf('', $post->image); + } + $this->items[] = $item; + } + } +} diff --git a/bridges/ParuVenduImmoBridge.php b/bridges/ParuVenduImmoBridge.php index 7b2825bcc02..87b6b25500f 100644 --- a/bridges/ParuVenduImmoBridge.php +++ b/bridges/ParuVenduImmoBridge.php @@ -1,102 +1,115 @@ array( - 'name' => 'Minimal surface m²', - 'type' => 'number' - ), - 'maxprice' => array( - 'name' => 'Max price', - 'type' => 'number' - ), - 'pa' => array( - 'name' => 'Country code', - 'exampleValue' => 'FR' - ), - 'lo' => array( - 'name' => 'department numbers or postal codes, comma-separated' - ) - )); - - public function collectData(){ - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Could not request paruvendu.'); - - foreach($html->find('div.annonce a') as $element) { - - if(!$element->title) { - continue; - } - - $img = ''; - foreach($element->find('span.img img') as $img) { - if($img->original) { - $img = ''; - } - } - - $desc = $element->find('span.desc')[0]->innertext; - $desc = str_replace("voir l'annonce", '', $desc); - - $price = $element->find('span.price')[0]->innertext; - - list($href) = explode('#', $element->href); - - $item = array(); - $item['uri'] = self::URI . $href; - $item['title'] = $element->title; - $item['content'] = $img . $desc . $price; - $this->items[] = $item; - } - } - - public function getURI(){ - $appartment = '&tbApp=1&tbDup=1&tbChb=1&tbLof=1&tbAtl=1&tbPla=1'; - $maison = '&tbMai=1&tbVil=1&tbCha=1&tbPro=1&tbHot=1&tbMou=1&tbFer=1'; - $link = self::URI - . '/immobilier/annonceimmofo/liste/listeAnnonces?tt=1' - . $appartment - . $maison; - - if($this->getInput('minarea')) { - $link .= '&sur0=' . urlencode($this->getInput('minarea')); - } - - if($this->getInput('maxprice')) { - $link .= '&px1=' . urlencode($this->getInput('maxprice')); - } - - if($this->getInput('pa')) { - $link .= '&pa=' . urlencode($this->getInput('pa')); - } - - if($this->getInput('lo')) { - $link .= '&lo=' . urlencode($this->getInput('lo')); - } - return $link; - } - - public function getName(){ - if(!is_null($this->getInput('minarea'))) { - $request = ''; - $minarea = $this->getInput('minarea'); - if(!empty($minarea)) { - $request .= ' ' . $minarea . ' m2'; - } - $location = $this->getInput('lo'); - if(!empty($location)) { - $request .= ' In: ' . $location; - } - return 'Paru Vendu Immobilier' . $request; - } - - return parent::getName(); - } + +class ParuVenduImmoBridge extends BridgeAbstract +{ + const MAINTAINER = 'polo2ro'; + const NAME = 'Paru Vendu Immobilier'; + const URI = 'https://www.paruvendu.fr'; + const CACHE_TIMEOUT = 10800; // 3h + const DESCRIPTION = 'Returns the ads from the first page of search result.'; + + const PARAMETERS = [ [ + 'minarea' => [ + 'name' => 'Minimal surface m²', + 'type' => 'number' + ], + 'maxprice' => [ + 'name' => 'Max price', + 'type' => 'number' + ], + 'pa' => [ + 'name' => 'Country code', + 'exampleValue' => 'FR' + ], + 'lo' => [ + 'name' => 'department numbers or postal codes, comma-separated' + ] + ]]; + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + + $elements = $html->find('#bloc_liste > div.ergov3-annonce a'); + + foreach ($elements as $element) { + if (!$element->title) { + continue; + } + + $img = ''; + foreach ($element->find('span.img img') as $img) { + if ($img->original) { + $img = ''; + } + } + + $description = $element->find('p', 0); + if ($description) { + $desc = str_replace("voir l'annonce", '', $description->innertext); + } else { + $desc = ''; + } + + $priceElement = $element->find('div.ergov3-priceannonce', 0); + if ($priceElement) { + $price = $priceElement->innertext; + } else { + $price = ''; + } + + [$href] = explode('#', $element->href); + + $item = []; + $item['uri'] = self::URI . $href; + $item['title'] = $element->title; + $item['content'] = $img . $desc . $price; + $this->items[] = $item; + } + } + + public function getURI() + { + $appartment = '&tbApp=1&tbDup=1&tbChb=1&tbLof=1&tbAtl=1&tbPla=1'; + $maison = '&tbMai=1&tbVil=1&tbCha=1&tbPro=1&tbHot=1&tbMou=1&tbFer=1'; + $link = self::URI + . '/immobilier/annonceimmofo/liste/listeAnnonces?tt=1' + . $appartment + . $maison; + + if ($this->getInput('minarea')) { + $link .= '&sur0=' . urlencode($this->getInput('minarea')); + } + + if ($this->getInput('maxprice')) { + $link .= '&px1=' . urlencode($this->getInput('maxprice')); + } + + if ($this->getInput('pa')) { + $link .= '&pa=' . urlencode($this->getInput('pa')); + } + + if ($this->getInput('lo')) { + $link .= '&lo=' . urlencode($this->getInput('lo')); + } + return $link; + } + + public function getName() + { + if (!is_null($this->getInput('minarea'))) { + $request = ''; + $minarea = $this->getInput('minarea'); + if (!empty($minarea)) { + $request .= ' ' . $minarea . ' m2'; + } + $location = $this->getInput('lo'); + if (!empty($location)) { + $request .= ' In: ' . $location; + } + return 'Paru Vendu Immobilier' . $request; + } + + return parent::getName(); + } } diff --git a/bridges/PatreonBridge.php b/bridges/PatreonBridge.php index 57727a3e97d..895a9306fa2 100644 --- a/bridges/PatreonBridge.php +++ b/bridges/PatreonBridge.php @@ -1,203 +1,337 @@ array( - 'name' => 'Creator', - 'type' => 'text', - 'required' => true, - 'title' => 'Creator name as seen in their page URL' - ) - )); - - public function collectData(){ - $html = getSimpleHTMLDOMCached($this->getURI(), 86400) - or returnServerError('Failed to load creator page at ' . $this->getURI()); - $regex = '#/api/campaigns/([0-9]+)#'; - if(preg_match($regex, $html->save(), $matches) > 0) { - $campaign_id = $matches[1]; - } else { - returnServerError('Could not find campaign ID'); - } - - $query = array( - 'include' => implode(',', array( - 'user', - 'attachments', - 'user_defined_tags', - //'campaign', - //'poll.choices', - //'poll.current_user_responses.user', - //'poll.current_user_responses.choice', - //'poll.current_user_responses.poll', - //'access_rules.tier.null', - //'images.null', - //'audio.null' - )), - 'fields' => array( - 'post' => implode(',', array( - //'change_visibility_at', - //'comment_count', - 'content', - //'current_user_can_delete', - //'current_user_can_view', - //'current_user_has_liked', - //'embed', - 'image', - //'is_paid', - //'like_count', - //'min_cents_pledged_to_view', - //'patreon_url', - //'patron_count', - //'pledge_url', - //'post_file', - //'post_metadata', - //'post_type', - 'published_at', - 'teaser_text', - //'thumbnail_url', - 'title', - //'upgrade_url', - 'url', - //'was_posted_by_campaign_owner' - )), - 'user' => implode(',', array( - //'image_url', - 'full_name', - //'url' - )) - ), - 'filter' => array( - 'contains_exclusive_posts' => true, - 'is_draft' => false, - 'campaign_id' => $campaign_id - ), - 'sort' => '-published_at' - ); - $posts = $this->apiGet('posts', $query); - - foreach($posts->data as $post) { - $item = array( - 'uri' => $post->attributes->url, - 'title' => $post->attributes->title, - 'timestamp' => $post->attributes->published_at, - 'content' => '', - 'uid' => 'patreon.com/' . $post->id - ); - - $user = $this->findInclude($posts, - 'user', - $post->relationships->user->data->id); - $item['author'] = $user->full_name; - - if(isset($post->attributes->image)) - $item['content'] .= ''; - - if(isset($post->attributes->content)) { - $item['content'] .= $post->attributes->content; - } elseif (isset($post->attributes->teaser_text)) { - $item['content'] .= '' - . $post->attributes->teaser_text - . '
'; - } - - if(isset($post->relationships->user_defined_tags)) { - $item['categories'] = array(); - foreach($post->relationships->user_defined_tags->data as $tag) { - $attrs = $this->findInclude($posts, 'post_tag', $tag->id); - $item['categories'][] = $attrs->value; - } - } - - if(isset($post->relationships->attachments)) { - $item['enclosures'] = array(); - foreach($post->relationships->attachments->data as $attachment) { - $attrs = $this->findInclude($posts, 'attachment', $attachment->id); - $item['enclosures'][] = $attrs->url; - } - } - - $this->items[] = $item; - } - } - - /* - * Searches the "included" array in an API response and returns attributes - * for the first match. - */ - private function findInclude($data, $type, $id) { - foreach($data->included as $include) - if($include->type === $type && $include->id === $id) - return $include->attributes; - } - - private function apiGet($endpoint, $query_data = array()) { - $query_data['json-api-version'] = 1.0; - $query_data['json-api-use-default-includes'] = 0; - - $url = 'https://www.patreon.com/api/' - . $endpoint - . '?' - . http_build_query($query_data); - - /* - * Accept-Language header and the CURL cipher list are for bypassing the - * Cloudflare anti-bot protection on the Patreon API. If this ever breaks, - * here are some other project that also deal with this: - * https://github.com/mikf/gallery-dl/issues/342 - * https://github.com/daemionfox/patreon-feed/issues/7 - * https://www.patreondevelopers.com/t/api-returning-cloudflare-challenge/2025 - * https://github.com/splitbrain/patreon-rss/issues/4 - */ - $header = array( - 'Accept-Language: en-US', - 'Content-Type: application/json' - ); - $opts = array( - CURLOPT_SSL_CIPHER_LIST => implode(':', array( - 'DEFAULT', - '!DHE-RSA-CHACHA20-POLY1305' - )) - ); - - $data = json_decode(getContents($url, $header, $opts)) - or returnServerError('API request to "' . $url . '" failed.'); - - return $data; - } - - public function getName(){ - if(!is_null($this->getInput('creator'))) - return $this->getInput('creator') . ' posts'; - - return parent::getName(); - } - - public function getURI(){ - if(!is_null($this->getInput('creator'))) - return self::URI . $this->getInput('creator'); - - return parent::getURI(); - } - - public function detectParameters($url){ - $params = array(); - - // Matches e.g. https://www.patreon.com/SomeCreator - $regex = '/^(https?:\/\/)?(www\.)?patreon\.com\/([^\/&?\n]+)/'; - if(preg_match($regex, $url, $matches) > 0) { - $params['creator'] = urldecode($matches[3]); - return $params; - } - - return null; - } + +class PatreonBridge extends BridgeAbstract +{ + const NAME = 'Patreon Bridge'; + const URI = 'https://www.patreon.com/'; + const CACHE_TIMEOUT = 300; // 5min + const DESCRIPTION = 'Returns posts by creators on Patreon'; + const MAINTAINER = 'Roliga, mruac'; + const PARAMETERS = [[ + 'creator' => [ + 'name' => 'Creator', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'user?u=13425451', + 'title' => 'Creator name as seen in their page URL' + ] + ]]; + + public function collectData() + { + $url = $this->getURI(); + $html = getSimpleHTMLDOMCached($url); + $regex = '#/api/campaigns/([0-9]+)#'; + if (preg_match($regex, $html->save(), $matches) > 0) { + $campaign_id = $matches[1]; + } else { + returnServerError('Could not find campaign ID'); + } + + $query = [ + 'include' => implode(',', [ + 'user', + 'attachments', + 'user_defined_tags', + //'campaign', + 'poll.choices', + //'poll.current_user_responses.user', + //'poll.current_user_responses.choice', + //'poll.current_user_responses.poll', + //'access_rules.tier.null', + 'images.null', + 'audio.null', + // 'user.null', + 'attachments.null', + 'audio_preview.null', + 'poll.choices.null' + // 'poll.current_user_responses.null' + ]), + 'fields' => [ + 'post' => implode(',', [ + //'change_visibility_at', + //'comment_count', + 'content', + //'current_user_can_delete', + //'current_user_can_view', + //'current_user_has_liked', + 'embed', + 'image', + //'is_paid', + //'like_count', + //'min_cents_pledged_to_view', + //'patreon_url', + //'patron_count', + //'pledge_url', + // 'post_file', + // 'post_metadata', + 'post_type', + 'published_at', + 'teaser_text', + //'thumbnail_url', + 'title', + //'upgrade_url', + 'url', + //'was_posted_by_campaign_owner' + // 'content_teaser_text', + // 'current_user_can_report', + 'thumbnail', + // 'video_preview' + ]), + 'user' => implode(',', [ + //'image_url', + 'full_name', + //'url' + ]), + 'media' => implode(',', [ + 'id', + 'image_urls', + 'download_url', + 'metadata', + 'file_name', + 'mimetype', + 'size_bytes' + ]) + ], + 'filter' => [ + 'contains_exclusive_posts' => true, + 'is_draft' => false, + 'campaign_id' => $campaign_id + ], + 'sort' => '-published_at' + ]; + $posts = $this->apiGet('posts', $query); + + foreach ($posts->data as $post) { + $item = [ + 'uri' => $post->attributes->url, + 'title' => $post->attributes->title, + 'timestamp' => $post->attributes->published_at, + 'content' => '', + 'uid' => 'patreon.com/' . $post->id + ]; + + $user = $this->findInclude( + $posts, + 'user', + $post->relationships->user->data->id + )->attributes; + $item['author'] = $user->full_name; + + //image, video, audio, link (featured post content) + switch ($post->attributes->post_type) { + case 'audio_file': + //check if download_url is null before assigning $audio + $id = $post->relationships->audio->data->id ?? null; + if (isset($id)) { + $audio = $this->findInclude($posts, 'media', $id)->attributes ?? null; + } + if (!isset($audio->download_url)) { //if not unlocked + $id = $post->relationships->audio_preview->data->id ?? null; + if (isset($id)) { + $audio = $this->findInclude($posts, 'media', $id)->attributes ?? null; + } + } + $thumbnail = $post->attributes->thumbnail->large ?? null; + $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null; + $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null; + $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null; + $audio_filename = $audio->file_name ?? $item['title']; + $download_url = $audio->download_url ?? $item['uri']; + $item['content'] .= "'; + break; + + case 'video_embed': + $thumbnail = $post->attributes->thumbnail->large ?? null; + $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null; + $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null; + $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null; + $item['content'] .= ""; + break; + + case 'video_external_file': + $thumbnail = $post->attributes->thumbnail->large ?? null; + $thumbnail = $thumbnail ?? $post->attributes->thumbnail->url ?? null; + $thumbnail = $thumbnail ?? $post->attributes->image->thumb_url ?? null; + $thumbnail = $thumbnail ?? $post->attributes->image->url ?? null; + $item['content'] .= ""; + break; + + case 'image_file': + $item['content'] .= '
🎧 {$audio_filename}
"; + if ($download_url !== $item['uri']) { + $item['enclosures'][] = $download_url; + $item['content'] .= ""; + } + $item['content'] .= ''; + foreach ($post->relationships->images->data as $key => $image) { + $image = $this->findInclude($posts, 'media', $image->id)->attributes; + $image_fullres = $image->download_url ?? $image->image_urls->url ?? $image->image_urls->original ?? null; + $filename = $image->file_name ?? ''; + $image_url = $image->image_urls->url ?? $image->image_urls->original ?? null; + $item['enclosures'][] = $image_fullres; + $item['content'] .= "{$filename}
'; + break; + + case 'link': + //make it locked safe + if (isset($post->attributes->embed)) { + $embed = $post->attributes->embed; + $thumbnail = $post->attributes->image->large_url ?? $post->attributes->image->thumb_url ?? $post->attributes->image->url; + $item['content'] .= '
"; + } + $item['content'] .= ''; + $item['content'] .= "
"; + $item['content'] .= " url}\"> "; + $item['content'] .= " {$embed->subject} "; + $item['content'] .= ' {$embed->description}
'; + } + break; + } + + //content of the post + if (isset($post->attributes->content)) { + $item['content'] .= $post->attributes->content; + } elseif (isset($post->attributes->teaser_text)) { + $item['content'] .= '' + . $post->attributes->teaser_text; + if (strlen($post->attributes->teaser_text) === 140) { + $item['content'] .= '…'; + } + $item['content'] .= '
'; + } + + //post tags + if (isset($post->relationships->user_defined_tags)) { + $item['categories'] = []; + foreach ($post->relationships->user_defined_tags->data as $tag) { + $attrs = $this->findInclude($posts, 'post_tag', $tag->id)->attributes; + $item['categories'][] = $attrs->value; + } + } + + //poll + if (isset($post->relationships->poll->data)) { + $poll = $this->findInclude($posts, 'poll', $post->relationships->poll->data->id); + $item['content'] .= "'; + } + + + //post attachments + if ( + isset($post->relationships->attachments->data) && + count($post->relationships->attachments->data) > 0 + ) { + $item['enclosures'] = []; + $item['content'] .= '
"; + foreach ($poll->relationships->choices->data as $key => $poll_option) { + $poll_option = $this->findInclude($posts, 'poll_choice', $poll_option->id); + $poll_option_text = $poll_option->attributes->text_content ?? null; + if (isset($poll_option_text)) { + $item['content'] .= " Poll: {$poll->attributes->question_text} "; + } + } + $item['content'] .= ' {$poll_option_text} Attachments:
'; + foreach ($post->relationships->attachments->data as $attachment) { + $attrs = $this->findInclude($posts, 'attachment', $attachment->id)->attributes; + $filename = $attrs->name; + $n = strrpos($filename, '.'); + $ext = ($n === false) ? '' : substr($filename, $n); + $item['enclosures'][] = $attrs->url . '#' . $ext; + $item['content'] .= '
'; + } + + $this->items[] = $item; + } + } + + /* + * Searches the "included" array in an API response and returns the result for the first match. + * A result will include attributes containing further details of the included object + * (e.g. an audio object), and an optional relationships object that links to more "included" + * objects. (e.g. a poll object with related poll_choice(s)) + */ + private function findInclude($data, $type, $id) + { + foreach ($data->included as $include) { + if ($include->type === $type && $include->id === $id) { + return $include; + } + } + } + + private function apiGet($endpoint, $query_data = []) + { + $query_data['json-api-version'] = 1.0; + $query_data['json-api-use-default-includes'] = 0; + + $url = 'https://www.patreon.com/api/' + . $endpoint + . '?' + . http_build_query($query_data); + + /* + * Accept-Language header and the CURL cipher list are for bypassing the + * Cloudflare anti-bot protection on the Patreon API. If this ever breaks, + * here are some other project that also deal with this: + * https://github.com/mikf/gallery-dl/issues/342 + * https://github.com/daemionfox/patreon-feed/issues/7 + * https://www.patreondevelopers.com/t/api-returning-cloudflare-challenge/2025 + * https://github.com/splitbrain/patreon-rss/issues/4 + */ + $header = [ + 'Accept-Language: en-US', + 'Content-Type: application/json' + ]; + $opts = [ + CURLOPT_SSL_CIPHER_LIST => implode(':', [ + 'DEFAULT', + '!DHE-RSA-CHACHA20-POLY1305' + ]) + ]; + + $data = json_decode(getContents($url, $header, $opts)); + + return $data; + } + + public function getName() + { + if (!is_null($this->getInput('creator'))) { + $html = getSimpleHTMLDOMCached($this->getURI()); + if ($html) { + preg_match('#"name": "(.*)"#', $html->save(), $matches); + return 'Patreon posts from ' . stripcslashes($matches[1]); + } else { + return $this->getInput('creator') . 'posts from Patreon'; + } + } + + return parent::getName(); + } + + public function getURI() + { + if (!is_null($this->getInput('creator'))) { + return self::URI . $this->getInput('creator'); + } + + return parent::getURI(); + } + + public function detectParameters($url) + { + $params = []; + + // Matches e.g. https://www.patreon.com/SomeCreator + $regex = '/^(https?:\/\/)?(www\.)?patreon\.com\/([^\/&?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + $params['creator'] = urldecode($matches[3]); + return $params; + } + + return null; + } } diff --git a/bridges/PcGamerBridge.php b/bridges/PcGamerBridge.php index e0e55ce41e3..bac01ee8830 100644 --- a/bridges/PcGamerBridge.php +++ b/bridges/PcGamerBridge.php @@ -1,23 +1,47 @@ self::LIMIT, + ] + ]; + + public function collectData() + { + $html = getSimpleHTMLDOMCached($this->getURI(), 300); + $stories = $html->find('a.article-link'); + $limit = $this->getInput('limit') ?? 10; + foreach (array_slice($stories, 0, $limit) as $element) { + $item = []; + $item['uri'] = $element->href; + $articleHtml = getSimpleHTMLDOMCached($item['uri']); + + // Relying on meta tags ought to be more reliable. + $item['title'] = $articleHtml->find('meta[name=parsely-title]', 0)->content; + $item['content'] = html_entity_decode($articleHtml->find('meta[name=description]', 0)->content); + $item['author'] = $articleHtml->find('meta[name=parsely-author]', 0)->content; + + $imageUrl = $articleHtml->find('meta[name=parsely-image-url]', 0); + if ($imageUrl) { + $item['enclosures'][] = $imageUrl->content; + } - public function collectData() - { - $html = getSimpleHTMLDOMCached($this->getURI(), 300); - $stories = $html->find('div#popularcontent li.most-popular-item'); - foreach ($stories as $element) { - $item['uri'] = $element->find('a', 0)->href; - $articleHtml = getSimpleHTMLDOMCached($item['uri']); - $item['title'] = $element->find('h4 a', 0)->plaintext; - $item['timestamp'] = strtotime($articleHtml->find('meta[name=pub_date]', 0)->content); - $item['content'] = $articleHtml->find('meta[name=description]', 0)->content; - $item['author'] = $articleHtml->find('a[itemprop=author]', 0)->plaintext; - $this->items[] = $item; - } - } + /* I don't know why every article has two extra tags, but because + one matches another common tag, "guide," it needs to be removed. */ + $item['categories'] = array_diff( + explode(',', $articleHtml->find('meta[name=parsely-tags]', 0)->content), + ['van_buying_guide_progressive', 'serversidehawk'] + ); + $item['timestamp'] = strtotime($articleHtml->find('meta[name=pub_date]', 0)->content); + $this->items[] = $item; + } + } } diff --git a/bridges/PepperBridgeAbstract.php b/bridges/PepperBridgeAbstract.php new file mode 100644 index 00000000000..43dbe82906e --- /dev/null +++ b/bridges/PepperBridgeAbstract.php @@ -0,0 +1,562 @@ +queriedContext) { + case $this->i8n('context-keyword'): + return $this->collectDataKeywords(); + break; + case $this->i8n('context-group'): + return $this->collectDataGroup(); + break; + case $this->i8n('context-talk'): + return $this->collectDataTalk(); + break; + } + } + + /** + * Get the Deal data from the choosen group in the choosed order + */ + protected function collectDataGroup() + { + $url = $this->getGroupURI(); + $this->collectDeals($url); + } + + /** + * Get the Deal data from the choosen keywords and parameters + */ + protected function collectDataKeywords() + { + /* Even if the original website uses POST with the search page, GET works too */ + $url = $this->getSearchURI(); + $this->collectDeals($url); + } + + /** + * Get the Deal data using the given URL + */ + protected function collectDeals($url) + { + $html = getSimpleHTMLDOM($url); + $list = $html->find('article[id][class*=thread--deal]]'); + + // Deal Description CSS Selector + $selectorDescription = implode( + ' ', /* Notice this is a space! */ + [ + 'overflow--wrap-break' + ] + ); + + // If there is no results, we don't parse the content because it display some random deals + $noresult = $html->find('div[id=content-list]', 0)->find('h2', 0); + if ($noresult !== null) { + $this->items = []; + } else { + foreach ($list as $deal) { + // Get the JSON Data stored as vue + $jsonDealData = $this->getDealJsonData($deal); + $dealMeta = Json::decode($deal->find('div[class=js-vue2]', 1)->getAttribute('data-vue2')); + + $item = []; + $item['uri'] = $this->getDealURI($jsonDealData); + $item['title'] = $this->getTitle($jsonDealData); + $item['author'] = $this->getDealAuthor($jsonDealData); + + $item['content'] = '- ' . $filename . '
'; + } + $item['content'] .= ''; + + $item['timestamp'] = $this->getPublishedDate($jsonDealData); + $this->items[] = $item; + } + } + } + + /** + * Get the Talk lastest comments + */ + protected function collectDataTalk() + { + $threadURL = $this->getInput('url'); + $onlyWithUrl = $this->getInput('only_with_url'); + + // Get Thread ID from url passed in parameter + $threadSearch = preg_match('/-([0-9]{1,20})$/', $threadURL, $matches); + + // Show an error message if we can't find the thread ID in the URL sent by the user + if ($threadSearch !== 1) { + returnClientError($this->i8n('thread-error')); + } + $threadID = $matches[1]; + + $url = $this->i8n('bridge-uri') . 'graphql'; + + // Get Cookies header to do the query + $cookiesHeaderValue = $this->getCookiesHeaderValue($url); + + // GraphQL String + // This was extracted from https://www.dealabs.com/assets/js/modern/common_211b99.js + // This string was extracted during a Website visit, and minified using this neat tool : + // https://codepen.io/dangodev/pen/Baoqmoy + $graphqlString = <<<'HEREDOC' +query comments($filter:CommentFilter!,$limit:Int,$page:Int){comments(filter:$filter,limit:$limit,page:$page){ +items{...commentFields}pagination{...paginationFields}}}fragment commentFields on Comment{commentId threadId url +preparedHtmlContent user{...userMediumAvatarFields...userNameFields...userPersonaFields bestBadge{...badgeFields}} +reactionCounts{type count}deletable currentUserReaction{type}reported reportable source status createdAt updatedAt +ignored popular deletedBy{username}notes{content createdAt user{username}}lastEdit{reason timeAgo userId}}fragment +userMediumAvatarFields on User{userId isDeletedOrPendingDeletion imageUrls(slot:"default",variations: +["user_small_avatar"])}fragment userNameFields on User{userId username isUserProfileHidden isDeletedOrPendingDeletion} +fragment userPersonaFields on User{persona{type text}}fragment badgeFields on Badge{badgeId level{...badgeLevelFields}} +fragment badgeLevelFields on BadgeLevel{key name description}fragment paginationFields on Pagination{count current last + next previous size order} +HEREDOC; + + // Construct the JSON object to send to the Website + $queryArray = [ + 'query' => $graphqlString, + 'variables' => [ + 'filter' => [ + 'threadId' => [ + 'eq' => $threadID, + ], + 'order' => [ + 'direction' => 'Descending', + ], + + ], + 'page' => 1, + ], + ]; + $queryJSON = json_encode($queryArray); + + // HTTP headers + $header = [ + 'Content-Type: application/json', + 'Accept: application/json, text/plain, */*', + 'X-Pepper-Txn: threads.show', + 'X-Request-Type: application/vnd.pepper.v1+json', + 'X-Requested-With: XMLHttpRequest', + "Cookie: $cookiesHeaderValue", + ]; + // CURL Options + $opts = [ + CURLOPT_POST => 1, + CURLOPT_POSTFIELDS => $queryJSON + ]; + $json = getContents($url, $header, $opts); + $objects = json_decode($json); + foreach ($objects->data->comments->items as $comment) { + $item = []; + $item['uri'] = $comment->url; + $item['title'] = $comment->user->username . ' - ' . $comment->createdAt; + $item['author'] = $comment->user->username; + $item['content'] = $comment->preparedHtmlContent; + $item['uid'] = $comment->commentId; + // Timestamp handling needs a new parsing function + if ($onlyWithUrl == true) { + // Only parse the comment if it is not empry + if ($item['content'] != '') { + // Count Links and Quote Links + $content = str_get_html($item['content']); + $countLinks = count($content->find('a[href]')); + $countQuoteLinks = count($content->find('a[href][class=userHtml-quote-source]')); + // Only add element if there are Links and more links tant Quote links + if ($countLinks > 0 && $countLinks > $countQuoteLinks) { + $this->items[] = $item; + } + } + } else { + $this->items[] = $item; + } + } + } + + private function getCookiesHeaderValue($url) + { + $response = getContents($url, [], [], true); + $setCookieHeaders = $response->getHeader('set-cookie', true); + $cookies = array_map(fn($c): string => explode(';', $c)[0], $setCookieHeaders); + + return implode('; ', $cookies); + } + + /** + * Check if the string $str contains any of the string of the array $arr + * @return boolean true if the string matched anything otherwise false + */ + private function contains($str, array $arr) + { + foreach ($arr as $a) { + if (stripos($str, $a) !== false) { + return true; + } + } + return false; + } + + /** + * Get the Price from a Deal if it exists + * @return string String of the deal price + */ + private function getPrice($jsonDealData) + { + if ($jsonDealData['props']['thread']['discountType'] == null) { + $price = $jsonDealData['props']['thread']['price']; + return '
' + . $this->getImage($deal) + . ' ' + . $this->getHTMLTitle($jsonDealData) + . $this->getPrice($jsonDealData) + . $this->getDiscount($jsonDealData) + . $this->getShipsFrom($dealMeta) + . $this->getShippingCost($jsonDealData) + . $this->getSource($jsonDealData) + . $this->getDealLocation($jsonDealData) + . $deal->find('div[class*=' . $selectorDescription . ']', 0)->innertext + . ' ' + . $this->getTemperature($jsonDealData) + . ' ' . $this->i8n('price') . ' : ' + . $price . ' ' . $this->i8n('currency') . ''; + } else { + return ''; + } + } + + /** + * Get the Publish Date from a Deal if it exists + * @return integer Timestamp of the published date of the deal + */ + private function getPublishedDate($jsonDealData) + { + return $jsonDealData['props']['thread']['publishedAt']; + } + + /** + * Get the Deal Author from a Deal if it exists + * @return String Author of the deal + */ + private function getDealAuthor($jsonDealData) + { + return $jsonDealData['props']['thread']['user']['username']; + } + + /** + * Get the Title from a Deal if it exists + * @return string String of the deal title + */ + private function getTitle($jsonDealData) + { + $title = $jsonDealData['props']['thread']['title']; + return $title; + } + + /** + * Get the Title from a Talk if it exists + * @return string String of the Talk title + */ + private function getTalkTitle() + { + $cacheKey = $this->getInput('url') . 'TITLE'; + $title = $this->loadCacheValue($cacheKey); + // The cache does not contain the title of the bridge, we must get it and save it in the cache + if ($title === null) { + $html = getSimpleHTMLDOMCached($this->getInput('url')); + $title = $html->find('title', 0)->plaintext; + // Save the value in the cache for the next 15 days + $this->saveCacheValue($cacheKey, $title, 86400 * 15); + } + return $title; + } + + /** + * Get the Title from a Group if it exists + * @return string String of the Talk title + */ + private function getGroupTitle() + { + $cacheKey = $this->getInput('group') . 'TITLE'; + $title = $this->loadCacheValue($cacheKey); + // The cache does not contain the title of the bridge, we must get it and save it in the cache + if ($title == null) { + $html = getSimpleHTMLDOMCached($this->getGroupURI()); + // Search the title in the javascript mess + preg_match('/threadGroupName":"([^"]*)","threadGroupUrlName":"' . $this->getInput('group') . '"/m', $html, $matches); + $title = $matches[1]; + // Save the value in the cache for the next 15 days + $this->saveCacheValue($cacheKey, $title, 86400 * 15); + } + + $order = $this->getKey('order'); + return $title . ' - ' . $order; + } + + /** + * Get the HTML Title code from an item + * @return string String of the deal title + */ + private function getHTMLTitle($jsonDealData) + { + $html = '' + . $this->getTitle($jsonDealData) . '
'; + + return $html; + } + + /** + * Get the URI from a Deal if it exists + * @return string String of the deal URI + */ + private function getDealURI($jsonDealData) + { + $dealSlug = $jsonDealData['props']['thread']['titleSlug']; + $dealId = $jsonDealData['props']['thread']['threadId']; + $uri = $this->i8n('bridge-uri') . $this->i8n('uri-deal') . $dealSlug . '-' . $dealId; + return $uri; + } + + /** + * Get the Shipping costs from a Deal if it exists + * @return string String of the deal shipping Cost + */ + private function getShippingCost($jsonDealData) + { + $isFree = $jsonDealData['props']['thread']['shipping']['isFree']; + $price = $jsonDealData['props']['thread']['shipping']['price']; + if ($isFree !== null) { + return '' . $this->i8n('shipping') . ' : ' + . $price . ' ' . $this->i8n('currency') + . ''; + } else { + return ''; + } + } + + /** + * Get the temperature from a Deal if it exists + * @return string String of the deal temperature + */ + private function getTemperature($data) + { + return $data['props']['thread']['temperature'] . '°'; + } + + + /** + * Get the Deal data from the "data-vue2" JSON attribute + * @return array Array containg the deal properties contained in the "data-vue2" attribute + */ + private function getDealJsonData($deal) + { + $data = Json::decode($deal->find('div[class=js-vue2]', 0)->getAttribute('data-vue2')); + return $data; + } + + /** + * Get the source of a Deal if it exists + * @return string String of the deal source + */ + private function getSource($jsonData) + { + if ($jsonData['props']['thread']['merchant'] != null) { + $path = $this->i8n('uri-merchant') . $jsonData['props']['thread']['merchant']['merchantId']; + $text = $jsonData['props']['thread']['merchant']['merchantName']; + return '' . $this->i8n('origin') . ' : ' . $text . ''; + } else { + return ''; + } + } + + /** + * Get the original Price and discout from a Deal if it exists + * @return string String of the deal original price and discount + */ + private function getDiscount($jsonDealData) + { + $oldPrice = $jsonDealData['props']['thread']['nextBestPrice']; + $newPrice = $jsonDealData['props']['thread']['price']; + $percentage = $jsonDealData['props']['thread']['percentage']; + + if ($oldPrice != 0) { + // If there is no percentage calculated, then calculate it manually + if ($percentage == 0) { + $percentage = round(100 - ($newPrice * 100 / $oldPrice), 2); + } + return '' . $this->i8n('discount') . ' : ' + . $oldPrice . ' ' . $this->i8n('currency') + . ' -' + . $percentage + . ' %'; + } else { + return ''; + } + } + + /** + * Get the Deal location if it exists + * @return string String of the deal location + */ + private function getDealLocation($jsonDealData) + { + if ($jsonDealData['props']['thread']['isLocal']) { + $content = '' . $this->i8n('deal-type') . ' : ' . $this->i8n('localdeal') . ''; + } else { + $content = ''; + } + return $content; + } + + /** + * Get the Picture URL from a Deal if it exists + * @return string String of the deal Picture URL + */ + private function getImage($deal) + { + // Get thread Image JSON content + $content = Json::decode($deal->find('div[class=js-vue2]', 0)->getAttribute('data-vue2')); + //return ''; + return ''; + } + + /** + * Get the originating country from a Deal if it exists + * @return string String of the deal originating country + */ + private function getShipsFrom($dealMeta) + { + $metas = $dealMeta['props']['metaRibbons'] ?? []; + $shipsFrom = null; + foreach ($metas as $meta) { + if ($meta['type'] == 'dispatched-from') { + $shipsFrom = $meta['text']; + } + } + if ($shipsFrom != null) { + return '' . $shipsFrom . ''; + } + return ''; + } + + /** + * Returns the RSS Feed title according to the parameters + * @return string the RSS feed Tiyle + */ + public function getName() + { + switch ($this->queriedContext) { + case $this->i8n('context-keyword'): + return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-keyword') . ' : ' . $this->getInput('q'); + break; + case $this->i8n('context-group'): + return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-group') . ' : ' . $this->getGroupTitle(); + break; + case $this->i8n('context-talk'): + return $this->i8n('bridge-name') . ' - ' . $this->i8n('title-talk') . ' : ' . $this->getTalkTitle(); + break; + default: // Return default value + return static::NAME; + } + } + + /** + * Returns the RSS Feed URI according to the parameters + * @return string the RSS feed Title + */ + public function getURI() + { + switch ($this->queriedContext) { + case $this->i8n('context-keyword'): + return $this->getSearchURI(); + break; + case $this->i8n('context-group'): + return $this->getGroupURI(); + break; + case $this->i8n('context-talk'): + return $this->getTalkURI(); + break; + default: // Return default value + return static::URI; + } + } + + /** + * Returns the RSS Feed URI for a keyword Feed + * @return string the RSS feed URI + */ + private function getSearchURI() + { + $q = $this->getInput('q'); + $hide_expired = $this->getInput('hide_expired'); + $hide_local = $this->getInput('hide_local'); + $priceFrom = $this->getInput('priceFrom'); + $priceTo = $this->getInput('priceTo'); + $url = $this->i8n('bridge-uri') + . 'search?q=' + . urlencode($q) + . '&hide_expired=' . $hide_expired + . '&hide_local=' . $hide_local + . '&priceFrom=' . $priceFrom + . '&priceTo=' . $priceTo + /* Some default parameters + * search_fields : Search in Titres & Descriptions & Codes + * sort_by : Sort the search by new deals + * time_frame : Search will not be on a limited timeframe + */ + . '&search_fields[]=1&search_fields[]=2&search_fields[]=3&sort_by=new&time_frame=0'; + return $url; + } + + /** + * Returns the RSS Feed URI for a group Feed + * @return string the RSS feed URI + */ + private function getGroupURI() + { + $group = $this->getInput('group'); + $order = $this->getInput('order'); + $subgroups = $this->getInput('subgroups'); + + // This permit to keep the existing Feed to work + if ($order == $this->i8n('context-hot')) { + $sortBy = 'temp'; + } else if ($order == $this->i8n('context-new')) { + $sortBy = 'new'; + } + + $url = $this->i8n('bridge-uri') + . $this->i8n('uri-group') . $group . '?sortBy=' . $sortBy . '&groups=' . $subgroups; + return $url; + } + + /** + * Returns the RSS Feed URI for a Talk Feed + * @return string the RSS feed URI + */ + private function getTalkURI() + { + $url = $this->getInput('url'); + return $url; + } + + /** + * This is some "localisation" function that returns the needed content using + * the "$lang" class variable in the local class + * @return various the local content needed + */ + protected function i8n($key) + { + if (array_key_exists($key, $this->lang)) { + return $this->lang[$key]; + } else { + return null; + } + } +} diff --git a/bridges/PhoronixBridge.php b/bridges/PhoronixBridge.php new file mode 100644 index 00000000000..227685e0bb0 --- /dev/null +++ b/bridges/PhoronixBridge.php @@ -0,0 +1,70 @@ + [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => false, + 'title' => 'Maximum number of items to return', + 'defaultValue' => 10 + ], + 'svgAsImg' => [ + 'name' => 'SVG in "image" tag', + 'type' => 'checkbox', + 'title' => 'Some benchmarks are exported as SVG with "object" tag, +but some RSS readers don\'t support this. "img" tag are supported by most browsers', + 'defaultValue' => false + ], + ]]; + + public function collectData() + { + $this->collectExpandableDatas('https://www.phoronix.com/rss.php', $this->getInput('n')); + } + + protected function parseItem(array $item) + { + $itemUrl = $item['uri']; + + $articlePage = getSimpleHTMLDOM($itemUrl); + $articlePage = defaultLinkTo($articlePage, $this->getURI()); + // Extract final link. From Facebook's like plugin. + $parsedUrlQuery = parse_url($articlePage->find('iframe[src^=//www.facebook.com/plugins]', 0), PHP_URL_QUERY); + parse_str($parsedUrlQuery, $facebookQuery); + if (array_key_exists('href', $facebookQuery)) { + $itemUrl = $facebookQuery['href']; + } + $item['content'] = $this->extractContent($articlePage); + + $pages = $articlePage->find('.pagination a[!title]'); + foreach ($pages as $page) { + $pageURI = urljoin($itemUrl, html_entity_decode($page->href)); + $page = getSimpleHTMLDOM($pageURI); + $item['content'] .= $this->extractContent($page); + } + return $item; + } + + private function extractContent($page) + { + $content = $page->find('.content', 0); + $objects = $content->find('script[src^=//openbenchmarking.org]'); + foreach ($objects as $object) { + $objectSrc = preg_replace('/p=0/', 'p=2', $object->src); + if ($this->getInput('svgAsImg')) { + $object->outertext = ''; + } else { + $object->outertext = ''; + } + } + $content = stripWithDelimiters($content, ''); + // Now try expanding article + $article_url = $element->find('a.story-link', 0)->href; + $article_html = getSimpleHTMLDOMCached($article_url); + if ($article_html) { + // Content (expanded and cleaned) + $article_body = $article_html->find('div.articlebody', 0); + if ($article_body) { + $article_body = convertLazyLoading($article_body); + $article_body = defaultLinkTo($article_body, $article_url); + $header_img = $article_body->find('img', 0); + if ($header_img) { + $header_img->parent->style = ''; + } + foreach ($article_body->find('center.cf') as $center_ad) { + $center_ad->outertext = ''; + } + $article_content = $article_body->innertext; + } + // Author + $spans_author = $article_html->find('span.author'); + if (count($spans_author) > 0) { + $article_author = $spans_author[array_key_last($spans_author)]->plaintext; + } + } - //Date with time - if (is_object($article->find('meta[itemprop=dateModified]', 0))) { - $article_timestamp = strtotime( - extractFromDelimiters( - $article->find('meta[itemprop=dateModified]', 0)->outertext, - "content='", - "'" - ) - ); - } - } else { - $contents = 'Could not request TheHackerNews: ' . $article_url; - } - - $item = array(); - $item['uri'] = $article_url; - $item['title'] = $article_title; - $item['author'] = $article_author; - $item['enclosures'] = $article_thumbnail; - $item['timestamp'] = $article_timestamp; - $item['content'] = trim($contents); - $this->items[] = $item; - $limit++; - } - } - - } + $item = []; + $item['uri'] = $article_url; + $item['title'] = $article_title; + if (!empty($article_author)) { + $item['author'] = $article_author; + } + $item['enclosures'] = $article_thumbnail; + $item['timestamp'] = $article_timestamp; + $item['content'] = trim($article_content); + $this->items[] = $item; + $limit++; + } + } } diff --git a/bridges/ThePirateBayBridge.php b/bridges/ThePirateBayBridge.php index 4b45daf3f5b..fd4a31ac6e5 100644 --- a/bridges/ThePirateBayBridge.php +++ b/bridges/ThePirateBayBridge.php @@ -1,177 +1,331 @@ array( - 'name' => 'keywords/username/category, separated by semicolons', - 'exampleValue' => 'first list;second list;…', - 'required' => true - ), - 'crit' => array( - 'type' => 'list', - 'name' => 'Search type', - 'values' => array( - 'search' => 'search', - 'category' => 'cat', - 'user' => 'usr' - ) - ), - 'catCheck' => array( - 'type' => 'checkbox', - 'name' => 'Specify category for keyword search ?', - ), - 'cat' => array( - 'name' => 'Category number', - 'exampleValue' => '100, 200… See TPB for category number' - ), - 'trusted' => array( - 'type' => 'checkbox', - 'name' => 'Only get results from Trusted or VIP users ?', - ), - )); - - public function collectData(){ - - $catBool = $this->getInput('catCheck'); - if($catBool) { - $catNum = $this->getInput('cat'); - } - $critList = $this->getInput('crit'); - - $trustedBool = $this->getInput('trusted'); - $keywordsList = explode(';', $this->getInput('q')); - foreach($keywordsList as $keywords) { - switch($critList) { - case 'search': - if($catBool == false) { - $html = getSimpleHTMLDOM( - self::URI . - 'search/' . - rawurlencode($keywords) . - '/0/3/0' - ) or returnServerError('Could not request TPB.'); - } else { - $html = getSimpleHTMLDOM( - self::URI . - 'search/' . - rawurlencode($keywords) . - '/0/3/' . - rawurlencode($catNum) - ) or returnServerError('Could not request TPB.'); - } - break; - case 'cat': - $html = getSimpleHTMLDOM( - self::URI . - 'browse/' . - rawurlencode($keywords) . - '/0/3/0' - ) or returnServerError('Could not request TPB.'); - break; - case 'usr': - $html = getSimpleHTMLDOM( - self::URI . - 'user/' . - rawurlencode($keywords) . - '/0/3/0' - ) or returnServerError('Could not request TPB.'); - break; - } - - if ($html->find('table#searchResult', 0) == false) - returnServerError('No result for query ' . $keywords); - - foreach($html->find('tr') as $element) { - - if(!$trustedBool - || !is_null($element->find('img[alt=VIP]', 0)) - || !is_null($element->find('img[alt=Trusted]', 0))) { - $item = array(); - $item['uri'] = self::URI . $element->find('a.detLink', 0)->href; - $item['id'] = self::URI . $element->find('a.detLink', 0)->href; - $item['timestamp'] = $this->parseDateTimestamp($element); - $item['author'] = $element->find('a.detDesc', 0)->plaintext; - $item['title'] = $element->find('a.detLink', 0)->plaintext; - $item['magnet'] = $element->find('a', 3)->href; - $item['seeders'] = (int)$element->find('td', 2)->plaintext; - $item['leechers'] = (int)$element->find('td', 3)->plaintext; - $item['content'] = $element->find('font', 0)->plaintext - . '
seeders: ' - . $item['seeders'] - . ' | leechers: ' - . $item['leechers'] - . '
info page
magnet link'; - - if(isset($item['title'])) - $this->items[] = $item; - } - } - } - } - - private function parseDateTimestamp($element){ - $guessedDate = $element->find('font', 0)->plaintext; - $guessedDate = explode('Uploaded ', $guessedDate)[1]; - $guessedDate = explode(',', $guessedDate)[0]; - - if(count(explode(':', $guessedDate)) == 1) { - $guessedDate = strptime($guessedDate, '%m-%d %Y'); - $timestamp = mktime( - 0, - 0, - 0, - $guessedDate['tm_mon'] + 1, - $guessedDate['tm_mday'], - 1900 + $guessedDate['tm_year'] - ); - } elseif(explode(' ', $guessedDate)[0] == 'Today') { - $guessedDate = strptime( - explode(' ', $guessedDate)[1], '%H:%M' - ); - - $timestamp = mktime( - $guessedDate['tm_hour'], - $guessedDate['tm_min'], - 0, - date('m'), - date('d'), - date('Y') - ); - } elseif(explode(' ', $guessedDate)[0] == 'Y-day') { - $guessedDate = strptime( - explode(' ', $guessedDate)[1], '%H:%M' - ); - - $timestamp = mktime( - $guessedDate['tm_hour'], - $guessedDate['tm_min'], - 0, - date('m', time() - 24 * 60 * 60), - date('d', time() - 24 * 60 * 60), - date('Y', time() - 24 * 60 * 60) - ); - } else { - $guessedDate = strptime($guessedDate, '%m-%d %H:%M'); - $timestamp = mktime( - $guessedDate['tm_hour'], - $guessedDate['tm_min'], - 0, - $guessedDate['tm_mon'] + 1, - $guessedDate['tm_mday'], - date('Y')); - } - return $timestamp; - } + const PARAMETERS = [ [ + 'q' => [ + 'name' => 'keywords/username/category, separated by semicolons', + 'exampleValue' => 'simpsons', + 'required' => true + ], + 'crit' => [ + 'type' => 'list', + 'name' => 'Search type', + 'values' => [ + 'search' => 'search', + 'category' => 'cat', + 'user' => 'usr', + ] + ], + 'catCheck' => [ + 'type' => 'checkbox', + 'name' => 'Specify category for keyword search ?', + ], + 'cat' => [ + 'name' => 'Category number', + 'exampleValue' => '100, 200… See TPB for category number' + ], + 'trusted' => [ + 'type' => 'checkbox', + 'name' => 'Only get results from Trusted or VIP users ?', + ], + ]]; + + const STATIC_SERVER = 'https://torrindex.net'; + + const CATEGORIES = [ + '1' => 'Audio', + '2' => 'Video', + '3' => 'Applications', + '4' => 'Games', + '5' => 'Porn', + '6' => 'Other', + '101' => 'Music', + '102' => 'Audio Books', + '103' => 'Sound clips', + '104' => 'FLAC', + '199' => 'Other', + '201' => 'Movies', + '202' => 'Movies DVDR', + '203' => 'Music videos', + '204' => 'Movie Clips', + '205' => 'TV-Shows', + '206' => 'Handheld', + '207' => 'HD Movies', + '208' => 'HD TV-Shows', + '209' => '3D', + '210' => 'CAM/TS', + '211' => 'UHD/4k Movies', + '212' => 'UHD/4k TV-Shows', + '299' => 'Other', + '301' => 'Windows', + '302' => 'Mac/Apple', + '303' => 'UNIX', + '304' => 'Handheld', + '305' => 'IOS(iPad/iPhone)', + '306' => 'Android', + '399' => 'Other OS', + '401' => 'PC', + '402' => 'Mac/Apple', + '403' => 'PSx', + '404' => 'XBOX360', + '405' => 'Wii', + '406' => 'Handheld', + '407' => 'IOS(iPad/iPhone)', + '408' => 'Android', + '499' => 'Other OS', + '501' => 'Movies', + '502' => 'Movies DVDR', + '503' => 'Pictures', + '504' => 'Games', + '505' => 'HD-Movies', + '506' => 'Movie Clips', + '507' => 'UHD/4k Movies', + '599' => 'Other', + '601' => 'E-books', + '602' => 'Comics', + '603' => 'Pictures', + '604' => 'Covers', + '605' => 'Physibles', + '699' => 'Other', + ]; + + public function collectData() + { + $keywords = explode(';', $this->getInput('q')); + + foreach ($keywords as $keyword) { + $this->processKeyword($keyword); + } + } + + private function processKeyword($keyword) + { + $keyword = trim($keyword); + switch ($this->getInput('crit')) { + case 'search': + $catCheck = $this->getInput('catCheck'); + if ($catCheck) { + $categories = $this->getInput('cat'); + $query = sprintf( + '/q.php?q=%s&cat=%s', + rawurlencode($keyword), + rawurlencode($categories) + ); + } else { + $query = sprintf('/q.php?q=%s', rawurlencode($keyword)); + } + break; + case 'cat': + $query = sprintf('/q.php?q=category:%s', rawurlencode($keyword)); + break; + case 'usr': + $query = sprintf('/q.php?q=user:%s', rawurlencode($keyword)); + break; + default: + returnClientError('Impossible'); + } + $api = 'https://apibay.org'; + $json = getContents($api . $query); + $result = json_decode($json); + + if ($result[0]->name === 'No results returned') { + return; + } + foreach ($result as $torrent) { + // This is the check for whether to include results from Trusted or VIP users + if ( + $this->getInput('trusted') + && !in_array($torrent->status, ['vip', 'trusted']) + ) { + continue; + } + $this->processTorrent($torrent); + } + } + + private function processTorrent($torrent) + { + // Extracted these trackers from the magnet links on thepiratebay.org + $trackers = [ + 'udp://tracker.coppersurfer.tk:6969/announce', + 'udp://tracker.openbittorrent.com:6969/announce', + 'udp://9.rarbg.to:2710/announce', + 'udp://9.rarbg.me:2780/announce', + 'udp://9.rarbg.to:2730/announce', + 'udp://tracker.opentrackr.org:1337', + 'http://p4p.arenabg.com:1337/announce', + 'udp://tracker.torrent.eu.org:451/announce', + 'udp://tracker.tiny-vps.com:6969/announce', + 'udp://open.stealth.si:80/announce', + ]; + + $magnetLink = sprintf( + 'magnet:?xt=urn:btih:%s&dn=%s', + $torrent->info_hash, + rawurlencode($torrent->name) + ); + foreach ($trackers as $tracker) { + // Build magnet link manually instead of using http_build_query because it + // creates undesirable query such as ?tr[0]=foo&tr[1]=bar&tr[2]=baz + $magnetLink .= '&tr=' . rawurlencode($tracker); + } + + $item = []; + + $item['title'] = $torrent->name; + // This uri should be a magnet link so that feed readers can easily pick it up. + // However, rss-bridge only allows http or https schemes + $item['uri'] = sprintf('%s/description.php?id=%s', self::URI, $torrent->id); + $item['timestamp'] = $torrent->added; + $item['author'] = $torrent->username; + + $content = 'Type: ' + . $this->renderCategory($torrent->category) . '
'; + $content .= "Files: $torrent->num_files
"; + $content .= 'Size: ' . $this->renderSize($torrent->size) . '
'; + + $content .= 'Uploaded: ' + . $this->renderUploadDate($torrent->added) . '
'; + $content .= 'By: ' . $this->renderUser($torrent) . '
'; + + $content .= "Seeders: {$torrent->seeders}
"; + $content .= "Leechers: {$torrent->leechers}
"; + $content .= "Info hash: {$torrent->info_hash}
"; + + if ($torrent->imdb) { + $content .= 'Imdb: ' + . $this->renderImdbLink($torrent->imdb) . '
'; + } + + $html = << + GET THIS TORRENT + +
+HTML; + $content .= sprintf($html, $magnetLink, self::STATIC_SERVER); + + $item['content'] = $content; + + $this->items[] = $item; + } + + private function renderSize($size) + { + if ($size < 1024) { + return $size . ' B'; + } + if ($size < pow(1024, 2)) { + return round($size / 1024, 2) . ' KB'; + } + if ($size < pow(1024, 3)) { + return round($size / pow(1024, 2), 2) . ' MB'; + } + if ($size < pow(1024, 4)) { + return round($size / pow(1024, 3), 2) . ' GB'; + } + + return round($size / pow(1024, 4), 2) . ' TB'; + } + + private function renderUploadDate($added) + { + return date('Y-m-d', $added ?: time()); + } + + private function renderCategory($category) + { + $mainCategory = sprintf( + '%s', + self::URI, + $category[0] . '00', + self::CATEGORIES[$category[0]] + ); + + $subCategory = sprintf( + '%s', + self::URI, + $category, + self::CATEGORIES[$category] + ); + + return sprintf('%s > %s', $mainCategory, $subCategory); + } + + private function renderUser($torrent) + { + if ($torrent->username === 'Anonymous') { + return $torrent->username . ' ' . $this->renderStatusImage($torrent->status); + } + return sprintf( + '%s %s', + self::URI, + $torrent->username, + $torrent->username, + $this->renderStatusImage($torrent->status) + ); + } + + private function renderStatusImage($status) + { + if ($status == 'trusted') { + return sprintf( + '', + self::STATIC_SERVER + ); + } + if ($status == 'vip') { + return sprintf( + '', + self::STATIC_SERVER + ); + } + if ($status == 'helper') { + return sprintf( + '', + self::STATIC_SERVER + ); + } + if ($status == 'moderator') { + return sprintf( + '', + self::STATIC_SERVER + ); + } + if ($status == 'supermod') { + return sprintf( + '', + self::STATIC_SERVER + ); + } + if ($status == 'admin') { + return sprintf( + '', + self::STATIC_SERVER + ); + } + + return ''; + } + + private function renderImdbLink($imdb) + { + return sprintf( + '%s', + "https://www.imdb.com/title/$imdb", + "https://www.imdb.com/title/$imdb" + ); + } } diff --git a/bridges/TheRedHandFilesBridge.php b/bridges/TheRedHandFilesBridge.php new file mode 100644 index 00000000000..ec74af807a9 --- /dev/null +++ b/bridges/TheRedHandFilesBridge.php @@ -0,0 +1,29 @@ +getURI()); + + foreach ($html->find('#main article.posts__article') as $element) { + $item = []; + + $html_title = $element->find('h2', 0); + $html_subtitle = $element->find('h3', 0); + $html_image = $element->find('.posts__article-img', 0); + + $item['title'] = $html_subtitle->plaintext; + $item['uri'] = $html_title->find('a', 0)->href; + $item['content'] = $html_image->innertext . $html_title->plaintext; + + $this->items[] = $item; + } + } +} diff --git a/bridges/TheTVDBBridge.php b/bridges/TheTVDBBridge.php deleted file mode 100644 index 38b45a8b439..00000000000 --- a/bridges/TheTVDBBridge.php +++ /dev/null @@ -1,209 +0,0 @@ - array( - 'type' => 'number', - 'name' => 'ID', - 'required' => true, - ), - 'nb_episode' => array( - 'type' => 'number', - 'name' => 'Number of episodes', - 'defaultValue' => 10, - 'required' => true, - ), - ) - ); - const APIACCOUNT = 'RSSBridge'; - const APIKEY = '76DE1887EA401C9A'; - const APIUSERKEY = 'B52869AC6005330F'; - - private function getApiUri(){ - return self::APIURI; - } - - private function getToken(){ - //login and get token, don't use curlJob to do less adaptations - $login_array = array( - 'apikey' => self::APIKEY, - 'username' => self::APIACCOUNT, - 'userkey' => self::APIUSERKEY - ); - - $login_json = json_encode($login_array); - $ch = curl_init($this->getApiUri() . 'login'); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); - curl_setopt($ch, CURLOPT_POSTFIELDS, $login_json); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, array( - 'Content-Type: application/json', - 'Accept: application/json' - ) - ); - - curl_setopt($ch, CURLOPT_TIMEOUT, 5); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - $result = curl_exec($ch); - curl_close($ch); - $token_json = (array)json_decode($result); - if(isset($token_json['Error'])) { - throw new Exception($token_json['Error']); - die; - } - $token = $token_json['token']; - return $token; - } - - private function curlJob($token, $url){ - $token_header = 'Authorization: Bearer ' . $token; - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, array( - 'Accept: application/json', - $token_header - ) - ); - curl_setopt($ch, CURLOPT_TIMEOUT, 5); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); - $result = curl_exec($ch); - curl_close($ch); - $result_array = (array)json_decode($result); - if(isset($result_array['Error'])) { - throw new Exception($result_array['Error']); - die; - } - return $result_array; - } - - private function getLatestSeasonNumber($token, $serie_id){ - // get the last season - $url = $this->getApiUri() . 'series/' . $serie_id . '/episodes/summary'; - $summary = $this->curlJob($token, $url); - return max($summary['data']->airedSeasons); - } - - private function getSerieName($token, $serie_id){ - $url = $this->getApiUri() . 'series/' . $serie_id; - $serie = $this->curlJob($token, $url); - return $serie['data']->seriesName; - } - - private function getSeasonEpisodes($token, - $serie_id, - $season, - $seriename, - &$episodelist, - $nbepisodemin, - $page = 1){ - $url = $this->getApiUri() - . 'series/' - . $serie_id - . '/episodes/query?airedSeason=' - . $season - . '?page=' - . $page; - - $episodes = $this->curlJob($token, $url); - // we don't check the number of page because we assume there is less - //than 100 episodes in every season - $episodes = (array)$episodes['data']; - $episodes = array_slice($episodes, -$nbepisodemin, $nbepisodemin); - foreach($episodes as $episode) { - $episodedata = array(); - $episodedata['uri'] = $this->getURI() - . '?tab=episode&seriesid=' - . $serie_id - . '&seasonid=' - . $episode->airedSeasonID - . '&id=' - . $episode->id; - - // check if the absoluteNumber exist - if(isset($episode->absoluteNumber)) { - $episodedata['title'] = 'S' - . $episode->airedSeason - . 'E' - . $episode->airedEpisodeNumber - . '(' - . $episode->absoluteNumber - . ') : ' - . $episode->episodeName; - } else { - $episodedata['title'] = 'S' - . $episode->airedSeason - . 'E' - . $episode->airedEpisodeNumber - . ' : ' - . $episode->episodeName; - } - $episodedata['author'] = $seriename; - $date = DateTime::createFromFormat( - 'Y-m-d H:i:s', - $episode->firstAired . ' 00:00:00' - ); - - $episodedata['timestamp'] = $date->getTimestamp(); - $episodedata['content'] = $episode->overview; - $episodelist[] = $episodedata; - } - } - - public function getIcon() { - return self::URI . 'application/themes/thetvdb/images/logo.png'; - } - - public function collectData(){ - $serie_id = $this->getInput('serie_id'); - $nbepisode = $this->getInput('nb_episode'); - $episodelist = array(); - $token = $this->getToken(); - $maxseason = $this->getLatestSeasonNumber($token, $serie_id); - $seriename = $this->getSerieName($token, $serie_id); - $season = $maxseason; - while(sizeof($episodelist) < $nbepisode && $season >= 1) { - $nbepisodetmp = $nbepisode - sizeof($episodelist); - $this->getSeasonEpisodes( - $token, - $serie_id, - $season, - $seriename, - $episodelist, - $nbepisodetmp - ); - - $season = $season - 1; - } - // add the 10 last specials episodes - try { // catch to avoid error if empty - $this->getSeasonEpisodes( - $token, - $serie_id, - 0, - $seriename, - $episodelist, - $nbepisode - ); - } catch(Exception $e) { - unset($e); - } - // sort and keep the 10 last episodes, works bad with the netflix serie - // (all episode lauch at once) - usort( - $episodelist, - function ($a, $b){ - return $a['timestamp'] < $b['timestamp']; - } - ); - $this->items = array_slice($episodelist, 0, $nbepisode); - } -} diff --git a/bridges/TheWhiteboardBridge.php b/bridges/TheWhiteboardBridge.php index 051d15e4a06..c36cc5f602d 100644 --- a/bridges/TheWhiteboardBridge.php +++ b/bridges/TheWhiteboardBridge.php @@ -1,22 +1,25 @@ find('center', 1)->find('img', 0); - $image->src = self::URI . '/' . $image->src; + $html = getSimpleHTMLDOM(self::URI); - $item['title'] = explode("\r\n", $html->find('center', 1)->plaintext)[0]; - $item['content'] = $image; - $item['timestamp'] = explode("\r\n", $html->find('center', 1)->plaintext)[0]; + $image = $html->find('center', 1)->find('img', 0); + $image->src = self::URI . '/' . $image->src; - $this->items[] = $item; - } + $item['title'] = explode("\r\n", $html->find('center', 1)->plaintext)[0]; + $item['content'] = $image; + $item['timestamp'] = explode("\r\n", $html->find('center', 1)->plaintext)[0]; + + $this->items[] = $item; + } } diff --git a/bridges/TheYeteeBridge.php b/bridges/TheYeteeBridge.php index fa5a64558bd..b0d81c2d31f 100644 --- a/bridges/TheYeteeBridge.php +++ b/bridges/TheYeteeBridge.php @@ -1,41 +1,39 @@ find('.hero-col'); - foreach($div as $element) { - - $item = array(); - $item['enclosures'] = array(); - - $title = $element->find('h2', 0)->plaintext; - $item['title'] = $title; - - $author = trim($element->find('div[class=credit]', 0)->plaintext); - $item['author'] = $author; - - $uri = $element->find('div[class=controls] a', 0)->href; - $item['uri'] = static::URI . $uri; - - $content = '' . $element->find('section[class=product-listing-info] p', -1)->plaintext . '
'; - $photos = $element->find('a[class=js-modaal-gallery] img'); - foreach($photos as $photo) { - $content = $content . "
"; - $item['enclosures'][] = $photo->src; - } - $item['content'] = $content; - - $this->items[] = $item; - } - } +class TheYeteeBridge extends BridgeAbstract +{ + const MAINTAINER = 'Monsieur Poutounours'; + const NAME = 'TheYetee'; + const URI = 'https://theyetee.com'; + const CACHE_TIMEOUT = 14400; // 4 h + const DESCRIPTION = 'Fetch daily shirts from The Yetee'; + + public function collectData() + { + $html = getSimpleHTMLDOM(self::URI); + + $div = $html->find('.module_timed-item.is--full'); + foreach ($div as $element) { + $item = []; + $item['enclosures'] = []; + + $title = $element->find('h2', 0)->plaintext; + $item['title'] = $title; + + $author = trim($element->find('.module_timed-item--artist a', 0)->plaintext); + $item['author'] = $author; + + $item['uri'] = static::URI; + + $content = '' . $title . ' by ' . $author . '
'; + $photos = $element->find('a.img'); + foreach ($photos as $photo) { + $content = $content . "
"; + $item['enclosures'][] = $photo->src; + } + $item['content'] = $content; + + $this->items[] = $item; + } + } } diff --git a/bridges/ThingiverseBridge.php b/bridges/ThingiverseBridge.php deleted file mode 100644 index 2412f79eaca..00000000000 --- a/bridges/ThingiverseBridge.php +++ /dev/null @@ -1,165 +0,0 @@ - array( - 'name' => 'Search query', - 'type' => 'text', - 'required' => true, - 'title' => 'Insert your search term here', - 'exampleValue' => 'Enter your search term' - ), - 'sortby' => array( - 'name' => 'Sort by', - 'type' => 'list', - 'required' => false, - 'values' => array( - 'Relevant' => 'relevant', - 'Text' => 'text', - 'Popular' => 'popular', - '# of Makes' => 'makes', - 'Newest' => 'newest', - ), - 'defaultValue' => 'newest' - ), - 'category' => array( - 'name' => 'Category', - 'type' => 'list', - 'required' => false, - 'values' => array( - 'Any' => '', - '3D Printing' => '73', - 'Art' => '63', - 'Fashion' => '64', - 'Gadgets' => '65', - 'Hobby' => '66', - 'Household' => '67', - 'Learning' => '69', - 'Models' => '70', - 'Tools' => '71', - 'Toys & Games' => '72', - '2D Art' => '144', - 'Art Tools' => '75', - 'Coins & Badges' => '143', - 'Interactive Art' => '78', - 'Math Art' => '79', - 'Scans & Replicas' => '145', - 'Sculptures' => '80', - 'Signs & Logos' => '76', - 'Accessories' => '81', - 'Bracelets' => '82', - 'Costume' => '142', - 'Earrings' => '139', - 'Glasses' => '83', - 'Jewelry' => '84', - 'Keychains' => '130', - 'Rings' => '85', - 'Audio' => '141', - 'Camera' => '86', - 'Computer' => '87', - 'Mobile Phone' => '88', - 'Tablet' => '90', - 'Video Games' => '91', - 'Automotive' => '155', - 'DIY' => '93', - 'Electronics' => '92', - 'Music' => '94', - 'R/C Vehicles' => '95', - 'Robotics' => '96', - 'Sport & Outdoors' => '140', - 'Bathroom' => '147', - 'Containers' => '146', - 'Decor' => '97', - 'Household Supplies' => '99', - 'Kitchen & Dining' => '100', - 'Office' => '101', - 'Organization' => '102', - 'Outdoor & Garden' => '98', - 'Pets' => '103', - 'Replacement Parts' => '153', - 'Biology' => '106', - 'Engineering' => '104', - 'Math' => '105', - 'Physics & Astronomy' => '148', - 'Animals' => '107', - 'Buildings & Structures' => '108', - 'Creatures' => '109', - 'Food & Drink' => '110', - 'Model Furniture' => '111', - 'Model Robots' => '115', - 'People' => '112', - 'Props' => '114', - 'Vehicles' => '116', - 'Hand Tools' => '118', - 'Machine Tools' => '117', - 'Parts' => '119', - 'Tool Holders & Boxes' => '120', - 'Chess' => '151', - 'Construction Toys' => '121', - 'Dice' => '122', - 'Games' => '123', - 'Mechanical Toys' => '124', - 'Playsets' => '113', - 'Puzzles' => '125', - 'Toy & Game Accessories' => '149', - '3D Printer Accessories' => '127', - '3D Printer Extruders' => '152', - '3D Printer Parts' => '128', - '3D Printers' => '126', - '3D Printing Tests' => '129', - ) - ), - 'showimage' => array( - 'name' => 'Show image in content', - 'type' => 'checkbox', - 'required' => false, - 'title' => 'Activate to show the image in the content', - 'defaultValue' => 'checked' - ) - ) - ); - - public function collectData(){ - $html = getSimpleHTMLDOM($this->getURI()) - or returnServerError('Failed to receive ' . $this->getURI()); - - $results = $html->find('div.thing-card'); - - foreach($results as $result) { - - $item = array(); - - $item['title'] = $result->find('span.ellipsis', 0); - $item['uri'] = self::URI . $result->find('a', 1)->href; - $item['author'] = $result->find('span.item-creator', 0); - $item['content'] = ''; - - $image = $result->find('img.card-img', 0)->src; - - if($this->getInput('showimage')) { - $item['content'] .= ''; - } - - $item['enclosures'] = array($image); - - $this->items[] = $item; - } - } - - public function getURI(){ - if(!is_null($this->getInput('query'))) { - $uri = self::URI . '/search?q=' . urlencode($this->getInput('query')); - $uri .= '&sort=' . $this->getInput('sortby'); - $uri .= '&category_id=' . $this->getInput('category'); - - return $uri; - } - - return parent::getURI(); - } -} diff --git a/bridges/ThreadsBridge.php b/bridges/ThreadsBridge.php new file mode 100644 index 00000000000..cfcbba0ee75 --- /dev/null +++ b/bridges/ThreadsBridge.php @@ -0,0 +1,119 @@ + [ + 'u' => [ + 'name' => 'username', + 'required' => true, + 'exampleValue' => 'zuck', + 'title' => 'Insert a user name' + ], + 'limit' => [ + 'name' => 'Limit', + 'type' => 'number', + 'required' => false, + 'title' => 'Specify number of posts to fetch', + 'defaultValue' => 5 + ] + ] + ]; + + protected $feedName = self::NAME; + public function getName() + { + return $this->feedName; + } + + public function detectParameters($url) + { + // By username + $regex = '/^(https?:\/\/)?(www\.)?threads\.net\/(@)?([^\/?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + $params['context'] = 'By username'; + $params['u'] = urldecode($matches[3]); + return $params; + } + return null; + } + + public function getURI() + { + return self::URI . '@' . $this->getInput('u'); + } + + // https://stackoverflow.com/a/3975706/421140 + // Found this in FlaschenpostBridge, modified to return an array and take an object. + private function recursiveFind($haystack, $needle) + { + $found = []; + $iterator = new \RecursiveArrayIterator($haystack); + $recursive = new \RecursiveIteratorIterator( + $iterator, + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($recursive as $key => $value) { + if ($key === $needle) { + $found[] = $value; + } + } + return $found; + } + + public function collectData() + { + $html = getSimpleHTMLDOMCached($this->getURI(), static::CACHE_TIMEOUT); + + $jsonBlobs = $html->find('script[type="application/json"]'); + + $gatheredCodes = []; + $limit = $this->getInput('limit'); + foreach ($jsonBlobs as $jsonBlob) { + // The structure of the JSON document is likely to change, but we're looking for a "code" inside a "post" + foreach ($this->recursiveFind($this->recursiveFind(json_decode($jsonBlob->innertext), 'post'), 'code') as $candidateCode) { + // code should be like CzZk4-USq1O or Cy3m1VnRiwP or Cywjyrdv9T6 or CzZk4-USq1O + if (grapheme_strlen($candidateCode) == 11 and !in_array($candidateCode, $gatheredCodes)) { + $gatheredCodes[] = $candidateCode; + if (count($gatheredCodes) >= $limit) { + break 2; + } + } + } + } + + $this->feedName = html_entity_decode($html->find('meta[property=og:title]', 0)->content); + // todo: meta[property=og:description] could populate the feed description + + foreach ($gatheredCodes as $postCode) { + $item = []; + // post URL is like: https://www.threads.net/@zuck/post/Czrr520PZfh + $item['uri'] = $this->getURI() . '/post/' . $postCode; + $articleHtml = getSimpleHTMLDOMCached($item['uri'], 15778800); // cache time: six months + + // Relying on meta tags ought to be more reliable. + if ($articleHtml->find('meta[property=og:type]', 0)->content != 'article') { + continue; + } + $item['title'] = $articleHtml->find('meta[property=og:description]', 0)->content; + $item['content'] = $articleHtml->find('meta[property=og:description]', 0)->content; + $item['author'] = html_entity_decode($articleHtml->find('meta[property=og:title]', 0)->content); + + $imageUrl = $articleHtml->find('meta[property=og:image]', 0); + if ($imageUrl) { + $item['enclosures'][] = html_entity_decode($imageUrl->content); + } + + // todo: parse hashtags out of content for $item['categories'] + // todo: try to scrape out a timestamp for $item['timestamp'], it's not in the meta tags + + $this->items[] = $item; + } + } +} diff --git a/bridges/TikTokBridge.php b/bridges/TikTokBridge.php new file mode 100644 index 00000000000..43a9cb310d8 --- /dev/null +++ b/bridges/TikTokBridge.php @@ -0,0 +1,106 @@ + [ + 'username' => [ + 'name' => 'Username', + 'type' => 'text', + 'required' => true, + 'exampleValue' => '@tiktok', + ] + ]]; + + const TEST_DETECT_PARAMETERS = [ + 'https://www.tiktok.com/@tiktok' => [ + 'context' => 'By user', 'username' => '@tiktok' + ] + ]; + + const CACHE_TIMEOUT = 900; // 15 minutes + + public function collectData() + { + $html = getSimpleHTMLDOMCached('https://www.tiktok.com/embed/' . $this->processUsername()); + + $author = $html->find('span[data-e2e=creator-profile-userInfo-TUXText]', 0)->plaintext ?? self::NAME; + $authorProfilePicture = $html->find('img[data-e2e=creator-profile-userInfo-Avatar]', 0)->src ?? ''; + + $videos = $html->find('div[data-e2e=common-videoList-VideoContainer]'); + + foreach ($videos as $video) { + $item = []; + + // Omit query string (remove tracking parameters) + $a = $video->find('a', 0); + $href = $a->href; + $parsedUrl = parse_url($href); + $url = $parsedUrl['scheme'] . '://' . $parsedUrl['host'] . '/' . ltrim($parsedUrl['path'], '/'); + + $image = $video->find('video', 0)->poster; + $views = $video->find('div[data-e2e=common-Video-Count]', 0)->plaintext; + + $enclosures = [$image, $authorProfilePicture]; + + $item['uri'] = $url; + $item['title'] = 'Video'; + $item['author'] = '@' . $author; + $item['enclosures'] = $enclosures; + $item['content'] = <<+ {$views} views
+EOD; + + $this->items[] = $item; + } + } + + public function getURI() + { + switch ($this->queriedContext) { + case 'By user': + return self::URI . '/' . $this->processUsername(); + default: + return parent::getURI(); + } + } + + public function getName() + { + switch ($this->queriedContext) { + case 'By user': + return $this->processUsername() . ' - TikTok'; + default: + return parent::getName(); + } + } + + private function processUsername() + { + $username = trim($this->getInput('username')); + if (preg_match('#^https?://www\.tiktok\.com/@(.*)$#', $username, $m)) { + return '@' . $m[1]; + } + if (substr($username, 0, 1) !== '@') { + return '@' . $username; + } + return $username; + } + + public function detectParameters($url) + { + if (preg_match('/tiktok\.com\/(@[\w]+)/', $url, $matches) > 0) { + return [ + 'context' => 'By user', + 'username' => $matches[1] + ]; + } + + return null; + } +} diff --git a/bridges/TinyLetterBridge.php b/bridges/TinyLetterBridge.php new file mode 100644 index 00000000000..0315c5b4fd3 --- /dev/null +++ b/bridges/TinyLetterBridge.php @@ -0,0 +1,58 @@ + [ + 'name' => 'User Name', + 'required' => true, + 'exampleValue' => 'forwards', + ] + ] + ]; + + public function getName() + { + $username = $this->getInput('username'); + if (!is_null($username)) { + return static::NAME . ' | ' . $username; + } + + return parent::getName(); + } + + public function getURI() + { + $username = $this->getInput('username'); + if (!is_null($username)) { + return static::URI . urlencode($username); + } + + return parent::getURI(); + } + + public function collectData() + { + $archives = $this->getURI() . '/archive'; + $html = getSimpleHTMLDOMCached($archives); + + foreach ($html->find('.message-list li') as $element) { + $item = []; + + $snippet = $element->find('p.message-snippet', 0); + $link = $element->find('.message-link', 0); + + $item['title'] = $link->plaintext; + $item['content'] = $snippet->innertext; + $item['uri'] = $link->href; + $item['timestamp'] = strtotime($element->find('.message-date', 0)->plaintext); + + $this->items[] = $item; + } + } +} diff --git a/bridges/TldrTechBridge.php b/bridges/TldrTechBridge.php new file mode 100644 index 00000000000..222cd49ef2c --- /dev/null +++ b/bridges/TldrTechBridge.php @@ -0,0 +1,131 @@ + [ + 'limit' => [ + 'name' => 'Maximum number of articles to return', + 'type' => 'number', + 'required' => true, + 'defaultValue' => 10 + ], + 'topic' => [ + 'name' => 'Topic', + 'type' => 'list', + 'values' => [ + 'Tech' => 'tech', + 'Web Dev' => 'webdev', + 'AI' => 'ai', + 'Information Security' => 'infosec', + 'Product Management' => 'product', + 'DevOps' => 'devops', + 'Crypto' => 'crypto', + 'Design' => 'design', + 'Marketing' => 'marketing', + 'Founders' => 'founders', + ], + 'defaultValue' => 'tech' + ] + ] + ]; + + public function collectData() + { + $topic = $this->getInput('topic'); + $limit = $this->getInput('limit'); + + $url = self::URI . 'api/latest/' . $topic; + $response = getContents($url, [], [], true); + $location = $response->getHeader('Location'); + $locationUrl = Url::fromString($location); + + $this->extractItem($locationUrl); + + $archives_url = self::URI . $topic . '/archives'; + $archives_html = getSimpleHTMLDOM($archives_url); + $entries_root = $archives_html->find('div.content-center.mt-5', 0); + foreach ($entries_root->children() as $child) { + if ($child->tag != 'a') { + continue; + } + $itemUrl = Url::fromString(self::URI . ltrim($child->href, '/')); + $this->extractItem($itemUrl); + if (count($this->items) >= $limit) { + break; + } + } + } + + private function extractItem(Url $url) + { + $pathParts = explode('/', $url->getPath()); + $date = strtotime(end($pathParts)); + try { + [$content, $title] = $this->extractContent($url); + + $this->items[] = [ + 'uri' => (string) $url, + 'title' => $title, + 'timestamp' => $date, + 'content' => $content, + ]; + } catch (HttpException $e) { + // archive occasionally returns broken URLs + return; + } + } + + private function extractContent($url) + { + $html = getSimpleHTMLDOMCached($url); + $content = $html->find('div.content-center.mt-5', 0); + if (!$content) { + throw new \Exception('Could not find content'); + } + $subscribe_form = $content->find('div.mt-5 > div > form', 0); + if ($subscribe_form) { + $content->removeChild($subscribe_form->parent->parent); + } + $privacy_link = $content->find("a[href='/privacy']", 0); + if ($privacy_link) { + $content->removeChild($privacy_link->parent->parent); + } + $headers = $content->find('h6.text-center.font-bold'); + foreach ($headers as $header) { + $elem = $html->createElement('h3', $header->parent->plaintext); + $elem->style = 'margin-top: 1.2em; margin-bottom: 0.5em;'; + $header_root = $header->parent; + foreach ($header_root->children() as $child) { + $header_root->removeChild($child); + } + $header_root->appendChild($elem); + } + + foreach ($content->find('a.font-bold') as $a) { + $a->removeAttribute('class'); + $elem = $html->createElement('b', $a->plaintext); + $a->removeChild($a->firstChild()); + $a->appendChild($elem); + } + foreach ($content->children() as $child) { + if ($child->tag != 'div') { + continue; + } + foreach ($child->children() as $grandchild) { + if ($grandchild->tag == 'div') { + $grandchild->style = 'margin-bottom: 12px;'; + } + } + } + $title = $content->find('h2', 0); + return [$content->innertext, $title->plaintext]; + } +} diff --git a/bridges/TorrentGalaxyBridge.php b/bridges/TorrentGalaxyBridge.php new file mode 100644 index 00000000000..052af262416 --- /dev/null +++ b/bridges/TorrentGalaxyBridge.php @@ -0,0 +1,131 @@ + [ + 'name' => 'search', + 'required' => true, + 'exampleValue' => 'simpsons', + 'title' => 'Type your query' + ], + 'lang' => [ + 'name' => 'language', + 'type' => 'list', + 'exampleValue' => 'All languages', + 'title' => 'Select your language', + 'values' => [ + 'All languages' => '0', + 'English' => '1', + 'French' => '2', + 'German' => '3', + 'Italian' => '4', + 'Japanese' => '5', + 'Spanish' => '6', + 'Russian' => '7', + 'Hindi' => '8', + 'Other / Multiple' => '9', + 'Korean' => '10', + 'Danish' => '11', + 'Norwegian' => '12', + 'Dutch' => '13', + 'Manderin' => '14', + 'Portuguese' => '15', + 'Bengali' => '16', + 'Polish' => '17', + 'Turkish' => '18', + 'Telugu' => '19', + 'Urdu' => '20', + 'Arabic' => '21', + 'Swedish' => '22', + 'Romanian' => '23' + ] + ] + ] + ]; + + public function collectData() + { + $url = self::URI + . '/torrents.php?search=' . urlencode($this->getInput('search')) + . '&lang=' . $this->getInput('lang') + . '&sort=id&order=desc'; + $html = getSimpleHTMLDOM($url); + + foreach ($html->find('div.tgxtablerow') as $result) { + $identity = $result->find('div.tgxtablecell', 3)->find('div a', 0); + $authorid = $result->find('div.tgxtablecell', 6)->find('a', 0); + $creadate = $result->find('div.tgxtablecell', 11)->plaintext; + $glxlinks = $result->find('div.tgxtablecell', 4); + + $item = []; + $item['uri'] = self::URI . $identity->href; + $item['title'] = $identity->plaintext; + + // todo: parse date strings such as '1Hr ago' etc. + $createdAt = DateTime::createFromFormat('d/m/y H:i', $creadate); + if ($createdAt) { + $item['timestamp'] = $createdAt->format('U'); + } + + $item['author'] = $authorid->plaintext; + $item['content'] = <<{$identity->plaintext} +Links
+ + +Infos
+Size: {$result->find('div.tgxtablecell', 7)->plaintext}
+Added by: {$authorid->plaintext}
+Upload time: {$creadate}
+HTML; + $item['enclosures'] = [$glxlinks->find('a', 0)->href]; + $item['categories'] = [$result->find('div.tgxtablecell', 0)->plaintext]; + if (preg_match('#/torrent/([^/]+)/#', self::URI . $identity->href, $torrentid)) { + $item['uid'] = $torrentid[1]; + } + $this->items[] = $item; + } + } + + public function getName() + { + if (!is_null($this->getInput('search'))) { + return $this->getInput('search') . ' : ' . self::NAME; + } + return parent::getName(); + } + + public function getURI() + { + if (!is_null($this->getInput('search'))) { + return self::URI + . '/torrents.php?search=' . urlencode($this->getInput('search')) + . '&lang=' . $this->getInput('lang'); + } + return parent::getURI(); + } + + public function getDescription() + { + if (!is_null($this->getInput('search'))) { + return 'Latest torrents for "' . $this->getInput('search') . '"'; + } + return parent::getDescription(); + } + + public function getIcon() + { + if (!is_null($this->getInput('search'))) { + return self::URI . '/common/favicon/favicon.ico'; + } + return parent::getIcon(); + } +} diff --git a/bridges/TraktBridge.php b/bridges/TraktBridge.php new file mode 100644 index 00000000000..7aa90dfd81b --- /dev/null +++ b/bridges/TraktBridge.php @@ -0,0 +1,70 @@ + [ + 'name' => 'username', + 'required' => true + ], + 'hide_shows' => [ + 'name' => 'Hide shows', + 'type' => 'checkbox', + 'title' => 'Hide shows', + ], + + ], + ]; + + public function detectParameters($url) + { + if (preg_match('/trakt\.tv\/users\/(.*?)\//', $url, $matches) > 0) { + return [ + 'username' => $matches[1] + ]; + } + return null; + } + + public function collectData() + { + $username = $this->getInput('username'); + $dom = getSimpleHTMLDOMCached(self::URI . '/users/' . $username . '/history'); + $this->feedName = $dom->find('#avatar-wrapper h1 a', 0)->plaintext; + $this->iconURL = $dom->find('img.avatar', 0)->{'src'}; + + foreach ($dom->find('#history-items .posters', 0)->find('div.grid-item') as $div) { + if ($this->getInput('hide_shows') && $div->{'data-type'} != 'movie') { + continue; + } + $item = []; + $item['author'] = $this->feedName; + $item['title'] = $div->find('img.real', 0)->{'title'}; + $item['timestamp'] = $div->find('.format-date', 0)->plaintext; + $item['content'] = ''; + $item['uri'] = self::URI . $div->{'data-url'}; + $this->items[] = $item; + } + } + public function getName() + { + if (empty($this->feedName)) { + return parent::getName(); + } else { + return $this->feedName; + } + } + public function getIcon() + { + if (empty($this->iconURL)) { + return parent::getIcon(); + } else { + return $this->iconURL; + } + } +} diff --git a/bridges/TrelloBridge.php b/bridges/TrelloBridge.php index 23db961741e..42651fd13fc 100644 --- a/bridges/TrelloBridge.php +++ b/bridges/TrelloBridge.php @@ -1,687 +1,694 @@ array( - 'b' => array( - 'name' => 'Board ID', - 'required' => true, - 'exampleValue' => 'g9mdhdzg', - 'title' => 'Taken from Trello URL, e.g. trello.com/b/[Board ID]' - ) - ), - 'Card' => array( - 'c' => array( - 'name' => 'Card ID', - 'required' => true, - 'exampleValue' => '8vddc9pE', - 'title' => 'Taken from Trello URL, e.g. trello.com/c/[Card ID]' - ) - ) - ); - /* - * This was extracted from webpack on a Trello page, e.g. trello.com/b/g9mdhdzg - * In the browser's inspector/debugger go to the Debugger (Firefox) or - * Sources (Chromium) tab, these values can be found at: - * webpack:///resources/strings/actions/en.json - */ - const ACTION_TEXTS = array( - 'action_accept_enterprise_join_request' - => '{memberCreator} added team {organization} to the enterprise {enterprise}', - 'action_add_attachment_to_card' - => '{memberCreator} attached {attachment} to {card} {attachmentPreview}', - 'action_add_attachment_to_card@card' - => '{memberCreator} attached {attachment} to this card {attachmentPreview}', - 'action_add_checklist_to_card' - => '{memberCreator} added {checklist} to {card}', - 'action_add_checklist_to_card@card' - => '{memberCreator} added {checklist} to this card', - 'action_add_label_to_card' - => '{memberCreator} added the {label} label to {card}', - 'action_add_label_to_card@card' - => '{memberCreator} added the {label} label to this card', - 'action_add_organization_to_enterprise' - => '{memberCreator} added team {organization} to the enterprise {enterprise}', - 'action_add_to_organization_board' - => '{memberCreator} added {board} to {organization}', - 'action_add_to_organization_board@board' - => '{memberCreator} added this board to {organization}', - 'action_added_a_due_date' - => '{memberCreator} set {card} to be due {date}', - 'action_added_a_due_date@card' - => '{memberCreator} set this card to be due {date}', - 'action_added_list_to_board' - => '{memberCreator} added list {list} to {board}', - 'action_added_list_to_board@board' - => '{memberCreator} added {list} to this board', - 'action_added_member_to_board' - => '{memberCreator} added {member} to {board}', - 'action_added_member_to_board@board' - => '{memberCreator} added {member} to this board', - 'action_added_member_to_board_as_admin' - => '{memberCreator} added {member} to {board} as an admin', - 'action_added_member_to_board_as_admin@board' - => '{memberCreator} added {member} to this board as an admin', - 'action_added_member_to_board_as_observer' - => '{memberCreator} added {member} to {board} as an observer', - 'action_added_member_to_board_as_observer@board' - => '{memberCreator} added {member} to this board as an observer', - 'action_added_member_to_card' - => '{memberCreator} added {member} to {card}', - 'action_added_member_to_card@card' - => '{memberCreator} added {member} to this card', - 'action_added_member_to_organization' - => '{memberCreator} added {member} to {organization}', - 'action_added_member_to_organization_as_admin' - => '{memberCreator} added {member} to {organization} as an admin', - 'action_admins_visibility' - => 'its admins', - 'action_another_board' - => 'another board', - 'action_archived_card' - => '{memberCreator} archived {card}', - 'action_archived_card@card' - => '{memberCreator} archived this card', - 'action_archived_list' - => '{memberCreator} archived list {list}', - 'action_became_a_normal_user_in_organization' - => '{memberCreator} became a normal user in {organization}', - 'action_became_a_normal_user_on' - => '{memberCreator} became a normal user on {board}', - 'action_became_a_normal_user_on@board' - => '{memberCreator} became a normal user on this board', - 'action_became_an_admin_of_organization' - => '{memberCreator} became an admin of {organization}', - 'action_board_perm_level' - => '{memberCreator} made {board} visible to {level}', - 'action_board_perm_level@board' - => '{memberCreator} made this board visible to {level}', - 'action_calendar' - => 'calendar', - 'action_cardAging' - => 'card aging', - 'action_changed_a_due_date' - => '{memberCreator} changed the due date of {card} to {date}', - 'action_changed_a_due_date@card' - => '{memberCreator} changed the due date of this card to {date}', - 'action_changed_board_background' - => '{memberCreator} changed the background of {board}', - 'action_changed_board_background@board' - => '{memberCreator} changed the background of this board', - 'action_changed_description_of_card' - => '{memberCreator} changed description of {card}', - 'action_changed_description_of_card@card' - => '{memberCreator} changed description of this card', - 'action_changed_description_of_organization' - => '{memberCreator} changed description of {organization}', - 'action_changed_display_name_of_organization' - => '{memberCreator} changed display name of {organization}', - 'action_changed_name_of_organization' - => '{memberCreator} changed name of {organization}', - 'action_changed_website_of_organization' - => '{memberCreator} changed website of {organization}', - 'action_closed_board' - => '{memberCreator} closed {board}', - 'action_closed_board@board' - => '{memberCreator} closed this board', - 'action_comment_on_card' - => '{memberCreator} {contextOn} {card} {comment}', - 'action_comment_on_card@card' - => '{memberCreator} {comment}', - 'action_completed_checkitem' - => '{memberCreator} completed {checkitem} on {card}', - 'action_completed_checkitem@card' - => '{memberCreator} completed {checkitem} on this card', - 'action_convert_to_card_from_checkitem' - => '{memberCreator} converted {card} from a checklist item on {cardSource}', - 'action_convert_to_card_from_checkitem@card' - => '{memberCreator} converted this card from a checklist item on {cardSource}', - 'action_convert_to_card_from_checkitem@cardSource' - => '{memberCreator} converted {card} from a checklist item on this card', - 'action_copy_board' - => '{memberCreator} copied this board from {board}', - 'action_copy_card' - => '{memberCreator} copied {card} from {cardSource} in list {list}', - 'action_copy_card@card' - => '{memberCreator} copied this card from {cardSource} in list {list}', - 'action_copy_comment_from_card' - => '{memberCreator} copied comment by {member} from card {card} {comment}', - 'action_create_board' - => '{memberCreator} created {board}', - 'action_create_board@board' - => '{memberCreator} created this board', - 'action_create_card' - => '{memberCreator} added {card} to {list}', - 'action_create_card@card' - => '{memberCreator} added this card to {list}', - 'action_create_custom_field' - => '{memberCreator} created the {customField} custom field on {board}', - 'action_create_custom_field@board' - => '{memberCreator} created the {customField} custom field on this board', - 'action_create_enterprise_join_request' - => '{memberCreator} requested to add team {organization} to the enterprise {enterprise}', - 'action_created_an_invitation_to_board' - => '{memberCreator} created an invitation to {board}', - 'action_created_an_invitation_to_board@board' - => '{memberCreator} created an invitation to this board', - 'action_created_an_invitation_to_organization' - => '{memberCreator} created an invitation to {organization}', - 'action_created_checklist_on_board' - => '{memberCreator} created {checklist} on {board}', - 'action_created_checklist_on_board@board' - => '{memberCreator} created {checklist} on this board', - 'action_created_organization' - => '{memberCreator} created {organization}', - 'action_decline_enterprise_join_request' - => '{memberCreator} declined the request to add team {organization} to the enterprise {enterprise}', - 'action_delete_attachment_from_card' - => '{memberCreator} deleted the {attachment} attachment from {card}', - 'action_delete_attachment_from_card@card' - => '{memberCreator} deleted the {attachment} attachment from this card', - 'action_delete_card' - => '{memberCreator} deleted card #{idCard} from {list}', - 'action_delete_custom_field' - => '{memberCreator} deleted the {customField} custom field from {board}', - 'action_delete_custom_field@board' - => '{memberCreator} deleted the {customField} custom field from this board', - 'action_deleted_account' - => '[deleted account]', - 'action_deleted_an_invitation_to_board' - => '{memberCreator} deleted an invitation to {board}', - 'action_deleted_an_invitation_to_board@board' - => '{memberCreator} deleted an invitation to this board', - 'action_deleted_an_invitation_to_organization' - => '{memberCreator} deleted an invitation to {organization}', - 'action_deleted_checkitem' - => '{memberCreator} deleted task {checkitem} on {checklist}', - 'action_disabled_calendar_feed' - => '{memberCreator} disabled the iCalendar feed on {board}', - 'action_disabled_calendar_feed@board' - => '{memberCreator} disabled the iCalendar feed on this board', - 'action_disabled_card_covers' - => '{memberCreator} disabled card cover images on {board}', - 'action_disabled_card_covers@board' - => '{memberCreator} disabled card cover images on this board', - 'action_disabled_commenting' - => '{memberCreator} disabled commenting on {board}', - 'action_disabled_commenting@board' - => '{memberCreator} disabled commenting on this board', - 'action_disabled_inviting' - => '{memberCreator} disabled inviting on {board}', - 'action_disabled_inviting@board' - => '{memberCreator} disabled inviting on this board', - 'action_disabled_plugin' - => '{memberCreator} disabled the {plugin} Power-Up', - 'action_disabled_powerup' - => '{memberCreator} disabled the {powerup} Power-Up', - 'action_disabled_self_join' - => '{memberCreator} disabled self join on {board}', - 'action_disabled_self_join@board' - => '{memberCreator} disabled self join on this board', - 'action_disabled_voting' - => '{memberCreator} disabled voting on {board}', - 'action_disabled_voting@board' - => '{memberCreator} disabled voting on this board', - 'action_due_date_change' - => '{memberCreator}', - 'action_email_card' - => '{memberCreator} emailed {card} to {list}', - 'action_email_card@card' - => '{memberCreator} emailed this card to {list}', - 'action_email_card_from' - => '{memberCreator} emailed {card} to {list} from {from}', - 'action_email_card_from@card' - => '{memberCreator} emailed this card to {list} from {from}', - 'action_enabled_calendar_feed' - => '{memberCreator} enabled the iCalendar feed on {board}', - 'action_enabled_calendar_feed@board' - => '{memberCreator} enabled the iCalendar feed on this board', - 'action_enabled_card_covers' - => '{memberCreator} enabled card cover images on {board}', - 'action_enabled_card_covers@board' - => '{memberCreator} enabled card cover images on this board', - 'action_enabled_plugin' - => '{memberCreator} enabled the {plugin} Power-Up', - 'action_enabled_powerup' - => '{memberCreator} enabled the {powerup} Power-Up', - 'action_enabled_self_join' - => '{memberCreator} enabled self join on {board}', - 'action_enabled_self_join@board' - => '{memberCreator} enabled self join on this board', - 'action_hid_board' - => '{memberCreator} hid {board}', - 'action_hid_board@board' - => '{memberCreator} hid this board', - 'action_invited_an_unconfirmed_member_to_board' - => '{memberCreator} invited an unconfirmed member to {board}', - 'action_invited_an_unconfirmed_member_to_board@board' - => '{memberCreator} invited an unconfirmed member to this board', - 'action_invited_an_unconfirmed_member_to_organization' - => '{memberCreator} invited an unconfirmed member to {organization}', - 'action_joined_board' - => '{memberCreator} joined {board}', - 'action_joined_board@board' - => '{memberCreator} joined this board', - 'action_joined_board_by_invitation_link' - => '{memberCreator} joined {board} with an invitation link from {memberInviter}', - 'action_joined_board_by_invitation_link@board' - => '{memberCreator} joined this board with an invitation link from {memberInviter}', - 'action_joined_organization' - => '{memberCreator} joined {organization}', - 'action_joined_organization_by_invitation_link' - => '{memberCreator} joined {organization} with an invitation link from {memberInviter}', - 'action_left_board' - => '{memberCreator} left {board}', - 'action_left_board@board' - => '{memberCreator} left this board', - 'action_left_organization' - => '{memberCreator} left {organization}', - 'action_made_a_normal_user_in_organization' - => '{memberCreator} made {member} a normal user in {organization}', - 'action_made_a_normal_user_on' - => '{memberCreator} made {member} a normal user on {board}', - 'action_made_a_normal_user_on@board' - => '{memberCreator} made {member} a normal user on this board', - 'action_made_admin_of_board' - => '{memberCreator} made {member} an admin of {board}', - 'action_made_admin_of_board@board' - => '{memberCreator} made {member} an admin of this board', - 'action_made_an_admin_of_organization' - => '{memberCreator} made {member} an admin of {organization}', - 'action_made_commenting_on' - => '{memberCreator} made commenting on {board} available to {level}', - 'action_made_commenting_on@board' - => '{memberCreator} made commenting on this board available to {level}', - 'action_made_inviting_on' - => '{memberCreator} made inviting on {board} available to {level}', - 'action_made_inviting_on@board' - => '{memberCreator} made inviting on this board available to {level}', - 'action_made_observer_of_board' - => '{memberCreator} made {member} an observer of {board}', - 'action_made_observer_of_board@board' - => '{memberCreator} made {member} an observer of this board', - 'action_made_self_admin_of_board' - => '{memberCreator} made themselves an admin of {board}', - 'action_made_self_admin_of_board@board' - => '{memberCreator} made themselves an admin of this board', - 'action_made_self_observer_of_board' - => '{memberCreator} became an observer of {board}', - 'action_made_self_observer_of_board@board' - => '{memberCreator} became an observer of this board', - 'action_made_voting_on' - => '{memberCreator} made voting on {board} available to {level}', - 'action_made_voting_on@board' - => '{memberCreator} made voting on this board available to {level}', - 'action_marked_checkitem_incomplete' - => '{memberCreator} marked {checkitem} incomplete on {card}', - 'action_marked_checkitem_incomplete@card' - => '{memberCreator} marked {checkitem} incomplete on this card', - 'action_marked_the_due_date_complete' - => '{memberCreator} marked the due date on {card} complete', - 'action_marked_the_due_date_complete@card' - => '{memberCreator} marked the due date complete', - 'action_marked_the_due_date_incomplete' - => '{memberCreator} marked the due date on {card} incomplete', - 'action_marked_the_due_date_incomplete@card' - => '{memberCreator} marked the due date incomplete', - 'action_member_joined_card' - => '{memberCreator} joined {card}', - 'action_member_joined_card@card' - => '{memberCreator} joined this card', - 'action_member_left_card' - => '{memberCreator} left {card}', - 'action_member_left_card@card' - => '{memberCreator} left this card', - 'action_members_visibility' - => 'its members', - 'action_move_card_from_board' - => '{memberCreator} transferred {card} to {board}', - 'action_move_card_from_board@card' - => '{memberCreator} transferred this card to {board}', - 'action_move_card_from_list_to_list' - => '{memberCreator} moved {card} from {listBefore} to {listAfter}', - 'action_move_card_from_list_to_list@card' - => '{memberCreator} moved this card from {listBefore} to {listAfter}', - 'action_move_card_to_board' - => '{memberCreator} transferred {card} from {board}', - 'action_move_card_to_board@card' - => '{memberCreator} transferred this card from {board}', - 'action_move_list_from_board' - => '{memberCreator} transferred {list} to {board}', - 'action_move_list_to_board' - => '{memberCreator} transferred {list} from {board}', - 'action_moved_card_higher' - => '{memberCreator} moved {card} higher', - 'action_moved_card_higher@card' - => '{memberCreator} moved this card higher', - 'action_moved_card_lower' - => '{memberCreator} moved {card} lower', - 'action_moved_card_lower@card' - => '{memberCreator} moved this card lower', - 'action_moved_checkitem_higher' - => '{memberCreator} moved {checkitem} higher in the checklist {checklist}', - 'action_moved_checkitem_lower' - => '{memberCreator} moved {checkitem} higher in the checklist {checklist}', - 'action_moved_list_left' - => '{memberCreator} moved list {list} left on {board}', - 'action_moved_list_left@board' - => '{memberCreator} moved {list} left on this board', - 'action_moved_list_right' - => '{memberCreator} moved list {list} right on {board}', - 'action_moved_list_right@board' - => '{memberCreator} moved {list} right on this board', - 'action_observers_visibility' - => 'members and observers', - 'action_on' - => 'on', - 'action_org_visibility' - => 'members of its team', - 'action_public_visibility' - => 'the public', - 'action_remove_checklist_from_card' - => '{memberCreator} removed {checklist} from {card}', - 'action_remove_checklist_from_card@card' - => '{memberCreator} removed {checklist} from this card', - 'action_remove_from_organization_board' - => '{memberCreator} removed {board} from {organization}', - 'action_remove_from_organization_board@board' - => '{memberCreator} removed this board from {organization}', - 'action_remove_label_from_card' - => '{memberCreator} removed the {label} label from {card}', - 'action_remove_label_from_card@card' - => '{memberCreator} removed the {label} label from this card', - 'action_remove_organization_from_enterprise' - => '{memberCreator} removed team {organization} from the enterprise {enterprise}', - 'action_removed_a_due_date' - => '{memberCreator} removed the due date from {card}', - 'action_removed_a_due_date@card' - => '{memberCreator} removed the due date from this card', - 'action_removed_from_board' - => '{memberCreator} removed {member} from {board}', - 'action_removed_from_board@board' - => '{memberCreator} removed {member} from this board', - 'action_removed_member_from_card' - => '{memberCreator} removed {member} from {card}', - 'action_removed_member_from_card@card' - => '{memberCreator} removed {member} from this card', - 'action_removed_member_from_organization' - => '{memberCreator} removed {member} from {organization}', - 'action_removed_vote_for_card' - => '{memberCreator} removed vote for {card}', - 'action_removed_vote_for_card@card' - => '{memberCreator} removed vote for this card', - 'action_rename_custom_field' - => '{memberCreator} renamed the {customField} custom field on {board} (from {name})', - 'action_rename_custom_field@board' - => '{memberCreator} renamed the {customField} custom field on this board (from {name})', - 'action_renamed_card' - => '{memberCreator} renamed {card} (from {name})', - 'action_renamed_card@card' - => '{memberCreator} renamed this card (from {name})', - 'action_renamed_checkitem' - => '{memberCreator} renamed {checkitem} (from {name})', - 'action_renamed_checklist' - => '{memberCreator} renamed {checklist} (from {name})', - 'action_renamed_list' - => '{memberCreator} renamed list {list} (from {name})', - 'action_reopened_board' - => '{memberCreator} re-opened {board}', - 'action_reopened_board@board' - => '{memberCreator} re-opened this board', - 'action_sent_card_to_board' - => '{memberCreator} sent {card} to the board', - 'action_sent_card_to_board@card' - => '{memberCreator} sent this card to the board', - 'action_sent_list_to_board' - => '{memberCreator} sent list {list} to the board', - 'action_set_card_aging_mode_pirate' - => '{memberCreator} changed card aging to pirate mode', - 'action_set_card_aging_mode_regular' - => '{memberCreator} changed card aging to regular mode', - 'action_update_board_desc' - => '{memberCreator} changed description of {board}', - 'action_update_board_desc@board' - => '{memberCreator} changed description of this board', - 'action_update_board_name' - => '{memberCreator} renamed {board} (from {name})', - 'action_update_board_name@board' - => '{memberCreator} renamed this board (from {name})', - 'action_update_custom_field' - => '{memberCreator} updated the {customField} custom field on {board}', - 'action_update_custom_field@board' - => '{memberCreator} updated the {customField} custom field on this board', - 'action_update_custom_field_item' - => '{memberCreator} updated the value for the {customFieldItem} custom field on {card}', - 'action_update_custom_field_item@card' - => '{memberCreator} updated the value for the {customFieldItem} custom field on this card', - 'action_updated_their_bio' - => '{memberCreator} updated their bio', - 'action_updated_their_display_name' - => '{memberCreator} updated their display name', - 'action_updated_their_initials' - => '{memberCreator} updated their initials', - 'action_updated_their_username' - => '{memberCreator} updated their username', - 'action_vote_on_card' - => '{memberCreator} voted for {card}', - 'action_vote_on_card@card' - => '{memberCreator} voted for this card', - 'action_voting' - => 'voting', - 'action_withdraw_enterprise_join_request' - => '{memberCreator} withdrew a request to add team {organization} to the enterprise {enterprise}' - ); +class TrelloBridge extends BridgeAbstract +{ + const NAME = 'Trello Bridge'; + const URI = 'https://trello.com/'; + const CACHE_TIMEOUT = 300; // 5min + const DESCRIPTION = 'Returns activity on Trello boards or cards'; + const MAINTAINER = 'Roliga'; + const PARAMETERS = [ + 'Board' => [ + 'b' => [ + 'name' => 'Board ID', + 'required' => true, + 'exampleValue' => 'g9mdhdzg', + 'title' => 'Taken from Trello URL, e.g. trello.com/b/[Board ID]' + ] + ], + 'Card' => [ + 'c' => [ + 'name' => 'Card ID', + 'required' => true, + 'exampleValue' => '8vddc9pE', + 'title' => 'Taken from Trello URL, e.g. trello.com/c/[Card ID]' + ] + ] + ]; - const REQUEST_ACTIONS_BOARDS = array( - 'addAttachmentToCard', - 'addChecklistToCard', - 'addMemberToCard', - 'commentCard', - 'copyCommentCard', - 'convertToCardFromCheckItem', - 'createCard', - 'copyCard', - 'deleteAttachmentFromCard', - 'emailCard', - 'moveCardFromBoard', - 'moveCardToBoard', - 'removeChecklistFromCard', - 'removeMemberFromCard', - 'updateCard:idList', - 'updateCard:closed', - 'updateCard:due', - 'updateCard:dueComplete', - 'updateCheckItemStateOnCard', - 'updateCustomFieldItem', - 'addMemberToBoard', - 'addToOrganizationBoard', - 'copyBoard', - 'createBoard', - 'createCustomField', - 'createList', - 'deleteCard', - 'deleteCustomField', - 'disablePlugin', - 'disablePowerUp', - 'enablePlugin', - 'enablePowerUp', - 'makeAdminOfBoard', - 'makeNormalMemberOfBoard', - 'makeObserverOfBoard', - 'moveListFromBoard', - 'moveListToBoard', - 'removeFromOrganizationBoard', - 'unconfirmedBoardInvitation', - 'unconfirmedOrganizationInvitation', - 'updateBoard', - 'updateCustomField', - 'updateList:closed' - ); + /* + * This was extracted from webpack on a Trello page, e.g. trello.com/b/g9mdhdzg + * In the browser's inspector/debugger go to the Debugger (Firefox) or + * Sources (Chromium) tab, these values can be found at: + * webpack:///resources/strings/actions/en.json + */ + const ACTION_TEXTS = [ + 'action_accept_enterprise_join_request' + => '{memberCreator} added team {organization} to the enterprise {enterprise}', + 'action_add_attachment_to_card' + => '{memberCreator} attached {attachment} to {card} {attachmentPreview}', + 'action_add_attachment_to_card@card' + => '{memberCreator} attached {attachment} to this card {attachmentPreview}', + 'action_add_checklist_to_card' + => '{memberCreator} added {checklist} to {card}', + 'action_add_checklist_to_card@card' + => '{memberCreator} added {checklist} to this card', + 'action_add_label_to_card' + => '{memberCreator} added the {label} label to {card}', + 'action_add_label_to_card@card' + => '{memberCreator} added the {label} label to this card', + 'action_add_organization_to_enterprise' + => '{memberCreator} added team {organization} to the enterprise {enterprise}', + 'action_add_to_organization_board' + => '{memberCreator} added {board} to {organization}', + 'action_add_to_organization_board@board' + => '{memberCreator} added this board to {organization}', + 'action_added_a_due_date' + => '{memberCreator} set {card} to be due {date}', + 'action_added_a_due_date@card' + => '{memberCreator} set this card to be due {date}', + 'action_added_list_to_board' + => '{memberCreator} added list {list} to {board}', + 'action_added_list_to_board@board' + => '{memberCreator} added {list} to this board', + 'action_added_member_to_board' + => '{memberCreator} added {member} to {board}', + 'action_added_member_to_board@board' + => '{memberCreator} added {member} to this board', + 'action_added_member_to_board_as_admin' + => '{memberCreator} added {member} to {board} as an admin', + 'action_added_member_to_board_as_admin@board' + => '{memberCreator} added {member} to this board as an admin', + 'action_added_member_to_board_as_observer' + => '{memberCreator} added {member} to {board} as an observer', + 'action_added_member_to_board_as_observer@board' + => '{memberCreator} added {member} to this board as an observer', + 'action_added_member_to_card' + => '{memberCreator} added {member} to {card}', + 'action_added_member_to_card@card' + => '{memberCreator} added {member} to this card', + 'action_added_member_to_organization' + => '{memberCreator} added {member} to {organization}', + 'action_added_member_to_organization_as_admin' + => '{memberCreator} added {member} to {organization} as an admin', + 'action_admins_visibility' + => 'its admins', + 'action_another_board' + => 'another board', + 'action_archived_card' + => '{memberCreator} archived {card}', + 'action_archived_card@card' + => '{memberCreator} archived this card', + 'action_archived_list' + => '{memberCreator} archived list {list}', + 'action_became_a_normal_user_in_organization' + => '{memberCreator} became a normal user in {organization}', + 'action_became_a_normal_user_on' + => '{memberCreator} became a normal user on {board}', + 'action_became_a_normal_user_on@board' + => '{memberCreator} became a normal user on this board', + 'action_became_an_admin_of_organization' + => '{memberCreator} became an admin of {organization}', + 'action_board_perm_level' + => '{memberCreator} made {board} visible to {level}', + 'action_board_perm_level@board' + => '{memberCreator} made this board visible to {level}', + 'action_calendar' + => 'calendar', + 'action_cardAging' + => 'card aging', + 'action_changed_a_due_date' + => '{memberCreator} changed the due date of {card} to {date}', + 'action_changed_a_due_date@card' + => '{memberCreator} changed the due date of this card to {date}', + 'action_changed_board_background' + => '{memberCreator} changed the background of {board}', + 'action_changed_board_background@board' + => '{memberCreator} changed the background of this board', + 'action_changed_description_of_card' + => '{memberCreator} changed description of {card}', + 'action_changed_description_of_card@card' + => '{memberCreator} changed description of this card', + 'action_changed_description_of_organization' + => '{memberCreator} changed description of {organization}', + 'action_changed_display_name_of_organization' + => '{memberCreator} changed display name of {organization}', + 'action_changed_name_of_organization' + => '{memberCreator} changed name of {organization}', + 'action_changed_website_of_organization' + => '{memberCreator} changed website of {organization}', + 'action_closed_board' + => '{memberCreator} closed {board}', + 'action_closed_board@board' + => '{memberCreator} closed this board', + 'action_comment_on_card' + => '{memberCreator} {contextOn} {card} {comment}', + 'action_comment_on_card@card' + => '{memberCreator} {comment}', + 'action_completed_checkitem' + => '{memberCreator} completed {checkitem} on {card}', + 'action_completed_checkitem@card' + => '{memberCreator} completed {checkitem} on this card', + 'action_convert_to_card_from_checkitem' + => '{memberCreator} converted {card} from a checklist item on {cardSource}', + 'action_convert_to_card_from_checkitem@card' + => '{memberCreator} converted this card from a checklist item on {cardSource}', + 'action_convert_to_card_from_checkitem@cardSource' + => '{memberCreator} converted {card} from a checklist item on this card', + 'action_copy_board' + => '{memberCreator} copied this board from {board}', + 'action_copy_card' + => '{memberCreator} copied {card} from {cardSource} in list {list}', + 'action_copy_card@card' + => '{memberCreator} copied this card from {cardSource} in list {list}', + 'action_copy_comment_from_card' + => '{memberCreator} copied comment by {member} from card {card} {comment}', + 'action_create_board' + => '{memberCreator} created {board}', + 'action_create_board@board' + => '{memberCreator} created this board', + 'action_create_card' + => '{memberCreator} added {card} to {list}', + 'action_create_card@card' + => '{memberCreator} added this card to {list}', + 'action_create_custom_field' + => '{memberCreator} created the {customField} custom field on {board}', + 'action_create_custom_field@board' + => '{memberCreator} created the {customField} custom field on this board', + 'action_create_enterprise_join_request' + => '{memberCreator} requested to add team {organization} to the enterprise {enterprise}', + 'action_created_an_invitation_to_board' + => '{memberCreator} created an invitation to {board}', + 'action_created_an_invitation_to_board@board' + => '{memberCreator} created an invitation to this board', + 'action_created_an_invitation_to_organization' + => '{memberCreator} created an invitation to {organization}', + 'action_created_checklist_on_board' + => '{memberCreator} created {checklist} on {board}', + 'action_created_checklist_on_board@board' + => '{memberCreator} created {checklist} on this board', + 'action_created_organization' + => '{memberCreator} created {organization}', + 'action_decline_enterprise_join_request' + => '{memberCreator} declined the request to add team {organization} to the enterprise {enterprise}', + 'action_delete_attachment_from_card' + => '{memberCreator} deleted the {attachment} attachment from {card}', + 'action_delete_attachment_from_card@card' + => '{memberCreator} deleted the {attachment} attachment from this card', + 'action_delete_card' + => '{memberCreator} deleted card #{idCard} from {list}', + 'action_delete_custom_field' + => '{memberCreator} deleted the {customField} custom field from {board}', + 'action_delete_custom_field@board' + => '{memberCreator} deleted the {customField} custom field from this board', + 'action_deleted_account' + => '[deleted account]', + 'action_deleted_an_invitation_to_board' + => '{memberCreator} deleted an invitation to {board}', + 'action_deleted_an_invitation_to_board@board' + => '{memberCreator} deleted an invitation to this board', + 'action_deleted_an_invitation_to_organization' + => '{memberCreator} deleted an invitation to {organization}', + 'action_deleted_checkitem' + => '{memberCreator} deleted task {checkitem} on {checklist}', + 'action_disabled_calendar_feed' + => '{memberCreator} disabled the iCalendar feed on {board}', + 'action_disabled_calendar_feed@board' + => '{memberCreator} disabled the iCalendar feed on this board', + 'action_disabled_card_covers' + => '{memberCreator} disabled card cover images on {board}', + 'action_disabled_card_covers@board' + => '{memberCreator} disabled card cover images on this board', + 'action_disabled_commenting' + => '{memberCreator} disabled commenting on {board}', + 'action_disabled_commenting@board' + => '{memberCreator} disabled commenting on this board', + 'action_disabled_inviting' + => '{memberCreator} disabled inviting on {board}', + 'action_disabled_inviting@board' + => '{memberCreator} disabled inviting on this board', + 'action_disabled_plugin' + => '{memberCreator} disabled the {plugin} Power-Up', + 'action_disabled_powerup' + => '{memberCreator} disabled the {powerup} Power-Up', + 'action_disabled_self_join' + => '{memberCreator} disabled self join on {board}', + 'action_disabled_self_join@board' + => '{memberCreator} disabled self join on this board', + 'action_disabled_voting' + => '{memberCreator} disabled voting on {board}', + 'action_disabled_voting@board' + => '{memberCreator} disabled voting on this board', + 'action_due_date_change' + => '{memberCreator}', + 'action_email_card' + => '{memberCreator} emailed {card} to {list}', + 'action_email_card@card' + => '{memberCreator} emailed this card to {list}', + 'action_email_card_from' + => '{memberCreator} emailed {card} to {list} from {from}', + 'action_email_card_from@card' + => '{memberCreator} emailed this card to {list} from {from}', + 'action_enabled_calendar_feed' + => '{memberCreator} enabled the iCalendar feed on {board}', + 'action_enabled_calendar_feed@board' + => '{memberCreator} enabled the iCalendar feed on this board', + 'action_enabled_card_covers' + => '{memberCreator} enabled card cover images on {board}', + 'action_enabled_card_covers@board' + => '{memberCreator} enabled card cover images on this board', + 'action_enabled_plugin' + => '{memberCreator} enabled the {plugin} Power-Up', + 'action_enabled_powerup' + => '{memberCreator} enabled the {powerup} Power-Up', + 'action_enabled_self_join' + => '{memberCreator} enabled self join on {board}', + 'action_enabled_self_join@board' + => '{memberCreator} enabled self join on this board', + 'action_hid_board' + => '{memberCreator} hid {board}', + 'action_hid_board@board' + => '{memberCreator} hid this board', + 'action_invited_an_unconfirmed_member_to_board' + => '{memberCreator} invited an unconfirmed member to {board}', + 'action_invited_an_unconfirmed_member_to_board@board' + => '{memberCreator} invited an unconfirmed member to this board', + 'action_invited_an_unconfirmed_member_to_organization' + => '{memberCreator} invited an unconfirmed member to {organization}', + 'action_joined_board' + => '{memberCreator} joined {board}', + 'action_joined_board@board' + => '{memberCreator} joined this board', + 'action_joined_board_by_invitation_link' + => '{memberCreator} joined {board} with an invitation link from {memberInviter}', + 'action_joined_board_by_invitation_link@board' + => '{memberCreator} joined this board with an invitation link from {memberInviter}', + 'action_joined_organization' + => '{memberCreator} joined {organization}', + 'action_joined_organization_by_invitation_link' + => '{memberCreator} joined {organization} with an invitation link from {memberInviter}', + 'action_left_board' + => '{memberCreator} left {board}', + 'action_left_board@board' + => '{memberCreator} left this board', + 'action_left_organization' + => '{memberCreator} left {organization}', + 'action_made_a_normal_user_in_organization' + => '{memberCreator} made {member} a normal user in {organization}', + 'action_made_a_normal_user_on' + => '{memberCreator} made {member} a normal user on {board}', + 'action_made_a_normal_user_on@board' + => '{memberCreator} made {member} a normal user on this board', + 'action_made_admin_of_board' + => '{memberCreator} made {member} an admin of {board}', + 'action_made_admin_of_board@board' + => '{memberCreator} made {member} an admin of this board', + 'action_made_an_admin_of_organization' + => '{memberCreator} made {member} an admin of {organization}', + 'action_made_commenting_on' + => '{memberCreator} made commenting on {board} available to {level}', + 'action_made_commenting_on@board' + => '{memberCreator} made commenting on this board available to {level}', + 'action_made_inviting_on' + => '{memberCreator} made inviting on {board} available to {level}', + 'action_made_inviting_on@board' + => '{memberCreator} made inviting on this board available to {level}', + 'action_made_observer_of_board' + => '{memberCreator} made {member} an observer of {board}', + 'action_made_observer_of_board@board' + => '{memberCreator} made {member} an observer of this board', + 'action_made_self_admin_of_board' + => '{memberCreator} made themselves an admin of {board}', + 'action_made_self_admin_of_board@board' + => '{memberCreator} made themselves an admin of this board', + 'action_made_self_observer_of_board' + => '{memberCreator} became an observer of {board}', + 'action_made_self_observer_of_board@board' + => '{memberCreator} became an observer of this board', + 'action_made_voting_on' + => '{memberCreator} made voting on {board} available to {level}', + 'action_made_voting_on@board' + => '{memberCreator} made voting on this board available to {level}', + 'action_marked_checkitem_incomplete' + => '{memberCreator} marked {checkitem} incomplete on {card}', + 'action_marked_checkitem_incomplete@card' + => '{memberCreator} marked {checkitem} incomplete on this card', + 'action_marked_the_due_date_complete' + => '{memberCreator} marked the due date on {card} complete', + 'action_marked_the_due_date_complete@card' + => '{memberCreator} marked the due date complete', + 'action_marked_the_due_date_incomplete' + => '{memberCreator} marked the due date on {card} incomplete', + 'action_marked_the_due_date_incomplete@card' + => '{memberCreator} marked the due date incomplete', + 'action_member_joined_card' + => '{memberCreator} joined {card}', + 'action_member_joined_card@card' + => '{memberCreator} joined this card', + 'action_member_left_card' + => '{memberCreator} left {card}', + 'action_member_left_card@card' + => '{memberCreator} left this card', + 'action_members_visibility' + => 'its members', + 'action_move_card_from_board' + => '{memberCreator} transferred {card} to {board}', + 'action_move_card_from_board@card' + => '{memberCreator} transferred this card to {board}', + 'action_move_card_from_list_to_list' + => '{memberCreator} moved {card} from {listBefore} to {listAfter}', + 'action_move_card_from_list_to_list@card' + => '{memberCreator} moved this card from {listBefore} to {listAfter}', + 'action_move_card_to_board' + => '{memberCreator} transferred {card} from {board}', + 'action_move_card_to_board@card' + => '{memberCreator} transferred this card from {board}', + 'action_move_list_from_board' + => '{memberCreator} transferred {list} to {board}', + 'action_move_list_to_board' + => '{memberCreator} transferred {list} from {board}', + 'action_moved_card_higher' + => '{memberCreator} moved {card} higher', + 'action_moved_card_higher@card' + => '{memberCreator} moved this card higher', + 'action_moved_card_lower' + => '{memberCreator} moved {card} lower', + 'action_moved_card_lower@card' + => '{memberCreator} moved this card lower', + 'action_moved_checkitem_higher' + => '{memberCreator} moved {checkitem} higher in the checklist {checklist}', + 'action_moved_checkitem_lower' + => '{memberCreator} moved {checkitem} higher in the checklist {checklist}', + 'action_moved_list_left' + => '{memberCreator} moved list {list} left on {board}', + 'action_moved_list_left@board' + => '{memberCreator} moved {list} left on this board', + 'action_moved_list_right' + => '{memberCreator} moved list {list} right on {board}', + 'action_moved_list_right@board' + => '{memberCreator} moved {list} right on this board', + 'action_observers_visibility' + => 'members and observers', + 'action_on' + => 'on', + 'action_org_visibility' + => 'members of its team', + 'action_public_visibility' + => 'the public', + 'action_remove_checklist_from_card' + => '{memberCreator} removed {checklist} from {card}', + 'action_remove_checklist_from_card@card' + => '{memberCreator} removed {checklist} from this card', + 'action_remove_from_organization_board' + => '{memberCreator} removed {board} from {organization}', + 'action_remove_from_organization_board@board' + => '{memberCreator} removed this board from {organization}', + 'action_remove_label_from_card' + => '{memberCreator} removed the {label} label from {card}', + 'action_remove_label_from_card@card' + => '{memberCreator} removed the {label} label from this card', + 'action_remove_organization_from_enterprise' + => '{memberCreator} removed team {organization} from the enterprise {enterprise}', + 'action_removed_a_due_date' + => '{memberCreator} removed the due date from {card}', + 'action_removed_a_due_date@card' + => '{memberCreator} removed the due date from this card', + 'action_removed_from_board' + => '{memberCreator} removed {member} from {board}', + 'action_removed_from_board@board' + => '{memberCreator} removed {member} from this board', + 'action_removed_member_from_card' + => '{memberCreator} removed {member} from {card}', + 'action_removed_member_from_card@card' + => '{memberCreator} removed {member} from this card', + 'action_removed_member_from_organization' + => '{memberCreator} removed {member} from {organization}', + 'action_removed_vote_for_card' + => '{memberCreator} removed vote for {card}', + 'action_removed_vote_for_card@card' + => '{memberCreator} removed vote for this card', + 'action_rename_custom_field' + => '{memberCreator} renamed the {customField} custom field on {board} (from {name})', + 'action_rename_custom_field@board' + => '{memberCreator} renamed the {customField} custom field on this board (from {name})', + 'action_renamed_card' + => '{memberCreator} renamed {card} (from {name})', + 'action_renamed_card@card' + => '{memberCreator} renamed this card (from {name})', + 'action_renamed_checkitem' + => '{memberCreator} renamed {checkitem} (from {name})', + 'action_renamed_checklist' + => '{memberCreator} renamed {checklist} (from {name})', + 'action_renamed_list' + => '{memberCreator} renamed list {list} (from {name})', + 'action_reopened_board' + => '{memberCreator} re-opened {board}', + 'action_reopened_board@board' + => '{memberCreator} re-opened this board', + 'action_sent_card_to_board' + => '{memberCreator} sent {card} to the board', + 'action_sent_card_to_board@card' + => '{memberCreator} sent this card to the board', + 'action_sent_list_to_board' + => '{memberCreator} sent list {list} to the board', + 'action_set_card_aging_mode_pirate' + => '{memberCreator} changed card aging to pirate mode', + 'action_set_card_aging_mode_regular' + => '{memberCreator} changed card aging to regular mode', + 'action_update_board_desc' + => '{memberCreator} changed description of {board}', + 'action_update_board_desc@board' + => '{memberCreator} changed description of this board', + 'action_update_board_name' + => '{memberCreator} renamed {board} (from {name})', + 'action_update_board_name@board' + => '{memberCreator} renamed this board (from {name})', + 'action_update_custom_field' + => '{memberCreator} updated the {customField} custom field on {board}', + 'action_update_custom_field@board' + => '{memberCreator} updated the {customField} custom field on this board', + 'action_update_custom_field_item' + => '{memberCreator} updated the value for the {customFieldItem} custom field on {card}', + 'action_update_custom_field_item@card' + => '{memberCreator} updated the value for the {customFieldItem} custom field on this card', + 'action_updated_their_bio' + => '{memberCreator} updated their bio', + 'action_updated_their_display_name' + => '{memberCreator} updated their display name', + 'action_updated_their_initials' + => '{memberCreator} updated their initials', + 'action_updated_their_username' + => '{memberCreator} updated their username', + 'action_vote_on_card' + => '{memberCreator} voted for {card}', + 'action_vote_on_card@card' + => '{memberCreator} voted for this card', + 'action_voting' + => 'voting', + 'action_withdraw_enterprise_join_request' + => '{memberCreator} withdrew a request to add team {organization} to the enterprise {enterprise}' + ]; - const REQUEST_ACTIONS_CARDS = array( - 'addAttachmentToCard', - 'addChecklistToCard', - 'addMemberToCard', - 'commentCard', - 'copyCommentCard', - 'convertToCardFromCheckItem', - 'createCard', - 'copyCard', - 'deleteAttachmentFromCard', - 'emailCard', - 'moveCardFromBoard', - 'moveCardToBoard', - 'removeChecklistFromCard', - 'removeMemberFromCard', - 'updateCard:idList', - 'updateCard:closed', - 'updateCard:due', - 'updateCard:dueComplete', - 'updateCheckItemStateOnCard', - 'updateCustomFieldItem' - ); + const REQUEST_ACTIONS_BOARDS = [ + 'addAttachmentToCard', + 'addChecklistToCard', + 'addMemberToCard', + 'commentCard', + 'copyCommentCard', + 'convertToCardFromCheckItem', + 'createCard', + 'copyCard', + 'deleteAttachmentFromCard', + 'emailCard', + 'moveCardFromBoard', + 'moveCardToBoard', + 'removeChecklistFromCard', + 'removeMemberFromCard', + 'updateCard:idList', + 'updateCard:closed', + 'updateCard:due', + 'updateCard:dueComplete', + 'updateCheckItemStateOnCard', + 'updateCustomFieldItem', + 'addMemberToBoard', + 'addToOrganizationBoard', + 'copyBoard', + 'createBoard', + 'createCustomField', + 'createList', + 'deleteCard', + 'deleteCustomField', + 'disablePlugin', + 'disablePowerUp', + 'enablePlugin', + 'enablePowerUp', + 'makeAdminOfBoard', + 'makeNormalMemberOfBoard', + 'makeObserverOfBoard', + 'moveListFromBoard', + 'moveListToBoard', + 'removeFromOrganizationBoard', + 'unconfirmedBoardInvitation', + 'unconfirmedOrganizationInvitation', + 'updateBoard', + 'updateCustomField', + 'updateList:closed' + ]; - private $feedName = ''; - private $feedURI = ''; + const REQUEST_ACTIONS_CARDS = [ + 'addAttachmentToCard', + 'addChecklistToCard', + 'addMemberToCard', + 'commentCard', + 'copyCommentCard', + 'convertToCardFromCheckItem', + 'createCard', + 'copyCard', + 'deleteAttachmentFromCard', + 'emailCard', + 'moveCardFromBoard', + 'moveCardToBoard', + 'removeChecklistFromCard', + 'removeMemberFromCard', + 'updateCard:idList', + 'updateCard:closed', + 'updateCard:due', + 'updateCard:dueComplete', + 'updateCheckItemStateOnCard', + 'updateCustomFieldItem' + ]; - private function queryAPI($path, $params = array()) { - $data = json_decode(getContents('https://trello.com/1/' - . $path - . '?' - . http_build_query($params))) - or returnServerError('Failed to query trello API'); - return $data; - } + private $feedName = ''; + private $feedURI = ''; - private function renderAction($action, $textOnly = false) { - if(!array_key_exists($action->display->translationKey, self::ACTION_TEXTS)) { - return ''; - } + private function queryAPI($path, $params = []) + { + $url = 'https://trello.com/1/' . $path . '?' . http_build_query($params); + $data = json_decode(getContents($url)); + return $data; + } - $strings = array(); - $entities = (array)$action->display->entities; + private function renderAction($action, $textOnly = false) + { + if (!array_key_exists($action->display->translationKey, self::ACTION_TEXTS)) { + return ''; + } - foreach($entities as $entity_name => $entity) { - $type = $entity->type; - if($type === 'attachmentPreview' - && !$textOnly - && isset($entity->originalUrl)) { - $string = ''; - } elseif($type === 'card' && !$textOnly) { - $string = '' - . $entity->text - . ''; - } elseif($type === 'member' && !$textOnly) { - $string = '' - . $entity->text - . ''; - } elseif($type === 'date') { - $string = gmdate('M j, Y \a\t g:i A T', strtotime($entity->date)); - } elseif($type === 'translatable') { - $string = self::ACTION_TEXTS[$entity->translationKey]; - } else { - if(isset($entity->text)) { - $string = $entity->text; - } else { - $string = ''; - } - } - $strings['{' . $entity_name . '}'] = $string; - } + $strings = []; + $entities = (array)$action->display->entities; - return str_replace(array_keys($strings), - array_values($strings), - self::ACTION_TEXTS[$action->display->translationKey]); - } + foreach ($entities as $entity_name => $entity) { + $type = $entity->type; + if ( + $type === 'attachmentPreview' + && !$textOnly + && isset($entity->originalUrl) + ) { + $string = sprintf( + '', + $entity->originalUrl, + $entity->previewUrl ?? '' + ); + } elseif ($type === 'card' && !$textOnly) { + $string = sprintf('%s', $entity->shortLink, $entity->text); + } elseif ($type === 'member' && !$textOnly) { + $string = sprintf('%s', $entity->username, $entity->text); + } elseif ($type === 'date') { + $string = gmdate('M j, Y \a\t g:i A T', strtotime($entity->date)); + } elseif ($type === 'translatable') { + $string = self::ACTION_TEXTS[$entity->translationKey]; + } else { + $string = $entity->text ?? ''; + } + $strings['{' . $entity_name . '}'] = $string; + } - public function collectData() { - $apiParams = array( - 'actions_display' => 'true', - 'fields' => 'name,url' - ); - switch($this->queriedContext) { - case 'Board': - $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_BOARDS); - $data = $this->queryAPI('boards/' . $this->getInput('b'), $apiParams); - break; - case 'Card': - $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_CARDS); - $data = $this->queryAPI('cards/' . $this->getInput('c'), $apiParams); - break; - default: - returnClientError('Invalid context'); - } + return str_replace( + array_keys($strings), + array_values($strings), + self::ACTION_TEXTS[$action->display->translationKey] + ); + } - $this->feedName = $data->name; - $this->feedURI = $data->url; + public function collectData() + { + $apiParams = [ + 'actions_display' => 'true', + 'fields' => 'name,url' + ]; + switch ($this->queriedContext) { + case 'Board': + $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_BOARDS); + $data = $this->queryAPI('boards/' . $this->getInput('b'), $apiParams); + break; + case 'Card': + $apiParams['actions'] = implode(',', self::REQUEST_ACTIONS_CARDS); + $data = $this->queryAPI('cards/' . $this->getInput('c'), $apiParams); + break; + default: + returnClientError('Invalid context'); + } - foreach($data->actions as $action) { - $item = array(); + $this->feedName = $data->name; + $this->feedURI = $data->url; - $item['title'] = $this->renderAction($action, true); - $item['timestamp'] = strtotime($action->date); - $item['author'] = $action->memberCreator->fullName; - $item['categories'] = array( - 'trello', - $action->data->board->name, - $action->type - ); - if(isset($action->data->card)) { - $item['categories'][] = $action->data->card->name; - $item['uri'] = 'https://trello.com/c/' - . $action->data->card->shortLink - . '#action-' - . $action->id; - } else { - $item['uri'] = 'https://trello.com/b/' - . $action->data->board->shortLink; - } - $item['content'] = $this->renderAction($action, false); - if(isset($action->data->attachment->url)) { - $item['enclosures'] = array($action->data->attachment->url); - } + foreach ($data->actions as $action) { + $item = []; - $this->items[] = $item; - } - } + $item['title'] = $this->renderAction($action, true); + $item['timestamp'] = strtotime($action->date); + $item['author'] = $action->memberCreator->fullName; + $item['categories'] = [ + 'trello', + $action->data->board->name, + $action->type + ]; + if (isset($action->data->card)) { + $item['categories'][] = $action->data->card->name ?? $action->data->card->id; + $item['uri'] = 'https://trello.com/c/' + . $action->data->card->shortLink + . '#action-' + . $action->id; + } else { + $item['uri'] = 'https://trello.com/b/' + . $action->data->board->shortLink; + } + $item['content'] = $this->renderAction($action, false); + if (isset($action->data->attachment->url)) { + $item['enclosures'] = [$action->data->attachment->url]; + } - public function detectParameters($url) { - $regex = '/^(https?:\/\/)?trello\.com\/([bc])\/([^\/?\n]+)/'; - if(preg_match($regex, $url, $matches) > 0) { - return array($matches[2] => $matches[3]); - } else { - return null; - } - } + $this->items[] = $item; + } + } - public function getURI() { - switch($this->queriedContext) { - case 'Board': - case 'Card': - return $this->feedURI; - default: return parent::getURI(); - } - } + public function detectParameters($url) + { + $regex = '/^(https?:\/\/)?trello\.com\/([bc])\/([^\/?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + if ($matches[2] == 'b') { + $context = 'Board'; + } else if ($matches[2] == 'c') { + $context = 'Card'; + } + return [ + 'context' => $context, + $matches[2] => $matches[3] + ]; + } else { + return null; + } + } - public function getName() { - switch($this->queriedContext) { - case 'Board': - case 'Card': - return $this->feedName; - default: return parent::getName(); - } - } + public function getURI() + { + switch ($this->queriedContext) { + case 'Board': + case 'Card': + return $this->feedURI; + default: + return parent::getURI(); + } + } + + public function getName() + { + switch ($this->queriedContext) { + case 'Board': + case 'Card': + return $this->feedName; + default: + return parent::getName(); + } + } } diff --git a/bridges/TwitScoopBridge.php b/bridges/TwitScoopBridge.php new file mode 100644 index 00000000000..b3ebe8af276 --- /dev/null +++ b/bridges/TwitScoopBridge.php @@ -0,0 +1,155 @@ + [ + 'name' => 'Country', + 'type' => 'list', + 'values' => [ + 'Worldwide' => 'worldwide', + 'Algeria' => 'algeria', + 'Argentina' => 'argentina', + 'Australia' => 'australia', + 'Austria' => 'austria', + 'Bahrain' => 'bahrain', + 'Belarus' => 'belarus', + 'Belgium' => 'belgium', + 'Brazil' => 'brazil', + 'Canada' => 'canada', + 'Chile' => 'chile', + 'Colombia' => 'colombia', + 'Denmark' => 'denmark', + 'Dominican Republic' => 'dominican-republic', + 'Ecuador' => 'ecuador', + 'Egypt' => 'egypt', + 'France' => 'france', + 'Germany' => 'germany', + 'Ghana' => 'ghana', + 'Greece' => 'greece', + 'Guatemala' => 'guatemala', + 'India' => 'india', + 'Indonesia' => 'indonesia', + 'Ireland' => 'ireland', + 'Israel' => 'israel', + 'Italy' => 'italy', + 'Japan' => 'japan', + 'Jordan' => 'jordan', + 'Kenya' => 'kenya', + 'Korea' => 'korea', + 'Kuwait' => 'kuwait', + 'Latvia' => 'latvia', + 'Lebanon' => 'lebanon', + 'Malaysia' => 'malaysia', + 'Mexico' => 'mexico', + 'Netherlands' => 'netherlands', + 'New Zealand' => 'new-zealand', + 'Nigeria' => 'nigeria', + 'Norway' => 'norway', + 'Oman' => 'oman', + 'Pakistan' => 'pakistan', + 'Panama' => 'panama', + 'Peru' => 'peru', + 'Philippines' => 'philippines', + 'Poland' => 'poland', + 'Portugal' => 'portugal', + 'Puerto Rico' => 'puerto-rico', + 'Qatar' => 'qatar', + 'Russia' => 'russia', + 'Saudi Arabia' => 'saudi-arabia', + 'Singapore' => 'singapore', + 'South Africa' => 'south-africa', + 'Spain' => 'spain', + 'Sweden' => 'sweden', + 'Switzerland' => 'switzerland', + 'Thailand' => 'thailand', + 'Turkey' => 'turkey', + 'Ukraine' => 'ukraine', + 'United Arab Emirates' => 'united-arab-emirates', + 'United Kingdom' => 'united-kingdom', + 'United States' => 'united-states', + 'Venezuela' => 'venezuela', + 'Vietnam' => 'vietnam', + ] + ], + 'limit' => [ + 'name' => 'Topics', + 'type' => 'number', + 'title' => 'Number of trending topics to return. Max 50', + 'defaultValue' => 20, + ] + ] + ]; + + const CACHE_TIMEOUT = 900; // 15 mins + + public function collectData() + { + $html = getSimpleHTMLDOM($this->getURI()); + + $updated = $html->find('time', 0)->datetime; + $trends = $html->find('div.trends', 0); + + $limit = $this->getInput('limit'); + + if ($limit > 50 || $limit < 1) { + $limit = 50; + } + + foreach ($trends->find('ol.items > li') as $index => $li) { + $number = $index + 1; + + $item = []; + + $name = rtrim($li->find('span.trend.name', 0)->plaintext, ' '); + $tweets = str_replace(' tweets', '', $li->find('span.tweets', 0)->plaintext); + $tweets = str_replace('<', '', $tweets); + + $item['title'] = '#' . $number . ' - ' . $name . ' (' . $tweets . ' tweets)'; + $item['uri'] = 'https://twitter.com/search?q=' . rawurlencode($name); + + if ($tweets === '10K') { + $tweets = 'less than 10K'; + } + + $item['content'] = <<Rank
+{$number}
+Topic
+{$name}
+Tweets
+{$tweets}
+EOD; + $item['timestamp'] = $updated; + + $this->items[] = $item; + + if (count($this->items) >= $limit) { + break; + } + } + } + + public function getURI() + { + if (!is_null($this->getInput('country'))) { + return self::URI . '/' . $this->getInput('country'); + } + + return parent::getURI(); + } + + public function getName() + { + if (!is_null($this->getInput('country'))) { + return $this->getKey('country') . ' - TwitScoop'; + } + + return parent::getName(); + } +} diff --git a/bridges/TwitchBridge.php b/bridges/TwitchBridge.php index 39b460107e0..6605a973bbd 100644 --- a/bridges/TwitchBridge.php +++ b/bridges/TwitchBridge.php @@ -1,202 +1,278 @@ array( - 'name' => 'Channel', - 'type' => 'text', - 'required' => true, - 'title' => 'Lowercase channel name as seen in channel URL' - ), - 'type' => array( - 'name' => 'Type', - 'type' => 'list', - 'values' => array( - 'All' => 'all', - 'Archive' => 'archive', - 'Highlights' => 'highlight', - 'Uploads' => 'upload' - ), - 'defaultValue' => 'archive' - ) - )); - - /* - * Official instructions for obtaining your own client ID can be found here: - * https://dev.twitch.tv/docs/v5/#getting-a-client-id - */ - const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; - - public function collectData(){ - // get channel user - $query_data = array( - 'login' => $this->getInput('channel') - ); - $users = $this->apiGet('users', $query_data)->users; - if(count($users) === 0) - returnClientError('User "' - . $this->getInput('channel') - . '" could not be found'); - $user = $users[0]; - - // get video list - $query_endpoint = 'channels/' . $user->_id . '/videos'; - $query_data = array( - 'broadcast_type' => $this->getInput('type'), - 'limit' => 10 - ); - $videos = $this->apiGet($query_endpoint, $query_data)->videos; - - foreach($videos as $video) { - $item = array( - 'uri' => $video->url, - 'title' => $video->title, - 'timestamp' => $video->published_at, - 'author' => $video->channel->display_name, - ); - - // Add categories for tags and played game - $item['categories'] = array_filter(explode(' ', $video->tag_list)); - if(!empty($video->game)) - $item['categories'][] = $video->game; - - // Add enclosures for thumbnails from a few points in the video - $item['enclosures'] = array(); - foreach($video->thumbnails->large as $thumbnail) - $item['enclosures'][] = $thumbnail->url; - - /* - * Content format example: - * - * [Preview Image] - * - * Some optional video description. - * - * Duration: 1:23:45 - * Views: 123 - * - * Played games: - * * 00:00:00 Game 1 - * * 00:12:34 Game 2 - * - */ - $item['content'] = '' - . $video->description_html - . '
Duration: ' - . $this->formatTimestampTime($video->length) - . '
'; - - // Add played games list to content - $video_id = trim($video->_id, 'v'); // _id gives 'v1234' but API wants '1234' - $markers = $this->apiGet('videos/' . $video_id . '/markers')->markers; - $item['content'] .= '
Views: ' - . $video->views - . 'Played games:
'; - - $this->items[] = $item; - } - } - - // e.g. 01:53:27 - private function formatTimestampTime($seconds) { - return sprintf('%02d:%02d:%02d', - floor($seconds / 3600), - ($seconds / 60) % 60, - $seconds % 60); - } - - // e.g. 01h53m27s - private function formatQueryTime($seconds) { - return sprintf('%02dh%02dm%02ds', - floor($seconds / 3600), - ($seconds / 60) % 60, - $seconds % 60); - } - - /* - * Ideally the new 'helix' API should be used as v5/'kraken' is deprecated. - * The new API however still misses many features (markers, played game..) of - * the old one, so let's use the old one for as long as it's available. - */ - private function apiGet($endpoint, $query_data = array()) { - $query_data['api_version'] = 5; - $url = 'https://api.twitch.tv/kraken/' - . $endpoint - . '?' - . http_build_query($query_data); - $header = array( - 'Client-ID: ' . self::CLIENT_ID - ); - - $data = json_decode(getContents($url, $header)) - or returnServerError('API request to "' . $url . '" failed.'); - - return $data; - } - - public function getName(){ - if(!is_null($this->getInput('channel'))) { - return $this->getInput('channel') . ' twitch videos'; - } - - return parent::getName(); - } - - public function getURI(){ - if(!is_null($this->getInput('channel'))) { - return self::URI . $this->getInput('channel'); - } - - return parent::getURI(); - } - - public function detectParameters($url){ - $params = array(); - - // Matches e.g. https://www.twitch.tv/someuser/videos?filter=archives - $regex = '/^(https?:\/\/)? + +class TwitchBridge extends BridgeAbstract +{ + const MAINTAINER = 'Roliga'; + const NAME = 'Twitch Bridge'; + const URI = 'https://twitch.tv/'; + const CACHE_TIMEOUT = 300; // 5min + const DESCRIPTION = 'Twitch channel videos'; + const PARAMETERS = [ [ + 'channel' => [ + 'name' => 'Channel', + 'type' => 'text', + 'required' => true, + 'exampleValue' => 'criticalrole', + 'title' => 'Lowercase channel name as seen in channel URL' + ], + 'type' => [ + 'name' => 'Type', + 'type' => 'list', + 'values' => [ + 'All' => 'all', + 'Archive' => 'archive', + 'Highlights' => 'highlight', + 'Uploads' => 'upload', + 'Past Premieres' => 'past_premiere', + 'Premiere Uploads' => 'premiere_upload' + ], + 'defaultValue' => 'archive' + ] + ]]; + + const BROADCAST_TYPES = [ + 'all' => [ + 'ARCHIVE', + 'HIGHLIGHT', + 'UPLOAD', + 'PAST_PREMIERE', + 'PREMIERE_UPLOAD' + ], + 'archive' => 'ARCHIVE', + 'highlight' => 'HIGHLIGHT', + 'upload' => 'UPLOAD', + 'past_premiere' => 'PAST_PREMIERE', + 'premiere_upload' => 'PREMIERE_UPLOAD' + ]; + + public function collectData() + { + $query = <<<'EOD' +query VODList($channel: String!, $types: [BroadcastType!]) { + user(login: $channel) { + displayName + videos(types: $types, sort: TIME) { + edges { + node { + id + title + publishedAt + lengthSeconds + viewCount + thumbnailURLs(width: 640, height: 360) + previewThumbnailURL(width: 640, height: 360) + description + tags + contentTags { + isLanguageTag + localizedName + } + game { + displayName + } + moments(momentRequestType: VIDEO_CHAPTER_MARKERS) { + edges { + node { + description + positionMilliseconds + } + } + } + } + } + } + } +} +EOD; + $channel = $this->getInput('channel'); + $type = $this->getInput('type'); + $variables = [ + 'channel' => $channel, + 'types' => self::BROADCAST_TYPES[$type] + ]; + $response = $this->apiRequest($query, $variables); + $data = $response->data; + if ($data->user === null) { + throw new \Exception(sprintf('Unable to find channel `%s`', $channel)); + } + + $user = $data->user; + if ($user->videos === null) { + // twitch regularly does this for unknown reasons + $this->debug->info('Twitch returned empty set of videos', ['data' => $data]); + return; + } + + foreach ($user->videos->edges as $edge) { + $video = $edge->node; + + $url = 'https://www.twitch.tv/videos/' . $video->id; + + $item = [ + 'uri' => $url, + 'title' => $video->title, + 'timestamp' => $video->publishedAt, + 'author' => $user->displayName, + ]; + + // Add categories for tags and played game + $item['categories'] = $video->tags; + if (!is_null($video->game)) { + $item['categories'][] = $video->game->displayName; + } + + $contentTags = $video->contentTags ?? []; + foreach ($contentTags as $tag) { + if (!$tag->isLanguageTag) { + $item['categories'][] = $tag->localizedName; + } + } + + // Add enclosures for thumbnails from a few points in the video + // Thumbnail list has duplicate entries sometimes so remove those + $item['enclosures'] = array_unique($video->thumbnailURLs); + + /* + * Content format example: + * + * [Preview Image] + * + * Some optional video description. + * + * Duration: 1:23:45 + * Views: 123 + * + * Played games: + * * 00:00:00 Game 1 + * * 00:12:34 Game 2 + * + */ + $item['content'] = '
- 00:00:00 - ' - . $video->game - . '
'; - if(isset($markers->game_changes)) { - usort($markers->game_changes, function($a, $b) { - return $a->time - $b->time; - }); - foreach($markers->game_changes as $game_change) { - $item['categories'][] = $game_change->label; - $item['content'] .= '- ' - . $this->formatTimestampTime($game_change->time) - . ' - ' - . $game_change->label - . '
'; - } - } - $item['content'] .= '' + . $video->description // in markdown format + . '
Duration: ' + . $this->formatTimestampTime($video->lengthSeconds) + . '
'; + + // Add played games list to content + $item['content'] .= '
Views: ' + . $video->viewCount + . 'Played games:
'; + + $momentEdges = $video->moments->edges ?? []; + if (count($momentEdges) > 0) { + foreach ($momentEdges as $momentEdge) { + $moment = $momentEdge->node; + + $item['categories'][] = $moment->description; + $item['content'] .= '
'; + + $item['categories'] = array_unique($item['categories']); + + $this->items[] = $item; + } + } + + // e.g. 01:53:27 + private function formatTimestampTime($seconds) + { + $floor = floor($seconds / 3600); + $i = intval($seconds / 60) % 60; + $i1 = $seconds % 60; + + return sprintf('%02d:%02d:%02d', $floor, $i, $i1); + } + + // e.g. 01h53m27s + private function formatQueryTime($seconds) + { + $floor = floor($seconds / 3600); + $i = intval($seconds / 60) % 60; + $i1 = $seconds % 60; + + return sprintf('%02dh%02dm%02ds', $floor, $i, $i1); + } + + /** + * GraphQL: https://graphql.org/ + * Tool for developing/testing queries: https://github.com/skevy/graphiql-app + * + * Official instructions for obtaining your own client ID can be found here: + * https://dev.twitch.tv/docs/v5/#getting-a-client-id + */ + private function apiRequest($query, $variables) + { + $request = [ + 'query' => $query, + 'variables' => $variables, + ]; + $headers = [ + 'Client-ID: kimne78kx3ncx6brgo4mv6wki5h1ko', + ]; + $opts = [ + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => json_encode($request), + ]; + $json = getContents('https://gql.twitch.tv/gql', $headers, $opts); + $result = Json::decode($json, false); + return $result; + } + + public function getName() + { + if (!is_null($this->getInput('channel'))) { + return $this->getInput('channel') . ' twitch videos'; + } + + return parent::getName(); + } + + public function getURI() + { + if (!is_null($this->getInput('channel'))) { + return self::URI . $this->getInput('channel'); + } + + return parent::getURI(); + } + + public function detectParameters($url) + { + $params = []; + + // Matches e.g. https://www.twitch.tv/someuser/videos?filter=archives + $regex = '/^(https?:\/\/)? (www\.)? twitch\.tv\/ ([^\/&?\n]+) \/videos\?.*filter= (all|archive|highlight|upload)/x'; - if(preg_match($regex, $url, $matches) > 0) { - $params['channel'] = urldecode($matches[3]); - $params['type'] = $matches[4]; - return $params; - } - - return null; - } + if (preg_match($regex, $url, $matches) > 0) { + $params['channel'] = urldecode($matches[3]); + $params['type'] = $matches[4]; + return $params; + } + + return null; + } } diff --git a/bridges/TwitterBridge.php b/bridges/TwitterBridge.php index 2f5565b173b..800fd63ca82 100644 --- a/bridges/TwitterBridge.php +++ b/bridges/TwitterBridge.php @@ -1,34 +1,39 @@ array( - 'nopic' => array( - 'name' => 'Hide profile pictures', - 'type' => 'checkbox', - 'title' => 'Activate to hide profile pictures in content' - ), - 'noimg' => array( - 'name' => 'Hide images in tweets', - 'type' => 'checkbox', - 'title' => 'Activate to hide images in tweets' - ), - 'noimgscaling' => array( - 'name' => 'Disable image scaling', - 'type' => 'checkbox', - 'title' => 'Activate to disable image scaling in tweets (keeps original image)' - ) - ), - 'By keyword or hashtag' => array( - 'q' => array( - 'name' => 'Keyword or #hashtag', - 'required' => true, - 'exampleValue' => 'rss-bridge, #rss-bridge', - 'title' => <<- ' + . $this->formatTimestampTime($moment->positionMilliseconds / 1000) + . ' - ' + . $moment->description + . '
'; + } + } else { + $item['content'] .= '- 00:00:00 - ' + . ($video->game ? $video->game->displayName : 'No Game') + . '
'; + } + $item['content'] .= '[ + 'nopic' => [ + 'name' => 'Hide profile pictures', + 'type' => 'checkbox', + 'title' => 'Activate to hide profile pictures in content' + ], + 'noimg' => [ + 'name' => 'Hide images in tweets', + 'type' => 'checkbox', + 'title' => 'Activate to hide images in tweets' + ], + 'noimgscaling' => [ + 'name' => 'Disable image scaling', + 'type' => 'checkbox', + 'title' => 'Activate to disable image scaling in tweets (keeps original image)' + ] + ], + 'By keyword or hashtag' => [ + 'q' => [ + 'name' => 'Keyword or #hashtag', + 'required' => true, + 'exampleValue' => 'rss-bridge OR rssbridge', + 'title' => << array( - 'u' => array( - 'name' => 'username', - 'required' => true, - 'exampleValue' => 'sebsauvage', - 'title' => 'Insert a user name' - ), - 'norep' => array( - 'name' => 'Without replies', - 'type' => 'checkbox', - 'title' => 'Only return initial tweets' - ), - 'noretweet' => array( - 'name' => 'Without retweets', - 'required' => false, - 'type' => 'checkbox', - 'title' => 'Hide retweets' - ) - ), - 'By list' => array( - 'user' => array( - 'name' => 'User', - 'required' => true, - 'exampleValue' => 'sebsauvage', - 'title' => 'Insert a user name' - ), - 'list' => array( - 'name' => 'List', - 'required' => true, - 'title' => 'Insert the list name' - ), - 'filter' => array( - 'name' => 'Filter', - 'exampleValue' => '#rss-bridge', - 'required' => false, - 'title' => 'Specify term to search for' - ) - ) - ); - - public function detectParameters($url){ - $params = array(); - - // By keyword or hashtag (search) - $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/search.*(\?|&)q=([^\/&?\n]+)/'; - if(preg_match($regex, $url, $matches) > 0) { - $params['q'] = urldecode($matches[4]); - return $params; - } - - // By hashtag - $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/hashtag\/([^\/?\n]+)/'; - if(preg_match($regex, $url, $matches) > 0) { - $params['q'] = urldecode($matches[3]); - return $params; - } - - // By list - $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)\/lists\/([^\/?\n]+)/'; - if(preg_match($regex, $url, $matches) > 0) { - $params['user'] = urldecode($matches[3]); - $params['list'] = urldecode($matches[4]); - return $params; - } - - // By username - $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)/'; - if(preg_match($regex, $url, $matches) > 0) { - $params['u'] = urldecode($matches[3]); - return $params; - } - - return null; - } - - public function getName(){ - switch($this->queriedContext) { - case 'By keyword or hashtag': - $specific = 'search '; - $param = 'q'; - break; - case 'By username': - $specific = '@'; - $param = 'u'; - break; - case 'By list': - return $this->getInput('list') . ' - Twitter list by ' . $this->getInput('user'); - default: return parent::getName(); - } - return 'Twitter ' . $specific . $this->getInput($param); - } - - public function getURI(){ - switch($this->queriedContext) { - case 'By keyword or hashtag': - return self::URI - . 'search?q=' - . urlencode($this->getInput('q')) - . '&f=tweets'; - case 'By username': - return self::URI - . urlencode($this->getInput('u')); - // Always return without replies! - // . ($this->getInput('norep') ? '' : '/with_replies'); - case 'By list': - return self::URI - . urlencode($this->getInput('user')) - . '/lists/' - . str_replace(' ', '-', strtolower($this->getInput('list'))); - default: return parent::getURI(); - } - } - - public function collectData(){ - $html = ''; - $page = $this->getURI(); - - if(php_sapi_name() === 'cli' && empty(ini_get('curl.cainfo'))) { - $cookies = $this->getCookies($page); - $html = getSimpleHTMLDOM($page, array("Cookie: $cookies")); - } else { - $html = getSimpleHTMLDOM($page, array(), array(CURLOPT_COOKIEFILE => '')); - } - - if(!$html) { - switch($this->queriedContext) { - case 'By keyword or hashtag': - returnServerError('No results for this query.'); - case 'By username': - returnServerError('Requested username can\'t be found.'); - case 'By list': - returnServerError('Requested username or list can\'t be found'); - } - } - - $hidePictures = $this->getInput('nopic'); - - foreach($html->find('div.js-stream-tweet') as $tweet) { - - // Skip retweets? - if($this->getInput('noretweet') - && $tweet->find('div.context span.js-retweet-text a', 0)) { - continue; - } - - // remove 'invisible' content - foreach($tweet->find('.invisible') as $invisible) { - $invisible->outertext = ''; - } - - // Skip protmoted tweets - $heading = $tweet->previousSibling(); - if(!is_null($heading) && - $heading->getAttribute('class') === 'promoted-tweet-heading' - ) { - continue; - } - - $item = array(); - // extract username and sanitize - $item['username'] = htmlspecialchars_decode($tweet->getAttribute('data-screen-name'), ENT_QUOTES); - // extract fullname (pseudonym) - $item['fullname'] = htmlspecialchars_decode($tweet->getAttribute('data-name'), ENT_QUOTES); - // get author - $item['author'] = $item['fullname'] . ' (@' . $item['username'] . ')'; - if($rt = $tweet->find('div.context span.js-retweet-text a', 0)) { - $item['author'] .= ' RT: @' . $rt->plaintext; - } - // get avatar link - $item['avatar'] = $tweet->find('img', 0)->src; - // get TweetID - $item['id'] = $tweet->getAttribute('data-tweet-id'); - // get tweet link - $item['uri'] = self::URI . substr($tweet->find('a.js-permalink', 0)->getAttribute('href'), 1); - // extract tweet timestamp - $item['timestamp'] = $tweet->find('span.js-short-timestamp', 0)->getAttribute('data-time'); - // generate the title - $item['title'] = strip_tags($this->fixAnchorSpacing(htmlspecialchars_decode( - $tweet->find('p.js-tweet-text', 0), ENT_QUOTES), '')); - - switch($this->queriedContext) { - case 'By list': - // Check if filter applies to list (using raw content) - if($this->getInput('filter')) { - if(stripos($tweet->find('p.js-tweet-text', 0)->plaintext, $this->getInput('filter')) === false) { - continue 2; // switch + for-loop! - } - } - break; - default: - } - - $this->processContentLinks($tweet); - $this->processEmojis($tweet); - - // get tweet text - $cleanedTweet = str_replace( - 'href="/', - 'href="' . self::URI, - $tweet->find('p.js-tweet-text', 0)->innertext - ); - - // fix anchors missing spaces in-between - $cleanedTweet = $this->fixAnchorSpacing($cleanedTweet); - - // Add picture to content - $picture_html = ''; - if(!$hidePictures) { - $picture_html = << [ + 'u' => [ + 'name' => 'username', + 'required' => true, + 'exampleValue' => 'sebsauvage', + 'title' => 'Insert a user name' + ], + 'norep' => [ + 'name' => 'Without replies', + 'type' => 'checkbox', + 'title' => 'Only return initial tweets' + ], + 'noretweet' => [ + 'name' => 'Without retweets', + 'required' => false, + 'type' => 'checkbox', + 'title' => 'Hide retweets' + ], + 'nopinned' => [ + 'name' => 'Without pinned tweet', + 'required' => false, + 'type' => 'checkbox', + 'title' => 'Hide pinned tweet' + ] + ], + 'By list' => [ + 'user' => [ + 'name' => 'User', + 'required' => true, + 'exampleValue' => 'Scobleizer', + 'title' => 'Insert a user name' + ], + 'list' => [ + 'name' => 'List', + 'required' => true, + 'exampleValue' => 'Tech-News', + 'title' => 'Insert the list name' + ], + 'filter' => [ + 'name' => 'Filter', + 'exampleValue' => '#rss-bridge', + 'required' => false, + 'title' => 'Specify term to search for' + ] + ], + 'By list ID' => [ + 'listid' => [ + 'name' => 'List ID', + 'exampleValue' => '31748', + 'required' => true, + 'title' => 'Insert the list id' + ], + 'filter' => [ + 'name' => 'Filter', + 'exampleValue' => '#rss-bridge', + 'required' => false, + 'title' => 'Specify term to search for' + ] + ] + ]; + + private $apiKey = null; + private $guestToken = null; + private $authHeaders = []; + private ?string $feedIconUrl = null; + + public function detectParameters($url) + { + $params = []; + + // By keyword or hashtag (search) + $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/search.*(\?|&)q=([^\/&?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + $params['context'] = 'By keyword or hashtag'; + $params['q'] = urldecode($matches[4]); + return $params; + } + + // By hashtag + $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/hashtag\/([^\/?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + $params['context'] = 'By keyword or hashtag'; + $params['q'] = urldecode($matches[3]); + return $params; + } + + // By list + $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)\/lists\/([^\/?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + $params['context'] = 'By list'; + $params['user'] = urldecode($matches[3]); + $params['list'] = urldecode($matches[4]); + return $params; + } + + // By username + $regex = '/^(https?:\/\/)?(www\.)?twitter\.com\/([^\/?\n]+)/'; + if (preg_match($regex, $url, $matches) > 0) { + $params['context'] = 'By username'; + $params['u'] = urldecode($matches[3]); + return $params; + } + + return null; + } + + public function getName() + { + switch ($this->queriedContext) { + case 'By keyword or hashtag': + $specific = 'search '; + $param = 'q'; + break; + case 'By username': + $specific = '@'; + $param = 'u'; + break; + case 'By list': + return $this->getInput('list') . ' - Twitter list by ' . $this->getInput('user'); + case 'By list ID': + return 'Twitter List #' . $this->getInput('listid'); + default: + return parent::getName(); + } + return 'Twitter ' . $specific . $this->getInput($param); + } + + public function getURI() + { + switch ($this->queriedContext) { + case 'By keyword or hashtag': + return self::URI + . 'search?q=' + . urlencode($this->getInput('q')) + . '&f=tweets'; + case 'By username': + return self::URI + . urlencode($this->getInput('u')); + // Always return without replies! + // . ($this->getInput('norep') ? '' : '/with_replies'); + case 'By list': + return self::URI + . urlencode($this->getInput('user')) + . '/lists/' + . str_replace(' ', '-', strtolower($this->getInput('list'))); + case 'By list ID': + return self::URI + . 'i/lists/' + . urlencode($this->getInput('listid')); + default: + return parent::getURI(); + } + } + + private function getFullText($id) + { + $url = sprintf( + 'https://cdn.syndication.twimg.com/tweet-result?id=%s&lang=en&token=449yf2pc4g', + $id + ); + + return json_decode(getContents($url), false); + } + + public function collectData() + { + // $data will contain an array of all found tweets (unfiltered) + $data = null; + // Contains user data (when in by username context) + $user = null; + // Array of all found tweets + $tweets = []; + + // Get authentication information + $api = new TwitterClient($this->cache); + // Try to get all tweets + switch ($this->queriedContext) { + case 'By username': + $screenName = $this->getInput('u'); + $screenName = trim($screenName); + $screenName = ltrim($screenName, '@'); + + $data = $api->fetchUserTweets($screenName); + + break; + + case 'By keyword or hashtag': + // Does not work with the recent twitter changes + $params = [ + 'q' => urlencode($this->getInput('q')), + 'tweet_mode' => 'extended', + 'tweet_search_mode' => 'live', + ]; + + $tweets = $api->search($params)->statuses; + $data = (object) [ + 'tweets' => $tweets + ]; + break; + + case 'By list': + // Does not work with the recent twitter changes + // $params = [ + // 'slug' => strtolower($this->getInput('list')), + // 'owner_screen_name' => strtolower($this->getInput('user')), + // 'tweet_mode' => 'extended', + // ]; + $query = [ + 'screenName' => strtolower($this->getInput('user')), + 'listSlug' => strtolower($this->getInput('list')) + ]; + + $data = $api->fetchListTweets($query, $this->queriedContext); + break; + + case 'By list ID': + // Does not work with the recent twitter changes + // $params = [ + // 'list_id' => $this->getInput('listid'), + // 'tweet_mode' => 'extended', + // ]; + + $query = [ + 'listId' => $this->getInput('listid') + ]; + + $data = $api->fetchListTweets($query, $this->queriedContext); + break; + default: + returnServerError('Invalid query context !'); + } + + if (!$data) { + switch ($this->queriedContext) { + case 'By keyword or hashtag': + returnServerError('twitter: No results for this query.'); + // fall-through + case 'By username': + returnServerError('Requested username can\'t be found.'); + // fall-through + case 'By list': + returnServerError('Requested username or list can\'t be found'); + } + } + + $hidePictures = $this->getInput('nopic'); + + $hidePinned = $this->getInput('nopinned'); + if ($hidePinned) { + $pinnedTweetId = null; + if ($data->user_info && $data->user_info->legacy->pinned_tweet_ids_str) { + $pinnedTweetId = $data->user_info->legacy->pinned_tweet_ids_str[0]; + } + } + + // Array of Tweet IDs + $tweetIds = []; + // Filter out unwanted tweets + foreach ($data->tweets as $tweet) { + if (!$tweet) { + continue; + } + + if (isset($tweet->legacy)) { + $legacy_info = $tweet->legacy; + } else { + $legacy_info = $tweet; + } + + // Filter out retweets to remove possible duplicates of original tweet + switch ($this->queriedContext) { + case 'By keyword or hashtag': + // phpcs:ignore + if ((isset($legacy_info->retweeted_status) || isset($legacy_info->retweeted_status_result)) && substr($legacy_info->full_text, 0, 4) === 'RT @') { + continue 2; + } + break; + } + + // Skip own Retweets... + if (isset($legacy_info->retweeted_status) && $legacy_info->retweeted_status->user->id_str === $tweet->user->id_str) { + continue; + // phpcs:ignore + } elseif (isset($legacy_info->retweeted_status_result) && $tweet->retweeted_status_result->result->legacy->user_id_str === $legacy_info->user_id_str) { + continue; + } + + $tweetId = (isset($legacy_info->id_str) ? $legacy_info->id_str : $tweet->rest_id); + // Skip pinned tweet + if ($hidePinned && ($tweetId === $pinnedTweetId)) { + continue; + } + + if (isset($tweet->rest_id)) { + $tweetIds[] = $tweetId; + } + $rtweet = $legacy_info; + $tweets[] = $rtweet; + } + + if ($this->queriedContext === 'By username') { + $this->feedIconUrl = $data->user_info->legacy->profile_image_url_https ?? null; + } + + $i = 0; + foreach ($tweets as $tweet) { + $item = []; + + $realtweet = $tweet; + $tweetId = (isset($tweetIds[$i]) ? $tweetIds[$i] : $realtweet->conversation_id_str); + if (isset($tweet->retweeted_status)) { + // Tweet is a Retweet, so set author based on original tweet and set realtweet for reference to the right content + $realtweet = $tweet->retweeted_status; + } elseif (isset($tweet->retweeted_status_result)) { + $tweetId = $tweet->retweeted_status_result->result->rest_id; + $realtweet = $tweet->retweeted_status_result->result->legacy; + } + + if (isset($realtweet->truncated) && $realtweet->truncated) { + try { + $realtweet = $this->getFullText($realtweet->id_str); + } catch (HttpException $e) { + $realtweet = $tweet; + } + } + + if (!$realtweet) { + $realtweet = $tweet; + } + + switch ($this->queriedContext) { + case 'By username': + if ($this->getInput('norep') && isset($tweet->in_reply_to_status_id)) { + continue 2; + } + $item['username'] = $data->user_info->legacy->screen_name; + $item['fullname'] = $data->user_info->legacy->name; + $item['avatar'] = $data->user_info->legacy->profile_image_url_https; + $item['id'] = (isset($realtweet->id_str) ? $realtweet->id_str : $tweetId); + break; + case 'By list': + case 'By list ID': + $item['username'] = $data->userIds[$i]->legacy->screen_name; + $item['fullname'] = $data->userIds[$i]->legacy->name; + $item['avatar'] = $data->userIds[$i]->legacy->profile_image_url_https; + $item['id'] = $realtweet->conversation_id_str; + break; + case 'By keyword or hashtag': + $item['username'] = $realtweet->user->screen_name; + $item['fullname'] = $realtweet->user->name; + $item['avatar'] = $realtweet->user->profile_image_url_https; + $item['id'] = $realtweet->id_str; + break; + } + + $item['timestamp'] = $realtweet->created_at; + $item['uri'] = self::URI . $item['username'] . '/status/' . $item['id']; + $item['author'] = ((isset($tweet->retweeted_status) || (isset($tweet->retweeted_status_result))) ? 'RT: ' : '') + . $item['fullname'] + . ' (@' + . $item['username'] . ')'; + + // Convert plain text URLs into HTML hyperlinks + if (isset($realtweet->full_text)) { + $fulltext = $realtweet->full_text; + } else { + $fulltext = $realtweet->text; + } + $cleanedTweet = $fulltext; + + $foundUrls = false; + + if (substr($cleanedTweet, 0, 4) === 'RT @') { + $cleanedTweet = substr($cleanedTweet, 3); + } + + if (isset($realtweet->entities->media)) { + foreach ($realtweet->entities->media as $media) { + $cleanedTweet = str_replace( + $media->url, + '' . $media->display_url . '', + $cleanedTweet + ); + $foundUrls = true; + } + } + if (isset($realtweet->entities->urls)) { + foreach ($realtweet->entities->urls as $url) { + $cleanedTweet = str_replace( + $url->url, + '' . $url->display_url . '', + $cleanedTweet + ); + $foundUrls = true; + } + } + if ($foundUrls === false) { + // fallback to regex'es + $reg_ex = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/'; + if (preg_match($reg_ex, $fulltext, $url)) { + $cleanedTweet = preg_replace( + $reg_ex, + "{$url[0]} ", + $cleanedTweet + ); + } + } + // generate the title + $item['title'] = strip_tags($cleanedTweet); + + // Add avatar + $picture_html = ''; + if (!$hidePictures) { + $picture_html = << EOD; - } - - // Add embeded image to content - $image_html = ''; - $images = $this->getImageURI($tweet); - if(!$this->getInput('noimg') && !is_null($images)) { - - foreach ($images as $image) { - - // Set image scaling - $image_orig = $this->getInput('noimgscaling') ? $image : $image . ':orig'; - $image_thumb = $this->getInput('noimgscaling') ? $image : $image . ':thumb'; - - // add enclosures - $item['enclosures'][] = $image_orig; - - $image_html .= <<+ } + + $medias = []; + if (isset($realtweet->extended_entities->media)) { + $medias = $realtweet->extended_entities->media; + } else if (isset($realtweet->mediaDetails)) { + $medias = $realtweet->mediaDetails; + } + + // Get images + $media_html = ''; + if (!$this->getInput('noimg')) { + foreach ($medias as $media) { + switch ($media->type) { + case 'photo': + $image = $media->media_url_https . '?name=orig'; + $display_image = $media->media_url_https; + // add enclosures + $item['enclosures'][] = $image; + + $media_html .= << + referrerpolicy="no-referrer" + src="{$display_image}" /> EOD; - } - } - - // add content - $item['content'] = << video_info)) { + $link = $media->expanded_url; + $poster = $media->media_url_https; + $video = null; + $maxBitrate = -1; + foreach ($media->video_info->variants as $variant) { + $bitRate = $variant->bitrate ?? -100; + if ($bitRate > $maxBitrate) { + $maxBitrate = $bitRate; + $video = $variant->url; + } + } + if (!is_null($video)) { + // add enclosures + $item['enclosures'][] = $video; + $item['enclosures'][] = $poster; + + $media_html .= << Video + +EOD; + } + } + break; + default: + break; + } + } + } + + switch ($this->queriedContext) { + case 'By list': + case 'By list ID': + // Check if filter applies to list (using raw content) + if ($this->getInput('filter')) { + if (stripos($cleanedTweet, $this->getInput('filter')) === false) { + continue 2; // switch + for-loop! + } + } + break; + case 'By username': + if ($this->getInput('noretweet') && strtolower($item['username']) != strtolower($this->getInput('u'))) { + continue 2; // switch + for-loop! + } + break; + default: + } + + $item['content'] = << {$picture_html} @@ -307,155 +572,25 @@ public function collectData(){ {$cleanedTweet}-EOD; - // add quoted tweet - $quotedTweet = $tweet->find('div.QuoteTweet', 0); - if($quotedTweet) { - // get tweet text - $cleanedQuotedTweet = str_replace( - 'href="/', - 'href="' . self::URI, - $quotedTweet->find('div.tweet-text', 0)->innertext - ); - - $this->processContentLinks($quotedTweet); - $this->processEmojis($quotedTweet); - - // Add embeded image to content - $quotedImage_html = ''; - $quotedImages = $this->getQuotedImageURI($tweet); - - if(!$this->getInput('noimg') && !is_null($quotedImages)) { + // put out + $i++; + $this->items[] = $item; + } - foreach ($quotedImages as $image) { + usort($this->items, ['TwitterBridge', 'compareTweetId']); + } - // Set image scaling - $image_orig = $this->getInput('noimgscaling') ? $image : $image . ':orig'; - $image_thumb = $this->getInput('noimgscaling') ? $image : $image . ':thumb'; + public function getIcon() + { + return $this->feedIconUrl ?? parent::getIcon(); + } - // add enclosures - $item['enclosures'][] = $image_orig; - - $quotedImage_html .= <<{$image_html}+{$media_html}- - -EOD; - } - } - - $item['content'] = << - --{$cleanedQuotedTweet}---EOD; - } - $item['content'] = htmlspecialchars_decode($item['content'], ENT_QUOTES); - - // put out - $this->items[] = $item; - } - } - - private function processEmojis($tweet){ - // process emojis (reduce size) - foreach($tweet->find('img.Emoji') as $img) { - $img->style .= ' height: 1em;'; - } - } - - private function processContentLinks($tweet){ - // processing content links - foreach($tweet->find('a') as $link) { - if($link->hasAttribute('data-expanded-url')) { - $link->href = $link->getAttribute('data-expanded-url'); - } - $link->removeAttribute('data-expanded-url'); - $link->removeAttribute('data-query-source'); - $link->removeAttribute('rel'); - $link->removeAttribute('class'); - $link->removeAttribute('target'); - $link->removeAttribute('title'); - } - } - - private function fixAnchorSpacing($content){ - // fix anchors missing spaces in-between - return str_replace( - 'find('div.AdaptiveMedia-container', 0); - - if($container && $container->find('img', 0)) { - foreach ($container->find('img') as $img) { - $images[] = $img->src; - } - } - - if (!empty($images)) { - return $images; - } - - return null; - } - - private function getQuotedImageURI($tweet){ - // Find media in tweet - $images = array(); - - $container = $tweet->find('div.QuoteMedia-container', 0); - - if($container && $container->find('img', 0)) { - foreach ($container->find('img') as $img) { - $images[] = $img->src; - } - } - - if (!empty($images)) { - return $images; - } - - return null; - } - - private function getCookies($pageURL){ - - $ctx = stream_context_create(array( - 'http' => array( - 'follow_location' => false - ) - ) - ); - $a = file_get_contents($pageURL, 0, $ctx); - - //First request to get the cookie - $cookies = ''; - foreach($http_response_header as $hdr) { - if(stripos($hdr, 'Set-Cookie') !== false) { - $cLine = explode(':', $hdr)[1]; - $cLine = explode(';', $cLine)[0]; - $cookies .= ';' . $cLine; - } - } - - return substr($cookies, 2); - } + private static function compareTweetId($tweet1, $tweet2) + { + return (intval($tweet1['id']) < intval($tweet2['id']) ? 1 : -1); + } } diff --git a/bridges/TwitterEngineeringBridge.php b/bridges/TwitterEngineeringBridge.php new file mode 100644 index 00000000000..96319c97514 --- /dev/null +++ b/bridges/TwitterEngineeringBridge.php @@ -0,0 +1,65 @@ +collectExpandableDatas($url); + } + + protected function parseItem(array $item) + { + $dom = getSimpleHTMLDOMCached($item['uri']); + if (!$dom) { + $item['content'] .= '{$quotedImage_html}-Could not request ' . $this->getName() . ': ' . $item['uri'] . '
'; + return $item; + } + $dom = defaultLinkTo($dom, $this->getURI()); + + $article_body = $dom->find('div.column.column-6', 0); + + // Remove elements that are not part of article content + $unwanted_selector = 'div.bl02-blog-post-text-masthead, div.tweet-error-text, div.bl13-tweet-template'; + foreach ($article_body->find($unwanted_selector) as $found) { + $found->outertext = ''; + } + + // Set src for images + foreach ($article_body->find('img') as $found) { + $found->setAttribute('src', $found->getAttribute('data-src')); + } + + $item['content'] = $article_body; + $item['timestamp'] = strtotime($dom->find('span.b02-blog-post-no-masthead__date', 0)->innertext); + $item['categories'] = self::getCategoriesFromTags($dom); + + return $item; + } + + private static function getCategoriesFromTags($article_html) + { + $tags_list_items = [$article_html->find('.post__tags > ul > li')]; + $categories = []; + + foreach ($tags_list_items as $tag_list_item) { + foreach ($tag_list_item as $tag) { + $categories[] = trim($tag->plaintext); + } + } + + return $categories; + } + + public function getName() + { + // Else the original feed returns "English (US)" as the title + return 'Twitter Engineering Blog'; + } +} diff --git a/bridges/TwitterV2Bridge.php b/bridges/TwitterV2Bridge.php new file mode 100644 index 00000000000..19b27137a1a --- /dev/null +++ b/bridges/TwitterV2Bridge.php @@ -0,0 +1,746 @@ + + Configuration Instructions.'; + const MAINTAINER = 'quickwick'; + const CONFIGURATION = [ + 'twitterv2apitoken' => [ + 'required' => true, + ] + ]; + const PARAMETERS = [ + 'global' => [ + 'filter' => [ + 'name' => 'Filter', + 'exampleValue' => 'rss-bridge', + 'required' => false, + 'title' => 'Specify a single term to search for' + ], + 'norep' => [ + 'name' => 'Without replies', + 'type' => 'checkbox', + 'title' => 'Activate to exclude reply tweets' + ], + 'noretweet' => [ + 'name' => 'Without retweets', + 'required' => false, + 'type' => 'checkbox', + 'title' => 'Activate to exclude retweets' + ], + 'nopinned' => [ + 'name' => 'Without pinned tweet', + 'required' => false, + 'type' => 'checkbox', + 'title' => 'Activate to exclude pinned tweets' + ], + 'maxresults' => [ + 'name' => 'Maximum results', + 'required' => false, + 'exampleValue' => '20', + 'title' => 'Maximum number of tweets to retrieve (limit is 100)' + ], + 'imgonly' => [ + 'name' => 'Only media tweets', + 'type' => 'checkbox', + 'title' => 'Activate to show only tweets with media (photo/video)' + ], + 'nopic' => [ + 'name' => 'Hide profile pictures', + 'type' => 'checkbox', + 'title' => 'Activate to hide profile pictures in content' + ], + 'noimg' => [ + 'name' => 'Hide images in tweets', + 'type' => 'checkbox', + 'title' => 'Activate to hide images in tweets' + ], + 'noimgscaling' => [ + 'name' => 'Disable image scaling', + 'type' => 'checkbox', + 'title' => 'Activate to display original sized images (no thumbnails)' + ], + 'noexternallink' => [ + 'name' => 'Hide external link from content html', + 'type' => 'checkbox', + 'title' => 'Activate to hide the links from the content html field' + ], + 'idastitle' => [ + 'name' => 'Use tweet id as title', + 'type' => 'checkbox', + 'title' => 'Activate to use tweet id as title (instead of tweet text)' + ] + ], + 'By username' => [ + 'u' => [ + 'name' => 'username', + 'required' => true, + 'exampleValue' => 'sebsauvage', + 'title' => 'Insert a user name' + ] + ], + 'By keyword or hashtag' => [ + 'query' => [ + 'name' => 'Keyword or #hashtag', + 'required' => true, + 'exampleValue' => 'rss-bridge OR #rss-bridge', + 'title' => <<[ + 'listid' => [ + 'name' => 'List ID', + 'exampleValue' => '31748', + 'required' => true, + 'title' => 'Enter a list id' + ] + ] + ]; + + // $Item variable needs to be accessible from multiple functions without passing + private $item = []; + + public function getName() + { + switch ($this->queriedContext) { + case 'By keyword or hashtag': + $specific = 'search '; + $param = 'query'; + break; + case 'By username': + $specific = '@'; + $param = 'u'; + break; + case 'By list ID': + return 'Twitter List #' . $this->getInput('listid'); + default: + return parent::getName(); + } + return 'Twitter ' . $specific . $this->getInput($param); + } + + public function collectData() + { + // $data will contain an array of all found tweets + $data = null; + // Contains user data (when in by username context) + $user = null; + // Array of all found tweets + $tweets = []; + + $hideProfilePic = $this->getInput('nopic'); + $hideImages = $this->getInput('noimg'); + $hideReplies = $this->getInput('norep'); + $hideRetweets = $this->getInput('noretweet'); + $hidePinned = $this->getInput('nopinned'); + $tweetFilter = $this->getInput('filter'); + $maxResults = $this->getInput('maxresults'); + if ($maxResults > 100) { + $maxResults = 100; + } + $idAsTitle = $this->getInput('idastitle'); + $onlyMediaTweets = $this->getInput('imgonly'); + + // Read API token from config.ini.php, put into Header + $apiToken = $this->getOption('twitterv2apitoken'); + $authHeaders = [ + 'authorization: Bearer ' . $apiToken, + ]; + + // Try to get all tweets + switch ($this->queriedContext) { + case 'By username': + //Get id from username + $params = [ + 'user.fields' => 'pinned_tweet_id,profile_image_url' + ]; + $user = $this->makeApiCall('/users/by/username/' + . $this->getInput('u'), $authHeaders, $params); + + if (isset($user->errors)) { + returnServerError('Requested username can\'t be found.'); + } + + // Set default params + $params = [ + 'max_results' => (empty($maxResults) ? '10' : $maxResults), + 'tweet.fields' + => 'created_at,referenced_tweets,entities,attachments', + 'user.fields' => 'pinned_tweet_id', + 'expansions' + => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url' + ]; + + // Set params to filter out replies and/or retweets + if ($hideReplies && $hideRetweets) { + $params['exclude'] = 'replies,retweets'; + } elseif ($hideReplies) { + $params['exclude'] = 'replies'; + } elseif ($hideRetweets) { + $params['exclude'] = 'retweets'; + } + + // Get the tweets + $data = $this->makeApiCall('/users/' . $user->data->id + . '/tweets', $authHeaders, $params); + break; + + case 'By keyword or hashtag': + $params = [ + 'query' => $this->getInput('query'), + 'max_results' => (empty($maxResults) ? '10' : $maxResults), + 'tweet.fields' + => 'created_at,referenced_tweets,entities,attachments', + 'expansions' + => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url' + ]; + + // Set params to filter out replies and/or retweets + if ($hideReplies) { + $params['query'] = $params['query'] . ' -is:reply'; + } + if ($hideRetweets) { + $params['query'] = $params['query'] . ' -is:retweet'; + } + + $data = $this->makeApiCall('/tweets/search/recent', $authHeaders, $params); + break; + + case 'By list ID': + // Set default params + $params = [ + 'max_results' => (empty($maxResults) ? '10' : $maxResults), + 'tweet.fields' + => 'created_at,referenced_tweets,entities,attachments', + 'expansions' + => 'referenced_tweets.id.author_id,entities.mentions.username,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url' + ]; + + $data = $this->makeApiCall('/lists/' . $this->getInput('listid') . + '/tweets', $authHeaders, $params); + break; + + default: + returnServerError('Invalid query context !'); + } + + if ( + (isset($data->errors) && !isset($data->data)) || + (isset($data->meta) && $data->meta->result_count === 0) + ) { + switch ($this->queriedContext) { + case 'By keyword or hashtag': + returnServerError('No results for this query.'); + // fall-through + case 'By username': + returnServerError('Requested username cannnot be found.'); + // fall-through + case 'By list ID': + returnServerError('Requested list cannnot be found'); + // fall-through + } + } + + // figure out the Pinned Tweet Id + if ($hidePinned) { + $pinnedTweetId = null; + if (isset($user) && isset($user->data->pinned_tweet_id)) { + $pinnedTweetId = $user->data->pinned_tweet_id; + } + } + + // Extract Media data into array + isset($data->includes->media) ? $includesMedia = $data->includes->media : $includesMedia = null; + + // Extract additional Users data into array + isset($data->includes->users) ? $includesUsers = $data->includes->users : $includesUsers = null; + + // Extract additional Tweets data into array + isset($data->includes->tweets) ? $includesTweets = $data->includes->tweets : $includesTweets = null; + + // Extract main Tweets data into array + $tweets = $data->data; + + // Make another API call to get user and media info for retweets + // Is there some way to get this info included in original API call? + $retweetedData = null; + $retweetedMedia = null; + $retweetedUsers = null; + if (!$hideImages && isset($includesTweets)) { + // There has to be a better PHP way to extract the tweet Ids? + $includesTweetsIds = []; + foreach ($includesTweets as $includesTweet) { + $includesTweetsIds[] = $includesTweet->id; + } + + // Set default params for API query + $params = [ + 'ids' => join(',', $includesTweetsIds), + 'tweet.fields' => 'entities,attachments', + 'expansions' => 'author_id,attachments.media_keys', + 'media.fields' => 'type,url,preview_image_url', + 'user.fields' => 'id,profile_image_url' + ]; + + // Get the retweeted tweets + $retweetedData = $this->makeApiCall('/tweets', $authHeaders, $params); + + // Extract retweets Media data into array + isset($retweetedData->includes->media) ? $retweetedMedia + = $retweetedData->includes->media : $retweetedMedia = null; + + // Extract retweets additional Users data into array + isset($retweetedData->includes->users) ? $retweetedUsers + = $retweetedData->includes->users : $retweetedUsers = null; + } + + // Create output array with all required elements for each tweet + foreach ($tweets as $tweet) { + // Skip pinned tweet (if selected) + if ($hidePinned && $tweet->id === $pinnedTweetId) { + continue; + } + + // Check if tweet is Retweet, Quote or Reply + $isRetweet = false; + $isReply = false; + $isQuote = false; + + if (isset($tweet->referenced_tweets)) { + switch ($tweet->referenced_tweets[0]->type) { + case 'retweeted': + $isRetweet = true; + break; + case 'quoted': + $isQuote = true; + break; + case 'replied_to': + $isReply = true; + break; + } + } + + // Skip replies and/or retweets (if selected). This check is primarily for lists + // These should already be pre-filtered for username and keyword queries + if (($hideRetweets && $isRetweet) || ($hideReplies && $isReply)) { + continue; + } + + // Initialize empty array to hold feed item values + $this->item = []; + + // Start getting and setting values needed for HTML output + $quotedTweet = null; + $cleanedQuotedTweet = null; + $quotedUser = null; + if ($isQuote) { + foreach ($includesTweets as $includesTweet) { + if ($includesTweet->id === $tweet->referenced_tweets[0]->id) { + $quotedTweet = $includesTweet; + $cleanedQuotedTweet = nl2br($quotedTweet->text); + break; + } + } + + $quotedUser = $this->getTweetUser($quotedTweet, $retweetedUsers, $includesUsers); + } + if ($isRetweet || is_null($user)) { + // Replace tweet object with original retweeted object + if ($isRetweet) { + foreach ($includesTweets as $includesTweet) { + if ($includesTweet->id === $tweet->referenced_tweets[0]->id) { + $tweet = $includesTweet; + break; + } + } + } + + // Skip self-Retweets (can cause duplicate entries in output) + if (isset($user) && $tweet->author_id === $user->data->id) { + continue; + } + + // Get user object for retweeted tweet + $originalUser = $this->getTweetUser($tweet, $retweetedUsers, $includesUsers); + + $this->item['username'] = $originalUser->username; + $this->item['fullname'] = $originalUser->name; + if (isset($originalUser->profile_image_url)) { + $this->item['avatar'] = $originalUser->profile_image_url; + } else { + $this->item['avatar'] = null; + } + } else { + $this->item['username'] = $user->data->username; + $this->item['fullname'] = $user->data->name; + $this->item['avatar'] = $user->data->profile_image_url; + } + $this->item['id'] = $tweet->id; + $this->item['timestamp'] = $tweet->created_at; + $this->item['uri'] + = self::URI . $this->item['username'] . '/status/' . $this->item['id']; + $this->item['author'] = ($isRetweet ? 'RT: ' : '') + . $this->item['fullname'] + . ' (@' + . $this->item['username'] . ')'; + + $cleanedTweet = nl2br($tweet->text); + + // Perform optional keyword filtering (only keep tweet if keyword is found) + if (! empty($tweetFilter)) { + if (stripos($cleanedTweet, $this->getInput('filter')) === false) { + continue; + } + } + + // Perform optional non-media tweet skip + // This check must wait until after retweets are identified + if ( + $onlyMediaTweets && !isset($tweet->attachments->media_keys) && + (($isQuote && !isset($quotedTweet->attachments->media_keys)) || !$isQuote) + ) { + // There is no media in current tweet or quoted tweet, skip to next + continue; + } + + // Search for and replace URLs in Tweet text + $cleanedTweet = $this->replaceTweetURLs($tweet, $cleanedTweet); + if (isset($cleanedQuotedTweet)) { + $cleanedQuotedTweet = $this->replaceTweetURLs($quotedTweet, $cleanedQuotedTweet); + } + + // Generate Title text + if ($idAsTitle) { + $titleText = $tweet->id; + } else { + $titleText = strip_tags($cleanedTweet); + } + + if ($isRetweet) { + if (substr($titleText, 0, 4) === 'RT @') { + $titleText = substr_replace($titleText, ':', 2, 0); + } else { + $titleText = 'RT: @' . $this->item['username'] . ': ' . $titleText; + } + } elseif ($isReply && !$idAsTitle) { + $titleText = 'R: ' . $titleText; + } + + $this->item['title'] = $titleText; + + // Get external link info + $extURL = null; + if (isset($tweet->entities->urls) && strpos($tweet->entities->urls[0]->expanded_url, 'twitter.com') === false) { + $extURL = $tweet->entities->urls[0]->expanded_url; + $extDisplayURL = $tweet->entities->urls[0]->display_url; + $extTitle = $tweet->entities->urls[0]->title; + $extDesc = $tweet->entities->urls[0]->description; + if (isset($tweet->entities->urls[0]->images)) { + $extMediaOrig = $tweet->entities->urls[0]->images[0]->url; + $extMediaScaled = $tweet->entities->urls[0]->images[1]->url; + } else { + $extMediaOrig = ''; + $extMediaScaled = ''; + } + } + + // Generate Avatar HTML block + $picture_html = ''; + if (!$hideProfilePic && isset($this->item['avatar'])) { + $picture_html = << + + +EOD; + } + + // Generate media HTML block + $media_html = ''; + $quoted_media_html = ''; + $ext_media_html = ''; + if (!$hideImages) { + if (isset($tweet->attachments->media_keys)) { + $media_html = $this->createTweetMediaHTML($tweet, $includesMedia, $retweetedMedia); + } + if (isset($quotedTweet->attachments->media_keys)) { + $quoted_media_html = $this->createTweetMediaHTML($quotedTweet, $includesMedia, $retweetedMedia); + } + if (isset($extURL)) { + if ($this->getInput('noimgscaling')) { + $extMediaURL = $extMediaOrig; + } else { + $extMediaURL = $extMediaScaled; + } + $ext_media_html = << +EOD; + } + } + + // Generate the HTML for Item content + $this->item['content'] = << + {$picture_html} + + + {$cleanedTweet} +++ {$media_html} +EOD; + + // Add Quoted Tweet HTML, if relevant + if (isset($quotedTweet)) { + $quotedTweetURI = self::URI . $quotedUser->username . '/status/' . $quotedTweet->id; + $quote_html = <<+QUOTE; + $this->item['content'] .= $quote_html; + } + + // Add External Link HTML, if relevant + if (isset($extURL) && !$this->getInput('noexternallink')) { + $ext_html = <<+ +$quotedUser->name @$quotedUser->username · + $quotedTweet->created_at
+ $cleanedQuotedTweet + $quoted_media_html ++ $ext_media_html
+ $extDisplayURL
+ $extTitle
+ $extDesc + +EXTERNAL; + $this->item['content'] .= $ext_html; + } + + $this->item['content'] = htmlspecialchars_decode($this->item['content'], ENT_QUOTES); + + // Add current Item to Items array + $this->items[] = $this->item; + } + + // Sort all tweets in array by date + usort($this->items, ['TwitterV2Bridge', 'compareTweetDate']); + } + + private static function compareTweetDate($tweet1, $tweet2) + { + return (strtotime($tweet1['timestamp']) < strtotime($tweet2['timestamp']) ? 1 : -1); + } + + /** + * Tries to make an API call to Twitter. + * @param $api string API entry point + * @param $params array additional URI parmaeters + * @return object json data + */ + private function makeApiCall($api, $authHeaders, $params) + { + $uri = self::API_URI . $api . '?' . http_build_query($params); + $result = getContents($uri, $authHeaders); + $data = json_decode($result); + return $data; + } + + /** + * Change format of URLs in tweet text + * @param $tweetObject object current Tweet JSON + * @param $tweetText string current Tweet text + * @return string modified tweet text + */ + private function replaceTweetURLs($tweetObject, $tweetText) + { + $foundUrls = false; + // Rewrite URL links, based on URL list in tweet object + if (isset($tweetObject->entities->urls)) { + foreach ($tweetObject->entities->urls as $url) { + $tweetText = str_replace( + $url->url, + '' . $url->display_url . '', + $tweetText + ); + } + $foundUrls = true; + } + // Regex fallback for rewriting URL links. Should never trigger? + if ($foundUrls === false) { + $reg_ex = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/'; + if (preg_match($reg_ex, $tweetText, $url)) { + $tweetText = preg_replace( + $reg_ex, + "{$url[0]} ", + $tweetText + ); + } + } + // Fix back-to-back URLs by adding a
+ $reg_ex = '/\/a>\s*
id === $tweetObject->author_id) { + $matchedUser = $retweetedUser; + break; + } + } + } + if (!isset($matchedUser->username) && isset($includesUsers)) { + foreach ($includesUsers as $includesUser) { + if ($includesUser->id === $tweetObject->author_id) { + $matchedUser = $includesUser; + + break; + } + } + } + return $matchedUser; + } + + /** + * Generates HTML for embedded media + * @param $tweetObject object current Tweet JSON + * @param $includesMedia + * @param $retweetedMedia + * @return string modified tweet text + */ + private function createTweetMediaHTML($tweetObject, $includesMedia, $retweetedMedia) + { + $media_html = ''; + // Match media_keys in tweet to media list from, put matches into new array + $tweetMedia = []; + // Start by checking the original list of tweet Media includes + if (isset($includesMedia)) { + foreach ($includesMedia as $includesMedium) { + if ( + in_array( + $includesMedium->media_key, + $tweetObject->attachments->media_keys + ) + ) { + $tweetMedia[] = $includesMedium; + } + } + } + // If no matches found, check the retweet Media includes + if (empty($tweetMedia) && isset($retweetedMedia)) { + foreach ($retweetedMedia as $retweetedMedium) { + if ( + in_array( + $retweetedMedium->media_key, + $tweetObject->attachments->media_keys + ) + ) { + $tweetMedia[] = $retweetedMedium; + } + } + } + + foreach ($tweetMedia as $media) { + switch ($media->type) { + case 'photo': + if ($this->getInput('noimgscaling')) { + $image = $media->url; + $display_image = $media->url; + } else { + $image = $media->url . '?name=orig'; + $display_image = $media->url; + } + // add enclosures + $this->item['enclosures'][] = $image; + + $media_html .= <<+ + +EOD; + break; + case 'video': + // To Do: Is there a way to easily match this + // to a direct Video URL? + $display_image = $media->preview_image_url; + + $media_html .= <<Video: + +EOD; + break; + case 'animated_gif': + // To Do: Is there a way to easily match this to a + // direct animated Gif URL? + $display_image = $media->preview_image_url; + + $media_html .= << Animated Gif: + +EOD; + break; + default: + break; + } + } + + return $media_html; + } +} diff --git a/bridges/UberNewsroomBridge.php b/bridges/UberNewsroomBridge.php new file mode 100644 index 00000000000..333200cd08e --- /dev/null +++ b/bridges/UberNewsroomBridge.php @@ -0,0 +1,185 @@ + [ + 'name' => 'Region', + 'type' => 'list', + 'values' => [ + 'Africa' => [ + 'Egypt' => 'ar-EG', + 'Ghana' => 'en-GH', + 'Kenya' => 'en-KE', + 'Morocco' => 'fr-MA', + 'Nigeria' => 'en-NG', + 'South Africa' => 'en-ZA', + 'Tanzania' => 'en-TZ', + 'Uganda' => 'en-UG', + ], + 'Asia' => [ + 'Bangladesh' => 'en-BD', + 'Cambodia' => 'km-KH', + 'China' => 'zh-CN', + 'Hong Kong' => 'zh-HK', + 'India' => 'en-IN', + 'Indonesia' => 'en-ID', + 'Japan' => 'ja-JP', + 'Korea' => 'ko-KR', + 'Macau' => 'zh-MO', + 'Malaysia' => 'en-MY', + 'Myanmar' => 'en-MM', + 'Philippines' => 'en-PH', + 'Singapore' => 'en-SG', + 'Sri Lanka' => 'en-LK', + 'Taiwan' => 'zh-TW', + 'Thailand' => 'th-TH', + 'Vietnam' => 'vi-VN', + ], + 'Central America' => [ + 'Costa Rica' => 'es-CR', + 'Dominican Republic' => 'es-DO', + 'El Salvador' => 'es-SV', + 'Guatemala' => 'es-GT', + 'Honduras' => 'es-HN', + 'Mexico' => 'es-MX', + 'Nicaragua' => 'es-NI', + 'Panama' => 'es-PA', + 'Puerto Rico' => 'es-PR', + ], + 'Europe' => [ + 'Austria' => 'de-AT', + 'Azerbaijan' => 'az', + 'Belarus' => 'ru-BY', + 'Belgium' => 'fr-BE', + 'Bulgaria' => 'bg', + 'Croatia' => 'hr', + 'Czech Republic' => 'cs-CZ', + 'Denmark' => 'da-DK', + 'Estonia' => 'et-EE', + 'Finland' => 'fi', + 'France' => 'fr', + 'Germany' => 'de', + 'Greece' => 'el-GR', + 'Hungary' => 'hu', + 'Ireland' => 'en-IE', + 'Italy' => 'it', + 'Kazakhstan' => 'ru-KZ', + 'Lithuania' => 'lt', + 'Netherlands' => 'nl', + 'Norway' => 'nb-NO', + 'Poland' => 'pl', + 'Portugal' => 'pt', + 'Romania' => 'ro', + 'Russia' => 'ru', + 'Slovakia' => 'sk', + 'Spain' => 'es-ES', + 'Sweden' => 'sv-SE', + 'Switzerland' => 'fr-CH', + 'Turkey' => 'tr', + 'Ukraine' => 'uk-UA', + 'United Kingdom' => 'en-GB', + ], + 'Middle East' => [ + 'Bahrain' => 'en-BH', + 'Israel' => 'he-IL', + 'Jordan' => 'en-JO', + 'Kuwait' => 'en-KW', + 'Lebanon' => 'en-LB', + 'Pakistan' => 'en-PK', + 'Qatar' => 'en-QA', + 'Saudi Arabia' => 'ar-SA', + 'United Arab Emirates' => 'en-AE', + ], + 'North America' => [ + 'Canada' => 'en-CA', + 'United States' => 'en-US', + ], + 'Pacific' => [ + 'Australia' => 'en-AU', + 'New Zealand' => 'en-NZ', + ], + 'South America' => [ + 'Argentina' => 'es-AR', + 'Bolivia' => 'es-BO', + 'Brazil' => 'pt-BR', + 'Chile' => 'es-CL', + 'Colombia' => 'es-CO', + 'Ecuador' => 'es-EC', + 'Paraguay' => 'es-PY', + 'Peru' => 'es-PE', + 'Trinidad & Tobago' => 'en-TT', + 'Uruguay' => 'es-UY', + 'Venezuela' => 'es-VE', + ], + ], + 'defaultValue' => 'en-US', + ] + ]]; + + const CACHE_TIMEOUT = 3600; + + private $regionName = ''; + + public function collectData() + { + $json = getContents(self::URI_API_DATA . $this->getInput('region')); + $data = json_decode($json); + + $this->regionName = $data->region->name; + + foreach ($data->articles as $article) { + $json = getContents(self::URI_API_POST . $article->id); + $post = json_decode($json); + + $item = []; + $item['title'] = $post->title->rendered; + $item['timestamp'] = $post->date; + $item['uri'] = $post->link; + $item['content'] = $this->formatContent($post->content->rendered); + $item['enclosures'][] = $article->image_full; + + $this->items[] = $item; + } + } + + public function getURI() + { + if (is_null($this->getInput('region')) === false) { + return self::URI . '/' . $this->getInput('region') . '/newsroom'; + } + + return parent::getURI() . '/newsroom'; + } + + public function getName() + { + if (is_null($this->getInput('region')) === false) { + return $this->regionName . ' - Uber Newsroom'; + } + + return parent::getName(); + } + + private function formatContent($html) + { + $html = str_get_html($html); + + foreach ($html->find('div.wp-video') as $div) { + $div->style = ''; + } + + foreach ($html->find('video') as $video) { + $video->width = '100%'; + $video->height = ''; + } + + return $html; + } +} diff --git a/bridges/UnogsBridge.php b/bridges/UnogsBridge.php new file mode 100644 index 00000000000..7aff10c6d8c --- /dev/null +++ b/bridges/UnogsBridge.php @@ -0,0 +1,182 @@ + [ + 'feed' => [ + 'name' => 'feed', + 'type' => 'list', + 'title' => 'Choose whether you want latest movies or removal on Netflix', + 'values' => [ + 'What\'s New' => 'new last 7 days', + 'Expiring' => 'expiring' + ] + ], + 'limit' => self::LIMIT, + ], + 'Global' => [], + 'Country' => [ + 'country_code' => [ + 'name' => 'Country', + 'type' => 'list', + 'title' => 'Choose your preferred country', + 'values' => [ + 'Argentina' => 21, + 'Australia' => 23, + 'Belgium' => 26, + 'Brazil' => 29, + 'Canada' => 33, + 'Colombia' => 36, + 'Czech Republic' => 307, + 'France' => 45, + 'Germany' => 39, + 'Greece' => 327, + 'Hong Kong' => 331, + 'Hungary' => 334, + 'Iceland' => 265, + 'India' => 337, + 'Israel' => 336, + 'Italy' => 269, + 'Japan' => 267, + 'Lithuania' => 357, + 'Malaysia' => 378, + 'Mexico' => 65, + 'Netherlands' => 67, + 'Philippines' => 390, + 'Poland' => 392, + 'Portugal' => 268, + 'Romania' => 400, + 'Russia' => 402, + 'Singapore' => 408, + 'Slovakia' => 412, + 'South Africa' => 447, + 'South Korea' => 348, + 'Spain' => 270, + 'Sweden' => 73, + 'Switzerland' => 34, + 'Thailand' => 425, + 'Turkey' => 432, + 'Ukraine' => 436, + 'United Kingdom' => 46, + 'United States' => 78 + ] + ] + ] + ]; + + public function getName() + { + $feedName = ''; + if ($this->queriedContext == 'Global') { + $feedName .= 'Netflix Global - '; + } elseif ($this->queriedContext == 'Country') { + $feedName .= 'Netflix ' . $this->getKey('country_code') . ' - '; + } + if ($this->getInput('feed') == 'expiring') { + $feedName .= 'Expiring title'; + } elseif ($this->getInput('feed') == 'new last 7 days') { + $feedName .= 'What\'s New'; + } else { + $feedName = self::NAME; + } + return $feedName; + } + + private function getJSON($url) + { + $header = [ + 'Referer: https://unogs.com/', + 'referrer: http://unogs.com', + ]; + + $raw = getContents($url, $header); + return json_decode($raw, true); + } + + private function getImage($nfid) + { + $url = self::URI . '/api/title/bgimages?netflixid=' . $nfid; + $json = $this->getJSON($url); + $image_wrapper = ''; + if (isset($json['bo1280x448'])) { + $image_wrapper = 'bo1280x448'; + } else { + $image_wrapper = 'bo665x375'; + } + end($json[$image_wrapper]); + $position = key($json[$image_wrapper]); + $image_link = $json[$image_wrapper][$position]['url']; + return $image_link; + } + + private function handleData($data) + { + $item = []; + $item['title'] = $data['title'] . ' - ' . $data['year']; + $item['timestamp'] = $data['titledate']; + $netflix_id = $data['nfid']; + $item['uri'] = 'https://www.netflix.com/title/' . $netflix_id; + $image_url = $this->getImage($netflix_id); + $netflix_synopsis = $data['synopsis']; + $expired_warning = ''; + if (isset($data['expires'])) { + $expired_warning .= ' Expired on: ' . $data['expires'] . '
'; + $item['timestamp'] = $data['expires']; + } + $unogs_url = self::URI . '/title/' . $netflix_id; + + $item['content'] = <<+$expired_warning + $netflix_synopsis
+Details: $unogs_url
+EOD; + $this->items[] = $item; + } + + public function collectData() + { + $feed = $this->getInput('feed'); + $is_global = false; + $country_code = ''; + + switch ($this->queriedContext) { + case 'Country': + $country_code = $this->getInput('country_code'); + break; + } + + $limit = $this->getInput('limit') ?? 30; + + // https://rapidapi.com/unogs/api/unogsng/details + $api_url = sprintf( + '%s/api/search?query=%s%s&limit=%s', + self::URI, + urlencode($feed), + $country_code ? '&countrylist=' . $country_code : '', + $limit + ); + + $json_data = $this->getJSON($api_url); + $movies = $json_data['results']; + + if ($this->getInput('feed') == 'expiring') { + /* uNoGS API returns movies/series that going to remove + * today according to the day you fetch the data. + * They put items that going to remove in the future on the last + * so I reverse this to get those items, not to bothers those that already removed today. + */ + $movies = array_reverse($movies); + } + + foreach ($movies as $movie) { + $this->handleData($movie); + } + } +} diff --git a/bridges/UnraidCommunityApplicationsBridge.php b/bridges/UnraidCommunityApplicationsBridge.php new file mode 100644 index 00000000000..1295e8278a6 --- /dev/null +++ b/bridges/UnraidCommunityApplicationsBridge.php @@ -0,0 +1,81 @@ +apps = getContents(self::APPSURI); + $this->apps = json_decode($this->apps, true)['applist']; + } + + private function sortApps() + { + usort($this->apps, function ($app1, $app2) { + return $app1['FirstSeen'] < $app2['FirstSeen'] ? 1 : -1; + }); + } + + public function collectData() + { + $this->fetchApps(); + $this->sortApps(); + foreach ($this->apps as $app) { + if (array_key_exists('Language', $app)) { + continue; + } + $item = []; + $item['title'] = $app['Name']; + $item['timestamp'] = $app['FirstSeen']; + $item['author'] = explode('\'', $app['Repo'])[0]; + $item['content'] = ''; + + if (isset($app['CategoryList'])) { + $item['categories'] = $app['CategoryList']; + } + + if (array_key_exists('Icon', $app)) { + $item['content'] .= ''; + } + + if (array_key_exists('Overview', $app)) { + $item['content'] .= '' + . $app['Overview'] + . '
'; + } + + if (array_key_exists('Project', $app)) { + $item['uri'] = $app['Project']; + } + + if (array_key_exists('Registry', $app)) { + $item['content'] .= '
Docker Hub'; + } + + if (array_key_exists('Support', $app)) { + $item['content'] .= '
Support'; + } + + $this->items[] = $item; + + if (count($this->items) >= 150) { + break; + } + } + } +} diff --git a/bridges/UnsplashBridge.php b/bridges/UnsplashBridge.php index dad0efc2eaf..590d16ab201 100644 --- a/bridges/UnsplashBridge.php +++ b/bridges/UnsplashBridge.php @@ -1,68 +1,119 @@ array( - 'name' => 'Max number of photos', - 'type' => 'number', - 'defaultValue' => 20 - ), - 'w' => array( - 'name' => 'Width', - 'exampleValue' => '1920, 1680, …', - 'defaultValue' => '1920' - ), - 'q' => array( - 'name' => 'JPEG quality', - 'type' => 'number', - 'defaultValue' => 75 - ) - )); + const PARAMETERS = [[ + 'u' => [ + 'name' => 'Filter by username (optional)', + 'type' => 'text', + 'defaultValue' => 'unsplash' + ], + 'm' => [ + 'name' => 'Max number of photos', + 'type' => 'number', + 'defaultValue' => 20, + 'required' => true + ], + 'prev_q' => [ + 'name' => 'Preview quality', + 'type' => 'list', + 'values' => [ + 'full' => 'full', + 'regular' => 'regular', + 'small' => 'small', + 'thumb' => 'thumb', + ], + 'defaultValue' => 'regular' + ], + 'w' => [ + 'name' => 'Max download width (optional)', + 'exampleValue' => 1920, + 'type' => 'number', + 'defaultValue' => 1920, + ], + 'jpg_q' => [ + 'name' => 'Max JPEG quality (optional)', + 'exampleValue' => 75, + 'type' => 'number', + 'defaultValue' => 75, + ] + ]]; - public function collectData(){ - $width = $this->getInput('w'); - $max = $this->getInput('m'); - $quality = $this->getInput('q'); + public function collectData() + { + $filteredUser = $this->getInput('u'); + $width = $this->getInput('w'); + $max = $this->getInput('m'); + $previewQuality = $this->getInput('prev_q'); + $jpgQuality = $this->getInput('jpg_q'); - $api_response = getContents('https://unsplash.com/napi/photos?page=1&per_page=' . $max) - or returnServerError('Could not request Unsplash API.'); - $json = json_decode($api_response, true); + $url = 'https://unsplash.com/napi'; + if (strlen($filteredUser) > 0) { + $url .= '/users/' . $filteredUser; + } + $url .= '/photos?page=1&per_page=' . $max; + $api_response = getContents($url); - foreach ($json as $json_item) { - $item = array(); + $json = json_decode($api_response, true); - // Get image URI - $uri = $json_item['urls']['regular'] . '.jpg'; // '.jpg' only for format hint - $uri = str_replace('q=80', 'q=' . $quality, $uri); - $uri = str_replace('w=1080', 'w=' . $width, $uri); - $item['uri'] = $uri; + foreach ($json as $json_item) { + $item = []; - // Get title from description - if (is_null($json_item['alt_description'])) { - if (is_null($json_item['description'])) { - $item['title'] = 'Unsplash picture from ' . $json_item['user']['name']; - } else { - $item['title'] = $json_item['description']; - } - } else { - $item['title'] = $json_item['alt_description']; - } + // Get image URI + $uri = $json_item['urls']['raw'] . '&fm=jpg'; + if ($jpgQuality > 0) { + $uri .= '&q=' . $jpgQuality; + } + if ($width > 0) { + $uri .= '&w=' . $width . '&fit=max'; + } + $uri .= '.jpg'; // only for format hint + $item['uri'] = $uri; - $item['timestamp'] = time(); - $item['content'] = $item['title'] - . '
'; + // Get title from description + if (is_null($json_item['description'])) { + $item['title'] = 'Unsplash picture from ' . $json_item['user']['name']; + } else { + $item['title'] = $json_item['description']; + } - $this->items[] = $item; - } - } + $item['timestamp'] = $json_item['created_at']; + $content = 'User: @' + . $json_item['user']['username'] + . ''; + if (isset($json_item['location']['name'])) { + $content .= ' | Location: ' . $json_item['location']['name']; + } + $content .= ' | Image on Unsplash
'; + $item['content'] = $content; + + $this->items[] = $item; + } + } + + public function getName() + { + $filteredUser = $this->getInput('u') ?? ''; + if (strlen($filteredUser) > 0) { + return $filteredUser . ' - ' . self::NAME; + } else { + return self::NAME; + } + } } diff --git a/bridges/UrlebirdBridge.php b/bridges/UrlebirdBridge.php new file mode 100644 index 00000000000..38f73249ef6 --- /dev/null +++ b/bridges/UrlebirdBridge.php @@ -0,0 +1,97 @@ + [ + 'name' => '@username or #hashtag', + 'type' => 'text', + 'required' => true, + 'exampleValue' => '@willsmith', + 'title' => '@username or #hashtag' + ] + ] + ]; + + private $title; + + public function collectData() + { + switch ($this->getInput('query')[0]) { + case '@': + $url = 'https://urlebird.com/user/' . substr($this->getInput('query'), 1) . '/'; + break; + case '#': + $url = 'https://urlebird.com/hash/' . substr($this->getInput('query'), 1) . '/'; + break; + default: + returnServerError('Please, enter valid username or hashtag!'); + break; + } + + $html = getSimpleHTMLDOM($url); + $limit = 10; + + $this->title = $html->find('title', 0)->innertext; + $articles = $html->find('div.thumb'); + $articles = array_slice($articles, 0, $limit); + foreach ($articles as $article) { + $item = []; + $itemUrl = $article->find('a', 2)->href; + $item['uri'] = $this->encodePathSegments($itemUrl); + + $dom = getSimpleHTMLDOM($item['uri']); + $videoDiv = $dom->find('div.video', 0); + + // timestamp + $timestampH6 = $videoDiv->find('h6', 0); + $datetimeString = str_replace('Posted ', '', $timestampH6->plaintext); + $item['timestamp'] = $datetimeString; + + $innertext = $dom->find('a.user-video', 1)->innertext; + $alt = $article->find('img', 0)->alt; + $item['author'] = $alt . ' (' . $innertext . ')'; + + $item['title'] = $dom->find('title', 0)->innertext; + $item['enclosures'][] = $dom->find('video', 0)->poster; + + $video = $dom->find('video', 0); + $video->autoplay = null; + + $item['content'] = $video->outertext . '
' . + $dom->find('div.music', 0) . '
' . + $dom->find('div.info2', 0)->innertext . + '
Direct video link
Post link
'; + + $this->items[] = $item; + } + } + + private function encodePathSegments($url) + { + $path = parse_url($url, PHP_URL_PATH); + $pathSegments = explode('/', $path); + $encodedPathSegments = array_map('urlencode', $pathSegments); + $encodedPath = implode('/', $encodedPathSegments); + $result = str_replace($path, $encodedPath, $url); + return $result; + } + + public function getName() + { + return $this->title ?: parent::getName(); + } + + public function getIcon() + { + return 'https://urlebird.com/favicon.ico'; + } +} diff --git a/bridges/UsbekEtRicaBridge.php b/bridges/UsbekEtRicaBridge.php index 3cecd5dcb87..9cf2699589f 100644 --- a/bridges/UsbekEtRicaBridge.php +++ b/bridges/UsbekEtRicaBridge.php @@ -1,109 +1,119 @@ array( - 'name' => 'Number of articles to return', - 'type' => 'number', - 'required' => false, - 'title' => 'Specifies the maximum number of articles to return', - 'defaultValue' => -1 - ), - 'fullarticle' => array( - 'name' => 'Load full article', - 'type' => 'checkbox', - 'required' => false, - 'title' => 'Activate to load full articles', - ) - ) - ); - - public function collectData(){ - $limit = $this->getInput('limit'); - $fullarticle = $this->getInput('fullarticle'); - $html = getSimpleHTMLDOM($this->getURI()); - - $articles = $html->find('div.details'); - - foreach($articles as $article) { - $item = array(); - - $title = $article->find('div.card-title', 0); - if($title) { - $item['title'] = $title->plaintext; - } else { - // Sometimes we get rubbish, ignore. - continue; - } - - $author = $article->find('div.author span', 0); - if($author) { - $item['author'] = $author->plaintext; - } - - $uri = $article->find('a.read', 0)->href; - if(substr($uri, 0, 1) === 'h') { // absolute uri - $item['uri'] = $uri; - } else { // relative uri - $item['uri'] = $this->getURI() . $uri; - } - - if($fullarticle) { - $content = $this->loadFullArticle($item['uri']); - } - - if($fullarticle && !is_null($content)) { - $item['content'] = $content; - } else { - $excerpt = $article->find('div.card-excerpt', 0); - if($excerpt) { - $item['content'] = $excerpt->plaintext; - } - } - - $image = $article->find('div.card-img img', 0); - if($image) { - $item['enclosures'] = array( - $image->src - ); - } - - $this->items[] = $item; - - if($limit > 0 && count($this->items) >= $limit) { - break; - } - } - } - - /** - * Loads the full article and returns the contents - * @param $uri The article URI - * @return The article content - */ - private function loadFullArticle($uri){ - $html = getSimpleHTMLDOMCached($uri); - - $content = $html->find('section.main', 0); - if($content) { - return $this->replaceUriInHtmlElement($content); - } - - return null; - } - - /** - * Replaces all relative URIs with absolute ones - * @param $element A simplehtmldom element - * @return The $element->innertext with all URIs replaced - */ - private function replaceUriInHtmlElement($element){ - return str_replace('href="/', 'href="' . $this->getURI() . '/', $element->innertext); - } + +class UsbekEtRicaBridge extends BridgeAbstract +{ + const MAINTAINER = 'logmanoriginal'; + const NAME = 'Usbek & Rica Bridge'; + const URI = 'https://usbeketrica.com'; + const DESCRIPTION = 'Returns latest articles from the front page'; + + const PARAMETERS = [ + [ + 'limit' => [ + 'name' => 'Number of articles to return', + 'type' => 'number', + 'required' => false, + 'title' => 'Specifies the maximum number of articles to return', + 'defaultValue' => -1 + ], + 'fullarticle' => [ + 'name' => 'Load full article', + 'type' => 'checkbox', + 'required' => false, + 'title' => 'Activate to load full articles', + ] + ] + ]; + + public function collectData() + { + $limit = $this->getInput('limit'); + $fullarticle = $this->getInput('fullarticle'); + $html = getSimpleHTMLDOM($this->getURI()); + + $articles = $html->find('article'); + + foreach ($articles as $article) { + $item = []; + + $title = $article->find('h2', 0); + if ($title) { + $item['title'] = $title->plaintext; + } else { + // Sometimes we get rubbish, ignore. + continue; + } + + $author = $article->find('div.author span', 0); + if ($author) { + $item['author'] = $author->plaintext; + } + + $content = null; + + $u = $article->find('a.card-img', 0); + if ($u) { + $uri = $u->href; + if (substr($uri, 0, 1) === 'h') { + // absolute uri + $item['uri'] = $uri; + } else { + // relative uri + $item['uri'] = $this->getURI() . $uri; + } + if ($fullarticle) { + $content = $this->loadFullArticle($item['uri']); + } + } + + if ($fullarticle && $content) { + $item['content'] = $content; + } else { + $excerpt = $article->find('div.card-excerpt', 0); + if ($excerpt) { + $item['content'] = $excerpt->plaintext; + } + } + + $image = $article->find('div.card-img img', 0); + if ($image) { + $item['enclosures'] = [ + $image->src + ]; + } + + $this->items[] = $item; + + if ($limit > 0 && count($this->items) >= $limit) { + break; + } + } + } + + /** + * Loads the full article and returns the contents + * @param $uri The article URI + * @return The article content + */ + private function loadFullArticle($uri) + { + $html = getSimpleHTMLDOMCached($uri); + + $content = $html->find('div.rich-text', 1); + if ($content) { + return $this->replaceUriInHtmlElement($content); + } + + return null; + } + + /** + * Replaces all relative URIs with absolute ones + * @param $element A simplehtmldom element + * @return The $element->innertext with all URIs replaced + */ + private function replaceUriInHtmlElement($element) + { + return str_replace('href="/', 'href="' . $this->getURI() . '/', $element->innertext); + } } diff --git a/bridges/UsenixBridge.php b/bridges/UsenixBridge.php new file mode 100644 index 00000000000..659f012d2f7 --- /dev/null +++ b/bridges/UsenixBridge.php @@ -0,0 +1,69 @@ + [ + ], + ]; + + public function collectData() + { + if ($this->queriedContext === 'USENIX ;login:') { + $this->collectLoginOnlineItems(); + return; + } + returnClientError('Illegal Context'); + } + + private function collectLoginOnlineItems(): void + { + $url = 'https://www.usenix.org/publications/loginonline'; + $dom = getSimpleHTMLDOMCached($url); + $items = $dom->find('div.view-content > div'); + + foreach ($items as $item) { + $title = $item->find('.views-field-title > span', 0); + $author = $item->find('.views-field-pseudo-author-list > span.field-content', 0); + $relativeUrl = $item->find('.views-field-nothing-1 > span > a', 0); + $uri = sprintf('https://www.usenix.org%s', $relativeUrl->href); + // June 2, 2022 + $createdAt = $item->find('div.views-field-field-lv2-publication-date > div > span', 0); + + $item = [ + 'title' => $title->innertext, + 'author' => strstr($author->plaintext, ',', true) ?: $author->plaintext, + 'uri' => $uri, + 'timestamp' => $createdAt->innertext, + ]; + + $this->items[] = array_merge($item, $this->getItemContent($uri)); + } + } + + private function getItemContent(string $uri): array + { + $html = getSimpleHTMLDOMCached($uri); + $content = $html->find('.paragraphs-items-full', 0)->innertext; + $extra = $html->find('fieldset', 0); + if (!empty($extra)) { + $content .= $extra->innertext; + } + + $tags = []; + foreach ($html->find('.field-name-field-lv2-tags div.field-item') as $tag) { + $tags[] = $tag->plaintext; + } + + return [ + 'content' => $content, + 'categories' => $tags + ]; + } +} diff --git a/bridges/UsesTechBridge.php b/bridges/UsesTechBridge.php new file mode 100644 index 00000000000..28fc99085f4 --- /dev/null +++ b/bridges/UsesTechBridge.php @@ -0,0 +1,29 @@ +find('div[class=PersonInner]') as $index => $a) { + $item = []; // Create an empty item + $articlePath = $a->find('a[class=displayLink]', 0)->href; + $item['title'] = $a->find('img', 0)->getAttribute('alt'); + $item['author'] = $a->find('img', 0)->getAttribute('alt'); + $item['uri'] = $articlePath; + $item['content'] = $a->find('p', 0)->innertext; + + $this->items[] = $item; // Add item to the list + if (count($this->items) >= self::MAX_ITEM) { + break; + } + } + } +} diff --git a/bridges/VMwareSecurityBridge.php b/bridges/VMwareSecurityBridge.php deleted file mode 100644 index 326d26a8ff4..00000000000 --- a/bridges/VMwareSecurityBridge.php +++ /dev/null @@ -1,31 +0,0 @@ -find('div[class="news_block"]'); - - foreach ($articles as $element) { - $item['uri'] = $element->find('a', 0)->getAttribute('href'); - $title = $element->find('a', 0)->innertext; - $item['title'] = $title; - $item['timestamp'] = strtotime($element->find('p', 0)->innertext); - $item['content'] = $element->find('p', 1)->innertext; - $item['uid'] = $title; - - $this->items[] = $item; - } - } -} diff --git a/bridges/VarietyBridge.php b/bridges/VarietyBridge.php index a2e617009aa..a49ea353ffa 100644 --- a/bridges/VarietyBridge.php +++ b/bridges/VarietyBridge.php @@ -1,30 +1,31 @@ collectExpandableDatas('http://feeds.feedburner.com/variety/headlines', 15); - } + public function collectData() + { + $this->collectExpandableDatas('https://feeds.feedburner.com/variety/headlines', 15); + } - protected function parseItem($newsItem){ - $item = parent::parseItem($newsItem); - // $articlePage gets the entire page's contents - $articlePage = getSimpleHTMLDOM($newsItem->link); + protected function parseItem(array $item) + { + $articlePage = getSimpleHTMLDOM($item['uri']); - // Remove Script tags - foreach($articlePage->find('script') as $script_tag) { - $script_tag->remove(); - } - $article = $articlePage->find('div.c-featured-media', 0); - $article = $article . $articlePage->find('.c-content', 0); + // Remove Script tags + foreach ($articlePage->find('script') as $script_tag) { + $script_tag->remove(); + } + $article = $articlePage->find('div.c-featured-media', 0); + $article = $article . $articlePage->find('.c-content', 0); - $item['content'] = $article; + $item['content'] = $article; - return $item; - } + return $item; + } } diff --git a/bridges/ViadeoCompanyBridge.php b/bridges/ViadeoCompanyBridge.php index 3f7618801d3..3b147c41f68 100644 --- a/bridges/ViadeoCompanyBridge.php +++ b/bridges/ViadeoCompanyBridge.php @@ -1,37 +1,43 @@ apple)'; - const PARAMETERS = array( array( - 'c' => array( - 'name' => 'Company name', - 'required' => true - ) - )); + const PARAMETERS = [ [ + 'c' => [ + 'name' => 'Company name', + 'exampleValue' => 'apple', + 'required' => true + ] + ]]; - public function collectData(){ - $html = ''; - $link = self::URI . 'fr/company/' . $this->getInput('c'); + public function collectData() + { + // Redirects to https://emploi.lefigaro.fr/recherche/entreprises + $url = sprintf('%sfr/company/%s', self::URI, $this->getInput('c')); - $html = getSimpleHTMLDOM($link) - or returnServerError('Could not request Viadeo.'); + $html = getSimpleHTMLDOM($url); - foreach($html->find('//*[@id="company-newsfeed"]/ul/li') as $element) { - $title = $element->find('p', 0)->innertext; - if($title) { - $item = array(); - $item['uri'] = $link; - $item['title'] = mb_substr($element->find('p', 0)->innertext, 0, 100); - $item['content'] = $element->find('p', 0)->innertext;; - $this->items[] = $item; - $i++; - } - } - } + // TODO: Fix broken xpath selector + $elements = $html->find('//*[@id="company-newsfeed"]/ul/li'); + + foreach ($elements as $element) { + $title = $element->find('p', 0)->innertext; + if (!$title) { + continue; + } + $item = []; + $item['uri'] = $url; + $item['title'] = mb_substr($element->find('p', 0)->innertext, 0, 100); + $item['content'] = $element->find('p', 0)->innertext; + ; + $this->items[] = $item; + } + } } diff --git a/bridges/ViceBridge.php b/bridges/ViceBridge.php index 4dccb8effec..dd81c55921c 100644 --- a/bridges/ViceBridge.php +++ b/bridges/ViceBridge.php @@ -1,38 +1,44 @@ array( - 'name' => 'Feed', - 'type' => 'list', - 'values' => array( - 'Vice News' => 'rss', - 'Motherboard - Tech' => 'en_us/rss/topic/tech', - 'Entertainment' => 'en_us/rss/topic/entertainment', - 'Noisey - Music' => 'en_us/rss/topic/music', - 'Munchies - Food' => 'en_us/rss/topic/food' - ) - ) - )); - public function collectData(){ - $feed = $this->getInput('feed'); - $feedURL = 'https://www.vice.com/' . $feed; - $this->collectExpandableDatas($feedURL, 10); - } +class ViceBridge extends FeedExpander +{ + const MAINTAINER = 'IceWreck'; + const NAME = 'Vice Bridge'; + const URI = 'https://www.vice.com/'; + const CACHE_TIMEOUT = 3600; + const DESCRIPTION = 'RSS feed for vice publications like Vice News, Munchies, Motherboard, etc.'; + const PARAMETERS = [ [ + 'feed' => [ + 'name' => 'Feed', + 'type' => 'list', + 'values' => [ + 'Vice News' => 'rss', + 'Motherboard - Tech' => 'en_us/rss/topic/tech', + 'Entertainment' => 'en_us/rss/topic/entertainment', + 'Noisey - Music' => 'en_us/rss/topic/music', + 'Munchies - Food' => 'en_us/rss/topic/food' + ] + ] + ]]; - protected function parseItem($newsItem){ - $item = parent::parseItem($newsItem); - // $articlePage gets the entire page's contents - $articlePage = getSimpleHTMLDOM($newsItem->link); - // text and embedded content - $article = $article . $articlePage->find('.article__body', 0); - $item['content'] = $article; + public function collectData() + { + $feed = $this->getInput('feed'); + if ($feed === 'rss') { + // They changed url in Sep 2023 + $feed = 'en/rss'; + } + $feedURL = 'https://www.vice.com/' . $feed; + $this->collectExpandableDatas($feedURL, 10); + } - return $item; - } + protected function parseItem(array $item) + { + $articlePage = getSimpleHTMLDOM($item['uri']); + // text and embedded content + $article = $articlePage->find('.article__body', 0); + $item['content'] = $article ?? ''; + + return $item; + } } diff --git a/bridges/VideoCardzBridge.php b/bridges/VideoCardzBridge.php new file mode 100644 index 00000000000..84796c203fa --- /dev/null +++ b/bridges/VideoCardzBridge.php @@ -0,0 +1,76 @@ + [ + 'name' => 'News Feed', + 'type' => 'list', + 'title' => 'Feeds from VideoCardz.com', + 'values' => [ + 'News' => 'sections/news', + 'Featured' => 'sections/featured', + 'Leaks' => 'sections/leaks', + 'Press Releases' => 'sections/press-releases', + 'Preview Roundup' => 'sections/review-roundup', + 'Rumour' => 'sections/rumor', + ] + ] + ] + ]; + + public function getIcon() + { + return 'https://videocardz.com/favicon-32x32.png?x66580'; + } + + public function getName() + { + return !is_null($this->getKey('feed')) ? self::NAME . ' | ' . $this->getKey('feed') : self::NAME; + } + + public function getURI() + { + return self::URI . $this->getInput('feed'); + } + + public function collectData() + { + $url = sprintf('https://videocardz.com/%s', $this->getInput('feed')); + $dom = getSimpleHTMLDOM($url); + $dom = $dom->find('.subcategory-news', 0); + if (!$dom) { + throw new \Exception(sprintf('Unable to find css selector on `%s`', $url)); + } + $dom = defaultLinkTo($dom, $this->getURI()); + + foreach ($dom->find('article') as $article) { + $title = preg_replace('/\(PR\) /i', '', $article->find('h2', 0)->plaintext); + //Get thumbnail + $image = $article->style; + $image = preg_replace('/background-image:url\(/i', '', $image); + $image = substr_replace($image, '', -3); + //Get date and time of publishing + $datetime = date_parse($article->find('.main-index-article-datetitle-date > a', 0)->plaintext); + $year = $datetime['year']; + $month = $datetime['month']; + $day = $datetime['day']; + $hour = $datetime['hour']; + $minute = $datetime['minute']; + $timestamp = mktime($hour, $minute, 0, $month, $day, $year); + $content = ''; + $this->items[] = [ + 'title' => $title, + 'uri' => $article->find('p.main-index-article-datetitle-date > a', 0)->href, + 'content' => $content, + 'timestamp' => $timestamp, + ]; + } + } +} diff --git a/bridges/VieDeMerdeBridge.php b/bridges/VieDeMerdeBridge.php index 1224798020f..be3841577ac 100644 --- a/bridges/VieDeMerdeBridge.php +++ b/bridges/VieDeMerdeBridge.php @@ -1,56 +1,58 @@ array( - 'name' => 'Limit number of returned items', - 'type' => 'number', - 'defaultValue' => 20 - ) - )); - - public function collectData() { - $limit = $this->getInput('item_limit'); - - if ($limit < 1) { - $limit = 20; - } - - $html = getSimpleHTMLDOM(self::URI, array()) - or returnServerError('Could not request VieDeMerde.'); - - $quotes = $html->find('article.article-panel'); - if(sizeof($quotes) === 0) { - return; - } - - foreach($quotes as $quote) { - $item = array(); - $item['uri'] = self::URI . $quote->find('.article-contents a', 0)->href; - $titleContent = $quote->find('.article-contents a h2.classic-title', 0); - - if($titleContent) { - $item['title'] = html_entity_decode($titleContent->plaintext, ENT_QUOTES); - } else { - continue; - } - - $quote->find('.article-contents a h2.classic-title', 0)->outertext = ''; - $item['content'] = $quote->find('.article-contents a', 0)->innertext; - $item['author'] = $quote->find('.article-topbar', 0)->innertext; - $item['uid'] = hash('sha256', $item['title']); - - $this->items[] = $item; - - if (count($this->items) >= $limit) { - break; - } - } - } + +class VieDeMerdeBridge extends BridgeAbstract +{ + const MAINTAINER = 'floviolleau'; + const NAME = 'VieDeMerde Bridge'; + const URI = 'https://www.viedemerde.fr'; + const DESCRIPTION = 'Returns latest quotes from VieDeMerde.'; + const CACHE_TIMEOUT = 7200; + + const PARAMETERS = [[ + 'item_limit' => [ + 'name' => 'Limit number of returned items', + 'type' => 'number', + 'defaultValue' => 20 + ] + ]]; + + public function collectData() + { + $limit = $this->getInput('item_limit'); + + if ($limit < 1) { + $limit = 20; + } + + $html = getSimpleHTMLDOM(self::URI, []); + $quotes = $html->find('article.bg-white'); + if (count($quotes) === 0) { + return; + } + + foreach ($quotes as $quote) { + $item = []; + $item['uri'] = self::URI . $quote->find('a', 0)->href; + $titleContent = $quote->find('h2', 0); + + if ($titleContent) { + $item['title'] = html_entity_decode($titleContent->plaintext, ENT_QUOTES); + } else { + continue; + } + + $quoteText = $quote->find('a', 1)->plaintext; + $isAVDM = $quote->find('.vote-btn', 0)->plaintext; + $isNotAVDM = $quote->find('.vote-btn', 1)->plaintext; + $item['content'] = $quoteText . '
' . $isAVDM . '
' . $isNotAVDM; + $item['author'] = $quote->find('p', 0)->plaintext; + $item['uid'] = hash('sha256', $item['title']); + + $this->items[] = $item; + + if (count($this->items) >= $limit) { + break; + } + } + } } diff --git a/bridges/VimeoBridge.php b/bridges/VimeoBridge.php index d318e30e146..80bfb8aceef 100644 --- a/bridges/VimeoBridge.php +++ b/bridges/VimeoBridge.php @@ -1,175 +1,197 @@ array( - 'name' => 'Search Query', - 'type' => 'text', - 'required' => true - ), - 'type' => array( - 'name' => 'Show results for', - 'type' => 'list', - 'defaultValue' => 'Videos', - 'values' => array( - 'Videos' => 'search', - 'On Demand' => 'search/ondemand', - 'People' => 'search/people', - 'Channels' => 'search/channels', - 'Groups' => 'search/groups' - ) - ) - ) - ); - - public function getURI() { - if(($query = $this->getInput('q')) - && ($type = $this->getInput('type'))) { - return self::URI . $type . '/sort:latest?q=' . $query; - } - - return parent::getURI(); - } - - public function collectData() { - - $html = getSimpleHTMLDOM($this->getURI(), - $header = array(), - $opts = array(), - $lowercase = true, - $forceTagsClosed = true, - $target_charset = DEFAULT_TARGET_CHARSET, - $stripRN = false, // We want to keep newline characters - $defaultBRText = DEFAULT_BR_TEXT, - $defaultSpanText = DEFAULT_SPAN_TEXT) - or returnServerError('Could not request ' . $this->getURI()); - - $json = null; // Holds the JSON data - - /** - * Search results are included as JSON formatted string inside a script - * tag that has the variable 'vimeo.config'. The data is condensed into - * a single line of code, so we can just search for the newline. - * - * Everything after "vimeo.config = _extend((vimeo.config || {}), " is - * the JSON formatted string. - */ - foreach($html->find('script') as $script) { - foreach(explode("\n", $script) as $line) { - $line = trim($line); - - if(strpos($line, 'vimeo.config') !== 0) - continue; - - // 45 = strlen("vimeo.config = _extend((vimeo.config || {}), "); - // 47 = 45 + 2, because we don't want the final ");" - $json = json_decode(substr($line, 45, strlen($line) - 47)); - } - } - - if(is_null($json)) { - returnClientError('No results for this query!'); - } - - foreach($json->api->initial_json->data as $element) { - switch($element->type) { - case 'clip': $this->addClip($element); break; - case 'ondemand': $this->addOnDemand($element); break; - case 'people': $this->addPeople($element); break; - case 'channel': $this->addChannel($element); break; - case 'group': $this->addGroup($element); break; - - default: returnServerError('Unknown type: ' . $element->type); - } - } - - } - - private function addClip($element) { - $item = array(); - - $item['uri'] = $element->clip->link; - $item['title'] = $element->clip->name; - $item['author'] = $element->clip->user->name; - $item['timestamp'] = strtotime($element->clip->created_time); - - $item['enclosures'] = array( - end($element->clip->pictures->sizes)->link - ); - - $item['content'] = ""; - - $this->items[] = $item; - } - - private function addOnDemand($element) { - $item = array(); - - $item['uri'] = $element->ondemand->link; - $item['title'] = $element->ondemand->name; - - // Only for films - if(isset($element->ondemand->film)) - $item['timestamp'] = strtotime($element->ondemand->film->release_time); +class VimeoBridge extends BridgeAbstract +{ + const NAME = 'Vimeo Bridge'; + const URI = 'https://vimeo.com/'; + const DESCRIPTION = 'Returns search results from Vimeo'; + const MAINTAINER = 'logmanoriginal'; + + const PARAMETERS = [ + [ + 'q' => [ + 'name' => 'Search Query', + 'type' => 'text', + 'exampleValue' => 'birds', + 'required' => true + ], + 'type' => [ + 'name' => 'Show results for', + 'type' => 'list', + 'defaultValue' => 'Videos', + 'values' => [ + 'Videos' => 'search', + 'On Demand' => 'search/ondemand', + 'People' => 'search/people', + 'Channels' => 'search/channels', + 'Groups' => 'search/groups' + ] + ] + ] + ]; + + public function getURI() + { + if ( + ($query = $this->getInput('q')) + && ($type = $this->getInput('type')) + ) { + return self::URI . $type . '/sort:latest?q=' . $query; + } + + return parent::getURI(); + } + + public function collectData() + { + $html = getSimpleHTMLDOM( + $this->getURI(), + $header = [], + $opts = [], + $lowercase = true, + $forceTagsClosed = true, + $target_charset = DEFAULT_TARGET_CHARSET, + $stripRN = false, // We want to keep newline characters + $defaultBRText = DEFAULT_BR_TEXT, + $defaultSpanText = DEFAULT_SPAN_TEXT + ); + + $json = null; // Holds the JSON data + + /** + * Search results are included as JSON formatted string inside a script + * tag that has the variable 'vimeo.config'. The data is condensed into + * a single line of code, so we can just search for the newline. + * + * Everything after "vimeo.config = _extend((vimeo.config || {}), " is + * the JSON formatted string. + */ + foreach ($html->find('script') as $script) { + foreach (explode("\n", $script) as $line) { + $line = trim($line); + + if (strpos($line, 'vimeo.config') !== 0) { + continue; + } + + // 45 = strlen("vimeo.config = _extend((vimeo.config || {}), "); + // 47 = 45 + 2, because we don't want the final ");" + $json = json_decode(substr($line, 45, strlen($line) - 47)); + } + } + + if (is_null($json)) { + returnClientError('No results for this query!'); + } + + foreach ($json->api->initial_json->data as $element) { + switch ($element->type) { + case 'clip': + $this->addClip($element); + break; + case 'ondemand': + $this->addOnDemand($element); + break; + case 'people': + $this->addPeople($element); + break; + case 'channel': + $this->addChannel($element); + break; + case 'group': + $this->addGroup($element); + break; + + default: + returnServerError('Unknown type: ' . $element->type); + } + } + } + + private function addClip($element) + { + $item = []; + + $item['uri'] = $element->clip->link; + $item['title'] = $element->clip->name; + $item['author'] = $element->clip->user->name; + $item['timestamp'] = strtotime($element->clip->created_time); + + $item['enclosures'] = [ + end($element->clip->pictures->sizes)->link + ]; + + $item['content'] = ""; + + $this->items[] = $item; + } + + private function addOnDemand($element) + { + $item = []; + + $item['uri'] = $element->ondemand->link; + $item['title'] = $element->ondemand->name; + + // Only for films + if (isset($element->ondemand->film)) { + $item['timestamp'] = strtotime($element->ondemand->film->release_time); + } + + $item['enclosures'] = [ + end($element->ondemand->pictures->sizes)->link + ]; + + $item['content'] = ""; + + $this->items[] = $item; + } + + private function addPeople($element) + { + $item = []; + + $item['uri'] = $element->people->link; + $item['title'] = $element->people->name; + + $item['enclosures'] = [ + end($element->people->pictures->sizes)->link + ]; + + $item['content'] = ""; + + $this->items[] = $item; + } + + private function addChannel($element) + { + $item = []; + + $item['uri'] = $element->channel->link; + $item['title'] = $element->channel->name; - $item['enclosures'] = array( - end($element->ondemand->pictures->sizes)->link - ); + $item['enclosures'] = [ + end($element->channel->pictures->sizes)->link + ]; - $item['content'] = ""; + $item['content'] = ""; - $this->items[] = $item; - } + $this->items[] = $item; + } - private function addPeople($element) { - $item = array(); + private function addGroup($element) + { + $item = []; - $item['uri'] = $element->people->link; - $item['title'] = $element->people->name; + $item['uri'] = $element->group->link; + $item['title'] = $element->group->name; - $item['enclosures'] = array( - end($element->people->pictures->sizes)->link - ); + $item['enclosures'] = [ + end($element->group->pictures->sizes)->link + ]; - $item['content'] = ""; + $item['content'] = ""; - $this->items[] = $item; - } - - private function addChannel($element) { - $item = array(); - - $item['uri'] = $element->channel->link; - $item['title'] = $element->channel->name; - - $item['enclosures'] = array( - end($element->channel->pictures->sizes)->link - ); - - $item['content'] = ""; - - $this->items[] = $item; - } - - private function addGroup($element) { - $item = array(); - - $item['uri'] = $element->group->link; - $item['title'] = $element->group->name; - - $item['enclosures'] = array( - end($element->group->pictures->sizes)->link - ); - - $item['content'] = ""; - - $this->items[] = $item; - } + $this->items[] = $item; + } } diff --git a/bridges/VixenBridge.php b/bridges/VixenBridge.php new file mode 100644 index 00000000000..048b9a7bb7e --- /dev/null +++ b/bridges/VixenBridge.php @@ -0,0 +1,111 @@ + [ + 'type' => 'list', + 'name' => 'Site', + 'title' => 'Choose site of interest', + 'values' => [ + 'Blacked' => 'Blacked', + 'BlackedRaw' => 'BlackedRaw', + 'Tushy' => 'Tushy', + 'TushyRaw' => 'TushyRaw', + 'Vixen' => 'Vixen', + 'Slayed' => 'Slayed', + 'Deeper' => 'Deeper' + ], + ] + ] + ]; + + public function collectData() + { + $videosURL = $this->getURI() . '/videos'; + + $website = getSimpleHTMLDOM($videosURL); + $json = $website->getElementById('__NEXT_DATA__'); + $data = json_decode($json->innertext(), true); + $nodes = array_column($data['props']['pageProps']['edges'], 'node'); + + foreach ($nodes as $n) { + $imageURL = $n['images']['listing'][2]['highdpi']['triple']; + + $item = [ + 'title' => $n['title'], + 'uri' => "$videosURL/$n[slug]", + 'uid' => $n['videoId'], + 'timestamp' => strtotime($n['releaseDate']), + 'enclosures' => [ $imageURL ], + 'author' => implode(' & ', array_column($n['modelsSlugged'], 'name')), + ]; + + /* + * No images retrieved from here. Should be cached for as long as + * possible to avoid rate throttling + */ + $target = getSimpleHtmlDOMCached($item['uri'], 86400); + $item['content'] = $this->generateContent( + $imageURL, + $target->find('meta[name=description]', 0)->content, + $n['modelsSlugged'] + ); + + $item['categories'] = array_map( + 'ucwords', + explode(',', $target->find('meta[name=keywords]', 0)->content) + ); + + $this->items[] = $item; + } + } + + public function getURI() + { + $param = $this->getInput('site'); + return $param ? "https://www.$param.com" : self::URI; + } + + /** + * Return name of the bridge. Default is needed for bridge index list + */ + public function getName() + { + $param = $this->getInput('site'); + return $param ? "$param Bridge" : self::NAME; + } + + private static function makeLink($URI, $text) + { + return "$text"; + } + + private function generateContent($imageURI, $description, $models) + { + $content = "$description
"; + $modelLinks = array_map( + function ($model) { + return self::makeLink( + $this->getURI() . "/models/$model[slugged]", + $model['name'] + ); + }, + $models + ); + return $content . 'Starring: ' . implode(' & ', $modelLinks) . '
'; + } +} diff --git a/bridges/Vk2Bridge.php b/bridges/Vk2Bridge.php new file mode 100644 index 00000000000..f52850ced98 --- /dev/null +++ b/bridges/Vk2Bridge.php @@ -0,0 +1,329 @@ + [ + 'name' => 'Короткое имя группы или профиля (из ссылки)', + 'exampleValue' => 'goblin_oper_ru', + 'required' => true + ], + 'hide_reposts' => [ + 'name' => 'Скрыть репосты', + 'type' => 'checkbox', + ] + ] + ]; + + const CONFIGURATION = [ + 'access_token' => [ + 'required' => true, + ], + ]; + + const TEST_DETECT_PARAMETERS = [ + 'https://vk.com/id1' => ['u' => 'id1'], + 'https://vk.com/groupname' => ['u' => 'groupname'], + 'https://m.vk.com/groupname' => ['u' => 'groupname'], + 'https://vk.com/groupname/anythingelse' => ['u' => 'groupname'], + 'https://vk.com/groupname?w=somethingelse' => ['u' => 'groupname'], + 'https://vk.com/with_underscore' => ['u' => 'with_underscore'], + 'https://vk.com/vk.cats' => ['u' => 'vk.cats'], + ]; + + protected $ownerNames = []; + protected $pageName; + private $urlRegex = '/vk\.com\/([\w.]+)/'; + private $rateLimitCacheKey = 'vk2_rate_limit'; + + public function getURI() + { + if (!is_null($this->getInput('u'))) { + return urljoin(static::URI, urlencode($this->getInput('u'))); + } + + return parent::getURI(); + } + + public function getName() + { + if ($this->pageName) { + return $this->pageName; + } + + return parent::getName(); + } + + public function detectParameters($url) + { + if (preg_match($this->urlRegex, $url, $matches)) { + return ['u' => $matches[1]]; + } + + return null; + } + + protected function getPostURI($post) + { + $r = 'https://vk.com/wall' . $post['owner_id'] . '_'; + if (isset($post['reply_post_id'])) { + $r .= $post['reply_post_id'] . '?reply=' . $post['id'] . '&thread=' . $post['parents_stack'][0]; + } else { + $r .= $post['id']; + } + return $r; + } + + // This function is based on SlackCoyote's vkfeed2rss + // https://github.com/em92/vkfeed2rss + protected function generateContentFromPost($post) + { + // it's what we will return + $ret = $post['text']; + + // html special characters convertion + $ret = htmlentities($ret, ENT_QUOTES | ENT_HTML401); + // change all linebreak to HTML compatible
+ $ret = nl2br($ret); + + $ret = "$ret
"; + + // find URLs + $ret = preg_replace( + '/((https?|ftp|gopher)\:\/\/[a-zA-Z0-9\-\.]+(:[a-zA-Z0-9]*)?\/?([@\w\-\+\.\?\,\'\/&%\$#\=~\x5C])*)/', + "$1", + $ret + ); + + // find [id1|Pawel Durow] form links + $ret = preg_replace('/\[(\w+)\|([^\]]+)\]/', "$2", $ret); + + + // attachments + if (isset($post['attachments'])) { + // level 1 + foreach ($post['attachments'] as $attachment) { + if ($attachment['type'] == 'video') { + // VK videos + $title = e($attachment['video']['title']); + $photo = e($this->getImageURLWithLargestWidth($attachment['video']['image'])); + $href = "https://vk.com/video{$attachment['video']['owner_id']}_{$attachment['video']['id']}"; + $ret .= ""; + } elseif ($attachment['type'] == 'audio') { + // VK audio + $artist = e($attachment['audio']['artist']); + $title = e($attachment['audio']['title']); + $ret .= "Audio: {$artist} - {$title}
"; + } elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] != 'gif') { + // any doc apart of gif + $doc_url = e($attachment['doc']['url']); + $title = e($attachment['doc']['title']); + $ret .= ""; + } + } + // level 2 + foreach ($post['attachments'] as $attachment) { + if ($attachment['type'] == 'photo') { + // JPEG, PNG photos + // GIF in vk is a document, so, not handled as photo + $photo = e($this->getImageURLWithLargestWidth($attachment['photo']['sizes'])); + $text = e($attachment['photo']['text']); + $ret .= ""; + } elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] == 'gif') { + // GIF docs + $url = e($attachment['doc']['url']); + $ret .= ""; + } elseif ($attachment['type'] == 'link') { + // links + $url = e($attachment['link']['url']); + $url = str_replace('https://m.vk.com', 'https://vk.com', $url); + $title = e($attachment['link']['title']); + if (isset($attachment['link']['photo'])) { + $photo = $this->getImageURLWithLargestWidth($attachment['link']['photo']['sizes']); + $ret .= ""; + } else { + $ret .= ""; + } + } elseif ($attachment['type'] == 'note') { + // notes + $title = e($attachment['note']['title']); + $url = e($attachment['note']['view_url']); + $ret .= ""; + } elseif ($attachment['type'] == 'poll') { + // polls + $question = e($attachment['poll']['question']); + $vote_count = $attachment['poll']['votes']; + $answers = $attachment['poll']['answers']; + $ret .= "Poll: {$question} ({$vote_count} votes)
'; + } elseif ($attachment['type'] == 'album') { + $album = $attachment['album']; + $url = "https://vk.com/album{$album['owner_id']}_{$album['id']}"; + $title = 'Альбом: ' . $album['title']; + $photo = $this->getImageURLWithLargestWidth($album['thumb']['sizes']); + $ret .= ""; + } elseif (!in_array($attachment['type'], ['video', 'audio', 'doc'])) { + $ret .= "
"; + foreach ($answers as $answer) { + $text = e($answer['text']); + $votes = $answer['votes']; + $rate = $answer['rate']; + $ret .= "* {$text}: {$votes} ({$rate}%)
"; + } + $ret .= 'Unknown attachment type: {$attachment['type']}
"; + } + } + } + + return $ret; + } + + protected function getImageURLWithLargestWidth($items) + { + usort($items, function ($a, $b) { + return $b['width'] - $a['width']; + }); + return $items[0]['url']; + } + + public function collectData() + { + if ($this->cache->get($this->rateLimitCacheKey)) { + throw new RateLimitException(); + } + + $u = $this->getInput('u'); + $ownerId = null; + + // getting ownerId from url + $r = preg_match('/^(club|public)(\d+)$/', $u, $matches); + if ($r) { + $ownerId = -intval($matches[2]); + } else { + $r = preg_match('/^(id)(\d+)$/', $u, $matches); + if ($r) { + $ownerId = intval($matches[2]); + } + } + + // getting owner id from API + if (is_null($ownerId)) { + $r = $this->api('groups.getById', [ + 'group_ids' => $u, + ], [100]); + if (isset($r['response'][0])) { + $ownerId = -$r['response'][0]['id']; + } else { + $r = $this->api('users.get', [ + 'user_ids' => $u, + ]); + if (count($r['response']) > 0) { + $ownerId = $r['response'][0]['id']; + } + } + } + + if (is_null($ownerId)) { + returnServerError('Could not detect owner id'); + } + + $r = $this->api('wall.get', [ + 'owner_id' => $ownerId, + 'extended' => '1', + ]); + + // preparing ownerNames dictionary + foreach ($r['response']['profiles'] as $profile) { + $this->ownerNames[$profile['id']] = $profile['first_name'] . ' ' . $profile['last_name']; + } + foreach ($r['response']['groups'] as $group) { + $this->ownerNames[-$group['id']] = $group['name']; + } + $this->generateFeed($r); + } + + protected function generateFeed($r) + { + $ownerId = 0; + + foreach ($r['response']['items'] as $post) { + if (!$ownerId) { + $ownerId = $post['owner_id']; + } + $item = []; + $content = $this->generateContentFromPost($post); + if (isset($post['copy_history'])) { + if ($this->getInput('hide_reposts')) { + continue; + } + $originalPost = $post['copy_history'][0]; + if ($originalPost['from_id'] < 0) { + $originalPostAuthorScreenName = 'club' . (-$originalPost['owner_id']); + } else { + $originalPostAuthorScreenName = 'id' . $originalPost['owner_id']; + } + $originalPostAuthorURI = 'https://vk.com/' . $originalPostAuthorScreenName; + $originalPostAuthorName = $this->ownerNames[$originalPost['from_id']]; + $originalPostAuthor = "$originalPostAuthorName"; + $content .= 'Репост (Пост от '; + $content .= $originalPostAuthor; + $content .= '):
'; + $content .= $this->generateContentFromPost($originalPost); + } + $item['content'] = $content; + $item['timestamp'] = $post['date']; + $item['author'] = $this->ownerNames[$post['from_id']]; + $item['title'] = $this->getTitle(strip_tags($content)); + $item['uri'] = $this->getPostURI($post); + + $this->items[] = $item; + } + + $this->pageName = $this->ownerNames[$ownerId]; + } + + protected function getTitle($content) + { + $content = explode('
', $content)[0]; + $content = strip_tags($content); + preg_match('/^[:\,"\w\ \p{L}\(\)\?#«»\-\–\—||&\.%\\₽\/+\;\!]+/mu', htmlspecialchars_decode($content), $result); + if (count($result) == 0) { + return 'untitled'; + } + return $result[0]; + } + + protected function api($method, array $params, $expected_error_codes = []) + { + $access_token = $this->getOption('access_token'); + if (!$access_token) { + returnServerError('You cannot run VK API methods without access_token'); + } + $params['v'] = '5.131'; + $r = json_decode( + getContents( + 'https://api.vk.com/method/' . $method . '?' . http_build_query($params), + ['Authorization: Bearer ' . $access_token] + ), + true + ); + if (isset($r['error']) && !in_array($r['error']['error_code'], $expected_error_codes)) { + if ($r['error']['error_code'] == 6) { + $this->cache->set($this->rateLimitCacheKey, true, 5); + } else if ($r['error']['error_code'] == 29) { + // wall.get has limit of 5000 requests per day + // if that limit is hit, VK returns error 29 + $this->cache->set($this->rateLimitCacheKey, true, 60 * 30); + } + returnServerError('API returned error: ' . $r['error']['error_msg'] . ' (' . $r['error']['error_code'] . ')'); + } + return $r; + } +} diff --git a/bridges/VkBridge.php b/bridges/VkBridge.php index 713b86f337a..11cfca5de29 100644 --- a/bridges/VkBridge.php +++ b/bridges/VkBridge.php @@ -2,446 +2,543 @@ class VkBridge extends BridgeAbstract { - - const MAINTAINER = 'em92'; - // const MAINTAINER = 'pmaziere'; - // const MAINTAINER = 'ahiles3005'; - const NAME = 'VK.com'; - const URI = 'https://vk.com/'; - const CACHE_TIMEOUT = 300; // 5min - const DESCRIPTION = 'Working with open pages'; - const PARAMETERS = array( - array( - 'u' => array( - 'name' => 'Group or user name', - 'required' => true - ), - 'hide_reposts' => array( - 'name' => 'Hide reposts', - 'type' => 'checkbox', - ) - ) - ); - - protected $videos = array(); - protected $pageName; - - protected function getAccessToken() - { - return 'e69b2db9f6cd4a97c0716893232587165c18be85bc1af1834560125c1d3c8ec281eb407a78cca0ae16776'; - } - - public function getURI() - { - if (!is_null($this->getInput('u'))) { - return static::URI . urlencode($this->getInput('u')); - } - - return parent::getURI(); - } - - public function getName() - { - if ($this->pageName) { - return $this->pageName; - } - - return parent::getName(); - } - - public function collectData() - { - $text_html = $this->getContents() - or returnServerError('No results for group or user name "' . $this->getInput('u') . '".'); - - $text_html = iconv('windows-1251', 'utf-8//ignore', $text_html); - // makes album link generating work correctly - $text_html = str_replace('"class="page_album_link">', '" class="page_album_link">', $text_html); - $html = str_get_html($text_html); - $pageName = $html->find('.page_name', 0); - if (is_object($pageName)) { - $pageName = $pageName->plaintext; - $this->pageName = htmlspecialchars_decode($pageName); - } - foreach ($html->find('div.replies') as $comment_block) { - $comment_block->outertext = ''; - } - $html->load($html->save()); - - $pinned_post_item = null; - $last_post_id = 0; - - foreach ($html->find('.post') as $post) { - - defaultLinkTo($post, self::URI); - - $post_videos = array(); - - $is_pinned_post = false; - if (strpos($post->getAttribute('class'), 'post_fixed') !== false) { - $is_pinned_post = true; - } - - if (is_object($post->find('a.wall_post_more', 0))) { - //delete link "show full" in content - $post->find('a.wall_post_more', 0)->outertext = ''; - } - - $content_suffix = ''; - - // looking for external links - $external_link_selectors = array( - 'a.page_media_link_title', - 'div.page_media_link_title > a', - 'div.media_desc > a.lnk', - ); - - foreach($external_link_selectors as $sel) { - if (is_object($post->find($sel, 0))) { - $a = $post->find($sel, 0); - $innertext = $a->innertext; - $parsed_url = parse_url($a->getAttribute('href')); - if (strpos($parsed_url['path'], '/away.php') !== 0) continue; - parse_str($parsed_url['query'], $parsed_query); - $content_suffix .= "
External link: $innertext"; - } - } - - // remove external link from content - $external_link_selectors_to_remove = array( - 'div.page_media_thumbed_link', - 'div.page_media_link_desc_wrap', - 'div.media_desc > a.lnk', - ); - - foreach($external_link_selectors_to_remove as $sel) { - if (is_object($post->find($sel, 0))) { - $post->find($sel, 0)->outertext = ''; - } - } - - // looking for article - $article = $post->find('a.article_snippet', 0); - if (is_object($article)) { - if (strpos($article->getAttribute('class'), 'article_snippet_mini') !== false) { - $article_title_selector = 'div.article_snippet_mini_title'; - $article_author_selector = 'div.article_snippet_mini_info > .mem_link, + // const MAINTAINER = 'em92'; + // const MAINTAINER = 'pmaziere'; + // const MAINTAINER = 'ahiles3005'; + const NAME = 'VK.com'; + const URI = 'https://vk.com/'; + const CACHE_TIMEOUT = 300; // 5min + const DESCRIPTION = 'Working with open pages'; + const PARAMETERS = [ + [ + 'u' => [ + 'name' => 'Group or user name', + 'exampleValue' => 'elonmusk_tech', + 'required' => true + ], + 'hide_reposts' => [ + 'name' => 'Hide reposts', + 'type' => 'checkbox', + ] + ] + ]; + const TEST_DETECT_PARAMETERS = [ + 'https://vk.com/id1' => ['u' => 'id1'], + 'https://vk.com/groupname' => ['u' => 'groupname'], + 'https://m.vk.com/groupname' => ['u' => 'groupname'], + 'https://vk.com/groupname/anythingelse' => ['u' => 'groupname'], + 'https://vk.com/groupname?w=somethingelse' => ['u' => 'groupname'], + 'https://vk.com/with_underscore' => ['u' => 'with_underscore'], + 'https://vk.com/vk.cats' => ['u' => 'vk.cats'], + ]; + + protected $pageName; + protected $tz = 0; + private $urlRegex = '/vk\.com\/([\w.]+)/'; + + public function getURI() + { + if (!is_null($this->getInput('u'))) { + return static::URI . urlencode($this->getInput('u')); + } + + return parent::getURI(); + } + + public function getName() + { + if ($this->pageName) { + return $this->pageName; + } + + return parent::getName(); + } + + public function detectParameters($url) + { + if (preg_match($this->urlRegex, $url, $matches)) { + return ['u' => $matches[1]]; + } + + return null; + } + + public function collectData() + { + $text_html = $this->getContents(); + + $text_html = iconv('windows-1251', 'utf-8//ignore', $text_html); + + $html = str_get_html($text_html); + foreach ($html->find('script') as $script) { + preg_match('/tz: ([0-9]+)/', $script->outertext, $matches); + if (count($matches) > 0) { + $this->tz = intval($matches[1]); + break; + } + } + $pageName = $html->find('meta[property="og:title"]', 0); + if (is_object($pageName)) { + $pageName = $pageName->getAttribute('content'); + $this->pageName = $pageName; + } + foreach ($html->find('div.replies') as $comment_block) { + $comment_block->outertext = ''; + } + + // expensive operation + $save = $html->save(); + $html->load($save); + + $pinned_post_item = null; + $last_post_id = 0; + + foreach ($html->find('.post') as $post) { + if ($post->find('.wall_post_text_deleted')) { + // repost of deleted post + continue; + } + + defaultLinkTo($post, self::URI); + + $is_pinned_post = false; + if (strpos($post->getAttribute('class'), 'post_fixed') !== false) { + $is_pinned_post = true; + } + + // Remove 'Show more' button + foreach ($post->find('button.PostTextMore') as $junk) { + $junk->outertext = ''; + } + + $content_suffix = ''; + + // looking for external links + $external_link_selectors = [ + 'a.page_media_link_title', + 'div.page_media_link_title > a', + 'div.media_desc > a.lnk', + ]; + + foreach ($external_link_selectors as $sel) { + if (is_object($post->find($sel, 0))) { + $a = $post->find($sel, 0); + $innertext = $a->innertext; + $parsed_url = parse_url($a->getAttribute('href')); + if (strpos($parsed_url['path'], '/away.php') !== 0) { + continue; + } + parse_str($parsed_url['query'], $parsed_query); + $content_suffix .= "
External link: $innertext"; + } + } + + // remove external link from content + $external_link_selectors_to_remove = [ + 'div.page_media_thumbed_link', + 'div.page_media_link_desc_wrap', + 'div.media_desc > a.lnk', + ]; + + foreach ($external_link_selectors_to_remove as $sel) { + if (is_object($post->find($sel, 0))) { + $post->find($sel, 0)->outertext = ''; + } + } + + // looking for article + $article = $post->find('a.article_snippet', 0); + if (is_object($article)) { + if (strpos($article->getAttribute('class'), 'article_snippet_mini') !== false) { + $article_title_selector = 'div.article_snippet_mini_title'; + $article_author_selector = 'div.article_snippet_mini_info > .mem_link, div.article_snippet_mini_info > .group_link'; - $article_thumb_selector = 'div.article_snippet_mini_thumb'; - } else { - $article_title_selector = 'div.article_snippet__title'; - $article_author_selector = 'div.article_snippet__author'; - $article_thumb_selector = 'div.article_snippet__image'; - } - $article_title = $article->find($article_title_selector, 0)->innertext; - $article_author = $article->find($article_author_selector, 0)->innertext; - $article_link = $article->getAttribute('href'); - $article_img_element_style = $article->find($article_thumb_selector, 0)->getAttribute('style'); - preg_match('/background-image: url\((.*)\)/', $article_img_element_style, $matches); - if (count($matches) > 0) { - $content_suffix .= "
"; - } - $content_suffix .= "
Article: $article_title ($article_author)"; - $article->outertext = ''; - } - - // get video on post - $video = $post->find('div.post_video_desc', 0); - $main_video_link = ''; - if (is_object($video)) { - $video_title = $video->find('div.post_video_title', 0)->plaintext; - $video_link = $video->find('a.lnk', 0)->getAttribute('href'); - $this->appendVideo($video_title, $video_link, $content_suffix, $post_videos); - $video->outertext = ''; - $main_video_link = $video_link; - } - - // get all other videos - foreach($post->find('a.page_post_thumb_video') as $a) { - $video_title = htmlspecialchars_decode($a->getAttribute('aria-label')); - $temp = explode(' ', $video_title, 2); - if (count($temp) > 1) $video_title = $temp[1]; - $video_link = $a->getAttribute('href'); - if ($video_link != $main_video_link) $this->appendVideo($video_title, $video_link, $content_suffix, $post_videos); - $a->outertext = ''; - } - - // get all photos - foreach($post->find('div.wall_text a.page_post_thumb_wrap') as $a) { - $result = $this->getPhoto($a); - if ($result == null) continue; - $a->outertext = ''; - $content_suffix .= "
$result"; - } - - // get albums - foreach($post->find('.page_album_wrap') as $el) { - $a = $el->find('.page_album_link', 0); - $album_title = $a->find('.page_album_title_text', 0)->getAttribute('title'); - $album_link = $a->getAttribute('href'); - $el->outertext = ''; - $content_suffix .= "
Album: $album_title"; - } - - // get photo documents - foreach($post->find('a.page_doc_photo_href') as $a) { - $doc_link = $a->getAttribute('href'); - $doc_gif_label_element = $a->find('.page_gif_label', 0); - $doc_title_element = $a->find('.doc_label', 0); - - if (is_object($doc_gif_label_element)) { - $gif_preview_img = backgroundToImg($a->find('.page_doc_photo', 0)); - $content_suffix .= "
Gif: $gif_preview_img"; - - } else if (is_object($doc_title_element)) { - $doc_title = $doc_title_element->innertext; - $content_suffix .= "
Doc: $doc_title"; - - } else { - continue; - - } - - $a->outertext = ''; - } - - // get other documents - foreach($post->find('div.page_doc_row') as $div) { - $doc_title_element = $div->find('a.page_doc_title', 0); - - if (is_object($doc_title_element)) { - $doc_title = $doc_title_element->innertext; - $doc_link = $doc_title_element->getAttribute('href'); - $content_suffix .= "
Doc: $doc_title"; - - } else { - continue; - - } - - $div->outertext = ''; - } - - // get polls - foreach($post->find('div.page_media_poll_wrap') as $div) { - $poll_title = $div->find('.page_media_poll_title', 0)->innertext; - $content_suffix .= "
Poll: $poll_title"; - foreach($div->find('div.page_poll_text') as $poll_stat_title) { - $content_suffix .= '
- ' . $poll_stat_title->innertext; - } - $div->outertext = ''; - } - - // get sign - $post_author = $pageName; - foreach($post->find('a.wall_signed_by') as $a) { - $post_author = $a->innertext; - $a->outertext = ''; - } - - // fix links and get post hashtags - $hashtags = array(); - foreach($post->find('a') as $a) { - $href = $a->getAttribute('href'); - $innertext = $a->innertext; - - $hashtag_prefix = '/feed?section=search&q=%23'; - $hashtag = null; - - if ($href && substr($href, 0, strlen($hashtag_prefix)) === $hashtag_prefix) { - $hashtag = urldecode(substr($href, strlen($hashtag_prefix))); - } else if (substr($innertext, 0, 1) == '#') { - $hashtag = $innertext; - } - - if ($hashtag) { - $a->outertext = $innertext; - $hashtags[] = $hashtag; - continue; - } - - $parsed_url = parse_url($href); - - if (array_key_exists('path', $parsed_url) === false) continue; - - if (strpos($parsed_url['path'], '/away.php') === 0) { - parse_str($parsed_url['query'], $parsed_query); - $a->setAttribute('href', iconv( - 'windows-1251', - 'utf-8//ignore', - $parsed_query['to'] - )); - } - } - - if (is_object($post->find('div.copy_quote', 0))) { - if ($this->getInput('hide_reposts') === true) { - continue; - } - $copy_quote = $post->find('div.copy_quote', 0); - if ($copy_post_header = $copy_quote->find('div.copy_post_header', 0)) { - $copy_post_header->outertext = ''; - } - $copy_quote_content = $copy_quote->innertext; - $copy_quote->outertext = "
Reposted:
$copy_quote_content"; - } - - $item = array(); - $item['content'] = strip_tags(backgroundToImg($post->find('div.wall_text', 0)->innertext), '
'); - $item['content'] .= $content_suffix; - $item['categories'] = $hashtags; - - // get post link - $post_link = $post->find('a.post_link', 0)->getAttribute('href'); - preg_match('/wall-?\d+_(\d+)/', $post_link, $preg_match_result); - $item['post_id'] = intval($preg_match_result[1]); - $item['uri'] = $post_link; - $item['timestamp'] = $this->getTime($post); - $item['title'] = $this->getTitle($item['content']); - $item['author'] = $post_author; - $item['videos'] = $post_videos; - if ($is_pinned_post) { - // do not append it now - $pinned_post_item = $item; - } else { - $last_post_id = $item['post_id']; - $this->items[] = $item; - } - - } - - if (!is_null($pinned_post_item)) { - if (count($this->items) == 0) { - $this->items[] = $pinned_post_item; - } else if ($last_post_id < $pinned_post_item['post_id']) { - $this->items[] = $pinned_post_item; - usort($this->items, function ($item1, $item2) { - return $item2['post_id'] - $item1['post_id']; - }); - } - } - - $this->getCleanVideoLinks(); - } - - private function getPhoto($a) { - $onclick = $a->getAttribute('onclick'); - preg_match('/return showPhoto\(.+?({.*})/', $onclick, $preg_match_result); - if (count($preg_match_result) == 0) return; - - $arg = htmlspecialchars_decode( str_replace('queue:1', '"queue":1', $preg_match_result[1]) ); - $data = json_decode($arg, true); - if ($data == null) return; - - $thumb = $data['temp']['base'] . $data['temp']['x_'][0] . '.jpg'; - $original = ''; - foreach(array('y_', 'z_', 'w_') as $key) { - if (!isset($data['temp'][$key])) continue; - if (!isset($data['temp'][$key][0])) continue; - if (substr($data['temp'][$key][0], 0, 4) == 'http') { - $base = ''; - } else { - $base = $data['temp']['base']; - } - $original = $base . $data['temp'][$key][0] . '.jpg'; - } - - if ($original) { - return ""; - } else { - return ""; - } - } - - private function getTitle($content) - { - preg_match('/^["\w\ \p{Cyrillic}\(\)\?#«»-]+/mu', htmlspecialchars_decode($content), $result); - if (count($result) == 0) return 'untitled'; - return $result[0]; - } - - private function getTime($post) - { - if ($time = $post->find('span.rel_date', 0)->getAttribute('time')) { - return $time; - } else { - $strdate = $post->find('span.rel_date', 0)->plaintext; - - $date = date_parse($strdate); - if (!$date['year']) { - if (strstr($strdate, 'today') !== false) { - $strdate = date('d-m-Y') . ' ' . $strdate; - } elseif (strstr($strdate, 'yesterday ') !== false) { - $time = time() - 60 * 60 * 24; - $strdate = date('d-m-Y', $time) . ' ' . $strdate; - } else { - $strdate = $strdate . ' ' . date('Y'); - } - - $date = date_parse($strdate); - } elseif ($date['hour'] === false) { - $date['hour'] = $date['minute'] = '00'; - } - return strtotime($date['day'] . '-' . $date['month'] . '-' . $date['year'] . ' ' . - $date['hour'] . ':' . $date['minute']); - } - - } - - private function getContents() - { - ini_set('user-agent', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:53.0) Gecko/20100101 Firefox/53.0'); - - $header = array('Accept-language: en', 'Cookie: remixlang=3'); - - return getContents($this->getURI(), $header); - } - - protected function appendVideo($video_title, $video_link, &$content_suffix, array &$post_videos) - { - if (!$video_title) $video_title = '(empty)'; - - preg_match('/video([0-9-]+_[0-9]+)/', $video_link, $preg_match_result); - - if (count($preg_match_result) > 1) { - $video_id = $preg_match_result[1]; - $this->videos[ $video_id ] = array( - 'url' => $video_link, - 'title' => $video_title, - ); - $post_videos[] = $video_id; - } else { - $content_suffix .= '
Video: ' . $video_title . ''; - } - } - - protected function getCleanVideoLinks() { - $result = $this->api('video.get', array( - 'videos' => implode(',', array_keys($this->videos)), - 'count' => 200 - )); - - if (isset($result['error'])) return; - - foreach($result['response']['items'] as $item) { - $video_id = strval($item['owner_id']) . '_' . strval($item['id']); - $this->videos[$video_id]['url'] = $item['player']; - } - - foreach($this->items as &$item) { - foreach($item['videos'] as $video_id) { - $video_link = $this->videos[$video_id]['url']; - $video_title = $this->videos[$video_id]['title']; - $item['content'] .= '
Video: ' . $video_title . ''; - } - unset($item['videos']); - } - } - - protected function api($method, array $params) - { - $params['v'] = '5.80'; - $params['access_token'] = $this->getAccessToken(); - return json_decode( getContents('https://api.vk.com/method/' . $method . '?' . http_build_query($params)), true ); - } + $article_thumb_selector = 'div.article_snippet_mini_thumb'; + } else { + $article_title_selector = 'div.article_snippet__title'; + $article_author_selector = 'div.article_snippet__author'; + $article_thumb_selector = 'div.article_snippet__image'; + } + $article_title = $article->find($article_title_selector, 0)->innertext ?? ''; + $article_author = $article->find($article_author_selector, 0)->innertext ?? ''; + $article_link = $article->getAttribute('href'); + $article_img_element_style = $article->find($article_thumb_selector, 0)->getAttribute('style'); + preg_match('/background-image: url\((.*)\)/', $article_img_element_style, $matches); + if (count($matches) > 0) { + $content_suffix .= "
"; + } + $content_suffix .= "
Article: $article_title ($article_author)"; + $article->outertext = ''; + } + + // get all videos + foreach ($post->find('a.page_post_thumb_video') as $a) { + $video_title = htmlspecialchars_decode($a->getAttribute('aria-label')); + $video_title_split_pos = strrpos($video_title, ' is '); + if ($video_title_split_pos !== false) { + $video_title = substr($video_title, 0, $video_title_split_pos); + } + $video_link = $a->getAttribute('href'); + $this->appendVideo($video_title, $video_link, backgroundToImg($a), $content_suffix); + $a->outertext = ''; + } + + // get all photos + foreach ($post->find('div.wall_text a.page_post_thumb_wrap') as $a) { + $result = $this->getPhoto($a); + if ($result == null) { + continue; + } + $a->outertext = ''; + $content_suffix .= "
$result"; + } + + // get albums + foreach ($post->find('.page_album_wrap') as $el) { + $a = $el->find('.page_album_link', 0); + $album_title = $a->find('.page_album_title_text', 0)->getAttribute('title'); + $album_link = $a->getAttribute('href'); + $el->outertext = ''; + $content_suffix .= "
Album: $album_title"; + } + + // get photo documents + foreach ($post->find('a.page_doc_photo_href') as $a) { + $doc_link = $a->getAttribute('href'); + $doc_gif_label_element = $a->find('.page_gif_label', 0); + $doc_title_element = $a->find('.doc_label', 0); + + if (is_object($doc_gif_label_element)) { + $gif_preview_img = backgroundToImg($a->find('.page_doc_photo', 0)); + $content_suffix .= "
Gif: $gif_preview_img"; + } elseif (is_object($doc_title_element)) { + $doc_title = $doc_title_element->innertext; + $content_suffix .= "
Doc: $doc_title"; + } else { + continue; + } + + $a->outertext = ''; + } + + // get other documents + foreach ($post->find('div.page_doc_row') as $div) { + $doc_title_element = $div->find('a.page_doc_title', 0); + + if (is_object($doc_title_element)) { + $doc_title = $doc_title_element->innertext; + $doc_link = $doc_title_element->getAttribute('href'); + $content_suffix .= "
Doc: $doc_title"; + } else { + continue; + } + + $div->outertext = ''; + } + + // get polls + foreach ($post->find('div.page_media_poll_wrap') as $div) { + $poll_title = $div->find('.page_media_poll_title', 0)->innertext; + $content_suffix .= "
Poll: $poll_title"; + foreach ($div->find('div.page_poll_text') as $poll_stat_title) { + $content_suffix .= '
- ' . $poll_stat_title->innertext; + } + $div->outertext = ''; + } + + // get sign / post author + $post_author = $pageName; + $author_selectors = ['a.wall_signed_by', 'a.author']; + foreach ($author_selectors as $author_selector) { + $a = $post->find($author_selector, 0); + if (is_object($a)) { + $post_author = $a->innertext; + $a->outertext = ''; + break; + } + } + + // fix links and get post hashtags + $hashtags = []; + foreach ($post->find('a') as $a) { + $href = $a->getAttribute('href'); + $innertext = $a->innertext; + + $hashtag_prefix = '/feed?section=search&q=%23'; + $hashtag = null; + + if ($href && substr($href, 0, strlen($hashtag_prefix)) === $hashtag_prefix) { + $hashtag = urldecode(substr($href, strlen($hashtag_prefix))); + } elseif (substr($innertext, 0, 1) == '#') { + $hashtag = $innertext; + } + + if ($hashtag) { + $a->outertext = $innertext; + $hashtags[] = $hashtag; + continue; + } + + $parsed_url = parse_url($href); + + if (array_key_exists('path', $parsed_url) === false) { + continue; + } + + if (strpos($parsed_url['path'], '/away.php') === 0) { + parse_str($parsed_url['query'], $parsed_query); + $a->setAttribute('href', iconv( + 'windows-1251', + 'utf-8//ignore', + $parsed_query['to'] + )); + } + } + + $copy_quote = $post->find('div.copy_quote', 0); + if (is_object($copy_quote)) { + if ($this->getInput('hide_reposts') === true) { + continue; + } + if ($copy_post_header = $copy_quote->find('div.copy_post_header', 0)) { + $copy_post_header->outertext = ''; + } + + $second_copy_quote = $copy_quote->find('div.published_sec_quote', 0); + if (is_object($second_copy_quote)) { + $second_copy_quote_author = $second_copy_quote->find('a.copy_author', 0)->outertext; + $second_copy_quote_content = $second_copy_quote->find('div.copy_post_date', 0)->outertext; + $second_copy_quote->outertext = "
Reposted ($second_copy_quote_author): $second_copy_quote_content"; + } + $copy_quote_author = $copy_quote->find('a.copy_author', 0)->outertext; + $copy_quote_content = $copy_quote->innertext; + $copy_quote->outertext = "
Reposted ($copy_quote_author):
$copy_quote_content"; + } + + foreach ($post->find('.PrimaryAttachment .PhotoPrimaryAttachment') as $pa) { + $img = $pa->find('.PhotoPrimaryAttachment__imageElement', 0); + if (is_object($img)) { + $pa->outertext = $img->outertext; + } + } + + foreach ($post->find('.SecondaryAttachment') as $sa) { + $sa_href = $sa->getAttribute('href'); + if (!$sa_href) { + $sa_href = ''; + } + $sa_task_click = $sa->getAttribute('data-task-click'); + + if (str_starts_with($sa_href, 'https://vk.com/doc')) { + // document + $doc_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext; + $doc_size = $sa->find('.SecondaryAttachmentSubhead', 0)->innertext; + $doc_link = $sa_href; + $content_suffix .= "
Doc: $doc_title ($doc_size)"; + $sa->outertext = ''; + } else if (str_starts_with($sa_href, 'https://vk.com/@')) { + // article + $article_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext; + $article_author = explode('Article · from ', $sa->find('.SecondaryAttachmentSubhead', 0)->innertext)[1]; + $article_link = $sa_href; + $content_suffix .= "
Article: $article_title ($article_author)"; + $sa->outertext = ''; + } else if ($sa_task_click == 'SecondaryAttachment/playAudio') { + // audio + $audio_json = json_decode(html_entity_decode($sa->getAttribute('data-audio'))); + $audio_link = $audio_json->url; + $audio_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext; + $audio_author = $sa->find('.SecondaryAttachmentSubhead', 0)->innertext; + $content_suffix .= "
Audio: $audio_title ($audio_author)"; + $sa->outertext = ''; + } else if ($sa_task_click == 'SecondaryAttachment/playPlaylist') { + // playlist link + $playlist_title = $sa->find('.SecondaryAttachment__childrenText', 0)->innertext; + $playlist_link = $sa->find('.SecondaryAttachment__link', 0)->getAttribute('href'); + $content_suffix .= "
Playlist: $playlist_title"; + $sa->outertext = ''; + } + } + + $item = []; + $content = strip_tags(backgroundToImg($post->find('div.wall_text', 0)->innertext), '
'); + $content .= $content_suffix; + if (!$content) { + $content = '(empty post)'; + } + $content = str_get_html($content); + foreach ($content->find('img') as $img) { + $parsed_src = parse_url($img->getAttribute('src')); + + // unblur images (case of impf) + // get original images instead of thumbnails (case of impg) + $imgPrefix = array_reduce(['/impf/', '/impg/'], function ($a, $c) use ($parsed_src) { + if ($a) { + return $a; + } + if (str_starts_with($parsed_src['path'], $c)) { + return $c; + } + return $a; + }, ''); + if ($imgPrefix) { + $new_src = $parsed_src['scheme'] . '://' . $parsed_src['host']; + $new_src .= substr($parsed_src['path'], strlen($imgPrefix) - 1); + $img->setAttribute('src', $new_src); + } + } + $item['content'] = $content->outertext; + $item['categories'] = $hashtags; + + // get post link + $post_link = $post->find('a.PostHeaderSubtitle__link', 0)->getAttribute('href'); + preg_match('/wall-?\d+_(\d+)/', $post_link, $preg_match_result); + $item['post_id'] = intval($preg_match_result[1]); + $item['uri'] = $post_link; + $item['timestamp'] = $this->getTime($post); + $item['title'] = $this->getTitle($item['content']); + $item['author'] = $post_author; + if ($is_pinned_post) { + // do not append it now + $pinned_post_item = $item; + } else { + $last_post_id = $item['post_id']; + $this->items[] = $item; + } + } + + if (!is_null($pinned_post_item)) { + if (count($this->items) == 0) { + $this->items[] = $pinned_post_item; + } elseif ($last_post_id < $pinned_post_item['post_id']) { + $this->items[] = $pinned_post_item; + usort($this->items, function ($item1, $item2) { + return $item2['post_id'] - $item1['post_id']; + }); + } + } + } + + private function getPhoto($a) + { + $onclick = $a->getAttribute('onclick'); + preg_match('/return showPhoto\(.+?({.*})/', $onclick, $preg_match_result); + if (count($preg_match_result) == 0) { + return; + } + + $arg = htmlspecialchars_decode(str_replace('queue:1', '"queue":1', $preg_match_result[1])); + $data = json_decode($arg, true); + if ($data == null) { + return; + } + + $thumb = $data['temp']['base'] . $data['temp']['x_'][0]; + $original = ''; + foreach (['y_', 'z_', 'w_'] as $key) { + if (!isset($data['temp'][$key])) { + continue; + } + if (!isset($data['temp'][$key][0])) { + continue; + } + if (substr($data['temp'][$key][0], 0, 4) == 'http') { + $base = ''; + } else { + $base = $data['temp']['base']; + } + $original = $base . $data['temp'][$key][0]; + } + + if ($original) { + return ""; + } else { + return backgroundToImg($a); + } + } + + private function getTitle($content) + { + $content = explode('
', $content)[0]; + $content = strip_tags($content); + preg_match('/^[:\,"\w\ \p{L}\(\)\?#«»\-\–\—||&\.%\\₽\/+\;\!]+/mu', htmlspecialchars_decode($content), $result); + if (count($result) == 0) { + return 'untitled'; + } + return $result[0]; + } + + private function getTime($post) + { + $accurateDateElement = $post->find('span.rel_date', 0); + if ($accurateDateElement) { + return $accurateDateElement->getAttribute('time'); + } else { + $strdate = $post->find('time.PostHeaderSubtitle__item', 0)->plaintext; + $strdate = preg_replace('/[\x00-\x1F\x7F-\xFF]/', ' ', $strdate); + + $date = date_parse($strdate); + if (!$date['year']) { + if (strstr($strdate, 'today') !== false) { + $strdate = date('d-m-Y') . ' ' . $strdate; + } elseif (strstr($strdate, 'yesterday ') !== false) { + $time = time() - 60 * 60 * 24; + $strdate = date('d-m-Y', $time) . ' ' . $strdate; + } elseif ($date['month'] && intval(date('m')) < $date['month']) { + $strdate = $strdate . ' ' . (date('Y') - 1); + } else { + $strdate = $strdate . ' ' . date('Y'); + } + + $date = date_parse($strdate); + } elseif ($date['hour'] === false) { + $date['hour'] = $date['minute'] = '00'; + } + return strtotime($date['day'] . '-' . $date['month'] . '-' . $date['year'] . ' ' . + $date['hour'] . ':' . $date['minute']) - $this->tz; + } + } + + private function getContents() + { + $httpHeaders = [ + 'Accept-language: en', + 'Cookie: remixlang=3', + ]; + $redirects = 0; + $uri = $this->getURI(); + + while ($redirects < 2) { + $response = getContents($uri, $httpHeaders, [CURLOPT_FOLLOWLOCATION => false], true); + + if (in_array($response->getCode(), [200, 304])) { + return $response->getBody(); + } + + $headers = $response->getHeaders(); + $uri = urljoin(self::URI, $headers['location'][0]); + + if (str_contains($uri, '/429.html')) { + throw new RateLimitException(); + } + + if (!preg_match('#^https?://vk.com/#', $uri)) { + returnServerError('Unexpected redirect location: ' . $uri); + } + + $redirects++; + } + + returnServerError('Too many redirects, while retreving content from VK'); + } + + protected function appendVideo($video_title, $video_link, $previewImg, &$content_suffix) + { + if (!$video_title) { + $video_title = '(empty)'; + } + + $content_suffix .= '
' . $previewImg; + $content_suffix .= 'Video: ' . $video_title . ''; + } } diff --git a/bridges/VproTegenlichtBridge.php b/bridges/VproTegenlichtBridge.php new file mode 100644 index 00000000000..44afba64d8b --- /dev/null +++ b/bridges/VproTegenlichtBridge.php @@ -0,0 +1,41 @@ +find('ul#browsable-news-overview', 0); + $dom = defaultLinkTo($dom, $this->getURI()); + foreach ($dom->find('li') as $article) { + $a = $article->find('a.complex-teaser', 0); + $title = $article->find('a.complex-teaser', 0)->title; + $url = $article->find('a.complex-teaser', 0)->href; + $author = 'VPRO tegenlicht'; + $content = $article->find('p.complex-teaser-summary', 0)->plaintext; + $timestamp = strtotime($article->find('div.complex-teaser-data', 0)->plaintext); + + $item = [ + 'uri' => $url, + 'author' => $author, + 'title' => $title, + 'timestamp' => $timestamp, + 'content' => $content + ]; + + $this->items[] = $item; + } + } +} diff --git a/bridges/WKYTNewsBridge.php b/bridges/WKYTNewsBridge.php new file mode 100644 index 00000000000..e3b95f007d0 --- /dev/null +++ b/bridges/WKYTNewsBridge.php @@ -0,0 +1,27 @@ +find('.card-body'); + + foreach ($articles as $article) { + $item = []; + $url = $article->find('.headline a', 0); + $item['uri'] = $url->href; + $item['title'] = trim($url->plaintext); + $item['author'] = $article->find('.author', 0)->plaintext; + $item['content'] = $article->find('.deck', 0)->plaintext; + $this->items[] = $item; + } + } +} diff --git a/bridges/WYMTNewsBridge.php b/bridges/WYMTNewsBridge.php new file mode 100644 index 00000000000..c19d6e25c72 --- /dev/null +++ b/bridges/WYMTNewsBridge.php @@ -0,0 +1,27 @@ +find('.card-body'); + + foreach ($articles as $article) { + $item = []; + $url = $article->find('.headline a', 0); + $item['uri'] = $url->href; + $item['title'] = trim($url->plaintext); + $item['author'] = $article->find('.author', 0)->plaintext; + $item['content'] = $article->find('.deck', 0)->plaintext; + $this->items[] = $item; + } + } +} diff --git a/bridges/WallmineNewsBridge.php b/bridges/WallmineNewsBridge.php new file mode 100644 index 00000000000..c5009172e23 --- /dev/null +++ b/bridges/WallmineNewsBridge.php @@ -0,0 +1,50 @@ +getURI() . '/news/'); + + $html = defaultLinkTo($html, self::URI); + + foreach ($html->find('div.container.news-card') as $div) { + $item = []; + $item['uri'] = $div->find('a', 0)->href; + + $image = $div->find('img.img-fluid', 0)->src; + + $page = getSimpleHTMLDOMCached($item['uri'], 7200); + + $article = $page->find('div.container.article-container', 0); + + $item['title'] = $article->find('h1', 0)->plaintext; + + $article->find('p.published-on', 0)->children(0)->outertext = ''; + $article->find('p.published-on', 0)->children(1)->outertext = ''; + $date = str_replace('at', '', $article->find('p.published-on', 0)->innertext); + + $item['timestamp'] = $date; + + $article->find('h1', 0)->outertext = ''; + $article->find('p.published-on', 0)->outertext = ''; + + $item['content'] = $article->innertext; + $item['enclosures'][] = $image; + + $this->items[] = $item; + + if (count($this->items) >= 10) { + break; + } + } + } +} diff --git a/bridges/WallpaperStopBridge.php b/bridges/WallpaperStopBridge.php deleted file mode 100644 index 3578e714f52..00000000000 --- a/bridges/WallpaperStopBridge.php +++ /dev/null @@ -1,107 +0,0 @@ - array( - 'name' => 'Category' - ), - 's' => array( - 'name' => 'subcategory' - ), - 'm' => array( - 'name' => 'Max number of wallpapers', - 'type' => 'number', - 'defaultValue' => 20 - ), - 'r' => array( - 'name' => 'resolution', - 'exampleValue' => '1920x1200, 1680x1050,…', - 'defaultValue' => '1920x1200' - ) - )); - - public function collectData(){ - $category = $this->getInput('c'); - $subcategory = $this->getInput('s'); - $resolution = $this->getInput('r'); - - $num = 0; - $max = $this->getInput('m'); - $lastpage = 1; - - for($page = 1; $page <= $lastpage; $page++) { - $link = self::URI - . '/' - . $category - . '-wallpaper/' - . (!empty($subcategory) ? $subcategory . '-wallpaper/' : '') - . 'desktop-wallpaper-' - . $page - . '.html'; - - $html = getSimpleHTMLDOM($link) - or returnServerError('No results for this query.'); - - if($page === 1) { - preg_match('/-(\d+)\.html$/', $html->find('.pagination > .last', 0)->href, $matches); - $lastpage = min($matches[1], ceil($max / 20)); - } - - foreach($html->find('article.item') as $element) { - $wplink = $element->getAttribute('data-permalink'); - if(preg_match('%^' . self::URI . '/(.+)/([^/]+)-(\d+)\.html$%', $wplink, $matches)) { - $thumbnail = $element->find('img', 0); - - $item = array(); - $item['uri'] = self::URI - . '/wallpapers/' - . str_replace('wallpaper', 'wallpapers', $matches[1]) - . '/' - . $matches[2] - . '-' - . $resolution - . '-' - . $matches[3] - . '.jpg'; - - $item['id'] = $matches[3]; - $item['timestamp'] = time(); - $item['title'] = $thumbnail->title; - $item['content'] = $item['title'] - . '
'; - - $this->items[] = $item; - - $num++; - if ($num >= $max) - break 2; - } - } - } - } - - public function getName(){ - if(!is_null($this->getInput('s')) && !is_null($this->getInput('c')) && !is_null($this->getInput('r'))) { - $subcategory = $this->getInput('s'); - return 'WallpaperStop - ' - . $this->getInput('c') - . (!empty($subcategory) ? ' > ' . $subcategory : '') - . ' [' - . $this->getInput('r') - . ']'; - } - - return parent::getName(); - } -} diff --git a/bridges/WallpaperflareBridge.php b/bridges/WallpaperflareBridge.php new file mode 100644 index 00000000000..907288d06d7 --- /dev/null +++ b/bridges/WallpaperflareBridge.php @@ -0,0 +1,46 @@ + [ + 'search' => [ + 'name' => 'Search', + 'exampleValue' => 'birds', + 'required' => true + ] + ]]; + const CACHE_TIMEOUT = 3600; //1 hour + const XPATH_EXPRESSION_ITEM = './/figure'; + const XPATH_EXPRESSION_ITEM_TITLE = './/img/@title'; + const XPATH_EXPRESSION_ITEM_CONTENT = ''; + const XPATH_EXPRESSION_ITEM_URI = './/a[@itemprop="url"]/@href'; + const XPATH_EXPRESSION_ITEM_AUTHOR = '/html[1]/body[1]/main[1]/section[1]/h1[1]'; + const XPATH_EXPRESSION_ITEM_TIMESTAMP = ''; + const XPATH_EXPRESSION_ITEM_ENCLOSURES = './/img/@data-src'; + const XPATH_EXPRESSION_ITEM_CATEGORIES = './/figcaption[@itemprop="caption description"]'; + const SETTING_FIX_ENCODING = false; + + protected function getSourceUrl() + { + return 'https://www.wallpaperflare.com/search?wallpaper=' . $this->getInput('search'); + } + + public function getIcon() + { + return 'https://www.google.com/s2/favicons?domain=wallpaperflare.com/'; + } + + public function getName() + { + if (!is_null($this->getInput('search'))) { + return 'Wallpaperflare - ' . $this->getInput('search'); + } else { + return 'Wallpaperflare'; + } + } +} diff --git a/bridges/WeLiveSecurityBridge.php b/bridges/WeLiveSecurityBridge.php index 59a094a7933..151484c45af 100644 --- a/bridges/WeLiveSecurityBridge.php +++ b/bridges/WeLiveSecurityBridge.php @@ -1,32 +1,56 @@ Could not request ' . $this->getName() . ': ' . $item['uri'] . ''; - return $item; - } - - $article_content = $article_html->find('div.formatted', 0)->innertext; - $article_content = stripWithDelimiters($article_content, ''); - $content = preg_replace('//', '', $content); - return $content; - } + const PARAMETERS = [ [ + 'url' => [ + 'name' => 'Blog URL', + 'exampleValue' => 'https://wordpress.org/', + 'required' => true + ], + 'limit' => self::LIMIT, + 'content-selector' => [ + 'name' => 'Content Selector (Optional - Advanced users)', + 'exampleValue' => '.custom-article-class', + ], + ]]; - protected function parseItem($newItem){ - $item = parent::parseItem($newItem); + public function collectData() + { + $limit = $this->getInput('limit') ?? 10; + if ($this->getInput('url') && substr($this->getInput('url'), 0, strlen('http')) !== 'http') { + // just in case someone find a way to access local files by playing with the url + returnClientError('The url parameter must either refer to http or https protocol.'); + } + try { + $this->collectExpandableDatas($this->getURI() . '/feed/atom/', $limit); + } catch (Exception $e) { + $this->collectExpandableDatas($this->getURI() . '/?feed=atom', $limit); + } + } - $article_html = getSimpleHTMLDOMCached($item['uri']); + protected function parseItem(array $item) + { + $dom = getSimpleHTMLDOMCached($item['uri']); - $article = null; - switch(true) { - case !is_null($article_html->find('[itemprop=articleBody]', 0)): - // highest priority content div - $article = $article_html->find('[itemprop=articleBody]', 0); - break; - case !is_null($article_html->find('article', 0)): - // most common content div - $article = $article_html->find('article', 0); - break; - case !is_null($article_html->find('.single-content', 0)): - // another common content div - $article = $article_html->find('.single-content', 0); - break; - case !is_null($article_html->find('.post-content', 0)): - // another common content div - $article = $article_html->find('.post-content', 0); - break; - case !is_null($article_html->find('.post', 0)): - // for old WordPress themes without HTML5 - $article = $article_html->find('.post', 0); - break; - } + // Find article body + $article = null; + switch (true) { + case !empty($this->getInput('content-selector')): + // custom contect selector (manually specified by user) + $article = $dom->find($this->getInput('content-selector'), 0); + break; + case !is_null($dom->find('[itemprop=articleBody]', 0)): + // highest priority content div (used for SEO) + $article = $dom->find('[itemprop=articleBody]', 0); + break; + case !is_null($dom->find('.article-content', 0)): + // more precise than article when present + $article = $dom->find('.article-content', 0); + break; + case !is_null($dom->find('article', 0)): + // most common content div + $article = $dom->find('article', 0); + break; + case !is_null($dom->find('.single-content', 0)): + // another common content div + $article = $dom->find('.single-content', 0); + break; + case !is_null($dom->find('.post-content', 0)): + // another common content div + $article = $dom->find('.post-content', 0); + break; + case !is_null($dom->find('.post', 0)): + // for old WordPress themes without HTML5 + $article = $dom->find('.post', 0); + break; + } - foreach ($article->find('h1.entry-title') as $title) - if ($title->plaintext == $item['title']) - $title->outertext = ''; + // Remove duplicate title from content + foreach ($article->find('h1') as $title) { + if (trim(html_entity_decode($title->plaintext) == $item['title'])) { + $title->outertext = ''; + } + } - $article_image = $article_html->find('img.wp-post-image', 0); - if(!empty($item['content']) && (!is_object($article_image) || empty($article_image->src))) { - $article_image = str_get_html($item['content'])->find('img.wp-post-image', 0); - } - if(is_object($article_image) && !empty($article_image->src)) { - if(empty($article_image->getAttribute('data-lazy-src'))) { - $article_image = $article_image->src; - } else { - $article_image = $article_image->getAttribute('data-lazy-src'); - } - $mime_type = getMimeType($article_image); - if (strpos($mime_type, 'image') === false) - $article_image .= '#.image'; // force image - if (empty($item['enclosures'])) - $item['enclosures'] = array($article_image); - else - $item['enclosures'] = array_merge($item['enclosures'], $article_image); - } + // Find article main image + $article = convertLazyLoading($article); + $article_image = $dom->find('img.wp-post-image', 0); + if (!empty($item['content']) && (!is_object($article_image) || empty($article_image->src))) { + $article_image = str_get_html($item['content'])->find('img.wp-post-image', 0); + } + if (is_object($article_image) && !empty($article_image->src)) { + $article_image = $article_image->src; + $mime_type = parse_mime_type($article_image); + if (strpos($mime_type, 'image') === false) { + $article_image .= '#.image'; // force image + } + if (empty($item['enclosures'])) { + $item['enclosures'] = [$article_image]; + } else { + $item['enclosures'] = array_merge($item['enclosures'], (array) $article_image); + } + } - if(!is_null($article)) { - $item['content'] = $this->cleanContent($article->innertext); - } + // Unwrap images figures + foreach ($article->find('figure.wp-block-image') as $figure) { + $figure->outertext = $figure->innertext; + } - return $item; - } + if (!is_null($article)) { + $item['content'] = $this->cleanContent($article->innertext); + $item['content'] = defaultLinkTo($item['content'], $item['uri']); + } - public function getURI(){ - $url = $this->getInput('url'); - if(empty($url)) { - $url = parent::getURI(); - } - return $url; - } + return $item; + } - public function collectData(){ - if($this->getInput('url') && substr($this->getInput('url'), 0, strlen('http')) !== 'http') { - // just in case someone find a way to access local files by playing with the url - returnClientError('The url parameter must either refer to http or https protocol.'); - } - try{ - $this->collectExpandableDatas($this->getURI() . '/feed/atom/'); - } catch (Exception $e) { - $this->collectExpandableDatas($this->getURI() . '/?feed=atom'); - } + private function cleanContent($content) + { + $content = stripWithDelimiters($content, ''); + $contents = stripWithDelimiters($contents, ''); + $contents = stripWithDelimiters($contents, '