Compare commits
54 Commits
jobs/hellc
...
fcc78fd560
Author | SHA1 | Date | |
---|---|---|---|
fcc78fd560 | |||
a57cbffbeb | |||
4623a52bcc | |||
8ab097ca1c | |||
9d0a1b5cf9 | |||
77fcee7a83 | |||
1f7f4c665d | |||
44d7d52f23 | |||
25b5b1be27 | |||
228d67a48d | |||
21abbcdff5 | |||
c13c1db638 | |||
24629adcd5 | |||
7054597696 | |||
67197c5c48 | |||
20fca31ced | |||
c3941b5e33 | |||
35327d7e14 | |||
e680a04c57 | |||
07e55d76e8 | |||
c98c704b16 | |||
b877912d05 | |||
33b9c7ce5f | |||
e011eed4ca | |||
ea2f21f8bf | |||
66c4752203 | |||
f92e9ff139 | |||
5a6b0639e7 | |||
ed089258c8 | |||
70cd2a41b1 | |||
d498203147 | |||
69fdad8f4e | |||
982f4ca7a7 | |||
7e37ea86d0 | |||
31c293e4a6 | |||
55a7b16d9e | |||
4008724169 | |||
235440078d | |||
5599e170a7 | |||
034a2c8754 | |||
e6ff59ff18 | |||
02bd6eae82 | |||
e6c6ab98fd | |||
5522195438 | |||
7f50822692 | |||
25a9063169 | |||
aa7d107c03 | |||
faf408582f | |||
b49af7e56e | |||
85aa0c7a04 | |||
c26a09701a | |||
fadb4d2748 | |||
c5f5d94912 | |||
af96f996ee |
@ -81,3 +81,11 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
# AI LLM
|
||||
LLM_API_HOST_URL=${LLM_API_HOST_URL}
|
||||
LLM_API_TOKEN=${LLM_API_TOKEN}
|
||||
LLM_CHAT_MODEL=${LLM_CHAT_MODEL}
|
||||
LLM_CHAT_MODEL_THINK=${LLM_CHAT_MODEL_THINK}
|
||||
LLM_VISION_MODEL=${LLM_VISION_MODEL}
|
||||
LLM_VISION_MODEL_THINK=${LLM_VISION_MODEL_THINK}
|
||||
|
@ -81,3 +81,11 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||
|
||||
# AI LLM
|
||||
LLM_API_HOST_URL="https://chat.myopen-webui.dev/ollama"
|
||||
LLM_API_TOKEN="myopen-webui-token-1234567890abcdef" # Replace with your actual token
|
||||
LLM_CHAT_MODEL="deepseek-r1:8b"
|
||||
LLM_CHAT_MODEL_THINK=true
|
||||
LLM_VISION_MODEL="llava:7b"
|
||||
LLM_VISION_MODEL_THINK=false
|
||||
|
15
Dockerfile
15
Dockerfile
@ -1,6 +1,6 @@
|
||||
|
||||
# INSTALL PHP COMPOSER DEPENDENCIES
|
||||
FROM composer:lts AS composer-deps
|
||||
FROM composer:2.7.9 AS composer-deps
|
||||
|
||||
WORKDIR /
|
||||
|
||||
@ -36,7 +36,7 @@ RUN mkdir -p public/build/ && npm i && npm run build
|
||||
# ========================================
|
||||
|
||||
# RUN
|
||||
FROM php:8.2-alpine AS final
|
||||
FROM php:8.3-alpine AS final
|
||||
|
||||
ARG APP_ENV_FILE=.env.docker
|
||||
|
||||
@ -54,11 +54,22 @@ RUN apk update && apk add --no-cache \
|
||||
openssl \
|
||||
linux-headers \
|
||||
supervisor \
|
||||
tesseract-ocr \
|
||||
ffmpeg \
|
||||
&& rm -rf /tmp/* /var/cache/apk/*
|
||||
|
||||
RUN docker-php-ext-configure zip && docker-php-ext-install zip
|
||||
RUN docker-php-ext-install gd pdo pdo_mysql zip
|
||||
|
||||
# Tesseract-OCR module downloads
|
||||
# Based on https://github.com/Franky1/docker-tesseract/blob/master/Dockerfile.main
|
||||
RUN wget --no-check-certificate https://github.com/tesseract-ocr/tessdata/raw/refs/heads/main/eng.traineddata -P /usr/share/tessdata \
|
||||
&& wget --no-check-certificate https://github.com/tesseract-ocr/tessdata/raw/refs/heads/main/fra.traineddata -P /usr/share/tessdata
|
||||
|
||||
# Install latest version of the linux binary of yt-dlp into /bin/yt-dlp
|
||||
# Get the file from https://github.com/yt-dlp/yt-dlp-master-builds/releases/latest/download/yt-dlp
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp-master-builds/releases/latest/download/yt-dlp -o /bin/yt-dlp \
|
||||
&& chmod +x /bin/yt-dlp
|
||||
|
||||
# Get latest Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
52
LLMPrompts.md
Normal file
52
LLMPrompts.md
Normal file
@ -0,0 +1,52 @@
|
||||
|
||||
# What is this file ?
|
||||
|
||||
This file provides the user prompts used to get the prompts directly from the LLM used to give answers.
|
||||
|
||||
For example, for the Instagram reel caption generation, here will be listed a prompt that asks the LLM to give
|
||||
the prompt, system message and output format that will be used in the Instagram reel caption generation.
|
||||
|
||||
This method comes from the idea that the best way to prompt engineer is to ask the concerned model to generate it directly.
|
||||
|
||||
# Prompts
|
||||
|
||||
Starting sentence is usually :
|
||||
```
|
||||
I’m using some LLM and I would need a prompt and a system message for every use case I will give you.
|
||||
```
|
||||
|
||||
## Instagram
|
||||
|
||||
### Instagram Reel caption generation
|
||||
|
||||
```
|
||||
I’m using some LLM and I would need a prompt, a system message and an output format for every use case I will give you.
|
||||
The first one is when I’m trying to generate a caption for an instagram Reel. For the moment, I can give the LLM the original instagram reel caption that was downloaded from, and a description by an LLM of the video, or the joke behind it.
|
||||
The caption must be short and well placed with the reel. For example, if the reel is funny, the caption must be short and funny, while still relating to the reel. The caption must not be describint the video like the LLM description does
|
||||
The LLM can add some appropriate hashtags if it wants to and seem appropriate.
|
||||
Sometimes, the original caption will credit the original author, most of the times on twitter like (“credit : t/twitteruser”). Those credit can appear in the generated caption too, But I don’t want any instagram account mention (“@instagramUser”) because most of the time it’s to incite to subscribe to the downloaded reel account. The use of emoji is encouraged, but not too much and it has to not look stupid or too.
|
||||
```
|
||||
|
||||
## Video Descriptor
|
||||
|
||||
I’m using some LLM and I would need a prompt and a system message for every use case I will give you.
|
||||
|
||||
The LLM here will be used to describe an Instagram Reel (video). Each screenshot of that video will be described using an LLM, prompt, system message and output format. The description of all the screenshots will be given to this LLM that will try to recreate the video based on the description of the screenshots, and describe the video.
|
||||
The required prompt here is for the LLM that will compile the description into one and try to understand the video and describe it. I’m particularly interested in the joke behind the reel if there is one.
|
||||
|
||||
This is an example of a screenshot description by an LLM : “The image shows a close-up of a person's hands holding what appears to be a brown object with a plastic covering, possibly food wrapped in paper or foil. There is also a small portion visible at the top right corner, which seems to be a red and white label. The focus of the image is on the hands holding the object.”
|
||||
|
||||
Most of the description won’t make sense, so some details should be omitted. For example, one screenshot description could say the main subject is a car, and another one 3 seconds later in the video could say the main subject is a cat. You could say the car transformed into a cat, but it would be safer to assume that one of the description is wrong and the main characted was a cat all along the video because another description in the video also says the main subject is a cat.
|
||||
It is safe to say that most analysed videos will be of bad quality. which means the screenshots description can vary a lot
|
||||
|
||||
### Screenshot descriptor
|
||||
|
||||
```
|
||||
I’m using some LLM and I would need a prompt, a system message and an output format for every use case I will give you.
|
||||
The first one must describe a screenshot from a video. Each screenshot of that video will be described using the same LLM, prompt, system message and output format. The description of all the screenshots will be given to another LLM that will try to recreate the video based on the description of the screenshots, and describe the video.
|
||||
The required prompt here is the one that describes a screenshot. The LLM will only be given the screenshot as input information. I need the LLM to describe the given screenshot. No need to specify that it is a screenshot. The LLM description must include specify the scene, the character or the main subject, the text present on the screenshots, most of the time it will be caption added after video editing, that may use emojis.
|
||||
|
||||
The LLM used here is llava:7b-v1.6-mistral-q4_1, it is not the best for text generation , but it is very prowerful when using it’s vision capabilty.
|
||||
```
|
||||
|
||||
The last part is personnal, I included it because I gave the prompt to another LLM that the one used because llava would'nt give me a good prompt.
|
@ -5,6 +5,7 @@ namespace App\Browser;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Notifications\JobErrorNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Closure;
|
||||
@ -22,6 +23,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Laravel\Dusk\Chrome\SupportsChrome;
|
||||
use Laravel\Dusk\Concerns\ProvidesBrowser;
|
||||
use Log;
|
||||
use PHPUnit\Framework\Attributes\BeforeClass;
|
||||
use Throwable;
|
||||
|
||||
@ -31,7 +33,7 @@ abstract class BrowserJob implements ShouldQueue
|
||||
|
||||
public int $jobId;
|
||||
|
||||
public $timeout = 500;
|
||||
public $timeout = 300; // 5 minutes
|
||||
|
||||
public function __construct(int $jobId)
|
||||
{
|
||||
@ -51,6 +53,7 @@ abstract class BrowserJob implements ShouldQueue
|
||||
|
||||
$this->browse(function (Browser $browser) use ($callback, &$log) {
|
||||
try {
|
||||
$browser->driver->manage()->timeouts()->implicitlyWait(20);
|
||||
$log = $callback($browser);
|
||||
// } catch (Exception $e) {
|
||||
// $browser->screenshot("failure-{$this->jobId}");
|
||||
@ -158,7 +161,7 @@ abstract class BrowserJob implements ShouldQueue
|
||||
'--disable-setuid-sandbox',
|
||||
'--whitelisted-ips=""',
|
||||
'--disable-dev-shm-usage',
|
||||
'--user-data-dir=/home/seluser/profile/',
|
||||
'--user-data-dir=/home/seluser/profile/nigga/', // seems that selenium doesn't like docker having a volume on the exact same folder ("session not created: probably user data directory is already in use")
|
||||
])->all());
|
||||
|
||||
return RemoteWebDriver::create(
|
||||
@ -167,6 +170,13 @@ abstract class BrowserJob implements ShouldQueue
|
||||
ChromeOptions::CAPABILITY,
|
||||
$options
|
||||
)
|
||||
->setCapability('timeouts', [
|
||||
'implicit' => 20000, // 20 seconds
|
||||
'pageLoad' => 300000, // 5 minutes
|
||||
'script' => 30000, // 30 seconds
|
||||
]),
|
||||
4000,
|
||||
$this->timeout * 1000
|
||||
);
|
||||
}
|
||||
|
||||
@ -286,4 +296,74 @@ abstract class BrowserJob implements ShouldQueue
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string for use in JavaScript.
|
||||
*
|
||||
* @param string $string The string to parse.
|
||||
* @return string The parsed string.
|
||||
*/
|
||||
private function parseJavaScriptString(string $string): string
|
||||
{
|
||||
$string = str_replace("\n", "\\n", $string);
|
||||
return str_replace("'", "\\'", $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text into an input field using JavaScript.
|
||||
*
|
||||
* @param Browser $browser
|
||||
* @param string $text The text to type.
|
||||
* @param string $querySelector The CSS selector for the input field.
|
||||
*/
|
||||
public function setInputValue(Browser $browser, string $text, string $querySelector): void
|
||||
{
|
||||
$text = $this->parseJavaScriptString($text);
|
||||
$querySelector = $this->parseJavaScriptString($querySelector);
|
||||
|
||||
$browser->script("
|
||||
let element = document.querySelector('{$querySelector}');
|
||||
if (element) {
|
||||
element.focus();
|
||||
element.value = '{$text}';
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
} else {
|
||||
console.error('Element not found: {$querySelector}');
|
||||
}
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste text into an element using JavaScript.
|
||||
* Can be useful for non input elements that need to get text. For example works
|
||||
* with `contenteditable` elements.
|
||||
*
|
||||
* @param Browser $browser
|
||||
* @param string $text The text to paste.
|
||||
* @param string $querySelector The CSS selector for the input field.
|
||||
*/
|
||||
public function pasteText(Browser $browser, string $text, string $querySelector): void
|
||||
{
|
||||
try {
|
||||
$text = $this->parseJavaScriptString($text);
|
||||
$querySelector = $this->parseJavaScriptString($querySelector);
|
||||
|
||||
$browser->script("
|
||||
var el = document.querySelector('{$querySelector}'), text = '{$text}';
|
||||
el.focus();
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text', text);
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true
|
||||
});
|
||||
el.dispatchEvent(event)
|
||||
");
|
||||
} catch (Exception $e) {
|
||||
$errorMessage = "Failed to paste text into element: {$querySelector} - " . $e->getMessage() . "\n With text: {$text}";
|
||||
Log::error($errorMessage);
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, $errorMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,19 +9,19 @@ use function rtrim;
|
||||
class JobDebugScreenshot {
|
||||
public const IMG_FILE_NAME = "debug-";
|
||||
|
||||
public static function getFileName(int $jobId): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ".png";
|
||||
public static function getFileName(int $jobId, bool $withExtension = false): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ($withExtension ? ".png": "");
|
||||
}
|
||||
|
||||
public static function getImgFileAbsolutePath(int $jobId): string {
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId);
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId, true);
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(int $jobId): string {
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId));
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(int $jobId): string {
|
||||
return "screenshots/" . static::getFileName($jobId);
|
||||
return "screenshots/" . static::getFileName($jobId, true);
|
||||
}
|
||||
}
|
||||
|
@ -9,19 +9,19 @@ use function rtrim;
|
||||
class JobErrorScreenshot {
|
||||
public const IMG_FILE_NAME = "failure-";
|
||||
|
||||
public static function getFileName(int $jobId): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ".png";
|
||||
public static function getFileName(int $jobId, bool $withExtension = false): string {
|
||||
return static::IMG_FILE_NAME . $jobId . ($withExtension ? ".png": "");
|
||||
}
|
||||
|
||||
public static function getImgFileAbsolutePath(int $jobId): string {
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId);
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/" . static::getFileName($jobId, true);
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(int $jobId): string {
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId));
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(int $jobId): string {
|
||||
return "screenshots/" . static::getFileName($jobId);
|
||||
return "screenshots/" . static::getFileName($jobId, true);
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,6 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
|
||||
public function __construct($jobId = 2)
|
||||
{
|
||||
Log::info("Constructing HellcaseJob");
|
||||
parent::__construct($jobId);
|
||||
}
|
||||
|
||||
@ -82,7 +81,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->makeSimpleJobRun(
|
||||
true,
|
||||
false,
|
||||
"Connexion échouée",
|
||||
"Datboi n'a pas réussi à se connecter sur Hellcase :\n" . $e->getMessage()
|
||||
);
|
||||
@ -171,7 +170,8 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
]));
|
||||
}
|
||||
try {
|
||||
$nextSlideButton = $browser->driver->findElement(WebDriverBy::xpath('//button[@class="_button_1ygbm_7 _next_1ygbm_24"]'));
|
||||
// $nextSlideButton = $browser->driver->findElement(WebDriverBy::xpath('//button[@class="_button_1ygbm_7 _next_1ygbm_24"]'));
|
||||
$nextSlideButton = "Button next slide is deactivated";
|
||||
} catch (\Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "No next slide button found"));
|
||||
@ -186,7 +186,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
} catch (\Exception $e) {
|
||||
$clickedFailsCounter++;
|
||||
try {
|
||||
$nextSlideButton->click();
|
||||
// $nextSlideButton->click();
|
||||
} catch (\Exception $_) {}
|
||||
sleep(3);
|
||||
continue;
|
||||
@ -226,7 +226,11 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
private function getDailyFree(Browser $browser)
|
||||
{
|
||||
$browser->visit('https://hellcase.com/dailyfree');
|
||||
$browser->waitForText("Get Daily free loot", 30, true);
|
||||
try {
|
||||
$browser->waitForText("Get Daily free loot", 30, true);
|
||||
} catch (\Exception $e) {
|
||||
return; // Pobably means we do not have access to a daily free loot
|
||||
}
|
||||
|
||||
// Do we fill the conditions ?
|
||||
if (sizeof(value: $browser->driver->findElements(WebDriverBy::xpath('//p[contains(text(), "Fulfill the conditions below")]'))) > 0) {
|
||||
|
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use function rtrim;
|
||||
|
||||
|
||||
class HellcaseBattleScreenshot {
|
||||
public const IMG_FILE_NAME = "Hellcase-battle";
|
||||
|
||||
public static function getImgFileAbsolutePath(): string {
|
||||
return rtrim(Browser::$storeScreenshotsAt, '/') . "/HellcaseBattles/" . static::IMG_FILE_NAME;
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(): string {
|
||||
return app_path("Browser/screenshots/HellcaseBattles/" . static::IMG_FILE_NAME);
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(): string {
|
||||
return "screenshots/HellcaseBattles/" . static::IMG_FILE_NAME;
|
||||
}
|
||||
}
|
@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
@ -20,10 +22,10 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
{
|
||||
private Collection $jobInfos;
|
||||
private array $battlesToAdd = [];
|
||||
private array $battlesSent = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Log::info("Constructing HellcaseBattlesJob");
|
||||
parent::__construct(3);
|
||||
}
|
||||
|
||||
@ -47,8 +49,16 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
|
||||
$this->sendFinishedBattles($browser);
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($this->battlesSent) . " battailles envoyées",
|
||||
]));
|
||||
|
||||
$this->createNewBattles();
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($this->battlesToAdd) . " nouvelles battailles ajoutées pour surveillage",
|
||||
]));
|
||||
|
||||
$this->jobRun->success = true;
|
||||
$this->jobRun->save();
|
||||
|
||||
@ -70,13 +80,12 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
$browser->visit('https://hellcase.com/casebattle');
|
||||
$browser->waitForText("CASES", 30, true);
|
||||
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "I hate niggers"));
|
||||
|
||||
// Sort by price
|
||||
try {
|
||||
$sortByPriceDiv = $browser->driver->findElement(WebDriverBy::xpath("//*[span[contains(text(), 'Value')]]"));
|
||||
$sortByPriceDiv->click();
|
||||
} catch (Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sort by price"));
|
||||
return;
|
||||
}
|
||||
@ -103,25 +112,28 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
$battleLinkButton = $battle->findElement(WebDriverBy::xpath('./div//button[text() = "watch"]'));
|
||||
$battleLinkButton->sendKeys("\n");
|
||||
sleep(3);
|
||||
$battleLink = $browser->driver->getCurrentURL();
|
||||
try { // If we still are on the casebattle page, it means the battle was cancelled or something else
|
||||
$browser->waitForLocation("https://hellcase.com/casebattle/", 3);
|
||||
} catch (Exception $e) {
|
||||
$battleLink = $browser->driver->getCurrentURL();
|
||||
|
||||
$this->battlesToAdd[$battleLink] = $battleValue;
|
||||
$this->battlesToAdd[$battleLink] = $battleValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendFinishedBattles(Browser $browser) {
|
||||
// foreach battle that we didn"t already planned to add with $this->battlesToAdd
|
||||
foreach (HellcaseBattle::all() as $battle) {
|
||||
dump($battle);
|
||||
if (!array_key_exists($battle->getUrl(), $this->battlesToAdd)) {
|
||||
dump("finished");
|
||||
$browser->visit($battle->getUrl());
|
||||
|
||||
try {
|
||||
$browser->waitForText("Started at");
|
||||
sleep(2);
|
||||
$browser->waitForText("Case Battle");
|
||||
|
||||
if ($this->findElementContainingText($browser, "Started at:") != null) { // battle is finished
|
||||
// Send the battle
|
||||
$this->sendBattle($browser, $battle);
|
||||
} catch (Exception $e) { // Battle is not finished or error (like battle cancelled)
|
||||
}
|
||||
|
||||
$battle->delete();
|
||||
@ -130,16 +142,35 @@ class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProce
|
||||
}
|
||||
|
||||
private function sendBattle(Browser $browser, HellcaseBattle $battle) {
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Battle sent" . $battle->getUrl()));
|
||||
try {
|
||||
$battleHeader = $browser->driver->findElement(WebDriverBy::xpath("//*[contains(@class, 'case-battle-game__header')]"));
|
||||
sleep(2); // Wait for the animations to finish
|
||||
$battleHeader->takeElementScreenshot(HellcaseBattleScreenshot::getImgFileAbsolutePath());
|
||||
} catch (Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to screenshot battle"));
|
||||
}
|
||||
$this->battlesSent[$battle->getUrl()] = $battle->value;
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($this->jobInfos->get("hellcase_battles_discord_webhook_url") !== null) { // Custom discord webhook
|
||||
$options["discord_webhook_url"] = $this->jobInfos->get("hellcase_battles_discord_webhook_url");
|
||||
}
|
||||
|
||||
AllNotification::send(new HellcaseBattlesNofication($this->jobId, $battle), $options);
|
||||
}
|
||||
|
||||
private function createNewBattles() {
|
||||
foreach ($this->battlesToAdd as $battleLink => $battleValue) {
|
||||
$battleLink = explode("/", $battleLink);
|
||||
HellcaseBattle::firstOrCreate([
|
||||
"battle_id" => $battleLink[count($battleLink) - 1],
|
||||
"value" => $battleValue,
|
||||
]);
|
||||
try {
|
||||
HellcaseBattle::create([
|
||||
"battle_id" => $battleLink[count($battleLink) - 1],
|
||||
"value" => $battleValue,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Notification\Notification;
|
||||
use App\Notification\Stringifiable;
|
||||
use App\Notification\Stringifiable\StringifiableSimpleText;
|
||||
|
||||
class HellcaseBattlesNofication extends Notification {
|
||||
|
||||
private HellcaseBattle $battle;
|
||||
|
||||
public function __construct(int $jobId, HellcaseBattle $battle) {
|
||||
parent::__construct($jobId);
|
||||
|
||||
$this->battle = $battle;
|
||||
|
||||
$this->setBody($this->generateBody());
|
||||
}
|
||||
|
||||
private function generateBody() {
|
||||
return new HellcaseBattlesNotificationBody($this->battle);
|
||||
}
|
||||
|
||||
public function getTitle(): Stringifiable {
|
||||
return new StringifiableSimpleText("Nouvelle bataille de caisses Hellcase");
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getImageProjectPath(): string|null {
|
||||
return HellcaseBattleScreenshot::getImgFileProjectPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLinkURL(): string|null {
|
||||
return $this->battle->getUrl();
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\HellcaseBattles;
|
||||
|
||||
use App\Models\HellcaseBattle;
|
||||
use App\Notification\NotificationBody;
|
||||
|
||||
class HellcaseBattlesNotificationBody extends NotificationBody {
|
||||
|
||||
private HellcaseBattle $battle;
|
||||
|
||||
public function __construct(HellcaseBattle $battle) {
|
||||
parent::__construct();
|
||||
|
||||
$this->battle = $battle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toMarkdownString(): string {
|
||||
return "
|
||||
- Valeur : **{$this->battle->value} €**
|
||||
- Lien : **{$this->battle->getUrl()}**
|
||||
";
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toString(): string {
|
||||
return "
|
||||
- Valeur : {$this->battle->value} €
|
||||
- Lien : {$this->battle->getUrl()}
|
||||
";
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost\DescriptionPipeline;
|
||||
|
||||
/**
|
||||
* Interface for Instagram description pipeline steps.
|
||||
*/
|
||||
interface IInstagramDescriptionPipelineStep
|
||||
{
|
||||
/**
|
||||
* Process the description.
|
||||
* @param string $description
|
||||
* @return string
|
||||
*/
|
||||
public function process(string $description): string;
|
||||
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
// Pipeline for processing Instagram post descriptions
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost\DescriptionPipeline;
|
||||
|
||||
class InstagramDescriptionPipeline
|
||||
{
|
||||
/**
|
||||
* @var array of IInstagramDescriptionPipelineStep
|
||||
*/
|
||||
private array $steps;
|
||||
|
||||
/**
|
||||
* InstagramDescriptionPipeline constructor.
|
||||
*
|
||||
* @param array $steps
|
||||
*/
|
||||
public function __construct(array $steps)
|
||||
{
|
||||
$this->steps = $steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the description through the pipeline.
|
||||
*
|
||||
* @param string $description
|
||||
* @return string
|
||||
*/
|
||||
public function process(string $description): string
|
||||
{
|
||||
foreach ($this->steps as $step) {
|
||||
if (!$step instanceof IInstagramDescriptionPipelineStep) {
|
||||
throw new \InvalidArgumentException('All steps must implement IInstagramDescriptionPipelineStep interface.');
|
||||
}
|
||||
|
||||
$description = $step->process($description);
|
||||
}
|
||||
|
||||
return $description;
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost\DescriptionPipeline;
|
||||
|
||||
/**
|
||||
* Step to remove account references from the description.
|
||||
*/
|
||||
|
||||
class RemoveAccountsReferenceStep implements IInstagramDescriptionPipelineStep
|
||||
{
|
||||
/**
|
||||
* Process the description to remove account references.
|
||||
*
|
||||
* @param string $description
|
||||
* @return string
|
||||
*/
|
||||
public function process(string $description): string
|
||||
{
|
||||
// Regular expression to match Instagram account references
|
||||
$pattern = '/@([a-zA-Z0-9._]+)/';
|
||||
|
||||
// Split by lines
|
||||
$lines = explode("\n", $description);
|
||||
|
||||
// Remove lines that contain account references
|
||||
$lines = array_filter($lines, function ($line) use ($pattern) {
|
||||
// Check if the line does not match the account reference pattern
|
||||
return !preg_match($pattern, $line);
|
||||
});
|
||||
|
||||
// Join the remaining lines back into a single string
|
||||
$cleanedDescription = implode("\n", $lines);
|
||||
|
||||
// Trim whitespace from the beginning and end of the description
|
||||
return trim($cleanedDescription);
|
||||
}
|
||||
}
|
54
app/Browser/Jobs/InstagramRepost/IInstagramVideo.php
Normal file
54
app/Browser/Jobs/InstagramRepost/IInstagramVideo.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Interface for Instagram video handling.
|
||||
* Holds the data of a video from Instagram.
|
||||
*/
|
||||
interface IInstagramVideo
|
||||
{
|
||||
/**
|
||||
* Get the URL of the video.
|
||||
*
|
||||
* @return string The URL of the video.
|
||||
*/
|
||||
public function getUrl(): string;
|
||||
|
||||
/**
|
||||
* Get the title of the video.
|
||||
*
|
||||
* @return string The title of the video.
|
||||
*/
|
||||
public function getTitle(): string;
|
||||
|
||||
/**
|
||||
* Get the caption of the video.
|
||||
*
|
||||
* @return string The caption of the video.
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
|
||||
/**
|
||||
* Get the date when the video was posted.
|
||||
*
|
||||
* @return ?DateTimeImmutable The date of the post.
|
||||
*/
|
||||
public function getPostDate(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Get the filename of the video.
|
||||
*
|
||||
* @return string The filename of the video.
|
||||
*/
|
||||
public function getFilename(): string;
|
||||
|
||||
/**
|
||||
* Set the filename of the video.
|
||||
*
|
||||
* @param string $filename The filename to set.
|
||||
*/
|
||||
public function setFilename(string $filename): void;
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Models\JobRun;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface IInstagramVideoDownloader
|
||||
{
|
||||
/**
|
||||
* Download the video from the given Instagram post URL.
|
||||
*
|
||||
* @param int $jobId The ID of the job for which the video is being downloaded.
|
||||
* @param JobRun $jobRun The job run instance for logging and tracking.
|
||||
* @param string $postUrl The URL of the Instagram post.
|
||||
* @param Collection $jobInfos The job information collection.
|
||||
* @param string $downloadFolder The folder where the video should be downloaded.
|
||||
* @param string $accountEmail The email or username of the Instagram account.
|
||||
* @param string $accountPassword The password for the Instagram account.
|
||||
* @return IInstagramVideo|null The path to the downloaded video file, or null if the download failed.
|
||||
*/
|
||||
public function downloadVideo(int $jobId, JobRun $jobRun, string $postUrl, string $downloadFolder, string $accountEmail, string $accountPassword): ?IInstagramVideo;
|
||||
}
|
515
app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php
Normal file
515
app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php
Normal file
@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Browser\BrowserJob;
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
|
||||
use App\Models\InstagramRepost;
|
||||
use App\Models\InstagramAccount;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobArtifact;
|
||||
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;
|
||||
|
||||
class InstagramRepostJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// === CONFIGURATION ===
|
||||
|
||||
public $timeout = 1800; // 30 minutes
|
||||
|
||||
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/";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
{
|
||||
Log::info("Running InstagramRepostJob");
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
$this->jobRun = new JobRun([
|
||||
"job_id" => $this->jobId,
|
||||
"success" => false,
|
||||
]);
|
||||
$this->jobRun->save();
|
||||
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(5);
|
||||
$this->removePopups($browser);
|
||||
sleep(2);
|
||||
$this->signin($browser);
|
||||
sleep(2);
|
||||
$this->repostLatestPosts($browser);
|
||||
sleep(5);
|
||||
|
||||
$this->jobRun->success = true;
|
||||
$this->jobRun->save();
|
||||
|
||||
Log::info("InstagramRepostJob 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);
|
||||
sleep(2);
|
||||
$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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function repostLatestPosts(Browser $browser) {
|
||||
|
||||
try {
|
||||
// Download the latest reels from the accounts specified in the job infos
|
||||
$toDownloadReels = []; // Array to store to download reels to post later
|
||||
|
||||
$accounts = explode(",", $this->jobInfos->get("instagram_repost_accounts"));
|
||||
foreach ($accounts as &$account) {
|
||||
$account = trim($account);
|
||||
|
||||
$toDownloadReels = array_merge($toDownloadReels, $this->getLatestReelsFromAccount($browser, $account));
|
||||
}
|
||||
|
||||
// Add unreposted reels to the job run if not enough reels were downloaded
|
||||
if (count($toDownloadReels) < config("jobs.instagramRepost.max_reposts_per_job")) {
|
||||
$toDownloadReelsIds = array_map(function ($reel) {
|
||||
return $reel->reel_id;
|
||||
}, $toDownloadReels);
|
||||
|
||||
$unrepostedReels = InstagramRepost::where("reposted", false)
|
||||
->where("repost_tries", "<", config('jobs.instagramRepost.max_repost_tries')) // Limit to 3 tries
|
||||
->whereNotIn("reel_id", $toDownloadReelsIds) // Avoid already downloaded reels
|
||||
->whereIn("account_id", InstagramAccount::whereIn("username", $accounts)->pluck("id"))
|
||||
->take(config("jobs.instagramRepost.max_reposts_per_job") - count($toDownloadReels))
|
||||
->get();
|
||||
|
||||
foreach ($unrepostedReels as $reel) {
|
||||
$toDownloadReels[] = $reel;
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffling and keeping only the x first posts
|
||||
shuffle($toDownloadReels);
|
||||
$toDownloadReels = array_slice($toDownloadReels, 0, config("jobs.instagramRepost.max_reposts_per_job"));
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($toDownloadReels) . " reels sélectionnés pour être repost",
|
||||
"content" => ""
|
||||
]));
|
||||
|
||||
// Download the reels
|
||||
/**
|
||||
* @var mixed[]
|
||||
*/
|
||||
$downloadedReels = [];
|
||||
foreach ($toDownloadReels as $repost) {
|
||||
$downloadInfos = $this->downloadReel(
|
||||
$browser,
|
||||
$repost
|
||||
);
|
||||
|
||||
$downloadedReels[] = [
|
||||
$repost,
|
||||
$downloadInfos
|
||||
];
|
||||
|
||||
$this->describeReel($repost, $downloadInfos);
|
||||
}
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => count($downloadedReels) . " reels sélectionnés qui ont été téléchargés",
|
||||
"content" => ""
|
||||
]));
|
||||
|
||||
// Now repost all downloaded reels
|
||||
$repostedReelsCounter = 0;
|
||||
foreach ($downloadedReels as $infos) {
|
||||
$reel = $infos[0];
|
||||
$videoInfo = $infos[1];
|
||||
|
||||
if ($videoInfo === null) {
|
||||
continue; // Skip this reel if it failed to download
|
||||
}
|
||||
|
||||
$repostSuccess = false;
|
||||
do {
|
||||
$repostSuccess = $this->repostReel($browser, $reel, $videoInfo);
|
||||
} while (!$repostSuccess && $reel->repost_tries < config("jobs.instagramRepost.max_repost_tries"));
|
||||
$repostedReelsCounter += $repostSuccess;
|
||||
}
|
||||
|
||||
$this->jobRun->addArtifact(new JobArtifact([
|
||||
"name" => $repostedReelsCounter . " reels sélectionnés qui ont été repostés",
|
||||
"content" => ""
|
||||
]));
|
||||
} catch (\Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Unknown error when trying to repost reels : " . $e->getMessage()));
|
||||
} finally {
|
||||
// Removes all videos in the download folder
|
||||
$files = glob($this->downloadFolder . '*'); // Get all files in the download folder
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
unlink($file); // Delete the file
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getLatestReelsFromAccount(Browser $browser, string $account): array
|
||||
{
|
||||
$accountReels = []; // Indexed array to store new reels from the account
|
||||
|
||||
$browser->visit("https://instagram.com/{$account}/reels");
|
||||
sleep(3);
|
||||
$browser->waitForText("followers", 10, true);
|
||||
|
||||
// If we are here, the account exists
|
||||
$accountModel = InstagramAccount::where("username", $account)->first();
|
||||
if ($accountModel == null) { // Does not exist in the database yet
|
||||
$accountModel = new InstagramAccount([
|
||||
"username" => $account,
|
||||
]);
|
||||
$accountModel->save();
|
||||
Log::info("New Instagram account added: {$account}");
|
||||
} else {
|
||||
Log::debug("Instagram account already exists: {$account}");
|
||||
}
|
||||
|
||||
$repostedPosts = $accountModel->reposts()->where("reposted", true)->pluck("reel_id");
|
||||
|
||||
// Posts must be sorted by latest post date first
|
||||
// TODO : Scroll when not enough post are shown
|
||||
$posts = $browser->driver->findElements(WebDriverBy::xpath('//a[contains(@href, "'.$account.'/reel/")][not(.//*[local-name() = "svg"][@aria-label="Pinned post icon"])]'));
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$postUrl = $post->getAttribute('href');
|
||||
$postId = explode("/", $postUrl)[3] ?? null;
|
||||
|
||||
if ($postId === null) {
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Can't get Instagram post ID from url"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Break the loop if the post has already been reposted
|
||||
if ($repostedPosts->contains($postId)) {
|
||||
Log::debug("Post already reposted: {$postUrl}");
|
||||
break;
|
||||
}
|
||||
|
||||
$reelModel = InstagramRepost::firstOrCreate(
|
||||
["reel_id" => $postId, "account_id" => $accountModel->id],
|
||||
["reposted" => false, "repost_tries" => 0]
|
||||
);
|
||||
|
||||
if (count($accountReels) < config("jobs.instagramRepost.max_reposts_per_account")) {
|
||||
$accountReels[] = $reelModel; // Add it to the to be downloaded reels array
|
||||
}
|
||||
}
|
||||
|
||||
return $accountReels;
|
||||
}
|
||||
|
||||
private function downloadReel(Browser $browser, InstagramRepost $reel): ?IInstagramVideo
|
||||
{
|
||||
$videoInfo = $this->videoDownloader->downloadVideo(
|
||||
$this->jobId,
|
||||
$this->jobRun,
|
||||
$reel->getUrl(),
|
||||
$this->downloadFolder,
|
||||
$this->jobInfos->get("instagram_repost_account_email"),
|
||||
$this->jobInfos->get("instagram_repost_account_password")
|
||||
);
|
||||
|
||||
if ($videoInfo === null) {
|
||||
Log::error("Failed to download video for post: {$reel->reel_id}");
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
// Set the filename to the post ID
|
||||
$newFilename = $this->downloadFolder . $reel->reel_id . ".mp4";
|
||||
rename($videoInfo->getFilename(), $newFilename);
|
||||
$videoInfo->setFilename($newFilename);
|
||||
}
|
||||
|
||||
Log::info("Downloaded video: {$videoInfo->getTitle()} : {$videoInfo->getDescription()}");
|
||||
|
||||
return $videoInfo;
|
||||
}
|
||||
|
||||
protected function describeReel(InstagramRepost $reel, IInstagramVideo $videoInfo): void
|
||||
{
|
||||
// Set the video description to the reel description
|
||||
$reel->video_description = $this->ReelDescriptor->getDescription($videoInfo->getFilename());
|
||||
$reel->save();
|
||||
|
||||
Log::info("Reel description set: {$reel->reel_id} - {$reel->video_description}");
|
||||
}
|
||||
|
||||
protected function repostReel(Browser $browser, InstagramRepost $reel, IInstagramVideo $videoInfo): bool
|
||||
{
|
||||
try {
|
||||
Log::info("Reposting reel: {$reel->reel_id} - {$videoInfo->getTitle()}");
|
||||
|
||||
// Increment the repost tries
|
||||
$reel->repost_tries++;
|
||||
$reel->save();
|
||||
|
||||
// TODO Reset if a problem occurs and try again with a limit of 3 attempts
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(2);
|
||||
|
||||
// Navigate to the reel upload page
|
||||
$createButton = $browser->driver->findElement(WebDriverBy::xpath('//a[./div//span[contains(text(), "Create")]]'));
|
||||
$createButton->click();
|
||||
sleep(2);
|
||||
$newPostButton = $browser->driver->findElement(WebDriverBy::xpath('//a[./div//span[contains(text(), "Post")]][@href="#"]'));
|
||||
$newPostButton->click();
|
||||
sleep(3);
|
||||
|
||||
// Upload the video file
|
||||
$selectFileButton = $browser->driver->findElement(WebDriverBy::xpath('//button[contains(text(), "Select from computer")]'));
|
||||
$selectFileButton->click();
|
||||
sleep(2);
|
||||
$browser->attach('input[type="file"]._ac69', $this->downloadFolder . $reel->reel_id . ".mp4");
|
||||
|
||||
sleep(10); // TODO : Wait for the file to be uploaded
|
||||
|
||||
$this->removePopups($browser);
|
||||
sleep(2);
|
||||
|
||||
// Put original resolution
|
||||
$this->putOriginalResolution($browser);
|
||||
|
||||
$this->clickNext($browser);
|
||||
$this->clickNext($browser); // Skip cover photo and trim
|
||||
|
||||
// Add a caption
|
||||
$captionText = $this->descriptionPipeline->process($this->getReelCaption($reel, $videoInfo));
|
||||
$this->pasteText($browser, $captionText, 'div[contenteditable]');
|
||||
|
||||
sleep(2); // Wait for the caption to be added
|
||||
|
||||
if (config("app.env") !== "local") { // Don't share the post in local environment
|
||||
$this->clickNext($browser); // Share the post
|
||||
}
|
||||
|
||||
sleep(7); // Wait for the post to be completed
|
||||
$this->removePopups($browser);
|
||||
|
||||
// Check if the post was successful
|
||||
try {
|
||||
$browser->waitForText("Your reel has been shared.", 60, true);
|
||||
Log::info("Reel reposted successfully: {$reel->reel_id}");
|
||||
|
||||
// Mark the reel as reposted in the database
|
||||
$reel->reposted = true;
|
||||
$reel->save();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
try {
|
||||
$browser->waitForText("Your post was shared", 60, true);
|
||||
$closeButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/*[local-name() = "svg"][@aria-label="Close"]]'));
|
||||
$closeButton->click();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to repost reel: {$reel->reel_id} - " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to repost reel: {$reel->reel_id} - " . $e->getMessage()));
|
||||
return false;
|
||||
}
|
||||
return true; // Reel reposted successfully, no error message found
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to repost reel: {$reel->reel_id} - " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to repost reel: {$reel->reel_id} - " . $e->getMessage()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getReelCaption(InstagramRepost $reel, IInstagramVideo $videoInfo): string
|
||||
{
|
||||
if (isset($reel->instagram_caption)) {
|
||||
return $reel->instagram_caption;
|
||||
}
|
||||
|
||||
// Get the reel description from the database or the video info
|
||||
$reelDescription = $reel->video_description;
|
||||
$originalDescription = $videoInfo->getDescription();
|
||||
$llmAnswer = $this->openAPIPrompt->generate(
|
||||
config('llm.models.chat.name'),
|
||||
"Original Caption: {$originalDescription}
|
||||
Video Description/Directive: {$reelDescription}",
|
||||
[],
|
||||
outputFormat: '{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}',
|
||||
systemMessage: "You are an AI assistant specialized in creating engaging and concise Instagram Reel captions. Your primary task is to transform the provided original caption (often from Twitter) and description/directions into a fresh, unique, but still relevant caption for Instagram Reels format.
|
||||
|
||||
Key instructions:
|
||||
1. **Analyze Input:** You will receive two things: an *original reel caption* (usually starting with \"credit:\" or mentioning a Twitter handle like `t/TwitterUser`), and either a *video description* or explicit directions about the joke/idea behind the video.
|
||||
2. **Transform, Don't Reproduce:** Your output must be significantly different from the original provided caption. It should capture the essence of the content described but phrase it anew – often with humor if appropriate.
|
||||
3. **Keep it Short & Punchy:** Instagram Reels thrive on quick engagement. Prioritize brevity (ideally under two lines, or three lines max) and impact. Make sure your caption is concise enough for fast-scroll viewing.
|
||||
4. **Maintain the Core Idea:** The new caption must directly relate to the video's content/direction/joke without simply restating it like a description would. Focus on what makes the reel *interesting* or *funny* in its own right.
|
||||
5. **Preserve Original Credit (Optional):** If an explicit \"credit\" line is provided, you may incorporate this into your new caption naturally, perhaps using `(via...)` or similar phrasing if it fits well and doesn't sound awkward. **Do not** include any original Instagram account mentions (@handles). They are often intended for promotion which isn't our goal.
|
||||
6. **Use Emoji Judiciously:** Incorporate relevant emojis to enhance the tone (funny, relatable, etc.) or add visual interest. Use them purposefully and in moderation – they should complement the caption, not overwhelm it.
|
||||
7. **Add Hashtags (Optional but Recommended):** Generate a few relevant Instagram hashtags automatically at the end of your output to increase visibility. Keep these organic to the content and avoid forcing irrelevant tags.
|
||||
|
||||
Your response structure is as follows:
|
||||
- The generated caption (your core answer).
|
||||
- Then, if you generate any hashtags, list them on the next line(s) prefixed with `#`.
|
||||
|
||||
Example Input Structure:
|
||||
Original Caption: credit: t/otherhandle This banana is looking fly today!
|
||||
Video Description/Directive: A man walks into a store holding a banana and wearing sunglasses. He looks around confidently before leaving.
|
||||
|
||||
Your answer should only contain the generated caption, and optionally hashtags if relevant.
|
||||
|
||||
Remember to be creative and ensure the generated caption feels like something you would see naturally on an Instagram Reel. Aim for personality and relevance.
|
||||
",
|
||||
keepAlive: true,
|
||||
shouldThink: config('llm.models.chat.shouldThink')
|
||||
);
|
||||
$llmAnswer = json_decode($llmAnswer, true)['answer'] ?? null;
|
||||
if ($llmAnswer !== null) {
|
||||
$reel->instagram_caption = $llmAnswer;
|
||||
$reel->save();
|
||||
Log::info("Reel caption generated: {$reel->reel_id} - {$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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
app/Browser/Jobs/InstagramRepost/ReelDescriptor.php
Normal file
11
app/Browser/Jobs/InstagramRepost/ReelDescriptor.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
|
||||
class ReelDescriptor extends \App\Services\FileTools\VideoDescriptor\OCRLLMVideoDescriptor
|
||||
{
|
||||
public const DESCRIPTION_PROMPT = "Analyze this Instagram Reel sequence. You are given information for each individual screenshot/analysis from the video:";
|
||||
}
|
56
app/Browser/Jobs/InstagramRepost/YTDLPDownloader.php
Normal file
56
app/Browser/Jobs/InstagramRepost/YTDLPDownloader.php
Normal file
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use YoutubeDl\Options;
|
||||
use YoutubeDl\YoutubeDl;
|
||||
|
||||
class YTDLPDownloader implements IInstagramVideoDownloader
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function downloadVideo(int $jobId, JobRun $jobRun, string $postUrl, string $downloadFolder, string $accountEmail, string $accountPassword): ?YTDLPVideo
|
||||
{
|
||||
$dl = new YoutubeDl();
|
||||
$options = Options::create()
|
||||
->downloadPath($downloadFolder)
|
||||
->apLogin($accountEmail, $accountPassword)
|
||||
->checkAllFormats(true)
|
||||
//->format('bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]') // TODO allow downloading audio and video separately and then merging them
|
||||
->format('best[ext=mp4]')
|
||||
->url($postUrl);
|
||||
|
||||
try {
|
||||
$videosCollection = $dl->download($options);
|
||||
foreach ($videosCollection->getVideos() as $video) {
|
||||
if ($video->getError() !== null) {
|
||||
$jobRun->addArtifact(new JobArtifact([
|
||||
"name" => "Erreur lors du téléchargement de la vidéo \"{$video->getTitle()}\"",
|
||||
"content" => $video->getError(),
|
||||
]));
|
||||
Log::error("Error downloading video: " . $video->getError());
|
||||
return null; // Return null if there was an error downloading the video
|
||||
} else {
|
||||
$IVideo = new YTDLPVideo(
|
||||
$video->getWebpageUrl(),
|
||||
$video->getTitle(),
|
||||
$video->getDescription() ?? "",
|
||||
$video->getUploadDate(),
|
||||
$video->getFilename(),
|
||||
);
|
||||
|
||||
return $IVideo; // Return the video object if download was successful
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
AllNotification::send(new JobDebugNotification($jobId, "Error while downloading video: " . $e->getMessage()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
71
app/Browser/Jobs/InstagramRepost/YTDLPVideo.php
Normal file
71
app/Browser/Jobs/InstagramRepost/YTDLPVideo.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
class YTDLPVideo implements IInstagramVideo
|
||||
{
|
||||
private string $url;
|
||||
private string $title;
|
||||
private string $description;
|
||||
private ?DateTimeImmutable $postDate;
|
||||
private string $fileName;
|
||||
|
||||
public function __construct(string $url, string $title, string $description, ?DateTimeImmutable $postDate, string $filename)
|
||||
{
|
||||
$this->url = $url;
|
||||
$this->title = $title;
|
||||
$this->description = $description;
|
||||
$this->postDate = $postDate;
|
||||
$this->fileName = $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getUrl(): string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPostDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->postDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getFilename(): string
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setFilename(string $filename): void
|
||||
{
|
||||
$this->fileName = $filename;
|
||||
}
|
||||
}
|
19
app/Models/InstagramAccount.php
Normal file
19
app/Models/InstagramAccount.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstagramAccount extends Model
|
||||
{
|
||||
protected $table = 'instagram_repost_accounts';
|
||||
|
||||
protected $fillable = [
|
||||
'username',
|
||||
];
|
||||
|
||||
public function reposts()
|
||||
{
|
||||
return $this->hasMany(InstagramRepost::class, 'account_id');
|
||||
}
|
||||
}
|
26
app/Models/InstagramRepost.php
Normal file
26
app/Models/InstagramRepost.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstagramRepost extends Model
|
||||
{
|
||||
protected $table = 'instagram_reposts';
|
||||
|
||||
protected $fillable = [
|
||||
'reel_id',
|
||||
'reposted',
|
||||
'account_id',
|
||||
];
|
||||
|
||||
public function account()
|
||||
{
|
||||
return $this->belongsTo(InstagramAccount::class, 'account_id');
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
return "https://www.instagram.com/reel/{$this->reel_id}/";
|
||||
}
|
||||
}
|
@ -3,5 +3,5 @@
|
||||
namespace App\Notification;
|
||||
|
||||
abstract class NotificationProvider {
|
||||
abstract public static function send(Notification $notification): void;
|
||||
abstract public static function send(Notification $notification, array $options): void;
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ class JobDebugNotification extends Notification {
|
||||
private string|null $title;
|
||||
private string|null $screenShotProjectPath;
|
||||
|
||||
public function __construct(int $jobId, string $body, string $title = null, string $error = null, ?string $screenshotProjectPath = "", bool $isError = false) {
|
||||
public function __construct(int $jobId, string $body, string $title = null, string $error = null, ?string $screenshotProjectPath = "", bool $isError = true) {
|
||||
parent::__construct($jobId, isError:$isError);
|
||||
$this->title = $title;
|
||||
if ($screenshotProjectPath === "") {
|
||||
|
@ -12,9 +12,9 @@ class AllNotification extends NotificationProvider {
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public static function send(\App\Notification\Notification $notification): void {
|
||||
public static function send(\App\Notification\Notification $notification, array $options = []): void {
|
||||
foreach (self::NOTIFICATIONS_PROVIDERS as $provider) {
|
||||
$provider::send($notification);
|
||||
$provider::send($notification, $options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ class DiscordWebHookNotification extends NotificationProvider {
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public static function send(\App\Notification\Notification $notification): void {
|
||||
public static function send(\App\Notification\Notification $notification, array $options): void {
|
||||
/*
|
||||
Test Json for a complete embed :
|
||||
{
|
||||
@ -44,7 +44,7 @@ class DiscordWebHookNotification extends NotificationProvider {
|
||||
"avatar_url": "https://www.fairytailrp.com/t40344-here-come-dat-boi"
|
||||
}
|
||||
*/
|
||||
$webHookUrl = static::getDiscordWebHookUrl($notification->isError);
|
||||
$webHookUrl = static::getDiscordWebHookUrl($notification->isError, $options);
|
||||
$body = [
|
||||
"content"=> "",
|
||||
"tts"=> false,
|
||||
@ -73,20 +73,28 @@ class DiscordWebHookNotification extends NotificationProvider {
|
||||
'payload_json' => $payloadJson,
|
||||
];
|
||||
|
||||
if ($notification->getImageURL() !== null) {
|
||||
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
|
||||
if ($notification->getImageURL() !== null && is_file($notification->getImageProjectPath())) {
|
||||
}
|
||||
$formData['file'] = curl_file_create($notification->getImageProjectPath(), 'image/png', 'image.png');
|
||||
|
||||
$ch = curl_init($webHookUrl);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-type: multipart/form-data'));
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $formData);
|
||||
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
$response = curl_exec($ch);
|
||||
if (!curl_exec($ch)) {
|
||||
$error = curl_error($ch);
|
||||
\Log::error("Discord WebHook Notification failed: {$error}");
|
||||
throw new \Exception("Discord WebHook Notification failed: {$error}");
|
||||
}
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
private static function getDiscordWebHookUrl(bool $isError): string {
|
||||
private static function getDiscordWebHookUrl(bool $isError, array $options): string {
|
||||
if (isset($options["discord_webhook_url"]) && $options["discord_webhook_url"] !== null) {
|
||||
return $options["discord_webhook_url"];
|
||||
}
|
||||
|
||||
$generalWebHookUrlKey = 'discord_webhook_url';
|
||||
$generalWebHookUrl = Cache::rememberForever($generalWebHookUrlKey, function () use ($generalWebHookUrlKey) {
|
||||
return JobInfo::where('key', $generalWebHookUrlKey)->first()->value;
|
||||
|
27
app/Providers/AIPromptServiceProvider.php
Normal file
27
app/Providers/AIPromptServiceProvider.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AIPromptServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(OpenAPIPrompt::class, function ($app) {
|
||||
return new OpenAPIPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
27
app/Providers/ImageOCRServiceProvider.php
Normal file
27
app/Providers/ImageOCRServiceProvider.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ImageOCRServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(IImageOCR::class, function ($app) {
|
||||
return new \App\Services\FileTools\OCR\TesseractImageOCR();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
36
app/Providers/VideoDescriptorServiceProvider.php
Normal file
36
app/Providers/VideoDescriptorServiceProvider.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
use App\Services\FileTools\VideoDescriptor\IVideoDescriptor;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class VideoDescriptorServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Register the VideoDescriptor service
|
||||
$this->app->singleton(IVideoDescriptor::class, function ($app) {
|
||||
return new \App\Services\FileTools\VideoDescriptor\LLMFullVideoDescriptor(
|
||||
$app->make(IImageOCR::class),
|
||||
$app->make(OpenAPIPrompt::class)
|
||||
);
|
||||
});
|
||||
|
||||
// Register the VideoDescriptor service
|
||||
$this->app->singleton(\App\Browser\Jobs\InstagramRepost\ReelDescriptor::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
10
app/Services/AIPrompt/IAIPrompt.php
Normal file
10
app/Services/AIPrompt/IAIPrompt.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AIPrompt;
|
||||
|
||||
interface IAIPrompt
|
||||
{
|
||||
public function generate(string $model, string $prompt, array $images = [], string $outputFormat = "json", string $systemMessage = null, bool $keepAlive = true, bool $shouldThink = false): string;
|
||||
|
||||
//public function chat(string $model, string $prompt, array $images = []): string;
|
||||
}
|
138
app/Services/AIPrompt/OpenAPIPrompt.php
Normal file
138
app/Services/AIPrompt/OpenAPIPrompt.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AIPrompt;
|
||||
|
||||
use Uri;
|
||||
|
||||
/**
|
||||
* Use OpenAI API to get answers from a model.
|
||||
*/
|
||||
class OpenAPIPrompt implements IAIPrompt
|
||||
{
|
||||
private ?string $host;
|
||||
private ?string $token = null;
|
||||
|
||||
public function __construct(?string $host = null) {
|
||||
//dd($host ?? config('llm.api.host'));
|
||||
$this->host = $host ?? config('llm.api.host');
|
||||
if (config('llm.api.token')) {
|
||||
$this->token = config('llm.api.token');
|
||||
}
|
||||
}
|
||||
|
||||
private function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization: ' . ($this->token ? 'Bearer ' . $this->token : ''),
|
||||
'Content-Type: application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the OpenAI API with the given endpoint and body.
|
||||
* @param string $endpoint
|
||||
* @param string $body
|
||||
* @throws \Exception
|
||||
* @return string
|
||||
*/
|
||||
private function callAPI(string $endpoint, string $body): string
|
||||
{
|
||||
$url = $this->host . $endpoint;
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $this->getHeaders());
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \Exception("Error calling OpenAI API: HTTP $httpCode - $response");
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the OpenAI API generate endpoint. to generate a response to a prompt.
|
||||
* @param string $model
|
||||
* @param string $prompt
|
||||
* @param array $images
|
||||
* @return void
|
||||
*/
|
||||
public function generate(string $model, string $prompt, array $images = [], string $outputFormat = null, string $systemMessage = null, bool $keepAlive = true, bool $shouldThink = false): string
|
||||
{
|
||||
/*
|
||||
Generate a completion
|
||||
|
||||
POST /api/generate
|
||||
|
||||
Generate a response for a given prompt with a provided model. This is a streaming endpoint, so there will be a series of responses. The final response object will include statistics and additional data from the request.
|
||||
Parameters
|
||||
|
||||
model: (required) the model name
|
||||
prompt: the prompt to generate a response for
|
||||
suffix: the text after the model response
|
||||
images: (optional) a list of base64-encoded images (for multimodal models such as llava)
|
||||
think: (for thinking models) should the model think before responding?
|
||||
|
||||
Advanced parameters (optional):
|
||||
|
||||
format: the format to return a response in. Format can be json or a JSON schema
|
||||
options: additional model parameters listed in the documentation for the Modelfile such as temperature
|
||||
system: system message to (overrides what is defined in the Modelfile)
|
||||
template: the prompt template to use (overrides what is defined in the Modelfile)
|
||||
stream: if false the response will be returned as a single response object, rather than a stream of objects
|
||||
raw: if true no formatting will be applied to the prompt. You may choose to use the raw parameter if you are specifying a full templated prompt in your request to the API
|
||||
keep_alive: controls how long the model will stay loaded into memory following the request (default: 5m)
|
||||
context (deprecated): the context parameter returned from a previous request to /generate, this can be used to keep a short conversational memory
|
||||
|
||||
Structured outputs
|
||||
|
||||
Structured outputs are supported by providing a JSON schema in the format parameter. The model will generate a response that matches the schema. See the structured outputs example below.
|
||||
JSON mode
|
||||
|
||||
Enable JSON mode by setting the format parameter to json. This will structure the response as a valid JSON object. See the JSON mode example below.
|
||||
|
||||
Important
|
||||
|
||||
**It's important to instruct the model to use JSON in the prompt. Otherwise, the model may generate large amounts whitespace.**
|
||||
*/
|
||||
|
||||
// Transform the images to base64
|
||||
foreach ($images as &$image) {
|
||||
if (file_exists($image)) {
|
||||
$image = base64_encode(file_get_contents($image));
|
||||
}
|
||||
}
|
||||
|
||||
$body = [
|
||||
'model' => $model,
|
||||
'prompt' => $prompt,
|
||||
'images' => $images,
|
||||
'think' => $shouldThink,
|
||||
'stream' => false,
|
||||
];
|
||||
|
||||
if ($systemMessage !== null) {
|
||||
$body['system'] = $systemMessage;
|
||||
}
|
||||
if ($outputFormat !== null) {
|
||||
$body['format'] = json_decode($outputFormat);
|
||||
}
|
||||
if (!$keepAlive) {
|
||||
$body['keep_alive'] = "0m";
|
||||
}
|
||||
|
||||
$body = json_encode($body);
|
||||
|
||||
dump($body);
|
||||
$response = $this->callAPI('/api/generate', $body);
|
||||
$decodedResponse = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \Exception("Error decoding JSON response: " . json_last_error_msg());
|
||||
}
|
||||
return $decodedResponse['response'] ?? '';
|
||||
}
|
||||
}
|
14
app/Services/FileTools/OCR/IImageOCR.php
Normal file
14
app/Services/FileTools/OCR/IImageOCR.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\OCR;
|
||||
|
||||
interface IImageOCR
|
||||
{
|
||||
/**
|
||||
* Perform OCR on the given file.
|
||||
*
|
||||
* @param string $filePath The path to the file to be processed.
|
||||
* @return string The extracted text from the file.
|
||||
*/
|
||||
public function performOCR(string $filePath): string;
|
||||
}
|
21
app/Services/FileTools/OCR/TesseractImageOCR.php
Normal file
21
app/Services/FileTools/OCR/TesseractImageOCR.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\OCR;
|
||||
use thiagoalessio\TesseractOCR\TesseractOCR;
|
||||
|
||||
class TesseractImageOCR implements IImageOCR
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function performOCR(string $filePath): string {
|
||||
try {
|
||||
$tesseract = new TesseractOCR($filePath);
|
||||
return $tesseract->run();
|
||||
} catch (\Exception $e) {
|
||||
// Handle the exception, log it, or rethrow it as needed
|
||||
// For now, we just return an empty string
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\VideoDescriptor;
|
||||
|
||||
use App\Services\FileTools\VideoDescriptor\IVideoDescriptor;
|
||||
use Log;
|
||||
|
||||
abstract class AbstractLLMVideoDescriptor implements IVideoDescriptor
|
||||
{
|
||||
public const MAX_FRAMES = 5;
|
||||
|
||||
abstract public function getDescription(string $filePath): ?string;
|
||||
|
||||
/**
|
||||
* Cut the video into screenshots.
|
||||
* Using ffmpeg to cut the video into screenshots at regular intervals.
|
||||
* The screenshots will be saved in a temporary directory.
|
||||
* @param string $filePath
|
||||
* @return array array with timestamps as key and screenshot file paths as values.
|
||||
*/
|
||||
protected function cutVideoIntoScreenshots(string $filePath): array
|
||||
{
|
||||
$tempDir = sys_get_temp_dir() . '/video_screenshots';
|
||||
if (!is_dir($tempDir)) {
|
||||
mkdir($tempDir, 0777, true);
|
||||
}
|
||||
else {
|
||||
// Clear the directory if it already exists
|
||||
array_map('unlink', glob($tempDir . '/*'));
|
||||
}
|
||||
|
||||
Log::info("Cutting video into screenshots: $filePath");
|
||||
|
||||
$videoDuration = shell_exec("ffprobe -v error -show_entries format=duration -of csv=p=0 " . escapeshellarg($filePath));
|
||||
if ($videoDuration === null) {
|
||||
Log::error("Failed to get video duration for file: $filePath");
|
||||
return [];
|
||||
}
|
||||
$videoDuration = floatval($videoDuration);
|
||||
|
||||
$framesInterval = ceil($videoDuration / self::MAX_FRAMES);
|
||||
$fps = 1/$framesInterval; // Frames per second for the screenshots
|
||||
|
||||
$outputPattern = $tempDir . '/screenshot_%d.png';
|
||||
$command = "ffmpeg -i " . escapeshellarg($filePath) . " -vf fps={$fps} " . escapeshellarg($outputPattern);
|
||||
exec($command);
|
||||
|
||||
// Collect all screenshots
|
||||
$screenshots = glob($tempDir . '/screenshot_*.png');
|
||||
$array = [];
|
||||
foreach ($screenshots as $screenshot) {
|
||||
$array[] = [
|
||||
"screenshot" => $screenshot,
|
||||
"timestamp" => floor(sizeof($array) * $framesInterval),
|
||||
];
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
}
|
14
app/Services/FileTools/VideoDescriptor/IVideoDescriptor.php
Normal file
14
app/Services/FileTools/VideoDescriptor/IVideoDescriptor.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\VideoDescriptor;
|
||||
|
||||
interface IVideoDescriptor
|
||||
{
|
||||
/**
|
||||
* Get the video description.
|
||||
*
|
||||
* @param string $filePath The path to the video file.
|
||||
* @return string The description of the video.
|
||||
*/
|
||||
public function getDescription(string $filePath): ?string;
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\VideoDescriptor;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
|
||||
class LLMFullVideoDescriptor extends AbstractLLMVideoDescriptor implements IVideoDescriptor
|
||||
{
|
||||
public const DESCRIPTION_PROMPT = "Describe the video based on the screenshots. Each screenshot has a timestamp of when in the video the screenshot was taken. Do not specify that it is a video, just describe the video. Do not describe the screenshots one by one, try to make sense out of all the screenshots, what could be the video about ? What capion is attached to the video ? is it a meme ? If yes, what is the joke ? Be the most descriptive without exceeding 5000 words.\n";
|
||||
|
||||
public function __construct(public IImageOCR $ocr, public OpenAPIPrompt $llm) {
|
||||
}
|
||||
|
||||
public function getDescription(string $filePath): ?string
|
||||
{
|
||||
/*
|
||||
1. Cut videos in screenshots
|
||||
2. Ask an LLM to describe the video with all the screenshots
|
||||
*/
|
||||
|
||||
// Step 1: Cut video into screenshots
|
||||
$screenshots = $this->cutVideoIntoScreenshots($filePath);
|
||||
|
||||
if (empty($screenshots)) {
|
||||
throw new \Exception("No screenshots were generated from the video {$filePath}.");
|
||||
}
|
||||
|
||||
// Step 4: Combine the descriptions of all screenshots into a single description
|
||||
$combinedDescription = '';
|
||||
$screenshotCount = 0;
|
||||
foreach ($screenshots as $values) {
|
||||
$screenshot = $values['screenshot'];
|
||||
$timestamp = $values['timestamp'];
|
||||
|
||||
$screenshotCount++;
|
||||
$combinedDescription .= "Screenshot: {$screenshotCount}\n";
|
||||
$combinedDescription .= "Timestamp: {$timestamp}s\n"; // TODO Cut the video in smaller parts when the video is short
|
||||
$ocrDescription = $this->ocr->performOCR($screenshot);
|
||||
$ocrDescription = empty($ocrDescription) ? 'No text found' : $ocrDescription;
|
||||
$combinedDescription .= "OCR: {$ocrDescription}\n"; // Perform OCR on the screenshot
|
||||
$combinedDescription .= "\n";
|
||||
}
|
||||
$combinedDescription = trim($combinedDescription);
|
||||
|
||||
// Step 5: Ask an LLM to describe the video based on the combined descriptions
|
||||
$llmDescription = $this->llm->generate(
|
||||
config('llm.models.vision.name'),
|
||||
static::DESCRIPTION_PROMPT . $combinedDescription,
|
||||
images: array_map(function ($screenshot) {return $screenshot["screenshot"];}, $screenshots), // Pass the screenshots to the LLM
|
||||
outputFormat: '{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}',
|
||||
systemMessage: "The user will ask something. Give your direct answer to that.",
|
||||
keepAlive: true,
|
||||
shouldThink: config('llm.models.vision.shouldThink')
|
||||
);
|
||||
|
||||
$llmDescription = json_decode($llmDescription, true)['answer'] ?? null;
|
||||
if (empty($llmDescription)) {
|
||||
$llmDescription = null;
|
||||
}
|
||||
|
||||
return $llmDescription;
|
||||
}
|
||||
}
|
144
app/Services/FileTools/VideoDescriptor/OCRLLMVideoDescriptor.php
Normal file
144
app/Services/FileTools/VideoDescriptor/OCRLLMVideoDescriptor.php
Normal file
@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\VideoDescriptor;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
|
||||
class OCRLLMVideoDescriptor extends AbstractLLMVideoDescriptor implements IVideoDescriptor
|
||||
{
|
||||
public const DESCRIPTION_PROMPT = "Analyze this Video sequence. You are given information for each individual screenshot/analysis from the video:";
|
||||
|
||||
public function __construct(public IImageOCR $ocr, public OpenAPIPrompt $llm) {
|
||||
}
|
||||
|
||||
public function getDescription(string $filePath): ?string
|
||||
{
|
||||
/*
|
||||
1. Cut videos in screenshots
|
||||
2. Use OCR to extract text from screenshots
|
||||
3. Use LLM to generate a description of the screenshot
|
||||
4. Combine the descriptions of all screenshots into a single description
|
||||
5. Ask an LLM to describe the video
|
||||
*/
|
||||
|
||||
// Step 1: Cut video into screenshots
|
||||
$screenshots = $this->cutVideoIntoScreenshots($filePath);
|
||||
|
||||
if (empty($screenshots)) {
|
||||
throw new \Exception("No screenshots were generated from the video {$filePath}.");
|
||||
}
|
||||
|
||||
// Step 2 & 3: Use OCR to extract text and LLM to get description from screenshots
|
||||
$descriptions = [];
|
||||
foreach ($screenshots as $values) {
|
||||
$screenshot = $values['screenshot'];
|
||||
$timestamp = $values['timestamp'];
|
||||
|
||||
$descriptions[$screenshot] = [];
|
||||
|
||||
$ocrDescription = $this->ocr->performOCR($screenshot);
|
||||
$ocrDescription = empty($ocrDescription) ? 'No text found' : $ocrDescription;
|
||||
$descriptions[$screenshot]['ocr'] = $ocrDescription;
|
||||
dump($ocrDescription); // DEBUG
|
||||
|
||||
$llmDescription = $this->llm->generate(
|
||||
config('llm.models.vision.name'),
|
||||
"Describe this image in detail, breaking it down into distinct parts as follows:
|
||||
|
||||
1. **Scene Description:** Describe the overall setting and environment of the image (e.g., forest clearing, futuristic city street, medieval castle interior).
|
||||
2. **Main Subject/Character(s):** Detail what is happening with the primary character or subject present in the frame.
|
||||
3. **Text Description (if any):** If there are visible text elements (like words, letters, captions), describe them exactly as they appear and note their location relative to other elements. This includes any emojis used in captions, describing their visual appearance and likely meaning.
|
||||
4. **Summary:** Briefly summarize the key content of the image for clarity.
|
||||
5. **Joke:** If the image is part of a meme or humorous content, describe the joke or humorous element present in the image. Do not include this part if you are not sure to understand the joke/meme.
|
||||
|
||||
Format your response strictly using numbered lines corresponding to these four points (1., 2., 3., 4., 5.). Do not use markdown formatting or extra text outside these lines; simply list them sequentially as plain text output.",
|
||||
images: [$screenshot],
|
||||
outputFormat: '{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}',
|
||||
systemMessage: "You are an image understanding AI specialized in describing visual scenes accurately and concisely. Your task is solely to describe the content of the provided image based on what you can visually perceive.
|
||||
|
||||
Please analyze the image carefully and provide a description focusing purely on the visible information without generating any text about concepts, interpretations, or future actions beyond the immediate scene. Describe everything that is clearly depicted.",
|
||||
keepAlive: $screenshot != end($screenshots), // Keep alive for all but the last screenshot
|
||||
shouldThink: config('llm.models.vision.shouldThink')
|
||||
);
|
||||
dump($llmDescription); // DEBUG
|
||||
$descriptions[$screenshot]['text'] = json_decode($llmDescription, true)['answer'] ?? 'No description generated';
|
||||
}
|
||||
|
||||
// HERE COULD BE SOME INTERMEDIATE PROCESSING OF DESCRIPTIONS
|
||||
|
||||
// Step 4: Combine the descriptions of all screenshots into a single description
|
||||
$combinedDescription = '';
|
||||
$screenshotCount = 0;
|
||||
foreach ($screenshots as $values) {
|
||||
$screenshot = $values['screenshot'];
|
||||
$timestamp = $values['timestamp'];
|
||||
|
||||
$screenshotCount++;
|
||||
$description = $descriptions[$screenshot] ?? [];
|
||||
|
||||
$combinedDescription .= "Screenshot: {$screenshotCount}\n";
|
||||
$combinedDescription .= "Timestamp: {$timestamp}s\n"; // TODO Cut the video in smaller parts when the video is short
|
||||
$combinedDescription .= "OCR: {$description['ocr']}\n";
|
||||
$combinedDescription .= "LLM Description: {$description['text']}\n";
|
||||
$combinedDescription .= "\n";
|
||||
}
|
||||
$combinedDescription = trim($combinedDescription);
|
||||
|
||||
// Step 5: Ask an LLM to describe the video based on the combined descriptions
|
||||
$llmDescription = $this->llm->generate(
|
||||
config('llm.models.chat.name'),
|
||||
static::DESCRIPTION_PROMPT . $combinedDescription . "\n\nBased only on these frame analyses, please provide:
|
||||
|
||||
A single, concise description that captures the main action or theme occurring in the reel across all frames.
|
||||
Identify and describe any joke or humorous element present in the video if you can discern one.
|
||||
|
||||
|
||||
Important Considerations
|
||||
|
||||
Remember that most videos are of poor quality; frame descriptions might be inaccurate, vague, or contradictory due to blurriness or fast cuts.
|
||||
Your task is synthesis: focus on the overall impression and sequence, not perfecting each individual piece of information. Some details mentioned in one analysis may simply be incorrect or misidentified from another perspective.
|
||||
|
||||
|
||||
Analyze all provided frames (separated by --- for clarity) to understand what's happening. Then, synthesize this understanding into point 1 above and identify the joke if present as per point 2.",
|
||||
outputFormat: '{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}',
|
||||
systemMessage: "You are an expert social media content analyst specializing in interpreting Instagram Reels. Your primary function is to generate a comprehensive description and identify any underlying humor or joke in a given video sequence. You will be provided with individual frame analyses, each containing:
|
||||
|
||||
Screenshot Number: The sequential number of the frame.
|
||||
Timestamp: When that specific frame occurs within the reel.
|
||||
OCR Text Result: Raw text extracted from the image content using OCR (Optical Character Recognition), which may contain errors or misinterpretations (\"may appear\" descriptions).
|
||||
LLM Description of Screenshot: A textual interpretation of what's visible in the frame, based on previous LLM processing.
|
||||
|
||||
|
||||
Please note:
|
||||
|
||||
The individual frame analyses can be inconsistent due to low video quality (e.g., blurriness) or rapid scene changes where details are hard to distinguish.
|
||||
Your task is not to perfect each frame description but to understand the overall sequence and likely narrative, focusing on identifying any joke, irony, absurdity, or humorous transformation occurring across these frames.
|
||||
|
||||
|
||||
Your response should be structured as follows:
|
||||
|
||||
Overall Video Description: Provide a concise summary of what happens in the reel based on the combined information from all the provided screenshots.
|
||||
Humor/Joke Identification (If Applicable): If you can discern any joke or humorous element, explicitly state it and explain how the sequence of frames contributes to this.
|
||||
|
||||
|
||||
Instructions for Synthesis:
|
||||
|
||||
Focus on identifying recurring elements, main subject(s), consistent actions/actions that seem unlikely (potential contradiction).
|
||||
Look for patterns where details change rapidly or absurdly.
|
||||
Prioritize information from descriptions over relying solely on OCR text if the description seems more plausible. Ignore minor inconsistencies between frames unless they clearly contradict a central theme or joke premise.
|
||||
Be ready to point out where the humor lies, which might involve unexpected changes, wordplay captured by OCR errors in the context of the visual action described, absurdity, or irony.",
|
||||
keepAlive: true,
|
||||
shouldThink: config('llm.models.chat.shouldThink')
|
||||
);
|
||||
|
||||
$llmDescription = json_decode($llmDescription, true)['answer'] ?? null;
|
||||
if (empty($llmDescription)) {
|
||||
$llmDescription = null;
|
||||
}
|
||||
|
||||
dump($llmDescription); // DEBUG
|
||||
|
||||
return $llmDescription;
|
||||
}
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AIPromptServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\BrowserJobsServiceProvider::class,
|
||||
App\Providers\ImageOCRServiceProvider::class,
|
||||
App\Providers\TelescopeServiceProvider::class,
|
||||
App\Providers\VideoDescriptorServiceProvider::class,
|
||||
];
|
||||
|
@ -39,8 +39,8 @@ services:
|
||||
|
||||
undetected-chromedriver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: undetectedChromedriver/seleniumChromedriverDockerfile
|
||||
context: ./undetectedChromedriver
|
||||
dockerfile: seleniumChromedriverDockerfile
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /tmp:/tmp
|
||||
|
@ -9,7 +9,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "8.3.*",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/dusk": "^8.2",
|
||||
@ -18,6 +18,8 @@
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/telescope": "^5.5",
|
||||
"laravel/tinker": "^2.9",
|
||||
"norkunas/youtube-dl-php": "dev-master",
|
||||
"thiagoalessio/tesseract_ocr": "^2.13",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
186
composer.lock
generated
186
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6008577001548e6e63c074be98000d97",
|
||||
"content-hash": "20c0488746a861aecc1187374ca0aa7f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@ -3011,6 +3011,69 @@
|
||||
},
|
||||
"time": "2024-12-30T11:07:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "norkunas/youtube-dl-php",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/norkunas/youtube-dl-php.git",
|
||||
"reference": "4c954b3b8c6b30d0c0135ec758b61a91f0ac3b6a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/norkunas/youtube-dl-php/zipball/4c954b3b8c6b30d0c0135ec758b61a91f0ac3b6a",
|
||||
"reference": "4c954b3b8c6b30d0c0135ec758b61a91f0ac3b6a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": ">=7.4.0",
|
||||
"symfony/filesystem": "^5.1|^6.0|^7.0",
|
||||
"symfony/polyfill-php80": "^1.28",
|
||||
"symfony/process": "^5.1|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mikey179/vfsstream": "^1.6.11",
|
||||
"php-cs-fixer/shim": "^3.60",
|
||||
"phpstan/phpstan": "^1.11.8",
|
||||
"phpstan/phpstan-phpunit": "^1.4.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.6.0",
|
||||
"symfony/phpunit-bridge": "^6.4.10"
|
||||
},
|
||||
"default-branch": true,
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"YoutubeDl\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tomas Norkūnas",
|
||||
"email": "norkunas.tom@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "youtube-dl / yt-dlp wrapper for php",
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"yt-dlp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/norkunas/youtube-dl-php/issues",
|
||||
"source": "https://github.com/norkunas/youtube-dl-php/tree/v2.10.0"
|
||||
},
|
||||
"time": "2025-02-20T17:32:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nunomaduro/termwind",
|
||||
"version": "v2.3.0",
|
||||
@ -5211,6 +5274,72 @@
|
||||
],
|
||||
"time": "2024-09-25T14:20:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v7.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb",
|
||||
"reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^6.4|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v7.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-25T15:15:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.2.2",
|
||||
@ -6909,6 +7038,55 @@
|
||||
],
|
||||
"time": "2025-01-17T11:39:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thiagoalessio/tesseract_ocr",
|
||||
"version": "2.13.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thiagoalessio/tesseract-ocr-for-php.git",
|
||||
"reference": "232a8cb9d571992f9bd1e263f2f6909cf6c173a1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thiagoalessio/tesseract-ocr-for-php/zipball/232a8cb9d571992f9bd1e263f2f6909cf6c173a1",
|
||||
"reference": "232a8cb9d571992f9bd1e263f2f6909cf6c173a1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^5.3 || ^7.0 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/php-code-coverage": "^2.2.4 || ^9.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"thiagoalessio\\TesseractOCR\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "thiagoalessio",
|
||||
"email": "thiagoalessio@me.com"
|
||||
}
|
||||
],
|
||||
"description": "A wrapper to work with Tesseract OCR inside PHP.",
|
||||
"keywords": [
|
||||
"OCR",
|
||||
"Tesseract",
|
||||
"text recognition"
|
||||
],
|
||||
"support": {
|
||||
"irc": "irc://irc.freenode.net/tesseract-ocr-for-php",
|
||||
"issues": "https://github.com/thiagoalessio/tesseract-ocr-for-php/issues",
|
||||
"source": "https://github.com/thiagoalessio/tesseract-ocr-for-php"
|
||||
},
|
||||
"time": "2023-10-05T21:14:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tightenco/ziggy",
|
||||
"version": "v2.5.2",
|
||||
@ -9591,11 +9769,13 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {
|
||||
"norkunas/youtube-dl-php": 20
|
||||
},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.2"
|
||||
"php": "8.3.*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
|
@ -17,4 +17,28 @@ return [
|
||||
'max_runs_per_job' => 50,
|
||||
],
|
||||
|
||||
/**
|
||||
* Instagram repost job.
|
||||
*/
|
||||
'instagramRepost' => [
|
||||
/**
|
||||
* Maximum number of posts to repost per account per job run.
|
||||
*/
|
||||
'max_reposts_per_account' => 2,
|
||||
|
||||
/**
|
||||
* Max number of reposts per job
|
||||
* This is the maximum number of posts that will be reposted in a single job run.
|
||||
*
|
||||
* The value mus try to not trigge rany API limit or bot detection service.
|
||||
*/
|
||||
'max_reposts_per_job' => 3,
|
||||
|
||||
/**
|
||||
* Maximum number of tries to repost a reel
|
||||
* If a reel fails to be reposted, it will be retried up to this number of times.
|
||||
*/
|
||||
'max_repost_tries' => 3,
|
||||
],
|
||||
|
||||
];
|
||||
|
43
config/llm.php
Normal file
43
config/llm.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/**
|
||||
* API configuration
|
||||
*/
|
||||
'api' => [
|
||||
/**
|
||||
* Host for the OpenAI API.
|
||||
* This should be the base URL of the OpenAI API you are using.
|
||||
*/
|
||||
'host' => env('LLM_API_HOST_URL', null),
|
||||
|
||||
/**
|
||||
* Token for authenticating with the OpenAI API.
|
||||
* Null if not used
|
||||
*/
|
||||
'token' => env('LLM_API_TOKEN', null),
|
||||
],
|
||||
|
||||
/**
|
||||
* Models configuration.
|
||||
*/
|
||||
'models' => [
|
||||
/**
|
||||
* Great for chatting, can have reasoning capabilities.
|
||||
* This model is typically used for conversational or thinking AI tasks.
|
||||
*/
|
||||
'chat' => [
|
||||
'name' => env('LLM_CHAT_MODEL', null),
|
||||
'shouldThink' => env('LLM_CHAT_MODEL_THINK', false),
|
||||
],
|
||||
|
||||
/**
|
||||
* Great for analyzing images, can have reasoning capabilities.
|
||||
* This model is typically used for tasks that require understanding and interpreting images.
|
||||
*/
|
||||
'vision' => [
|
||||
'name' => env('LLM_VISION_MODEL', null),
|
||||
'shouldThink' => env('LLM_VISION_MODEL_THINK', false),
|
||||
],
|
||||
]
|
||||
];
|
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstagramAccount;
|
||||
use App\Models\InstagramRepost;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobInfo;
|
||||
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
|
||||
{
|
||||
$newJobId = 4;
|
||||
Job::forceCreate([
|
||||
"id" => $newJobId,
|
||||
"name" => "Instagram Repost",
|
||||
"description" => "Reposte les publications Instagram des comptes donnés.",
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "instagram_repost_accounts",
|
||||
"name" => "Comptes Instagram à reposter",
|
||||
"description" => "Liste des noms des comptes Instagram à partir desquels les publications seront repostées.\nSéparez les comptes par des virgules.",
|
||||
"placeholder" => "is.it.ninluc, freddiedredd",
|
||||
"is_required" => true,
|
||||
"job_info_type_id" => 1,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "instagram_repost_account_email",
|
||||
"name" => "Identifiant",
|
||||
"description" => "L'adresse e-mail/nom d'utilisateur/N° de téléphone utilisée pour le compte Instagram de repost.",
|
||||
"is_required" => true,
|
||||
"job_info_type_id" => 1,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "instagram_repost_account_password",
|
||||
"name" => "Mot de passe",
|
||||
"description" => "Le mot de passe utilisée pour le compte Instagram de repost.",
|
||||
"is_required" => true,
|
||||
"job_info_type_id" => 3,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
Schema::create('instagram_repost_accounts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string("username")->unique();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('instagram_reposts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string("reel_id")->unique();
|
||||
$table->boolean("reposted")->default(false);
|
||||
$table->integer("repost_tries")->default(0);
|
||||
$table->foreignIdFor(InstagramAccount::class, "account_id")
|
||||
->constrained('instagram_repost_accounts')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Already reposted posts
|
||||
$notDeadLmaoAccount = InstagramAccount::forceCreate([
|
||||
"username" => "notdeadlmao69",
|
||||
]);
|
||||
$negusflexAccount = InstagramAccount::forceCreate([
|
||||
"username" => "negusflex",
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DKbW7M_RWV7",
|
||||
"reposted" => true,
|
||||
"account_id" => $notDeadLmaoAccount->id,
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DKccuTMTmP_",
|
||||
"reposted" => true,
|
||||
"account_id" => $negusflexAccount->id,
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DJmUjhWSnqm",
|
||||
"reposted" => true,
|
||||
"account_id" => $negusflexAccount->id,
|
||||
]);
|
||||
InstagramRepost::forceCreate([
|
||||
"reel_id" => "DKcdSGnv6uq",
|
||||
"reposted" => true,
|
||||
"account_id" => $negusflexAccount->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Job::where("id", 4)->delete();
|
||||
JobInfo::where("job_id", 4)->delete();
|
||||
|
||||
Schema::dropIfExists('instagram_repost_accounts');
|
||||
Schema::dropIfExists('instagram_reposts');
|
||||
}
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
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::table('instagram_reposts', function (Blueprint $table) {
|
||||
$table->text('video_description')->nullable()->after('reel_id')
|
||||
->comment('Description of the video being reposted on Instagram');
|
||||
$table->text('instagram_caption')->nullable()->after('video_description')
|
||||
->comment('Caption generated for the Instagram video repost');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instagram_reposts', function (Blueprint $table) {
|
||||
$table->dropColumn('video_description');
|
||||
$table->dropColumn('instagram_caption');
|
||||
});
|
||||
}
|
||||
};
|
@ -13,7 +13,7 @@ Route::get('/jobs', function (Request $request) {
|
||||
Route::get('/test/{id}', function (Request $request, $id, BrowserJobsInstances $BrowserJobsInstances) {
|
||||
$log = $BrowserJobsInstances->getJobInstance($id)->execute();
|
||||
dump($log);
|
||||
return response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]);
|
||||
dump(response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]));
|
||||
});
|
||||
|
||||
Route::get('jobs/{job}/test', [JobController::class, 'test'])->name('jobs.test');
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob;
|
||||
use App\Browser\Jobs\InstagramRepost\InstagramRepostJob;
|
||||
use App\Jobs\PruneOldJobRuns;
|
||||
use App\Services\BrowserJobsInstances;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
@ -23,3 +24,4 @@ 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');
|
||||
|
@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_password_is_not_confirmed_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_password_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_update_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrors('current_password')
|
||||
->assertRedirect('/profile');
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_profile_page_is_displayed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/profile');
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/profile', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrors('password')
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
Binary file not shown.
@ -1,20 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# version variable
|
||||
# Can be found here : https://hub.docker.com/r/selenium/standalone-chrome/tags
|
||||
# Will need to change it in seleniumChromedriverDockerfile and probably download
|
||||
# it and change it in patchChromedriver.py
|
||||
VERSION="latest"
|
||||
|
||||
# From undetected chromedriver docker
|
||||
#sudo docker run --rm -it -p 3389:3389 -v ./undetectedChromedriver:/root/.local/share/undetected_chromedriver/ ultrafunk/undetected-chromedriver:latest
|
||||
|
||||
sudo docker pull selenium/standalone-chrome:$VERSION
|
||||
|
||||
# With undetected chromedriver patcher
|
||||
# Run the selenium/standalone-chrome:latest with a specific container name in the background
|
||||
sudo docker run -d --name standalone-chrome selenium/standalone-chrome:latest
|
||||
#sudo docker run -d --name standalone-chrome -v /home/ninluc/Documents/codage/DatBrowser/undetectedChromedriver/chrome/:/opt/google/chrome/ selenium/standalone-chrome:$VERSION
|
||||
sudo docker run -d --name standalone-chrome selenium/standalone-chrome:$VERSION
|
||||
|
||||
sleep 5
|
||||
sleep 7
|
||||
|
||||
# Copy the chromedriver binary from the container to the host
|
||||
sudo docker cp -L standalone-chrome:/bin/chromedriver ./chromedriver
|
||||
# Stop the container
|
||||
sudo docker stop standalone-chrome
|
||||
|
||||
sudo chmod 777 ./chromedriver
|
||||
|
||||
# Patch the chromedriver binary
|
||||
source venv/bin/activate
|
||||
python3 ./patchChromedriver.py
|
||||
|
||||
# Stop the container
|
||||
sudo docker stop standalone-chrome
|
||||
sudo docker rm standalone-chrome
|
||||
|
@ -4,5 +4,10 @@ import undetected_chromedriver as uc
|
||||
|
||||
options = uc.ChromeOptions()
|
||||
# Chromedriver is in current directory
|
||||
driver = uc.Chrome(options = options, browser_executable_path="/usr/bin/google-chrome", driver_executable_path="/home/ninluc/Documents/codage/DatBrowser/undetectedChromedriver/chromedriver")
|
||||
# ERROR : This version of ChromeDriver only supports Chrome version xxx
|
||||
# npx @puppeteer/browsers install chrome@xxx
|
||||
# Change the path to the Chrome binary if needed
|
||||
# "/home/ninluc/Documents/codage/DatBrowser/undetectedChromedriver/chrome/google-chrome"
|
||||
# "/home/ninluc/chrome/linux-124.0.6367.207/chrome-linux64/chrome"
|
||||
driver = uc.Chrome(options = options, browser_executable_path="/bin/google-chrome", driver_executable_path="/home/ninluc/Documents/codage/DatBrowser/undetectedChromedriver/chromedriver")
|
||||
driver.get('https://nowsecure.nl')
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
sudo docker build -f undetectedChromedriver/seleniumChromedriverDockerfile -t git.matthiasg.dev/ninluc/selenium/standalone-uc:latest .
|
||||
sudo docker build -f seleniumChromedriverDockerfile -t git.matthiasg.dev/ninluc/selenium/standalone-uc:latest .
|
||||
sudo docker push git.matthiasg.dev/ninluc/selenium/standalone-uc:latest
|
||||
|
@ -1,10 +1,13 @@
|
||||
# FROM selenium/standalone-chrome:108.0 AS final
|
||||
# FROM selenium/standalone-chrome:133.0-20250606 AS final
|
||||
FROM selenium/standalone-chrome:latest AS final
|
||||
|
||||
COPY undetectedChromedriver/chromedriver /bin/chromedriver
|
||||
RUN mkdir -p /home/seluser/profile/
|
||||
COPY ./chromedriver /bin/chromedriver
|
||||
#RUN mkdir -p /home/seluser/profile/
|
||||
|
||||
ENV TZ=Europe/Brussels
|
||||
# 15 minutes session timeout
|
||||
ENV SE_OPTS="--session-timeout 900"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -s http://localhost:4444/wd/hub/status | jq -e '.value.ready == true' || exit 1
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
services:
|
||||
undetected-chromedriver:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: undetectedChromedriver/seleniumChromedriverDockerfile
|
||||
context: ./
|
||||
dockerfile: seleniumChromedriverDockerfile
|
||||
volumes:
|
||||
- /tmp:/tmp
|
||||
- chromeProfile:/home/seluser/profile/
|
||||
|
Reference in New Issue
Block a user