Eldorado : Added the minimum stock threshold
All checks were successful
Test, build and push image to registry / phpunit-tests (push) Successful in 3m25s
Test, build and push image to registry / build-image (push) Successful in 2m59s

It now look at all of the offers of the first page and select the one matching the criterias
This commit is contained in:
2025-12-20 16:20:07 +01:00
parent 48fdc2f6f0
commit 5e6164016c
5 changed files with 223 additions and 48 deletions

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Browser\Jobs\EldoradoRobuxPriceSentry;
class EldoradoRobuxOffer
{
public int $robuxStock;
public float $robuxPrice;
public int $listingIndex;
public function __construct(int $robuxStock, float $robuxPrice, int $listingIndex)
{
$this->robuxStock = $robuxStock;
$this->robuxPrice = $robuxPrice;
$this->listingIndex = $listingIndex;
}
}
?>

View File

@@ -10,21 +10,25 @@ use App\Notification\Stringifiable\StringifiableSimpleText;
class EldoradoRobuxPriceNotification extends Notification {
private float $price;
private float $threshold;
private float $priceThreshold;
private string $link;
private int $stock;
private int|null $stockThreshold;
public function __construct(int $jobId, float $price, float $threshold, string $link) {
public function __construct(int $jobId, float $price, float $priceThreshold, int $stock, int|null $stockThreshold, string $link) {
parent::__construct($jobId);
$this->price = $price;
$this->threshold = $threshold;
$this->priceThreshold = $priceThreshold;
$this->stock = $stock;
$this->stockThreshold = $stockThreshold;
$this->link = $link;
$this->setBody($this->generateBody());
}
private function generateBody() {
return new EldoradoRobuxPriceNotificationBody($this->price, $this->threshold, $this->link);
return new EldoradoRobuxPriceNotificationBody($this->price, $this->priceThreshold, $this->stock, $this->stockThreshold, $this->link);
}
public function getTitle(): Stringifiable {

View File

@@ -7,14 +7,18 @@ use App\Notification\NotificationBody;
class EldoradoRobuxPriceNotificationBody extends NotificationBody {
private float $price;
private float $threshold;
private float $priceThreshold;
private int $stock;
private int|null $stockThreshold;
private string $link;
public function __construct(float $price, float $threshold, string $link) {
public function __construct(float $price, float $priceThreshold, int $stock, int|null $stockThreshold, string $link) {
parent::__construct();
$this->price = $price;
$this->threshold = $threshold;
$this->priceThreshold = $priceThreshold;
$this->stock = $stock;
$this->stockThreshold = $stockThreshold;
$this->link = $link;
}
@@ -22,13 +26,21 @@ class EldoradoRobuxPriceNotificationBody extends NotificationBody {
* @inheritDoc
*/
public function toMarkdownString(): string {
return "Le prix des Robux sur Eldorado est actuellement de **" . number_format($this->price, 5, ",", " ") . " €**/Robux, ce qui est inférieur ou égal au seuil de **" . number_format($this->threshold, 5, ",", " ") . " €**.\n\n[Voir l'offre sur Eldorado]( " . $this->link . " )";
return "Le prix des Robux sur Eldorado est actuellement de **" . $this->floatFormat($this->price) . " €**/Robux, ce qui est inférieur ou égal au seuil de **" . $this->floatFormat($this->priceThreshold) . " €**.\nLe stock est de **" . $this->intFormat($this->stock) . "**" . ($this->stockThreshold !== null ? " (seuil : **" . $this->intFormat($this->stockThreshold) . "**)" : "") . "\n[Voir l'offre sur Eldorado]( " . $this->link . " )";
}
/**
* @inheritDoc
*/
public function toString(): string {
return "Le prix des Robux sur Eldorado est actuellement de " . number_format($this->price, 5, ",", " ") . " €/Robux, ce qui est inférieur ou égal au seuil de " . number_format($this->threshold, 5, ",", " ") . " €.\n\nVoir l'offre sur Eldorado : " . $this->link;
return "Le prix des Robux sur Eldorado est actuellement de " . $this->floatFormat($this->price) . " €/Robux, ce qui est inférieur ou égal au seuil de " . $this->floatFormat($this->priceThreshold) . " €.\nLe stock est de " . $this->intFormat($this->stock) . ($this->stockThreshold !== null ? " (seuil : " . $this->intFormat($this->stockThreshold) . ")" : "") . "\nVoir l'offre sur Eldorado : " . $this->link;
}
private function floatFormat(float $number): string {
return number_format($number, 5, ",", " ");
}
private function intFormat(int $number): string {
return number_format($number, 0, ",", " ");
}
}

View File

@@ -3,23 +3,16 @@
namespace App\Browser\Jobs\EldoradoRobuxPriceSentry;
use App\Browser\BrowserJob;
use App\Browser\JobDebugScreenshot;
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
use App\Models\InstagramNotification;
use App\Models\InstagramRepost;
use App\Models\Job;
use App\Models\JobArtifact;
use App\Models\JobRun;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Notifications\SimpleNotification;
use App\Notification\Providers\AllNotification;
use App\Services\Instagram\NotificationTypeDetector;
use App\Browser\Jobs\EldoradoRobuxPriceSentry\EldoradoRobuxOffer;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Laravel\Dusk\Browser;
use App\Services\AIPrompt\OpenAPIPrompt;
class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
{
@@ -28,8 +21,10 @@ class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUn
public $timeout = 600; // 10 minutes
private const APPROXIMATIVE_RUNNING_MINUTES = 20;
private const LINK = "https://www.eldorado.gg/fr/buy-robux/g/70-0-0";
protected const int LOWEST_PRICE_INDEX = -1;
protected string $downloadFolder = "app/Browser/downloads/EldoradoRobuxPriceSentry/";
protected Collection $jobInfos;
protected JobRun $jobRun;
@@ -91,21 +86,47 @@ class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUn
private function sendPrices(Browser $browser): void
{
$lowestPriceElement = $browser->driver->findElement(WebDriverBy::xpath('(//eld-offer-price)[2]/strong'));
$lowestPriceText = $lowestPriceElement->getText(); // Ex: " 0,00478 € "
$lowestPrice = $this->textToFloat($lowestPriceText);
//$lowestPrice = $lowestPrice / 1000; // Price per Robux
// TODO : Look at the entire text to try to understand if it is per 1k or per single Robux
$offers = $this->getOffers($browser);
$priceThreshold = $this->textToFloat($this->jobInfos->get("eldorado_robux_price_threshold"));
$stockThreshold = $this->jobInfos->get("eldorado_stock_quantity_threshold") == null ?
null :
$this->getIntFromText($this->jobInfos->get("eldorado_stock_quantity_threshold"));
$threshold = $this->textToFloat($this->jobInfos->get("eldorado_robux_price_threshold"));
foreach ($offers as $offer) {
Log::info("EldoradoRobuxPriceSentryJob: found offer - Price: " . $offer->robuxPrice . " €, Stock: " . $offer->robuxStock);
// Checks
if ($offer->robuxPrice > $priceThreshold || ($stockThreshold !== null && $offer->robuxStock < $stockThreshold)) {
continue; // Not matching the criteria
}
Log::info("EldoradoRobuxPriceSentryJob: lowest price = $lowestPrice €, threshold = $threshold");
// Logging and artifact
Log::info("EldoradoRobuxPriceSentryJob: lowest price = $offer->robuxPrice €, threshold = $priceThreshold € - stock = $offer->robuxStock " . ($stockThreshold !== null ? "- stock threshold = $stockThreshold" : ""));
$this->jobRun->addArtifact(new JobArtifact([
"name" => "Trouvé le prix le plus bas",
"content" => "Prix le plus bas : $lowestPrice €/Robux - Seuil défini : $threshold €/Robux"
"content" => "Prix le plus bas : $offer->robuxPrice €/Robux - Seuil défini : $priceThreshold €/Robux
Stock disponible : $offer->robuxStock " . ($stockThreshold !== null ? "- Seuil de stock défini : $stockThreshold" : ""),
]));
if ($lowestPrice <= $threshold) {
// get the link
$offerLink = $this->getLinkFromOfferIndex($browser, $offer->listingIndex);
// Send alert
$this->sendAlertNotification(
$offer->robuxPrice,
$priceThreshold,
$offer->robuxStock,
$stockThreshold,
$offerLink
);
Log::info("EldoradoRobuxPriceSentryJob: alert sent");
return; // Stops at first matching offer
}
Log::info("EldoradoRobuxPriceSentryJob: no alert sent");
}
private function sendAlertNotification(float $lowestPrice, float $priceThreshold, int $stock, int|null $stockThreshold, string $link): void
{
$options = [];
if ($this->jobInfos->get("eldorado_robux_price_discord_webhook") !== null) { // Custom discord webhook
$options["discord_webhook_url"] = $this->jobInfos->get("eldorado_robux_price_discord_webhook");
@@ -115,8 +136,10 @@ class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUn
new EldoradoRobuxPriceNotification(
$this->jobId,
$lowestPrice,
$threshold,
self::LINK
$priceThreshold,
$stock,
$stockThreshold,
$link
),
$options
);
@@ -125,15 +148,93 @@ class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUn
"name" => "Envoyé une alerte",
"content" => ""
]));
Log::info("EldoradoRobuxPriceSentryJob: alert sent");
} else {
Log::info("EldoradoRobuxPriceSentryJob: no alert sent");
}
/**
* Retrieve the url for the given listing index.
* WILL NOT REDIRECT BACK TO THE OFFERS PAGE.
* @param Browser $browser
* @param int $listingIndex
* @return string
*/
private function getLinkFromOfferIndex(Browser $browser, int $listingIndex): string
{
if ($listingIndex == self::LOWEST_PRICE_INDEX) {
return self::LINK;
}
$offerCardElements = $this->getOffersCardElements($browser);
$offerCardElement = $offerCardElements[$listingIndex];
// Click the card element
$offerCardElement->click();
sleep(2);
// return the current url
return $browser->driver->getCurrentURL();
}
/**
* Get the list of offers on the page
* @return EldoradoRobuxOffer[]
*/
private function getOffers(Browser $browser): array
{
$offers = [];
// First offer (lowest price) (special case)
$firstOffer = $this->getFirstOffer($browser);
$offers[] = $firstOffer;
// Other offers
$offerCardElements = $this->getOffersCardElements($browser);
foreach ($offerCardElements as $index => $offerCardElement) {
$priceElement = $offerCardElement->findElement(WebDriverBy::xpath('.//eld-offer-price/strong'));
$priceText = $priceElement->getText(); // Ex: " 0,00520 € "
$price = $this->textToFloat($priceText);
// span.value next to a span.label with the text containing "stock"
$stockElement = $offerCardElement->findElement(WebDriverBy::xpath('.//span[@class="label" and contains(text(), "stock")]/following-sibling::*[@class="value"]'));
$stockText = $stockElement->getText(); // Ex: "En stock : 5000 unités"
$stock = $this->getIntFromText($stockText);
$offers[] = new EldoradoRobuxOffer(
robuxStock: $stock,
robuxPrice: $price,
listingIndex: $index
);
}
return $offers;
}
private function getFirstOffer(Browser $browser): EldoradoRobuxOffer
{
$lowestPriceElement = $browser->driver->findElement(WebDriverBy::xpath('(//eld-offer-price)[2]/strong'));
$lowestPriceText = $lowestPriceElement->getText(); // Ex: " 0,00478 € "
$lowestPrice = $this->textToFloat($lowestPriceText);
//$lowestPrice = $lowestPrice / 1000; // Price per Robux
// TODO : Look at the entire text to try to understand if it is per 1k or per single Robux
$lowestStockElement = $browser->driver->findElement(WebDriverBy::xpath('(//eld-quantity-details-wrapper)[1]//div[@class="quantity"]'));
$lowestStockText = $lowestStockElement->getText(); // Ex: "En stock : 9942199 unité"
$lowestStock = $this->getIntFromText($lowestStockText);
return new EldoradoRobuxOffer($lowestStock, $lowestPrice, self::LOWEST_PRICE_INDEX);
}
private function getOffersCardElements(Browser $browser): array
{
return $browser->driver->findElements(WebDriverBy::xpath('//div[contains(@class, "offer-seller-card")]'));
}
private function textToFloat(string $text): float
{
return floatval(str_replace(["", ","], ["", "."], trim($text)));
}
private function getIntFromText(string $text): int
{
$text = str_replace(["", " "], "", $text); // Remove spaces
preg_match('/\d+/', $text, $matches);
return intval($matches[0]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
use App\Models\Job;
use App\Models\JobInfo;
use App\Models\JobInfoType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$eldoradoJob = Job::where("name", "Eldorado Robux Price Sentry")->first();
JobInfo::forceCreate([
"key" => "eldorado_stock_quantity_threshold",
"name" => "Seuil de quantité de robux en stock",
"description" => "Le seuil de quantité de robux en stock pour déclencher une alerte.",
"placeholder" => "10000",
"is_required" => false,
"job_info_type_id" => JobInfoType::where("name", "number")->first()->id,
"job_id" => $eldoradoJob->id,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
JobInfo::where("key", "eldorado_stock_quantity_threshold")->delete();
}
};