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 @@ + [ + '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 = << +{$name} +

+$pricePerUnit +
+{$article->shortDescription} +
+$deposit +
+$alcohol +

+ +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-" 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 ?? []; + } +}