diff --git a/app/Browser/BrowserJob.php b/app/Browser/BrowserJob.php index e275450..3ee5976 100644 --- a/app/Browser/BrowserJob.php +++ b/app/Browser/BrowserJob.php @@ -366,4 +366,16 @@ abstract class BrowserJob implements ShouldQueue AllNotification::send(new JobDebugNotification($this->jobId, $errorMessage)); } } + + public function clickElementWithJavaScript(Browser $browser, string $xPathSelector): void + { + $browser->script(" + var element = document.evaluate('{$xPathSelector}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + if (element) { + element.click(); + } else { + console.error('Element not found: {$xPathSelector}'); + } + "); + } } diff --git a/app/Browser/Jobs/Instagram/InstagramAbstractJob.php b/app/Browser/Jobs/Instagram/InstagramAbstractJob.php new file mode 100644 index 0000000..1d88cf7 --- /dev/null +++ b/app/Browser/Jobs/Instagram/InstagramAbstractJob.php @@ -0,0 +1,112 @@ +openAPIPrompt = $openAPIPrompt ?? app(OpenAPIPrompt::class); + } + + protected function clickNext(Browser $browser) { + $nextButton = $browser->driver->findElement(WebDriverBy::xpath('//div[contains(text(), "Next") or contains(text(), "Share")]')); + $nextButton->click(); + sleep(2); + } + + protected function putOriginalResolution(Browser $browser) + { + try { + $chooseResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//button[./div/*[local-name() = "svg"][@aria-label="Select crop"]]')); + $chooseResolutionButton->click(); + sleep(2); + // Choos "original" resolution + $originalResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/div/span[contains(text(), "Original")]]')); + $originalResolutionButton->click(); + sleep(2); + } catch (\Exception $e) { + Log::error("Failed to set original resolution: " . $e->getMessage()); + $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); + AllNotification::send(new JobDebugNotification($this->jobId, "Failed to set \"original\" resolution: " . $e->getMessage())); + } + } + + protected function signin(Browser $browser) + { + if ($browser->assertSee("Search", true)) { + return; // Already signed in, skip some waiting for non existing text + } + + try { + $browser->waitForText("Log in", 10, true); + sleep(3); + $emailButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "email")]')); + $emailButton->click(); + $emailButton->sendKeys($this->jobInfos->get("instagram_repost_account_email")); + sleep(3); + $passwordButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "Password")]')); + $passwordButton->click(); + $passwordButton->sendKeys($this->jobInfos->get("instagram_repost_account_password") . "\n"); + sleep(5); + } catch (\Exception $e) { + // Probably no need to signin + } + try { + $browser->waitForText("Search", 15, true); + $this->removePopups($browser); + } catch (\Exception $e) { + Log::error("Failed to sign in: " . $e->getMessage()); + $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); + AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sign in: " . $e->getMessage())); + // Stop the job run + throw $e; + } + } + + protected function removePopups(Browser $browser) + { + $popupsTypes = [ + ['//button[contains(text(), "Allow all cookies")]'], // Allow all cookies + ['//button[contains(text(), "Not Now")]', ["Popup Not Now clicked"]], // Not now + ['//button[contains(text(), "OK")]', ["Popup Ok clicked"]], // OK + ]; + + foreach ($popupsTypes as $popup) { + try { + $button = $browser->driver->findElement(WebDriverBy::xpath($popup[0])); + if ($button === null) { + continue; // No button found, continue to the next popup + } + if (isset($popup[1])) { + $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); + AllNotification::send(new JobDebugNotification($this->jobId, $popup[1][0])); + } + $button->click(); + sleep(2); + return; // Exit after clicking the first popup found + } catch (\Exception $e) { + // Porbably no popup found, continue + } + } + } +} diff --git a/app/Browser/Jobs/InstagramRepost/InstagramNotificationHandlingJob.php b/app/Browser/Jobs/InstagramRepost/InstagramNotificationHandlingJob.php new file mode 100644 index 0000000..32005ea --- /dev/null +++ b/app/Browser/Jobs/InstagramRepost/InstagramNotificationHandlingJob.php @@ -0,0 +1,183 @@ +downloadFolder = base_path($this->downloadFolder); + $this->videoDownloader = new YTDLPDownloader(); + $this->ReelDescriptor = $ReelDescriptor ?? app(ReelDescriptor::class); + $this->descriptionPipeline = new InstagramDescriptionPipeline([ + // Add steps to the pipeline here + new DescriptionPipeline\RemoveAccountsReferenceStep(), + ]); + } + + public function run(Browser $browser): ?JobRun + { + $startTime = microtime(true); + + Log::info("Running InstagramNotificationHandlingJob"); + $this->jobInfos = Job::find($this->jobId)->jobInfosTable(); + $this->jobRun = new JobRun([ + "job_id" => $this->jobId, + "success" => false, + ]); + $this->jobRun->save(); + + dump("visiting " . microtime(true) - $startTime); + $browser->visit('https://instagram.com'); + sleep(5); + // dump("removing popups " . microtime(true) - $startTime); + // $this->removePopups($browser); // TEMPORARY DISABLED + dump("signing in " . microtime(true) - $startTime); + $this->signin($browser); + sleep(2); + dump("Saving notifications " . microtime(true) - $startTime); + $this->saveNotifications($browser); + sleep(seconds: 5); + + $this->jobRun->success = true; + $this->jobRun->save(); + + Log::info("InstagramNotificationHandlingJob run ended"); + + return $this->jobRun; + } + + /** + * @inheritDoc + */ + public function runTest(Browser $browser): ?JobRun + { + $this->jobInfos = Job::find($this->jobId)->jobInfosTable(); + try { + $browser->visit('https://instagram.com'); + sleep(2); + $this->removePopups($browser); + $this->signin($browser); + sleep(3); + return $this->makeSimpleJobRun( + true, + "Connexion réussie", + "Datboi a réussi à se connecter sur Instagram" + ); + } catch (\Exception $e) { + return $this->makeSimpleJobRun( + false, + "Connexion échouée", + "Datboi n'a pas réussi à se connecter sur Instagram :\n" . $e->getMessage() + ); + } + } + + private function saveNotifications(Browser $browser): void + { + $this->openNotificationsPanel($browser); + try { + $DOMnotifications = $browser->driver->findElements(WebDriverBy::xpath('//div[@class="x6s0dn4 x1q4h3jn x78zum5 x1y1aw1k x64bnmy xwib8y2 x13jy36j x87ps6o x1wq6e7o x1ffbijf x1h4gsww xnp2e5m x1ypdohk x1l895ks"]')); // TODO : not rely on class names + $notifications = []; + foreach ($DOMnotifications as $DOMnotification) { + // Process each notification + $DOMnotificationText = $DOMnotification->findElement(WebDriverBy::xpath('./div[2]/span')); + + $notification = new InstagramNotification(); + $notification->username = str_replace( "/", "", $DOMnotificationText->findElement(WebDriverBy::xpath('./a[1]'))->getAttribute('href')); + $notification->notification_type = NotificationTypeDetector::detectType($DOMnotificationText->getText()); + // 05/08/2025 : Instagram removed the link to the post in the notification, the day I'm working on it :() + $postId = null; + try { + $postId = str_replace(["\/p/", "/"], "", $DOMnotification->findElement(WebDriverBy::xpath('./div[3]/a'))->getAttribute('href')); + dump("Post ID: " . $postId); + $repostId = InstagramRepost::where('repost_reel_id', $postId)->first()?->id; + dump("Repost ID: " . $repostId); + if ($repostId) { + $notification->instagram_repost_id = $repostId; + } + } + catch (\Exception $e) { + Log::error("Failed to get post ID from notification : " . $e->getMessage()); + } + $notification->message = $DOMnotificationText->getText(); + + // Save only if the notification is not already saved + try { + $notification->save(); + } catch (\Exception $e) { + Log::error("Failed to save notification : " . $e->getMessage()); + continue; // Skip to the next notification + } + dump("Notification saved : " . $notification); + $notifications[] = $notification; + } + } catch (\Exception $e) { + Log::error("Failed to get notifications : " . $e->getMessage()); + $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); + AllNotification::send(new JobDebugNotification($this->jobId, "Failed to open notifications panel: " . $e->getMessage())); + return; + } + } + + private function openNotificationsPanel(Browser $browser): void + { + try { + $notificationsButtonXpath = '//a[./div//span[contains(text(), "Notifications")]]'; + $notificationsButton = $browser->driver->findElement(WebDriverBy::xpath($notificationsButtonXpath)); + // $notificationsButton->click(); + $this->clickElementWithJavaScript($browser, $notificationsButtonXpath); + sleep(1); + $browser->waitForText('Filter', 10, true); + } catch (\Exception $e) { + Log::error("Failed to open notifications panel : " . $e->getMessage()); + $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); + AllNotification::send(new JobDebugNotification($this->jobId, "Failed to open notifications panel: " . $e->getMessage())); + return; + } + + if (app()->environment('local')) { + Log::debug("Notifications panel opened successfully waiting 7 seconds for manual interaction"); + sleep(7); // Allow time for manual interaction in local environment + } + } +} diff --git a/app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php b/app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php index 9f057ed..90ce2ef 100644 --- a/app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php +++ b/app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php @@ -2,8 +2,8 @@ namespace App\Browser\Jobs\InstagramRepost; -use App\Browser\BrowserJob; use App\Browser\JobDebugScreenshot; +use App\Browser\Jobs\Instagram\InstagramAbstractJob; use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline; use App\Models\InstagramRepost; use App\Models\InstagramAccount; @@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log; use Laravel\Dusk\Browser; use App\Services\AIPrompt\OpenAPIPrompt; -class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProcessing +class InstagramRepostJob extends InstagramAbstractJob implements ShouldBeUniqueUntilProcessing { // === CONFIGURATION === @@ -27,15 +27,10 @@ class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProces private const APPROXIMATIVE_RUNNING_MINUTES = 2; - private Collection $jobInfos; - protected JobRun $jobRun; - protected IInstagramVideoDownloader $videoDownloader; protected ReelDescriptor $ReelDescriptor; - protected OpenAPIPrompt $openAPIPrompt; - protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/"; /** @@ -49,12 +44,11 @@ class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProces public function __construct($jobId = 4, ReelDescriptor $ReelDescriptor = null, OpenAPIPrompt $openAPIPrompt = null) { - parent::__construct($jobId); + parent::__construct($jobId, $openAPIPrompt); $this->downloadFolder = base_path($this->downloadFolder); $this->videoDownloader = new YTDLPDownloader(); $this->ReelDescriptor = $ReelDescriptor ?? app(ReelDescriptor::class); - $this->openAPIPrompt = $openAPIPrompt ?? app(OpenAPIPrompt::class); $this->descriptionPipeline = new InstagramDescriptionPipeline([ // Add steps to the pipeline here new DescriptionPipeline\RemoveAccountsReferenceStep(), @@ -437,80 +431,4 @@ Your response format must strictly adhere to JSON with only one required field: } return $llmAnswer; } - - private function clickNext(Browser $browser) { - $nextButton = $browser->driver->findElement(WebDriverBy::xpath('//div[contains(text(), "Next") or contains(text(), "Share")]')); - $nextButton->click(); - sleep(2); - } - - private function putOriginalResolution(Browser $browser) - { - try { - $chooseResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//button[./div/*[local-name() = "svg"][@aria-label="Select crop"]]')); - $chooseResolutionButton->click(); - sleep(2); - // Choos "original" resolution - $originalResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/div/span[contains(text(), "Original")]]')); - $originalResolutionButton->click(); - sleep(2); - } catch (\Exception $e) { - Log::error("Failed to set original resolution: " . $e->getMessage()); - $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); - AllNotification::send(new JobDebugNotification($this->jobId, "Failed to set \"original\" resolution: " . $e->getMessage())); - } - } - - protected function signin(Browser $browser) - { - try { - $browser->waitForText("Log in", 10, true); - sleep(3); - $emailButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "email")]')); - $emailButton->click(); - $emailButton->sendKeys($this->jobInfos->get("instagram_repost_account_email")); - sleep(3); - $passwordButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "Password")]')); - $passwordButton->click(); - $passwordButton->sendKeys($this->jobInfos->get("instagram_repost_account_password") . "\n"); - sleep(5); - } catch (\Exception $e) { - // Probably no need to signin - } - try { - $browser->waitForText("Search", 15, true); - $this->removePopups($browser); - } catch (\Exception $e) { - Log::error("Failed to sign in: " . $e->getMessage()); - $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); - AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sign in: " . $e->getMessage())); - } - } - - protected function removePopups(Browser $browser) - { - $popupsTypes = [ - ['//button[contains(text(), "Allow all cookies")]'], // Allow all cookies - ['//button[contains(text(), "Not Now")]', ["Popup Not Now clicked"]], // Not now - ['//button[contains(text(), "OK")]', ["Popup Ok clicked"]], // OK - ]; - - foreach ($popupsTypes as $popup) { - try { - $button = $browser->driver->findElement(WebDriverBy::xpath($popup[0])); - if ($button === null) { - continue; // No button found, continue to the next popup - } - if (isset($popup[1])) { - $browser->screenshot(JobDebugScreenshot::getFileName($this->jobId)); - AllNotification::send(new JobDebugNotification($this->jobId, $popup[1][0])); - } - $button->click(); - sleep(2); - return; // Exit after clicking the first popup found - } catch (\Exception $e) { - // Porbably no popup found, continue - } - } - } } diff --git a/app/Models/InstagramNotification.php b/app/Models/InstagramNotification.php new file mode 100644 index 0000000..587ad96 --- /dev/null +++ b/app/Models/InstagramNotification.php @@ -0,0 +1,30 @@ + InstagramNotificationType::class, + 'is_read' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + protected $attributes = [ + 'is_read' => false, + 'is_processed' => false, + ]; +} diff --git a/app/Models/InstagramNotificationType.php b/app/Models/InstagramNotificationType.php new file mode 100644 index 0000000..0d15464 --- /dev/null +++ b/app/Models/InstagramNotificationType.php @@ -0,0 +1,14 @@ +id(); + + $table->string('username')->nullable(); + $table->foreignIdFor(InstagramRepost::class)->nullable() + ->constrained('instagram_reposts') + ->noActionOnDelete() + ->cascadeOnUpdate(); + $table->enum('notification_type', array_column(InstagramNotificationType::cases(), 'value')); + $table->text('message')->nullable(); + $table->boolean('is_read')->default(false); + $table->boolean('is_processed')->default(false); + + $table->unique(['username', 'instagram_repost_id', 'notification_type', 'message'], 'unique_instagram_notification'); + + $table->timestamps(); + }); + + Schema::table('instagram_reposts', function (Blueprint $table) { + $table->string("repost_reel_id")->unique()->nullable()->comment('Reel ID of the reposted content'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('instagram_notifications'); + + Schema::table('instagram_reposts', function (Blueprint $table) { + $table->dropUnique(['repost_reel_id']); + $table->dropColumn("repost_reel_id"); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 80f4aef..156ffb0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use App\Browser\Jobs\Hellcase\HellcaseJob; use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob; +use App\Browser\Jobs\InstagramRepost\InstagramNotificationHandlingJob; use App\Browser\Jobs\InstagramRepost\InstagramRepostJob; use App\Jobs\PruneOldJobRuns; use App\Services\BrowserJobsInstances; @@ -24,4 +25,5 @@ Schedule::job(new PruneOldJobRuns)->monthly()->onOneServer()->withoutOverlapping Schedule::job(new HellcaseJob)->daily()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job'); // Schedule::job(new HellcaseJob)->everyMinute()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job'); Schedule::job(new HellcaseBattlesJob)->hourly()->onOneServer()->withoutOverlapping()->name('hellcase_battles')->description('Hellcase battles job'); -Schedule::job(new InstagramRepostJob())->everyThreeHours()->onOneServer()->withoutOverlapping()->name('instagram_reposts')->description('Intagrame reposts job'); +Schedule::job(new InstagramRepostJob)->everyThreeHours()->onOneServer()->withoutOverlapping()->name('instagram_reposts')->description('Instagram reposts job'); +Schedule::job(new InstagramNotificationHandlingJob)->hourly()->onOneServer()->withoutOverlapping()->name('instagram_reposts_notifications')->description('Instagram reposts notification handling job');