diff --git a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxOffer.php b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxOffer.php new file mode 100644 index 0000000..f4a22ba --- /dev/null +++ b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxOffer.php @@ -0,0 +1,21 @@ +robuxStock = $robuxStock; + $this->robuxPrice = $robuxPrice; + $this->listingIndex = $listingIndex; + } + +} + +?> diff --git a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotification.php b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotification.php index 6cf7d8b..d4c8c70 100644 --- a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotification.php +++ b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotification.php @@ -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 { diff --git a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotificationBody.php b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotificationBody.php index 1d03a26..b9023b1 100644 --- a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotificationBody.php +++ b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceNotificationBody.php @@ -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, ",", " "); } } diff --git a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceSentryJob.php b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceSentryJob.php index 500d990..a407bcb 100644 --- a/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceSentryJob.php +++ b/app/Browser/Jobs/EldoradoRobuxPriceSentry/EldoradoRobuxPriceSentryJob.php @@ -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; @@ -90,6 +85,128 @@ class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUn } private function sendPrices(Browser $browser): void + { + $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")); + + 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 + } + + // 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 : $offer->robuxPrice €/Robux - Seuil défini : $priceThreshold €/Robux + Stock disponible : $offer->robuxStock " . ($stockThreshold !== null ? "- Seuil de stock défini : $stockThreshold" : ""), + ])); + + + // 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"); + } + + AllNotification::send( + new EldoradoRobuxPriceNotification( + $this->jobId, + $lowestPrice, + $priceThreshold, + $stock, + $stockThreshold, + $link + ), + $options + ); + + $this->jobRun->addArtifact(new JobArtifact([ + "name" => "Envoyé une alerte", + "content" => "" + ])); + } + + /** + * 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 € " @@ -97,43 +214,27 @@ class EldoradoRobuxPriceSentryJob extends BrowserJob implements ShouldBeUniqueUn //$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 - $threshold = $this->textToFloat($this->jobInfos->get("eldorado_robux_price_threshold")); + $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); - Log::info("EldoradoRobuxPriceSentryJob: lowest price = $lowestPrice €, threshold = $threshold €"); - $this->jobRun->addArtifact(new JobArtifact([ - "name" => "Trouvé le prix le plus bas", - "content" => "Prix le plus bas : $lowestPrice €/Robux - Seuil défini : $threshold €/Robux" - ])); + return new EldoradoRobuxOffer($lowestStock, $lowestPrice, self::LOWEST_PRICE_INDEX); + } - if ($lowestPrice <= $threshold) { - $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"); - } - - AllNotification::send( - new EldoradoRobuxPriceNotification( - $this->jobId, - $lowestPrice, - $threshold, - self::LINK - ), - $options - ); - - $this->jobRun->addArtifact(new JobArtifact([ - "name" => "Envoyé une alerte", - "content" => "" - ])); - - Log::info("EldoradoRobuxPriceSentryJob: alert sent"); - } else { - Log::info("EldoradoRobuxPriceSentryJob: no alert sent"); - } + 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]); + } } diff --git a/database/migrations/2025_12_20_135003_add_eldorado_min_stock_quantity_job_info.php b/database/migrations/2025_12_20_135003_add_eldorado_min_stock_quantity_job_info.php new file mode 100644 index 0000000..43e6bec --- /dev/null +++ b/database/migrations/2025_12_20_135003_add_eldorado_min_stock_quantity_job_info.php @@ -0,0 +1,37 @@ +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(); + } +};