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)); 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; namespace App\Browser\Jobs\InstagramRepost;
use App\Browser\BrowserJob;
use App\Browser\JobDebugScreenshot; use App\Browser\JobDebugScreenshot;
use App\Browser\Jobs\Instagram\InstagramAbstractJob;
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline; use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
use App\Models\InstagramRepost; use App\Models\InstagramRepost;
use App\Models\InstagramAccount; use App\Models\InstagramAccount;
@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use App\Services\AIPrompt\OpenAPIPrompt; use App\Services\AIPrompt\OpenAPIPrompt;
class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProcessing class InstagramRepostJob extends InstagramAbstractJob implements ShouldBeUniqueUntilProcessing
{ {
// === CONFIGURATION === // === CONFIGURATION ===
@ -27,15 +27,10 @@ class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProces
private const APPROXIMATIVE_RUNNING_MINUTES = 2; private const APPROXIMATIVE_RUNNING_MINUTES = 2;
private Collection $jobInfos;
protected JobRun $jobRun;
protected IInstagramVideoDownloader $videoDownloader; protected IInstagramVideoDownloader $videoDownloader;
protected ReelDescriptor $ReelDescriptor; protected ReelDescriptor $ReelDescriptor;
protected OpenAPIPrompt $openAPIPrompt;
protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/"; 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) 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->downloadFolder = base_path($this->downloadFolder);
$this->videoDownloader = new YTDLPDownloader(); $this->videoDownloader = new YTDLPDownloader();
$this->ReelDescriptor = $ReelDescriptor ?? app(ReelDescriptor::class); $this->ReelDescriptor = $ReelDescriptor ?? app(ReelDescriptor::class);
$this->openAPIPrompt = $openAPIPrompt ?? app(OpenAPIPrompt::class);
$this->descriptionPipeline = new InstagramDescriptionPipeline([ $this->descriptionPipeline = new InstagramDescriptionPipeline([
// Add steps to the pipeline here // Add steps to the pipeline here
new DescriptionPipeline\RemoveAccountsReferenceStep(), new DescriptionPipeline\RemoveAccountsReferenceStep(),
@ -437,80 +431,4 @@ Your response format must strictly adhere to JSON with only one required field:
} }
return $llmAnswer; 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
}
}
}
} }

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InstagramNotification extends Model
{
protected $table = 'instagram_notifications';
protected $fillable = [
'username',
'instagram_repost_id',
'notification_type',
'message',
'is_read',
];
protected $casts = [
'notification_type' => InstagramNotificationType::class,
'is_read' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
protected $attributes = [
'is_read' => false,
'is_processed' => false,
];
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Models;
enum InstagramNotificationType: string
{
case LIKE = 'LIKE';
case COMMENT = 'COMMENT';
case FOLLOW = 'FOLLOW';
case MESSAGE = 'MESSAGE';
case SYSTEM = 'SYSTEM'; // System message like congratulatory messages or updates
case MENTION = 'MENTION';
case OTHER = 'OTHER'; // Uncategorized or unclassified notifications
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Services\Instagram;
use App\Models\InstagramNotificationType;
use Facebook\WebDriver\Remote\RemoteWebElement;
class NotificationTypeDetector
{
/**
* Detects the Instagram notification type from the DOM.
*
* @param string $descriptionText The text content of the notification description.
* @return InstagramNotificationType
*/
public static function detectType(string $descriptionText): InstagramNotificationType
{
$descriptionText = trim($descriptionText); // Remove leading/trailing whitespace
// Prioritize exact matches for reliability
if (strpos($descriptionText, 'liked') !== false) {
return InstagramNotificationType::LIKE;
} elseif (strpos($descriptionText, 'commented') !== false) {
return InstagramNotificationType::COMMENT;
} elseif (strpos($descriptionText, 'following you') !== false) {
return InstagramNotificationType::FOLLOW;
} elseif (strpos($descriptionText, 'message') !== false) {
return InstagramNotificationType::MESSAGE;
} elseif (strpos($descriptionText, 'congratulations') !== false || strpos($descriptionText, 'update') !== false) {
return InstagramNotificationType::SYSTEM;
} elseif (strpos($descriptionText, 'mention') !== false) {
return InstagramNotificationType::MENTION;
}
return InstagramNotificationType::OTHER;
}
}

View File

@ -0,0 +1,50 @@
<?php
use App\Models\InstagramNotificationType;
use App\Models\InstagramRepost;
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
{
Schema::create('instagram_notifications', function (Blueprint $table) {
$table->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");
});
}
};

View File

@ -2,6 +2,7 @@
use App\Browser\Jobs\Hellcase\HellcaseJob; use App\Browser\Jobs\Hellcase\HellcaseJob;
use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob; use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob;
use App\Browser\Jobs\InstagramRepost\InstagramNotificationHandlingJob;
use App\Browser\Jobs\InstagramRepost\InstagramRepostJob; use App\Browser\Jobs\InstagramRepost\InstagramRepostJob;
use App\Jobs\PruneOldJobRuns; use App\Jobs\PruneOldJobRuns;
use App\Services\BrowserJobsInstances; 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)->daily()->onOneServer()->withoutOverlapping()->name('hellcase')->description('Hellcase job');
// Schedule::job(new HellcaseJob)->everyMinute()->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 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');