Instagram jobs refactor + started InstagramNotifications

This commit is contained in:
2025-08-05 13:25:25 +02:00
parent 1f23b112d7
commit aa936a2a11
9 changed files with 444 additions and 86 deletions

View File

@ -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}');
}
");
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Browser\Jobs\Instagram;
use App\Browser\BrowserJob;
use App\Browser\JobDebugScreenshot;
use App\Models\JobRun;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Providers\AllNotification;
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;
abstract class InstagramAbstractJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
{
// === CONFIGURATION ===
protected Collection $jobInfos;
protected JobRun $jobRun;
protected OpenAPIPrompt $openAPIPrompt;
public function __construct($jobId, OpenAPIPrompt $openAPIPrompt = null)
{
parent::__construct($jobId);
$this->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
}
}
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Browser\Jobs\InstagramRepost;
use App\Browser\JobDebugScreenshot;
use App\Browser\Jobs\Instagram\InstagramAbstractJob;
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
use App\Models\InstagramNotification;
use App\Models\InstagramRepost;
use App\Models\Job;
use App\Models\JobRun;
use App\Notification\Notifications\JobDebugNotification;
use App\Notification\Providers\AllNotification;
use App\Services\Instagram\NotificationTypeDetector;
use Facebook\WebDriver\WebDriverBy;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Support\Facades\Log;
use Laravel\Dusk\Browser;
use App\Services\AIPrompt\OpenAPIPrompt;
class InstagramNotificationHandlingJob extends InstagramAbstractJob implements ShouldBeUniqueUntilProcessing
{
// === CONFIGURATION ===
public $timeout = 1800; // 30 minutes
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
protected IInstagramVideoDownloader $videoDownloader;
protected ReelDescriptor $ReelDescriptor;
protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/";
/**
* Pipeline for processing Instagram post descriptions.
* This pipeline can be used to modify the description before reposting.
* For example, it can remove account references or add hashtags.
*
* @var InstagramDescriptionPipeline
*/
protected InstagramDescriptionPipeline $descriptionPipeline;
public function __construct($jobId = 4, ReelDescriptor $ReelDescriptor = null, OpenAPIPrompt $openAPIPrompt = null)
{
parent::__construct($jobId, $openAPIPrompt);
$this->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
}
}
}

View File

@ -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
}
}
}
}