From 7d941c289844522a999e61191b63bf2ed6a4144c Mon Sep 17 00:00:00 2001
From: sal0max <msal.coding@gmail.com>
Date: Mon, 4 Jul 2022 07:29:22 +0200
Subject: [PATCH] [Flaschenpost] Add bridge (#2808)

---
 bridges/FlaschenpostBridge.php | 364 +++++++++++++++++++++++++++++++++
 1 file changed, 364 insertions(+)
 create mode 100644 bridges/FlaschenpostBridge.php

diff --git a/bridges/FlaschenpostBridge.php b/bridges/FlaschenpostBridge.php
new file mode 100644
index 00000000..85a2740e
--- /dev/null
+++ b/bridges/FlaschenpostBridge.php
@@ -0,0 +1,364 @@
+<?php
+
+class FlaschenpostBridge extends BridgeAbstract
+{
+    const NAME = 'Flaschenpost Bridge';
+    const URI = 'https://www.flaschenpost.de/';
+    const DESCRIPTION = 'Aktuelle Angebote auf Flaschenpost.de';
+    const MAINTAINER = 'sal0max';
+    const CACHE_TIMEOUT = 3600; // 1 hour
+    const PARAMETERS = [
+        [
+            'zip-code' => [
+                'name' => 'Postleitzahl',
+                'type' => 'text',
+                'required' => true,
+                'exampleValue' => '80333',
+                // https://stackoverflow.com/a/7926743/421140
+                'pattern' => '^(?!01000|99999)(0[1-9]\d{3}|[1-9]\d{4})$',
+            ],
+            'water' => [
+                'name' => 'Wasser',
+                'type' => 'checkbox',
+            ],
+            'beer' => [
+                'name' => 'Bier',
+                'type' => 'checkbox',
+            ],
+            'lemonade' => [
+                'name' => 'Limonade',
+                'type' => 'checkbox',
+            ],
+            'juice' => [
+                'name' => 'Saft & Schorle',
+                'type' => 'checkbox',
+            ],
+            'wine' => [
+                'name' => 'Wein & Mehr',
+                'type' => 'checkbox',
+            ],
+            'liquor' => [
+                'name' => 'Spirituosen',
+                'type' => 'checkbox',
+            ],
+            'food' => [
+                'name' => 'Lebensmittel',
+                'type' => 'checkbox',
+            ],
+            'household' => [
+                'name' => 'Haushalt',
+                'type' => 'checkbox',
+            ],
+        ]
+    ];
+
+    public function collectData()
+    {
+        $categories = [];
+        if ($this->getInput('water')) {
+            array_push(
+                $categories,
+                'wasser/spritzig',
+                'wasser/medium',
+                'wasser/still',
+                'wasser/aromatisiert',
+                'wasser/heilwasser',
+                'wasser/bio-wasser',
+                'wasser/gourmet'
+            );
+        }
+        if ($this->getInput('beer')) {
+            array_push(
+                $categories,
+                'bier/alkoholfrei',
+                'bier/biermischgetraenke',
+                'bier/craft-beer',
+                'bier/export-lager-maerzen',
+                'bier/helles',
+                'bier/internationale-biere',
+                'bier/koelsch',
+                'bier/land-kellerbier',
+                'bier/malzbier',
+                'bier/pils',
+                'bier/radler',
+                'bier/spezialitaeten',
+                'bier/weizen-weissbier'
+            );
+        }
+        if ($this->getInput('lemonade')) {
+            array_push(
+                $categories,
+                'limonade/cola',
+                'limonade/orangenlimonade',
+                'limonade/zitronenlimonade',
+                'limonade/cola-mix',
+                'limonade/teegetraenke',
+                'limonade/fassbrause',
+                'limonade/mate',
+                'limonade/bio',
+                'limonade/zum-mixen',
+                'limonade/sonstige-limos'
+            );
+        }
+        if ($this->getInput('juice')) {
+            array_push(
+                $categories,
+                'saft-und-schorle/apfelsaft',
+                'saft-und-schorle/apfelschorle',
+                'saft-und-schorle/orangensaft',
+                'saft-und-schorle/multivitaminsaft',
+                'saft-und-schorle/maracujasaft',
+                'saft-und-schorle/traubensaft',
+                'saft-und-schorle/johannisbeersaft',
+                'saft-und-schorle/rhabarbersaft',
+                'saft-und-schorle/rhabarberschorle',
+                'saft-und-schorle/kirschsaft',
+                'saft-und-schorle/sonstige-saefte',
+                'saft-und-schorle/sonstige-schorlen'
+            );
+        }
+        if ($this->getInput('wine')) {
+            array_push(
+                $categories,
+                'wein-und-mehr/weisswein',
+                'wein-und-mehr/rotwein',
+                'wein-und-mehr/rose',
+                'wein-und-mehr/bio-wein',
+                'wein-und-mehr/sonstige-weine',
+                'wein-und-mehr/sekt-mehr',
+                'wein-und-mehr/probierpakete',
+                'wein-und-mehr/gluehwein'
+            );
+        }
+        if ($this->getInput('liquor')) {
+            array_push(
+                $categories,
+                'spirituosen/wodka',
+                'spirituosen/gin',
+                'spirituosen/whisky',
+                'spirituosen/rum',
+                'spirituosen/weitere-spirituosen',
+                'spirituosen/kraeuterlikoer',
+                'spirituosen/weitere-likoere',
+                'spirituosen/aperitif'
+            );
+        }
+        if ($this->getInput('food')) {
+            array_push(
+                $categories,
+                'lebensmittel/veggie-vegan',
+                'lebensmittel/kaffee-tee',
+                'lebensmittel/milch-alternativen',
+                'lebensmittel/tiefkuehltruhe',
+                'lebensmittel/nuesse-trockenobst',
+                'lebensmittel/suesses-salziges',
+                'lebensmittel/nudeln-reis-getreide',
+                'lebensmittel/fertiges-konserven',
+                'lebensmittel/sossen-oele-gewuerze'
+            );
+        }
+        if ($this->getInput('household')) {
+            array_push(
+                $categories,
+                'haushalt/hygieneartikel',
+                'haushalt/gesundheit-verhuetung',
+                'haushalt/kueche',
+                'haushalt/haushaltsartikel',
+                'haushalt/spuelen-reinigen',
+                'haushalt/waschen'
+            );
+        }
+
+        foreach ($categories as $category) {
+            try {
+                $url = sprintf('https://www.flaschenpost.de/%s?plz=%s', $category, $this->getInput('zip-code'));
+                // Gives redirect on unknown zip-code
+                $html = getSimpleHTMLDOM($url, [], [CURLOPT_FOLLOWLOCATION => false]);
+            } catch (\Exception $e) {
+                // skip
+                continue;
+            }
+
+            // extract the JavaScript block which contains all the data we need
+            $regex = '/(\{childElements:\[.*\})\];/';
+            preg_match($regex, $html, $matches);
+            $js = $matches[1];
+
+            // convert JavaScript to JSON
+            $js = $this->jsToJson($js);
+
+            // get all products
+            $json_decode = json_decode($js, false, 512, JSON_THROW_ON_ERROR);
+            $products = $this->recursiveFind((array) $json_decode, 'products');
+            foreach ($products as $product) {
+                // there can be multiple variants, like 0.5l and 0.33l bottles
+                foreach ($product->product->articles as $article) {
+                    $this->addArticle($article, $product->product);
+                }
+            }
+        }
+    }
+
+    public function getName(): string
+    {
+        $categories = [];
+        if ($this->getInput('water')) {
+            $categories[] = 'Wasser';
+        }
+        if ($this->getInput('beer')) {
+            $categories[] = 'Bier';
+        }
+        if ($this->getInput('lemonade')) {
+            $categories[] = 'Limonade';
+        }
+        if ($this->getInput('juice')) {
+            $categories[] = 'Saft & Schorle';
+        }
+        if ($this->getInput('wine')) {
+            $categories[] = 'Wein & Mehr';
+        }
+        if ($this->getInput('liquor')) {
+            $categories[] = 'Spirituosen';
+        }
+        if ($this->getInput('food')) {
+            $categories[] = 'Lebensmittel';
+        }
+        if ($this->getInput('household')) {
+            $categories[] = 'Haushalt';
+        }
+        if (empty($categories)) {
+            return $this::NAME;
+        } else {
+            return $this::NAME . ' – ' . implode(', ', $categories);
+        }
+    }
+
+    private function jsToJson(string $js): string
+    {
+        // remove all html
+        $js = strip_tags($js);
+        // escape double quotes
+        $js = str_replace('"', '\\"', $js);
+        // add double quotes to all keys
+        $js = preg_replace('/(?<=[,{])(\w+)(?=:)/', '"$1"', $js);
+        // replace all single quotes with double quotes at all values
+        $js = str_replace('\'', '"', $js);
+        // sometimes, there are more than one JSON blocks; we're interested in the first one
+        $js = $this->splitJsonObjects($js)[0];
+        return $js;
+    }
+
+    private function addArticle($article, $product)
+    {
+        $regularPrice = $article->trackingDefaultPrice;
+        $discountPrice = $article->crossedPrice;
+        $discount = round((($regularPrice - $discountPrice) / $regularPrice) * 100.0);
+        $regularPriceString = $article->defaultPrice;
+        $discountPriceString = $article->price;
+
+        // only discounted products
+        if ($regularPrice != $discountPrice) {
+            $name = str_replace('"', '\'', $product->name);
+            $imageUrl = 'https://image.flaschenpost.de/cdn-cgi/image/width=120,height=120,q=50/articles/small/'
+                . $article->articleId . '.png';
+            $pricePerUnit = str_replace(['(', ')'], '', $article->pricePerUnit);
+            $deposit = $article->deposit ? "Pfand: $article->deposit" : 'Pfandfrei';
+            $alcohol = $product->alcoholInfo ? str_replace(['enthält', 'Vol.-', 'Alkohol'], '', $product->alcoholInfo)
+                . ' Alkohol' : '';
+            $description = <<<EOD
+<div style="padding: 20px; display: flex;">
+<img src="{$imageUrl}" alt="{$name}" style="float: left; margin-right: 35px;"/>
+<p style="display: inline-block; align-self: center; line-height: 1.5rem;">
+$pricePerUnit
+<br>
+{$article->shortDescription}
+<br>
+$deposit
+<br>
+$alcohol
+</p>
+</div>
+EOD;
+
+            $item['title'] = "$name: $discountPriceString statt $regularPriceString (-$discount\u{2009}%)";
+            $item['content'] = $description;
+
+            // use current date (@midnight) as timestamp
+            $item['timestamp'] = (new \DateTime())
+                ->setTimezone(new \DateTimeZone('Europe/Berlin'))
+                ->setTime(0, 0)
+                ->getTimestamp();
+
+            $item['uri'] = urljoin(
+                'https://www.flaschenpost.de/',
+                "{$product->brandWebShopUrl}/{$product->webShopUrl}"
+            );
+
+            // use "name-<timestamp>" as uid; that way, there's a new entry each day, when a product stays discounted
+            $item['uid'] = $name . '-' . $item['timestamp'];
+
+            // only add if unique
+            $exists = false;
+            foreach ($this->items as $i) {
+                if ($i['uri'] === $item['uri']) {
+                    $exists = true;
+                    break;
+                }
+            }
+            if (!$exists) {
+                $this->items[] = $item;
+            }
+        }
+    }
+
+    public function getIcon()
+    {
+        return 'https://image.flaschenpost.de/CI/fp-favicon.png';
+    }
+
+    // https://stackoverflow.com/a/3975706/421140
+    private function recursiveFind(array $haystack, $needle)
+    {
+        $iterator = new \RecursiveArrayIterator($haystack);
+        $recursive = new \RecursiveIteratorIterator(
+            $iterator,
+            \RecursiveIteratorIterator::SELF_FIRST
+        );
+        foreach ($recursive as $key => $value) {
+            if ($key === $needle) {
+                return $value;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * http://ryanuber.com/07-31-2012/split-and-decode-json-php.html
+     *
+     * json_split_objects - Return an array of many JSON objects
+     *
+     * In some applications (such as PHPUnit, or salt), JSON output is presented as multiple
+     * objects, which you cannot simply pass in to json_decode(). This function will split
+     * the JSON objects apart and return them as an array of strings, one object per indice.
+     *
+     * @param string $json The JSON data to parse
+     *
+     * @return array (of strings)
+     */
+    private function splitJsonObjects(string $json): array
+    {
+        $q = false;
+        $len = strlen($json);
+        for ($l = $c = $i = 0; $i < $len; $i++) {
+            $json[$i] == '"' && ($i > 0 ? $json[$i - 1] : '') != '\\' && $q = !$q;
+            if (!$q && in_array($json[$i], [' ', "\r", "\n", "\t"])) {
+                continue;
+            }
+            in_array($json[$i], ['{', '[']) && !$q && $l++;
+            in_array($json[$i], ['}', ']']) && !$q && $l--;
+            (isset($objects[$c]) && $objects[$c] .= $json[$i]) || $objects[$c] = $json[$i];
+            $c += ($l == 0);
+        }
+        return $objects ?? [];
+    }
+}