Compare commits
84 Commits
jobs/epic-
...
e3713773b7
Author | SHA1 | Date | |
---|---|---|---|
e3713773b7 | |||
aa936a2a11 | |||
1f23b112d7 | |||
0aa34d170a | |||
f0e52147e4 | |||
a00c5ba4b8 | |||
f9f1b8ed3d | |||
f192cba1f8 | |||
6b9b5a60e9 | |||
6d92eb76d8 | |||
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 | |||
e8b9517664 | |||
cfbae6ddbf | |||
4368aae6c4 | |||
070235e011 | |||
ad10dcaa0f | |||
63216ae7e8 | |||
4a68c1d223 | |||
d48c0cd148 | |||
8e356ac1ef | |||
a693339f97 | |||
5bfaba4a8e | |||
140966fb6b | |||
e3443ca632 | |||
17af60471b | |||
0014045d52 | |||
fec63ff249 | |||
cdcdcd9e8b | |||
599ef77d64 | |||
edb07dd960 | |||
6a95653c52 |
@ -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
|
||||
|
208
LLMPrompts.md
Normal file
208
LLMPrompts.md
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
# 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 :
|
||||
|
||||
```text
|
||||
I’m using some LLM and I would need a prompt and a system message for every use case I will give you.
|
||||
I’m using structured JSON output provided by the openAI API. The output structure is a simple {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}, so only “answer” can be filled. For the input, everything will be given in the prompt. Give me the system message and prompt separately, preferably in text format.
|
||||
```
|
||||
|
||||
## Instagram
|
||||
|
||||
### Instagram Reel caption generation
|
||||
|
||||
```text
|
||||
I’m using some LLM and I would need a prompt, a system message 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 describing the video like the LLM description does (for example this bad example describe the content of the video instead of doing a caption based on the description given : “Three animated friends chilling in the woods at night until someone's phone inevitably starts ringing somewhere nearby... 😅🌲✨” or this one : “This reel shows me trying to make my sad texts shorter with ChatGPT, but it just frustrates me more! 😅😂”).
|
||||
It also shouldn’t begin with something like ‘This reel…’. For example this is a bad output : “This reel hilariously mocks every awkward fan reaction to those intense DCU movie scenes. 🎭 #DCFanDrama”
|
||||
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 usually it’s to incite to subscribe to the downloaded reel account (like “Seen me already ? follow me @instagramUser”). I don’t want long credits too, juste a simple “credit tt/twitteraccount” is enough. Not like this bad example : “Credited via the brilliant mind at tt/batinterface!…”
|
||||
The use of emoji is encouraged, but not too much and it has to not look stupid or too.
|
||||
|
||||
When using it, I encoutered some problems like this one :
|
||||
“Credit to: [Original Creator] for this hilarious video game scene where the characters look suspiciously like Kermit the Frog! 😂”. The [Original creator] is not filled in, I don’t even know if the original caption had one.
|
||||
Also sometimes the results says something about the OCR, of course, it shouldn't say anything about the input being wrong like the OCR not making sense in the final answer. The entire text the LLM produces will be set as caption.
|
||||
|
||||
Some caption are just lame and feels like a facebook post. The intended audience here is young.
|
||||
```
|
||||
|
||||
## Video Descriptor
|
||||
|
||||
```text
|
||||
I’m using some LLM and I would need a prompt and a system message for every use case I will give you.
|
||||
|
||||
I’m using structured JSON output provided by the openAI API. The output structure is a simple {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}, so only “answer” can be filled. For the input, everything will be given in the prompt. Give me the system message and prompt separately, preferably in text format.
|
||||
|
||||
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.”
|
||||
|
||||
The informations given in the prompts are :
|
||||
- An audio transcription of the full video
|
||||
- For each of the screenshots :
|
||||
|
||||
The screenshot number ("Screenshot: 3" for example)
|
||||
|
||||
The timestamp in the video of when the screenshot is taken
|
||||
|
||||
An OCR result (may contain some weird character, the OCR is not filtered or cleansed), if not text is found, it mentions "No text found"
|
||||
|
||||
The LLM description of the screenshot
|
||||
|
||||
Here is an example of prompt given :
|
||||
\```text
|
||||
Audio Transcription: Hey !
|
||||
|
||||
Screenshot: 1
|
||||
|
||||
|
||||
Timestamp: 0s
|
||||
|
||||
|
||||
OCR: See
|
||||
|
||||
|
||||
|
||||
fia) oiled 5 genuinely fh
|
||||
|
||||
|
||||
LLM Description: 1. Scene Description: The image shows a person standing inside a building, possibly a store or a commercial establishment. The individual is positioned in the middle of the frame and appears to be looking directly at the camera or the viewer. There are other people present as well, but they are not the main focus of this description. 2. Main Subject/Character(s): The primary subject is a person who seems to be trying to communicate with someone off-camera. They appear to be standing in line or waiting, and their posture suggests that they may be impatient or frustrated. 3. Text Description: There is visible text on the image, which reads as follows: 'when my day 1 try to dap me up but he's gonnily do it again when I step out that door love you os 4. Summary: The image captures a moment of frustration or impatience between two individuals in an indoor setting. 5. Joke: It seems there is no joke or humorous element present in this image.
|
||||
|
||||
◀
|
||||
|
||||
|
||||
Screenshot: 2
|
||||
|
||||
|
||||
Timestamp: 2s
|
||||
|
||||
|
||||
OCR: we v),®
|
||||
|
||||
|
||||
|
||||
When my Civ 1 tay to cep
|
||||
|
||||
|
||||
|
||||
movphwthes ganuinahy th
|
||||
|
||||
|
||||
|
||||
tune)witithatlovejisiand|ps)
|
||||
|
||||
|
||||
(o
|
||||
|
||||
|
||||
|
||||
W
|
||||
|
||||
|
||||
LLM Description: 1. **Scene Description:** The image shows an interior setting, which appears to be a spacious room with a high ceiling and a patterned floor. There is natural light coming in from the upper part of the space. The room seems to have some sort of event or gathering happening within it. 2. **Main Subject/Character(s):** The main subject is a person who is standing, walking across the room. They are wearing dark clothing and have their back turned towards the camera. 3. **Text Description (if any):** There is visible text overlaid on the image which reads
|
||||
|
||||
◀
|
||||
|
||||
|
||||
Screenshot: 3
|
||||
|
||||
|
||||
Timestamp: 4s
|
||||
|
||||
|
||||
OCR: a up erent a
|
||||
|
||||
|
||||
ae i Li bs
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ree
|
||||
|
||||
|
||||
|
||||
I
|
||||
|
||||
|
||||
LLM Description: 1. Scene Description: The image appears to be a smartphone screenshot of a social media post, specifically an Instagram story. There is a person in the foreground, who seems to be outdoors. The individual is standing near a storefront with a visible display window featuring mannequins and merchandise. The sky is clear, suggesting it might be daytime. The background also shows other people walking on the sidewalk, which indicates that this is likely an urban area. The presence of the store and the sidewalk suggest that this scene takes place in a commercial or shopping district. There are texts at the top of the image that appear to be part of Instagram's interface elements. It seems that there might have been some interaction with the post, as indicated by the emoji reactions. The overall setting appears to be an urban environment during daytime. 2. Main Subject/Character(s): The main subject in this image is a person who is standing in front of a storefront. This individual appears to be engaged with their smartphone, possibly viewing or interacting with the post. It's not possible to provide specific details about the person beyond what they are wearing and how they are positioned within the frame. 3. Text Description (if any): The image contains several text elements that include emoji reactions and captions. There are emojis indicating various types of interactions, such as
|
||||
|
||||
◀
|
||||
|
||||
|
||||
Screenshot: 4
|
||||
|
||||
|
||||
Timestamp: 6s
|
||||
|
||||
|
||||
OCR: a day 1 try to.dap
|
||||
|
||||
|
||||
but he’s Ce in
|
||||
|
||||
|
||||
RR OU oS
|
||||
|
||||
|
||||
LLM Description: 1. **Scene Description:** The image shows an interior space that appears to be a shopping area or mall, with visible merchandise and store displays. A person is walking through the scene, seemingly captured mid-step while using their phone. The setting suggests a casual, everyday environment. 2. **Main Subject/Character(s):** The main subject is a person in mid-stride, looking down at their cell phone, possibly engaged with it. There are no additional characters or significant interactions depicted. 3. **Text Description (if any):** There is text overlaid on the image which reads,
|
||||
|
||||
◀
|
||||
|
||||
|
||||
Screenshot: 5
|
||||
|
||||
|
||||
Timestamp: 8s
|
||||
|
||||
|
||||
OCR: No text found
|
||||
|
||||
|
||||
LLM Description: 1. Scene Description: The image depicts a person walking through an indoor shopping mall. It appears to be a public space, with visible storefronts and a ceiling-mounted security camera in the background. There is also an escalator in the scene, which suggests multiple levels to the building. The lighting and layout suggest a modern, clean design typical of contemporary malls. 2. Main Subject/Character(s): A young man is walking through the shopping mall, seemingly alone, with his head down. He appears to be engaged with something he's holding in his hand, possibly a mobile phone, which might imply that he is texting or looking at content on his device. 3. Text Description (if any): There is text overlaid on the image. It reads,
|
||||
\```
|
||||
|
||||
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.
|
||||
|
||||
Found text by OCR and screenshots descriptions can be passed to the final video description if it seems coherent.
|
||||
```
|
||||
|
||||
### Screenshot descriptor
|
||||
|
||||
```text
|
||||
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.
|
67
README.md
67
README.md
@ -1,66 +1,7 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
# DatBrowser
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
## Development
|
||||
|
||||
## About Laravel
|
||||
### Links
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com/)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[WebReinvent](https://webreinvent.com/)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
|
||||
- **[Cyber-Duck](https://cyber-duck.co.uk)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Jump24](https://jump24.co.uk)**
|
||||
- **[Redberry](https://redberry.international/laravel/)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
- **[byte5](https://byte5.de)**
|
||||
- **[OP.GG](https://op.gg)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
- [novnc](http://localhost:7900/?password=secret&autoconnect=true&scale=local)
|
||||
|
@ -5,6 +5,9 @@ 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;
|
||||
use Exception;
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
@ -20,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;
|
||||
|
||||
@ -29,7 +33,7 @@ abstract class BrowserJob implements ShouldQueue
|
||||
|
||||
public int $jobId;
|
||||
|
||||
public $timeout = 500;
|
||||
public $timeout = 300; // 5 minutes
|
||||
|
||||
public function __construct(int $jobId)
|
||||
{
|
||||
@ -49,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}");
|
||||
@ -56,7 +61,8 @@ abstract class BrowserJob implements ShouldQueue
|
||||
// throw $e;
|
||||
}
|
||||
catch (Throwable $e) {
|
||||
$browser->screenshot("failure-{$this->jobId}");
|
||||
$browser->screenshot(JobErrorScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobErrorNotification($this->jobId, $e->getMessage()));
|
||||
dump($e);
|
||||
throw $e;
|
||||
} finally {
|
||||
@ -155,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(
|
||||
@ -164,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
|
||||
);
|
||||
}
|
||||
|
||||
@ -178,8 +191,7 @@ abstract class BrowserJob implements ShouldQueue
|
||||
*/
|
||||
protected function hasHeadlessDisabled(): bool
|
||||
{
|
||||
return isset($_SERVER['DUSK_HEADLESS_DISABLED']) ||
|
||||
isset($_ENV['DUSK_HEADLESS_DISABLED']);
|
||||
return config('dusk.headlessDisabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -187,8 +199,7 @@ abstract class BrowserJob implements ShouldQueue
|
||||
*/
|
||||
protected function shouldStartMaximized(): bool
|
||||
{
|
||||
return isset($_SERVER['DUSK_START_MAXIMIZED']) ||
|
||||
isset($_ENV['DUSK_START_MAXIMIZED']);
|
||||
return config('dusk.shouldStartMaximized', false);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -285,4 +296,86 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
public function clickElementWithJavaScript(Browser $browser, string $xPathSelector): void
|
||||
{
|
||||
$browser->script("
|
||||
var element = document.evaluate('{$xPathSelector}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||
if (element) {
|
||||
element.click();
|
||||
} else {
|
||||
console.error('Element not found: {$xPathSelector}');
|
||||
}
|
||||
");
|
||||
}
|
||||
}
|
||||
|
27
app/Browser/JobDebugScreenshot.php
Normal file
27
app/Browser/JobDebugScreenshot.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use function rtrim;
|
||||
|
||||
|
||||
class JobDebugScreenshot {
|
||||
public const IMG_FILE_NAME = "debug-";
|
||||
|
||||
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, true);
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(int $jobId): string {
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(int $jobId): string {
|
||||
return "screenshots/" . static::getFileName($jobId, true);
|
||||
}
|
||||
}
|
27
app/Browser/JobErrorScreenshot.php
Normal file
27
app/Browser/JobErrorScreenshot.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use function rtrim;
|
||||
|
||||
|
||||
class JobErrorScreenshot {
|
||||
public const IMG_FILE_NAME = "failure-";
|
||||
|
||||
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, true);
|
||||
}
|
||||
|
||||
public static function getImgFileProjectPath(int $jobId): string {
|
||||
return app_path("Browser/screenshots/" . static::getFileName($jobId, true));
|
||||
}
|
||||
|
||||
public static function getImgFileExternalPath(int $jobId): string {
|
||||
return "screenshots/" . static::getFileName($jobId, true);
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ namespace App\Browser\Jobs\Hellcase;
|
||||
|
||||
use App\Browser\BrowserJob;
|
||||
use App\Browser\Components\Hellcase\MainNav;
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
|
||||
use App\Models\JobArtifact;
|
||||
use App\Models\JobRun;
|
||||
@ -11,6 +12,8 @@ use App\Notification\NotificationBody\Hellcase\HellcaseNotificationDailyFreeBody
|
||||
use App\Notification\NotificationBody\Hellcase\HellcaseNotificationLoginBody;
|
||||
use App\Notification\Notifications\Hellcase\HellcaseNotificationDailyFree;
|
||||
use App\Notification\Notifications\Hellcase\HellcaseNotificationLogin;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Notifications\JobErrorNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
@ -22,12 +25,11 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
private const STEAM_LOGIN_THRESHOLD = 5 * 60; // 5 minutes
|
||||
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
|
||||
|
||||
private JobRun $jobRun;
|
||||
protected JobRun $jobRun;
|
||||
|
||||
public function __construct()
|
||||
public function __construct($jobId = 2)
|
||||
{
|
||||
Log::info("Constructing HellcaseJob");
|
||||
parent::__construct(2);
|
||||
parent::__construct($jobId);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
@ -44,7 +46,13 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
$this->removePopups($browser);
|
||||
sleep(5);
|
||||
$this->signin($browser);
|
||||
$this->joinFreeGiveaways($browser);
|
||||
try {
|
||||
$this->joinFreeGiveaways($browser);
|
||||
} catch (\Exception $e) {
|
||||
$this->jobRun->success = false;
|
||||
$this->jobRun->save();
|
||||
AllNotification::send(new JobErrorNotification($this->jobId, "Erreur lors de la participation aux concours gratuits : " . $e->getMessage()));
|
||||
}
|
||||
$this->getDailyFree($browser);
|
||||
|
||||
$this->jobRun->success = true;
|
||||
@ -73,14 +81,14 @@ 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function signin(Browser $browser)
|
||||
protected function signin(Browser $browser)
|
||||
{
|
||||
try {
|
||||
$browser->clickAtXPath('//button[.//span[text() = "Sign in"]]');
|
||||
@ -91,13 +99,20 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
sleep(5);
|
||||
$browser->waitForText("Sign in with Steam", 30, true);
|
||||
sleep(3);
|
||||
$browser->driver->findElement(WebDriverBy::xpath('//button[@class = "_base_1uydq_1 _accent-1_1uydq_105 _m_1uydq_52 _full_1uydq_94 _primary_1uydq_100"]'))->click();
|
||||
$browser->driver->findElement(WebDriverBy::xpath("//button[. = 'Sign in with Steam']"))->click();
|
||||
sleep(5);
|
||||
|
||||
// QR CODE SCANNING
|
||||
try {
|
||||
$browser->waitForTextIn("div", "Or sign in with QR", 30, true);
|
||||
$qrCode = $browser->driver->findElement(WebDriverBy::xpath('//div[./*[contains(text(), "Or sign in with QR")]]'));
|
||||
sleep(10);
|
||||
try {
|
||||
$qrCode = $browser->driver->findElement(WebDriverBy::xpath('//div[./*[contains(text(), "Or sign in with QR")]]'));
|
||||
} catch (\Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Le QR code de la page de connexion de Steam n'a pas été trouvé"));
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Wait to be redirected to the Steam login page, while waiting take a new screenshot every 30 seconds
|
||||
$isBackOnHellcase = false;
|
||||
@ -122,9 +137,11 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If the QR code is not found, we are not on the QR code page
|
||||
Log::debug("Exception because qrcode not found : " . $e);
|
||||
$isBackOnHellcase = true;
|
||||
} catch (\Throwable $e) {
|
||||
// If the QR code is not found, we are not on the QR code page
|
||||
Log::debug("Exception because qrcode not found : " . $e);
|
||||
$isBackOnHellcase = true;
|
||||
}
|
||||
|
||||
@ -141,6 +158,8 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
try {
|
||||
$buttons = $browser->driver->findElements(WebDriverBy::xpath('//a[text() = "Join for free"]'));
|
||||
} catch (\Exception $e) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "No join for free buttons found"));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -150,9 +169,32 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
"content" => ""
|
||||
]));
|
||||
}
|
||||
try {
|
||||
// $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"));
|
||||
return;
|
||||
}
|
||||
foreach ($buttons as $button) {
|
||||
$button->click();
|
||||
sleep(5);
|
||||
// Click the next slide button if the button is not clickable
|
||||
$clickedFailsCounter = 0;
|
||||
while ($clickedFailsCounter < 7 && $clickedFailsCounter >= 0) {
|
||||
try {
|
||||
$button->click();
|
||||
} catch (\Exception $e) {
|
||||
$clickedFailsCounter++;
|
||||
try {
|
||||
// $nextSlideButton->click();
|
||||
} catch (\Exception $_) {}
|
||||
sleep(3);
|
||||
continue;
|
||||
}
|
||||
$clickedFailsCounter = -1;
|
||||
}
|
||||
|
||||
sleep(5); // Wait a bit for loading
|
||||
$this->joinGiveaway($browser);
|
||||
$browser->within(new MainNav, function (Browser $browser) {
|
||||
$browser->goToHome();
|
||||
@ -184,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) {
|
||||
@ -196,9 +242,12 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
if ($availibleInButton->getAttribute("disabled") == "true") {
|
||||
$hours = $availibleInButton->getText();
|
||||
// If the text is like "in 26 sec." we need to put one minute
|
||||
if (str_contains($hours, "sec")) {
|
||||
$this->reschedule(1);
|
||||
return;
|
||||
if (str_contains(strtolower($hours), "seconds")) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "I hate niggers"));
|
||||
// $this->reschedule(1);
|
||||
sleep(60);
|
||||
return $this->getDailyFree($browser);
|
||||
}
|
||||
$hours = explode(" ", $hours);
|
||||
$minutes = $hours[4];
|
||||
@ -345,7 +394,7 @@ class HellcaseJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
$browser->clickAtXPath('//*[contains(text(), "Edit Profile")]');
|
||||
}
|
||||
|
||||
private function removePopups(Browser $browser)
|
||||
protected function removePopups(Browser $browser)
|
||||
{
|
||||
// $browser->script('document.querySelector("div.app-modal")[0].remove();');
|
||||
// $browser->driver->executeScript('document.querySelector("div.app-modal")[0].remove();');
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
176
app/Browser/Jobs/HellcaseBattles/HellcaseBattlesJob.php
Normal file
176
app/Browser/Jobs/HellcaseBattles/HellcaseBattlesJob.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Exception;
|
||||
use Facebook\WebDriver\WebDriver;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class HellcaseBattlesJob extends HellcaseJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
private Collection $jobInfos;
|
||||
private array $battlesToAdd = [];
|
||||
private array $battlesSent = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(3);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
{
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
Log::info("Running HellcaseBattlesJob");
|
||||
$this->jobRun = new JobRun([
|
||||
"job_id" => $this->jobId,
|
||||
"success" => false,
|
||||
]);
|
||||
$this->jobRun->save();
|
||||
|
||||
$browser->visit('https://hellcase.com');
|
||||
$browser->waitForText("CASES", 30, true);
|
||||
$this->removePopups($browser);
|
||||
sleep(5);
|
||||
$this->signin($browser);
|
||||
|
||||
$this->saveInterestingBattles($browser);
|
||||
|
||||
$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();
|
||||
|
||||
Log::info("HellcaseBattlesJob run ended");
|
||||
|
||||
return $this->jobRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current cases battles to database for later processing
|
||||
* @param \Laravel\Dusk\Browser $browser
|
||||
* @return void
|
||||
*/
|
||||
private function saveInterestingBattles(Browser $browser)
|
||||
{
|
||||
$battleIndex = 0; // Index of the battle to get info from
|
||||
$running = true;
|
||||
while ($running) {
|
||||
$browser->visit('https://hellcase.com/casebattle');
|
||||
$browser->waitForText("CASES", 30, true);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
|
||||
$battles = $browser->driver->findElements(WebDriverBy::xpath("//*[contains(@class, 'casebattle-table__item')]"));
|
||||
$battle = $battles[$battleIndex];
|
||||
$battleIndex++;
|
||||
$browser->scrollIntoView(".casebattle-table__item:nth-child(" . max($battleIndex -1, 1) . ")");
|
||||
sleep(2);
|
||||
$battleValue = floatval(
|
||||
explode(
|
||||
"\n",
|
||||
$battle->findElement(WebDriverBy::xpath("./div/div[contains(@class, 'core-price')]"))->getDomProperty("innerText")
|
||||
)[1]
|
||||
);
|
||||
|
||||
if ($battleValue < floatval($this->jobInfos->get("hellcase_battles_minimum_value"))) {
|
||||
$running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
$battleLinkButton = $battle->findElement(WebDriverBy::xpath('./div//button[text() = "watch"]'));
|
||||
$battleLinkButton->sendKeys("\n");
|
||||
sleep(3);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendFinishedBattles(Browser $browser) {
|
||||
// foreach battle that we didn"t already planned to add with $this->battlesToAdd
|
||||
foreach (HellcaseBattle::all() as $battle) {
|
||||
if (!array_key_exists($battle->getUrl(), $this->battlesToAdd)) {
|
||||
$browser->visit($battle->getUrl());
|
||||
|
||||
sleep(2);
|
||||
$browser->waitForText("Case Battle");
|
||||
|
||||
if ($this->findElementContainingText($browser, "Started at:") != null) { // battle is finished
|
||||
// Send the battle
|
||||
$this->sendBattle($browser, $battle);
|
||||
}
|
||||
|
||||
$battle->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sendBattle(Browser $browser, HellcaseBattle $battle) {
|
||||
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);
|
||||
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()}
|
||||
";
|
||||
}
|
||||
}
|
112
app/Browser/Jobs/Instagram/InstagramAbstractJob.php
Normal file
112
app/Browser/Jobs/Instagram/InstagramAbstractJob.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\Instagram;
|
||||
|
||||
use App\Browser\BrowserJob;
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Dusk\Browser;
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
|
||||
abstract class InstagramAbstractJob extends BrowserJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// === CONFIGURATION ===
|
||||
protected Collection $jobInfos;
|
||||
protected JobRun $jobRun;
|
||||
protected OpenAPIPrompt $openAPIPrompt;
|
||||
|
||||
public function __construct($jobId, OpenAPIPrompt $openAPIPrompt = null)
|
||||
{
|
||||
parent::__construct($jobId);
|
||||
|
||||
$this->openAPIPrompt = $openAPIPrompt ?? app(OpenAPIPrompt::class);
|
||||
}
|
||||
|
||||
protected function clickNext(Browser $browser) {
|
||||
$nextButton = $browser->driver->findElement(WebDriverBy::xpath('//div[contains(text(), "Next") or contains(text(), "Share")]'));
|
||||
$nextButton->click();
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
protected function putOriginalResolution(Browser $browser)
|
||||
{
|
||||
try {
|
||||
$chooseResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//button[./div/*[local-name() = "svg"][@aria-label="Select crop"]]'));
|
||||
$chooseResolutionButton->click();
|
||||
sleep(2);
|
||||
// Choos "original" resolution
|
||||
$originalResolutionButton = $browser->driver->findElement(WebDriverBy::xpath('//div[./div/div/span[contains(text(), "Original")]]'));
|
||||
$originalResolutionButton->click();
|
||||
sleep(2);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to set original resolution: " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to set \"original\" resolution: " . $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
protected function signin(Browser $browser)
|
||||
{
|
||||
if ($browser->assertSee("Search", true)) {
|
||||
return; // Already signed in, skip some waiting for non existing text
|
||||
}
|
||||
|
||||
try {
|
||||
$browser->waitForText("Log in", 10, true);
|
||||
sleep(3);
|
||||
$emailButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "email")]'));
|
||||
$emailButton->click();
|
||||
$emailButton->sendKeys($this->jobInfos->get("instagram_repost_account_username"));
|
||||
sleep(3);
|
||||
$passwordButton = $browser->driver->findElement(WebDriverBy::xpath('//input[contains(@aria-label, "Password")]'));
|
||||
$passwordButton->click();
|
||||
$passwordButton->sendKeys($this->jobInfos->get("instagram_repost_account_password") . "\n");
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
// Probably no need to signin
|
||||
}
|
||||
try {
|
||||
$browser->waitForText("Search", 15, true);
|
||||
$this->removePopups($browser);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to sign in: " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to sign in: " . $e->getMessage()));
|
||||
// Stop the job run
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function removePopups(Browser $browser)
|
||||
{
|
||||
$popupsTypes = [
|
||||
['//button[contains(text(), "Allow all cookies")]'], // Allow all cookies
|
||||
['//button[contains(text(), "Not Now")]', ["Popup Not Now clicked"]], // Not now
|
||||
['//button[contains(text(), "OK")]', ["Popup Ok clicked"]], // OK
|
||||
];
|
||||
|
||||
foreach ($popupsTypes as $popup) {
|
||||
try {
|
||||
$button = $browser->driver->findElement(WebDriverBy::xpath($popup[0]));
|
||||
if ($button === null) {
|
||||
continue; // No button found, continue to the next popup
|
||||
}
|
||||
if (isset($popup[1])) {
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, $popup[1][0]));
|
||||
}
|
||||
$button->click();
|
||||
sleep(2);
|
||||
return; // Exit after clicking the first popup found
|
||||
} catch (\Exception $e) {
|
||||
// Porbably no popup found, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\Instagram\InstagramAbstractJob;
|
||||
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
|
||||
use App\Models\InstagramNotification;
|
||||
use App\Models\InstagramRepost;
|
||||
use App\Models\Job;
|
||||
use App\Models\JobRun;
|
||||
use App\Notification\Notifications\JobDebugNotification;
|
||||
use App\Notification\Providers\AllNotification;
|
||||
use App\Services\Instagram\NotificationTypeDetector;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Dusk\Browser;
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
|
||||
class InstagramNotificationHandlingJob extends InstagramAbstractJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// === CONFIGURATION ===
|
||||
|
||||
public $timeout = 1800; // 30 minutes
|
||||
|
||||
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
|
||||
|
||||
protected IInstagramVideoDownloader $videoDownloader;
|
||||
|
||||
protected ReelDescriptor $ReelDescriptor;
|
||||
|
||||
protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/";
|
||||
|
||||
/**
|
||||
* Pipeline for processing Instagram post descriptions.
|
||||
* This pipeline can be used to modify the description before reposting.
|
||||
* For example, it can remove account references or add hashtags.
|
||||
*
|
||||
* @var InstagramDescriptionPipeline
|
||||
*/
|
||||
protected InstagramDescriptionPipeline $descriptionPipeline;
|
||||
|
||||
public function __construct($jobId = 4, ReelDescriptor $ReelDescriptor = null, OpenAPIPrompt $openAPIPrompt = null)
|
||||
{
|
||||
parent::__construct($jobId, $openAPIPrompt);
|
||||
|
||||
$this->downloadFolder = base_path($this->downloadFolder);
|
||||
$this->videoDownloader = new YTDLPDownloader();
|
||||
$this->ReelDescriptor = $ReelDescriptor ?? app(ReelDescriptor::class);
|
||||
$this->descriptionPipeline = new InstagramDescriptionPipeline([
|
||||
// Add steps to the pipeline here
|
||||
new DescriptionPipeline\RemoveAccountsReferenceStep(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
Log::info("Running InstagramNotificationHandlingJob");
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
$this->jobRun = new JobRun([
|
||||
"job_id" => $this->jobId,
|
||||
"success" => false,
|
||||
]);
|
||||
$this->jobRun->save();
|
||||
|
||||
dump("visiting " . microtime(true) - $startTime);
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(5);
|
||||
// dump("removing popups " . microtime(true) - $startTime);
|
||||
// $this->removePopups($browser); // TEMPORARY DISABLED
|
||||
dump("signing in " . microtime(true) - $startTime);
|
||||
$this->signin($browser);
|
||||
sleep(2);
|
||||
dump("Saving notifications " . microtime(true) - $startTime);
|
||||
$this->saveNotifications($browser);
|
||||
sleep(seconds: 5);
|
||||
|
||||
$this->jobRun->success = true;
|
||||
$this->jobRun->save();
|
||||
|
||||
Log::info("InstagramNotificationHandlingJob run ended");
|
||||
|
||||
return $this->jobRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function runTest(Browser $browser): ?JobRun
|
||||
{
|
||||
$this->jobInfos = Job::find($this->jobId)->jobInfosTable();
|
||||
try {
|
||||
$browser->visit('https://instagram.com');
|
||||
sleep(2);
|
||||
$this->removePopups($browser);
|
||||
$this->signin($browser);
|
||||
sleep(3);
|
||||
return $this->makeSimpleJobRun(
|
||||
true,
|
||||
"Connexion réussie",
|
||||
"Datboi a réussi à se connecter sur Instagram"
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->makeSimpleJobRun(
|
||||
false,
|
||||
"Connexion échouée",
|
||||
"Datboi n'a pas réussi à se connecter sur Instagram :\n" . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function saveNotifications(Browser $browser): void
|
||||
{
|
||||
$this->openNotificationsPanel($browser);
|
||||
try {
|
||||
$DOMnotifications = $browser->driver->findElements(WebDriverBy::xpath('//div[@class="x6s0dn4 x1q4h3jn x78zum5 x1y1aw1k x64bnmy xwib8y2 x13jy36j x87ps6o x1wq6e7o x1ffbijf x1h4gsww xnp2e5m x1ypdohk x1l895ks"]')); // TODO : not rely on class names
|
||||
$notifications = [];
|
||||
foreach ($DOMnotifications as $DOMnotification) {
|
||||
// Process each notification
|
||||
$DOMnotificationText = $DOMnotification->findElement(WebDriverBy::xpath('./div[2]/span'));
|
||||
|
||||
$notification = new InstagramNotification();
|
||||
$notification->username = str_replace( "/", "", $DOMnotificationText->findElement(WebDriverBy::xpath('./a[1]'))->getAttribute('href'));
|
||||
$notification->notification_type = NotificationTypeDetector::detectType($DOMnotificationText->getText());
|
||||
// 05/08/2025 : Instagram removed the link to the post in the notification, the day I'm working on it :()
|
||||
$postId = null;
|
||||
try {
|
||||
$postId = str_replace(["\/p/", "/"], "", $DOMnotification->findElement(WebDriverBy::xpath('./div[3]/a'))->getAttribute('href'));
|
||||
dump("Post ID: " . $postId);
|
||||
$repostId = InstagramRepost::where('repost_reel_id', $postId)->first()?->id;
|
||||
dump("Repost ID: " . $repostId);
|
||||
if ($repostId) {
|
||||
$notification->instagram_repost_id = $repostId;
|
||||
}
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
Log::error("Failed to get post ID from notification : " . $e->getMessage());
|
||||
}
|
||||
$notification->message = $DOMnotificationText->getText();
|
||||
|
||||
// Save only if the notification is not already saved
|
||||
try {
|
||||
$notification->save();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to save notification : " . $e->getMessage());
|
||||
continue; // Skip to the next notification
|
||||
}
|
||||
dump("Notification saved : " . $notification);
|
||||
$notifications[] = $notification;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to get notifications : " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to open notifications panel: " . $e->getMessage()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function openNotificationsPanel(Browser $browser): void
|
||||
{
|
||||
try {
|
||||
$notificationsButtonXpath = '//a[./div//span[contains(text(), "Notifications")]]';
|
||||
$notificationsButton = $browser->driver->findElement(WebDriverBy::xpath($notificationsButtonXpath));
|
||||
// $notificationsButton->click();
|
||||
$this->clickElementWithJavaScript($browser, $notificationsButtonXpath);
|
||||
sleep(1);
|
||||
$browser->waitForText('Filter', 10, true);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to open notifications panel : " . $e->getMessage());
|
||||
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
||||
AllNotification::send(new JobDebugNotification($this->jobId, "Failed to open notifications panel: " . $e->getMessage()));
|
||||
return;
|
||||
}
|
||||
|
||||
if (app()->environment('local')) {
|
||||
Log::debug("Notifications panel opened successfully waiting 7 seconds for manual interaction");
|
||||
sleep(7); // Allow time for manual interaction in local environment
|
||||
}
|
||||
}
|
||||
}
|
475
app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php
Normal file
475
app/Browser/Jobs/InstagramRepost/InstagramRepostJob.php
Normal file
@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
namespace App\Browser\Jobs\InstagramRepost;
|
||||
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Browser\Jobs\Instagram\InstagramAbstractJob;
|
||||
use App\Browser\Jobs\InstagramRepost\DescriptionPipeline\InstagramDescriptionPipeline;
|
||||
use App\Models\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 InstagramAbstractJob implements ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// === CONFIGURATION ===
|
||||
|
||||
public $timeout = 1800; // 30 minutes
|
||||
|
||||
private const APPROXIMATIVE_RUNNING_MINUTES = 2;
|
||||
|
||||
protected IInstagramVideoDownloader $videoDownloader;
|
||||
|
||||
protected ReelDescriptor $ReelDescriptor;
|
||||
|
||||
protected string $downloadFolder = "app/Browser/downloads/InstagramRepost/";
|
||||
|
||||
/**
|
||||
* Pipeline for processing Instagram post descriptions.
|
||||
* This pipeline can be used to modify the description before reposting.
|
||||
* For example, it can remove account references or add hashtags.
|
||||
*
|
||||
* @var InstagramDescriptionPipeline
|
||||
*/
|
||||
protected InstagramDescriptionPipeline $descriptionPipeline;
|
||||
|
||||
public function __construct($jobId = 4, ReelDescriptor $ReelDescriptor = null, OpenAPIPrompt $openAPIPrompt = null)
|
||||
{
|
||||
parent::__construct($jobId, $openAPIPrompt);
|
||||
|
||||
$this->downloadFolder = base_path($this->downloadFolder);
|
||||
$this->videoDownloader = new YTDLPDownloader();
|
||||
$this->ReelDescriptor = $ReelDescriptor ?? app(ReelDescriptor::class);
|
||||
$this->descriptionPipeline = new InstagramDescriptionPipeline([
|
||||
// Add steps to the pipeline here
|
||||
new DescriptionPipeline\RemoveAccountsReferenceStep(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function run(Browser $browser): ?JobRun
|
||||
{
|
||||
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
|
||||
];
|
||||
|
||||
if ($downloadInfos !== null) {
|
||||
$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, int $maxReels = null, bool $save = true): array
|
||||
{
|
||||
if ($maxReels === null) {
|
||||
$maxReels = config("jobs.instagramRepost.max_reposts_per_account");
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
// Get the model for the reel
|
||||
$query = InstagramRepost::where("reel_id", $postId)
|
||||
->where("account_id", $accountModel->id)
|
||||
->first();
|
||||
|
||||
if ($query) {
|
||||
$reelModel = $query;
|
||||
} else {
|
||||
$reelModel = new InstagramRepost([
|
||||
"reel_id" => $postId,
|
||||
"account_id" => $accountModel->id,
|
||||
"reposted" => false,
|
||||
"repost_tries" => 0
|
||||
]);
|
||||
if ($save) {
|
||||
$reelModel = $reelModel->save();
|
||||
}
|
||||
}
|
||||
|
||||
if (count($accountReels) < $maxReels) {
|
||||
$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();
|
||||
|
||||
// set the repost ID to the lastest account reel ID
|
||||
$reel->repost_reel_id = $this->getLatestPostId($browser, $reel);
|
||||
$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 getLatestPostId(Browser $browser, InstagramRepost $reel): void
|
||||
{
|
||||
// Go to the profile page of the managed account
|
||||
$newReel = $this->getLatestReelsFromAccount(
|
||||
$browser,
|
||||
$this->jobInfos->get('instagram_repost_account_username'),
|
||||
1,
|
||||
false // Don't save the reel, we don't want to repost our own reposts
|
||||
)[0] ?? null;
|
||||
|
||||
if ($newReel === null) {
|
||||
Log::error("No reels found for account: " . $this->jobInfos->get('instagram_repost_account_username'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Return the reel ID
|
||||
return $newReel->reel_id;
|
||||
}
|
||||
|
||||
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}
|
||||
llm_description: {$reelDescription}",
|
||||
[],
|
||||
outputFormat: '{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}',
|
||||
systemMessage: "You are an expert Instagram caption writer. Your primary goal is to create short, engaging, concise captions for social media reels that capture the fun or relatability of the content without simply describing it like a transcript summary.
|
||||
|
||||
Captions must:
|
||||
1. Be brief and punchy.
|
||||
2. Capture the essence or mood of the video.
|
||||
3. Relate directly to the provided description (if available) or the core concept if no specific LLM description is given, but avoid copying phrasing awkwardly.
|
||||
4. Encourage engagement relevant to the platform's algorithm (e.g., asking a question related to the joke/scene).
|
||||
5. Optionally include relevant hashtags at the end (#hashtagsOnly), chosen appropriately for the reel's content or vibe. Use common tags if no specific ones are provided, but avoid overly generic ones unless fitting.
|
||||
6. If credit information is provided in the input (e.g., `credit: twitteruser`), acknowledge it minimally within the caption text *using only that source*. Do not invent any account handles (`@`) or platform prefixes (`tt/`). Use phrases like \"Credited to...\" or simply insert the credited name if appropriate, but don't force it unless the core concept naturally includes attribution. If no credit is provided, do not mention a specific creator.
|
||||
|
||||
**Do Not:**
|
||||
* Start captions with 'This reel...' or similar intros.
|
||||
* Describe the video content directly (replacing the LLM description role).
|
||||
* Include platform-specific mentions (`tt/`, `@`) unless naturally part of the credited source's name format itself and used minimally as instructed for credit handling.
|
||||
* Use overly complex sentences, slang that doesn't fit (#hashtags can be used), or long-winded explanations. Keep it to 1-3 short lines maximum.
|
||||
|
||||
**Emojis:**
|
||||
* Feel free to use emojis in moderation (e.g., 😂, 🤣, 😜, 👀) to add visual flair and emotion.
|
||||
* They should enhance the caption but not be the *main* focus. Avoid excessive or random emojis that look unprofessional.
|
||||
|
||||
Your response format must strictly adhere to JSON with only one required field: `answer`. Provide ONLY the generated caption string in this `answer` field, no explanations, markdown formatting, or other text.",
|
||||
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;
|
||||
}
|
||||
}
|
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;
|
||||
}
|
||||
}
|
@ -21,7 +21,7 @@ class JobController extends Controller
|
||||
public function show($jobId, Request $request)
|
||||
{
|
||||
return Inertia::render('Job', [
|
||||
'job' => Job::where('id', $jobId)->with('jobInfos')->first(),
|
||||
'job' => Job::where('id', $jobId)->with('jobInfos', 'jobRuns')->first(),
|
||||
'error' => $request->input('error'),
|
||||
]);
|
||||
}
|
||||
|
17
app/Models/HellcaseBattle.php
Normal file
17
app/Models/HellcaseBattle.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class HellcaseBattle extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
"battle_id",
|
||||
"value",
|
||||
];
|
||||
|
||||
public function getUrl() {
|
||||
return "https://hellcase.com/casebattle/{$this->battle_id}";
|
||||
}
|
||||
}
|
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');
|
||||
}
|
||||
}
|
30
app/Models/InstagramNotification.php
Normal file
30
app/Models/InstagramNotification.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InstagramNotification extends Model
|
||||
{
|
||||
protected $table = 'instagram_notifications';
|
||||
|
||||
protected $fillable = [
|
||||
'username',
|
||||
'instagram_repost_id',
|
||||
'notification_type',
|
||||
'message',
|
||||
'is_read',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'notification_type' => InstagramNotificationType::class,
|
||||
'is_read' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'is_read' => false,
|
||||
'is_processed' => false,
|
||||
];
|
||||
}
|
14
app/Models/InstagramNotificationType.php
Normal file
14
app/Models/InstagramNotificationType.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
enum InstagramNotificationType: string
|
||||
{
|
||||
case LIKE = 'LIKE';
|
||||
case COMMENT = 'COMMENT';
|
||||
case FOLLOW = 'FOLLOW';
|
||||
case MESSAGE = 'MESSAGE';
|
||||
case SYSTEM = 'SYSTEM'; // System message like congratulatory messages or updates
|
||||
case MENTION = 'MENTION';
|
||||
case OTHER = 'OTHER'; // Uncategorized or unclassified notifications
|
||||
}
|
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}/";
|
||||
}
|
||||
}
|
@ -21,6 +21,16 @@ class Job extends Model
|
||||
return $this->hasMany(JobInfo::class)->with("jobInfoType")->orderBy("created_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an associative collection of the job infos with their values
|
||||
* @return \Illuminate\Database\Eloquent\Collection<string, string>>
|
||||
*/
|
||||
public function jobInfosTable() {
|
||||
return $this->jobInfos->mapWithKeys(function ($jobInfo) {
|
||||
return [$jobInfo->key => $jobInfo->value];
|
||||
});
|
||||
}
|
||||
|
||||
public function jobRuns()
|
||||
{
|
||||
return $this->hasMany(JobRun::class)->orderBy("created_at");
|
||||
|
@ -4,6 +4,7 @@ namespace App\Notification;
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Notification\Stringifiable\StringifiableSimpleText;
|
||||
use function PHPUnit\Framework\isNull;
|
||||
|
||||
abstract class Notification {
|
||||
|
||||
@ -12,12 +13,18 @@ abstract class Notification {
|
||||
|
||||
public bool $isError;
|
||||
|
||||
public function __construct(int $jobId, NotificationBody $body, bool $isError = false) {
|
||||
public function __construct(int $jobId, NotificationBody $body = null, bool $isError = false) {
|
||||
$this->job = Job::find($jobId);
|
||||
$this->body = $body;
|
||||
if ($body !== null) {
|
||||
$this->body = $body;
|
||||
}
|
||||
$this->isError = $isError;
|
||||
}
|
||||
|
||||
public function setBody(NotificationBody $body) {
|
||||
$this->body = $body;
|
||||
}
|
||||
|
||||
public function getTitle(): Stringifiable {
|
||||
return new StringifiableSimpleText($this->job->name);
|
||||
}
|
||||
|
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notification\NotificationBody;
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Notification\NotificationBody;
|
||||
use App\Notification\Stringifiable;
|
||||
|
||||
class JobDebugNotificationBody extends NotificationBody {
|
||||
|
||||
private Job $job;
|
||||
private string $body;
|
||||
private ?string $error;
|
||||
private bool $hasScreenshot;
|
||||
|
||||
public function __construct(Job $job, string $body, string $error = null, bool $hasScreenshot = false) {
|
||||
$this->job = $job;
|
||||
$this->body = $body;
|
||||
$this->error = $error;
|
||||
$this->hasScreenshot = $hasScreenshot;
|
||||
}
|
||||
|
||||
private function constructString(bool $inMarkdown = false) {
|
||||
$mdBody = "";
|
||||
if ($this->body !== null) {
|
||||
$mdBody .= $this->body;
|
||||
}
|
||||
if ($this->error !== null) {
|
||||
$errorWrapper = $inMarkdown ? "```" : "";
|
||||
$mdBody .= " :\n" . $errorWrapper . $this->error . $errorWrapper;
|
||||
}
|
||||
if ($inMarkdown && $this->hasScreenshot) {
|
||||
$mdBody .= "\nScreenshot : ";
|
||||
}
|
||||
return $mdBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toMarkdownString(): string {
|
||||
return $this->constructString(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toString(): string {
|
||||
return $this->constructString();
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notification\NotificationBody;
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Notification\NotificationBody;
|
||||
use App\Notification\Stringifiable;
|
||||
|
||||
class JobErrorNotificationBody extends NotificationBody {
|
||||
|
||||
private Job $job;
|
||||
private string $error;
|
||||
|
||||
public function __construct(Job $job, $error) {
|
||||
$this->job = $job;
|
||||
$this->error = $error;
|
||||
}
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toMarkdownString(): string {
|
||||
return "Le job \"{$this->job->name}\" a échoué avec l'erreur :\n ```" . $this->error . "``` \nScreenshot : ";
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toString(): string {
|
||||
return "Le job \"{$this->job->name}\" a échoué avec l'erreur :\n " . $this->error;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -3,12 +3,9 @@
|
||||
namespace App\Notification\Notifications\Hellcase;
|
||||
|
||||
use App\Browser\Jobs\Hellcase\HellcaseDailyFreeScreenshot;
|
||||
use App\Browser\Jobs\Hellcase\HellcaseLoginQrCode;
|
||||
use App\Notification\Notification;
|
||||
use App\Notification\Notifications\NotificationLogin;
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class HellcaseNotificationDailyFree extends NotificationLogin {
|
||||
class HellcaseNotificationDailyFree extends Notification {
|
||||
|
||||
public function __construct(int $jobId, \App\Notification\NotificationBody $body) {
|
||||
parent::__construct($jobId, $body);
|
||||
|
44
app/Notification/Notifications/JobDebugNotification.php
Normal file
44
app/Notification/Notifications/JobDebugNotification.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notification\Notifications;
|
||||
|
||||
use App\Browser\JobDebugScreenshot;
|
||||
use App\Notification\Notification;
|
||||
use App\Notification\NotificationBody\JobDebugNotificationBody;
|
||||
use App\Notification\Stringifiable;
|
||||
use App\Notification\Stringifiable\StringifiableSimpleText;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
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 = true) {
|
||||
parent::__construct($jobId, isError:$isError);
|
||||
$this->title = $title;
|
||||
if ($screenshotProjectPath === "") {
|
||||
$screenshotProjectPath = JobDebugScreenshot::getImgFileProjectPath($jobId);
|
||||
}
|
||||
$this->screenShotProjectPath = $screenshotProjectPath;
|
||||
$this->setBody(new JobDebugNotificationBody($this->job, $body, $error, $this->screenShotProjectPath != null));
|
||||
}
|
||||
|
||||
public function getTitle(): Stringifiable {
|
||||
return new StringifiableSimpleText($this->title ?? "DEBUG Job {$this->job->name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getImageProjectPath(): string|null {
|
||||
return $this->screenShotProjectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLinkURL(): string|null {
|
||||
return route('jobs.show', ['job' => $this->job->id]);
|
||||
}
|
||||
}
|
37
app/Notification/Notifications/JobErrorNotification.php
Normal file
37
app/Notification/Notifications/JobErrorNotification.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notification\Notifications;
|
||||
|
||||
use App\Browser\JobErrorScreenshot;
|
||||
use App\Models\Job;
|
||||
use App\Notification\Notification;
|
||||
use App\Notification\NotificationBody\JobErrorNotificationBody;
|
||||
use App\Notification\Stringifiable;
|
||||
use App\Notification\Stringifiable\StringifiableSimpleText;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JobErrorNotification extends Notification {
|
||||
|
||||
public function __construct(int $jobId, string $error) {
|
||||
parent::__construct($jobId, isError:true);
|
||||
$this->setBody(new JobErrorNotificationBody($this->job, $error));
|
||||
}
|
||||
|
||||
public function getTitle(): Stringifiable {
|
||||
return new StringifiableSimpleText("Le job {$this->job->name} a échoué");
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getImageProjectPath(): string|null {
|
||||
return JobErrorScreenshot::getImgFileProjectPath($this->job->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLinkURL(): string|null {
|
||||
return route('jobs.show', ['job' => $this->job->id]);
|
||||
}
|
||||
}
|
@ -3,9 +3,6 @@
|
||||
namespace App\Notification\Providers;
|
||||
|
||||
use App\Notification\NotificationProvider;
|
||||
use App\Notification\INotificationProvider;
|
||||
use App\Models\JobInfo;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AllNotification extends NotificationProvider {
|
||||
private const NOTIFICATIONS_PROVIDERS = [
|
||||
@ -15,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@
|
||||
namespace App\Notification\Providers;
|
||||
|
||||
use App\Notification\NotificationProvider;
|
||||
use App\Notification\INotificationProvider;
|
||||
use App\Models\JobInfo;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
@ -14,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 :
|
||||
{
|
||||
@ -45,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,
|
||||
@ -74,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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
42
app/Providers/VideoDescriptorServiceProvider.php
Normal file
42
app/Providers/VideoDescriptorServiceProvider.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
use App\Services\FileTools\Transcription\IAudioTranscriptor;
|
||||
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);
|
||||
|
||||
// Audio transcription service
|
||||
$this->app->singleton(IAudioTranscriptor::class, function ($app) {
|
||||
return new \App\Services\FileTools\Transcription\OpenAIAPIAudioTranscriptor();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')); // DEBUG TODO : Is null ? so thows error because $host is normally of type non null string
|
||||
$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 '';
|
||||
}
|
||||
}
|
||||
}
|
14
app/Services/FileTools/Transcription/IAudioTranscriptor.php
Normal file
14
app/Services/FileTools/Transcription/IAudioTranscriptor.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\Transcription;
|
||||
|
||||
interface IAudioTranscriptor
|
||||
{
|
||||
/**
|
||||
* Perform transcription on the given audio file.
|
||||
*
|
||||
* @param string $filePath The path to the audio file to be transcribed.
|
||||
* @return string The transcribed text from the audio file.
|
||||
*/
|
||||
public function transcribe(string $filePath): ?string;
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\Transcription;
|
||||
|
||||
use Log;
|
||||
|
||||
class OpenAIAPIAudioTranscriptor implements IAudioTranscriptor
|
||||
{
|
||||
|
||||
private function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization: ' . (config('llm.api.transcription.token') ? 'Bearer ' . config('llm.api.transcription.token') : ''),
|
||||
//'Content-Type: application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function transcribe(string $filePath): ?string
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
Log::error("File not found: {$filePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make a call to the API with curl
|
||||
// Example of working curl command:
|
||||
// curl -s "SPEACHES_BASE_URL/v1/audio/transcriptions" -F "file=@/home/ninluc/Downloads/memeSalto/m19.mp3" -F "model=MODEL_ID"
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => config('llm.api.transcription.host') . '/audio/transcriptions',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => [
|
||||
'file' => new \CURLFile($filePath),
|
||||
'model' => config('llm.models.transcription.name'),
|
||||
],
|
||||
CURLOPT_HTTPHEADER => $this->getHeaders(),
|
||||
]);
|
||||
$response = curl_exec($curl);
|
||||
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
if ($httpCode !== 200) {
|
||||
Log::error("Error during transcription: HTTP code {$httpCode} : {$response}");
|
||||
return null;
|
||||
}
|
||||
$responseData = json_decode($response, true);
|
||||
return $responseData['text'] ?? null;
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract audio from the video file.
|
||||
* Using ffmpeg to extract audio from the video file.
|
||||
* The audio will be saved in a temporary directory as an MP3 file.
|
||||
* If the audio extraction fails, it will return null.
|
||||
* @param string $filePath
|
||||
* @return string|null
|
||||
*/
|
||||
protected function extractAudioFromVideo(string $filePath): ?string
|
||||
{
|
||||
$tempDir = sys_get_temp_dir() . '/video_audio';
|
||||
if (!is_dir($tempDir)) {
|
||||
mkdir($tempDir, 0777, true);
|
||||
}
|
||||
else {
|
||||
// Clear the directory if it already exists
|
||||
array_map('unlink', glob($tempDir . '/*'));
|
||||
}
|
||||
|
||||
$outputFile = $tempDir . '/audio.mp3';
|
||||
$command = "ffmpeg -i " . escapeshellarg($filePath) . " " . escapeshellarg($outputFile);
|
||||
exec($command);
|
||||
|
||||
if (file_exists($outputFile)) {
|
||||
return $outputFile;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
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;
|
||||
}
|
||||
}
|
157
app/Services/FileTools/VideoDescriptor/OCRLLMVideoDescriptor.php
Normal file
157
app/Services/FileTools/VideoDescriptor/OCRLLMVideoDescriptor.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileTools\VideoDescriptor;
|
||||
|
||||
use App\Services\AIPrompt\OpenAPIPrompt;
|
||||
use App\Services\FileTools\OCR\IImageOCR;
|
||||
use App\Services\FileTools\Transcription\IAudioTranscriptor;
|
||||
|
||||
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 IAudioTranscriptor $audioTranscriptor)
|
||||
{
|
||||
}
|
||||
|
||||
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);
|
||||
$audio = $this->extractAudioFromVideo($filePath);
|
||||
|
||||
// Audio transcription
|
||||
$audioTranscription = null;
|
||||
if (isset($audio)) {
|
||||
$audioTranscription = $this->audioTranscriptor->transcribe($audio);
|
||||
dump($audioTranscription); // DEBUG
|
||||
}
|
||||
|
||||
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 = '';
|
||||
// Add full video informations
|
||||
// Audio transcription
|
||||
if (isset($audio)) {
|
||||
$combinedDescription .= "Audio Transcription: {$audioTranscription}\n";
|
||||
}
|
||||
|
||||
if (!empty($combinedDescription)) {
|
||||
$combinedDescription .= "\n";
|
||||
}
|
||||
|
||||
// Add screenshots descriptions
|
||||
$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);
|
||||
|
||||
dump($combinedDescription); // DEBUG
|
||||
|
||||
// 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,
|
||||
outputFormat: '{"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}',
|
||||
systemMessage: "You are an expert social media content analyst specializing in Instagram Reels. Your task is to synthesize descriptions and OCR findings from multiple screenshots of a single video reel into a single, concise, and accurate overall description of the video's content, style, and potential humor.
|
||||
|
||||
Your input will consist of:
|
||||
1. An audio transcription of the entire video.
|
||||
2. Multiple entries containing:
|
||||
- Screenshot number (e.g., \"Screenshot: 1\")
|
||||
- Timestamp (in seconds) indicating its position in the reel
|
||||
- Raw OCR text from that specific screenshot, which may contain errors or unusual characters but should be interpreted for content relevance.
|
||||
- A description of the image content generated by an LLM for that screenshot.
|
||||
|
||||
The descriptions provided by the LLM for individual screenshots are often inconsistent with adjacent frames and might not capture subtle humor accurately. The raw OCR text can sometimes provide direct quotes relevant to the context, even if misspelled or partially recognized.
|
||||
|
||||
Your response must be in **exactly** the following JSON format:
|
||||
```json
|
||||
{
|
||||
\"answer\": \"{your synthesized description here}\"
|
||||
}
|
||||
```
|
||||
Please follow these instructions carefully:
|
||||
|
||||
Analyze All Data: Consider both the audio transcription and all the screenshot data (OCR text and descriptions) together.
|
||||
Synthesize Coherently: Create a single, flowing narrative that describes the main subject(s), actions, setting, transitions, sound/music, and overall style of the video reel based on the most consistent or contextually supported information across its frames.
|
||||
Handle Inconsistencies: Assume that individual screenshot analyses might contain errors (especially with OCR) or be limited in scope. Do not rely solely on one frame's description contradicting another unless strongly supported by context and multiple data points converge to a different understanding or the inconsistency is clearly part of a joke requiring literal interpretation.
|
||||
Focus on Repeated Elements: Pay close attention to subjects, actions, objects, text content (especially from OCR), sounds/words mentioned in the transcription, and visual styles that repeat across multiple frames, as this indicates continuity or recurring themes/humor.
|
||||
Identify Joke/Humor: Actively look for elements within the combined data that suggest a joke, satire, absurdity, irony, sarcasm, clever wordplay (from OCR/transcription), or unexpected humor. This includes inconsistent descriptions if they are clearly intended as part of a gag, visual puns, audio-visual mismatches mentioned in the transcription, or any content designed for comedic effect.
|
||||
Prioritize Core Content: Base your description primarily on the core subject and action within the reel (as identified repeatedly across frames). Use details from individual screenshots to flesh out specific moments only if they fit this narrative context.
|
||||
Filter Minor Details: Ignore highly variable or insignificant details that appear inconsistent unless they are clearly integral to the joke or overall theme (e.g., slight variations in background color might be acceptable, but a consistent change is important).
|
||||
Output Requirement: Your response must contain only valid JSON with an object having exactly one property answer of type string. Do not output any other text, explanations, lists, or code outside this JSON structure.",
|
||||
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;
|
||||
}
|
||||
}
|
37
app/Services/Instagram/NotificationTypeDetector.php
Normal file
37
app/Services/Instagram/NotificationTypeDetector.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Instagram;
|
||||
|
||||
use App\Models\InstagramNotificationType;
|
||||
use Facebook\WebDriver\Remote\RemoteWebElement;
|
||||
|
||||
class NotificationTypeDetector
|
||||
{
|
||||
/**
|
||||
* Detects the Instagram notification type from the DOM.
|
||||
*
|
||||
* @param string $descriptionText The text content of the notification description.
|
||||
* @return InstagramNotificationType
|
||||
*/
|
||||
public static function detectType(string $descriptionText): InstagramNotificationType
|
||||
{
|
||||
$descriptionText = trim($descriptionText); // Remove leading/trailing whitespace
|
||||
|
||||
// Prioritize exact matches for reliability
|
||||
if (strpos($descriptionText, 'liked') !== false) {
|
||||
return InstagramNotificationType::LIKE;
|
||||
} elseif (strpos($descriptionText, 'commented') !== false) {
|
||||
return InstagramNotificationType::COMMENT;
|
||||
} elseif (strpos($descriptionText, 'following you') !== false) {
|
||||
return InstagramNotificationType::FOLLOW;
|
||||
} elseif (strpos($descriptionText, 'message') !== false) {
|
||||
return InstagramNotificationType::MESSAGE;
|
||||
} elseif (strpos($descriptionText, 'congratulations') !== false || strpos($descriptionText, 'update') !== false) {
|
||||
return InstagramNotificationType::SYSTEM;
|
||||
} elseif (strpos($descriptionText, 'mention') !== false) {
|
||||
return InstagramNotificationType::MENTION;
|
||||
}
|
||||
|
||||
return InstagramNotificationType::OTHER;
|
||||
}
|
||||
}
|
@ -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,
|
||||
];
|
||||
|
@ -2,7 +2,6 @@
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tsConfigPath": "./tsconfig.json",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "resources/css/app.css",
|
||||
@ -10,9 +9,12 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"framework": "laravel",
|
||||
"aliases": {
|
||||
"components": "@/Components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
"composables": "@/composables",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/Components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
@ -39,8 +39,8 @@ services:
|
||||
|
||||
undetected-chromedriver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: undetectedChromedriver/seleniumChromedriverDockerfile
|
||||
context: ./undetectedChromedriver
|
||||
dockerfile: seleniumChromedriverDockerfile
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /tmp:/tmp
|
||||
|
@ -9,15 +9,17 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "8.3.*",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/dusk": "^8.2",
|
||||
"laravel/framework": "^11.31",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/reverb": "^1.0",
|
||||
"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": {
|
||||
|
557
composer.lock
generated
557
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,5 +4,7 @@ return [
|
||||
|
||||
"driver" => [
|
||||
"url" => $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? null
|
||||
]
|
||||
],
|
||||
"shouldStartMaximized" => $_ENV['DUSK_START_MAXIMIZED'] ?? env('DUSK_START_MAXIMIZED') ?? false,
|
||||
"headlessDisabled" => $_ENV['DUSK_HEADLESS_DISABLED'] ?? env('DUSK_HEADLESS_DISABLED') ?? false
|
||||
];
|
||||
|
@ -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,
|
||||
],
|
||||
|
||||
];
|
||||
|
61
config/llm.php
Normal file
61
config/llm.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?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),
|
||||
|
||||
'transcription' => [
|
||||
/**
|
||||
* Host for the OpenAI transcription API.
|
||||
* This should be the base URL of the OpenAI transcription API you are using with the API version (v1)
|
||||
*/
|
||||
'host' => env('TRANSC_API_HOST_URL', null),
|
||||
|
||||
/**
|
||||
* Token for authenticating with the OpenAI transcription API.
|
||||
* Null if not used
|
||||
*/
|
||||
'token' => env('TRANSC_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),
|
||||
],
|
||||
|
||||
'transcription' => [
|
||||
'name' => env('TRANSC_TRANSCRIPTION_MODEL', null)
|
||||
],
|
||||
]
|
||||
];
|
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Job;
|
||||
use App\Models\JobInfo;
|
||||
use App\Models\JobInfoType;
|
||||
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 = 3;
|
||||
Job::forceCreate([
|
||||
"id" => $newJobId,
|
||||
"name" => "Hellcase Battles",
|
||||
"description" => "Envoie les meilleures battles d'Hellcase",
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "hellcase_battles_discord_webhook_url",
|
||||
"name" => "Webhook Discord",
|
||||
"description" => "Le lien discord webhook utilisé pour envoyer les meilleures battles d'Hellcase.\nSi aucun n'est spécifié, le webhook Discord des paramètres généraux sera utilisé.",
|
||||
"placeholder" => "https://discord.com/api/webhooks/...",
|
||||
"is_required" => false,
|
||||
"job_info_type_id" => 4,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfoType::forceCreate([
|
||||
"id" => 5,
|
||||
"name" => "number",
|
||||
]);
|
||||
JobInfoType::forceCreate([
|
||||
"id" => 6,
|
||||
"name" => "boolean",
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "hellcase_battles_minimum_value",
|
||||
"name" => "Valeur minimum des battles",
|
||||
"description" => "La valeur minimale qu'une battle doit avoir pour être envoyée, en euros.",
|
||||
"placeholder" => "1000",
|
||||
"job_info_type_id" => 5,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
JobInfo::forceCreate([
|
||||
"key" => "hellcase_battles_allow_bots",
|
||||
"name" => "Autoriser les battles avec bots",
|
||||
"description" => "Envoyer les battles avec un seul joueur et des bots.",
|
||||
"is_required" => false,
|
||||
"job_info_type_id" => 6,
|
||||
"job_id" => $newJobId,
|
||||
]);
|
||||
|
||||
Schema::create('hellcase_battles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string("battle_id")->unique();
|
||||
$table->float("value");
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Job::where("id", 3)->delete();
|
||||
JobInfo::where("job_id", 3)->delete();
|
||||
JobInfoType::whereIn("id", [5, 6])->delete();
|
||||
|
||||
Schema::dropIfExists('hellcase_battles');
|
||||
}
|
||||
};
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstagramNotificationType;
|
||||
use App\Models\InstagramRepost;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('instagram_notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->string('username')->nullable();
|
||||
$table->foreignIdFor(InstagramRepost::class)->nullable()
|
||||
->constrained('instagram_reposts')
|
||||
->noActionOnDelete()
|
||||
->cascadeOnUpdate();
|
||||
$table->enum('notification_type', array_column(InstagramNotificationType::cases(), 'value'));
|
||||
$table->text('message')->nullable();
|
||||
$table->boolean('is_read')->default(false);
|
||||
$table->boolean('is_processed')->default(false);
|
||||
|
||||
$table->unique(['username', 'instagram_repost_id', 'notification_type', 'message'], 'unique_instagram_notification');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('instagram_reposts', function (Blueprint $table) {
|
||||
$table->string("repost_reel_id")->unique()->nullable()->comment('Reel ID of the reposted content');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('instagram_notifications');
|
||||
|
||||
Schema::table('instagram_reposts', function (Blueprint $table) {
|
||||
$table->dropUnique(['repost_reel_id']);
|
||||
$table->dropColumn("repost_reel_id");
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
JobInfo::where('key', 'instagram_repost_account_email')
|
||||
->update([
|
||||
'key' => 'instagram_repost_account_username',
|
||||
'description' => "Le nom d'utilisateur unique utilisé pour le compte Instagram de repost."
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
JobInfo::where('key', 'instagram_repost_account_username')
|
||||
->update([
|
||||
'key' => 'instagram_repost_account_email',
|
||||
'description' => "L'adresse e-mail/nom d'utilisateur/N° de téléphone utilisée pour le compte Instagram de repost."
|
||||
]);
|
||||
}
|
||||
};
|
77
package-lock.json
generated
77
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"lucide-react": "^0.474.0",
|
||||
"lucide-vue-next": "^0.474.0",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.1.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
@ -21,8 +22,10 @@
|
||||
"autoprefixer": "^10.4.12",
|
||||
"axios": "^1.7.4",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-echo": "^2.0.2",
|
||||
"laravel-vite-plugin": "^1.2.0",
|
||||
"postcss": "^8.4.31",
|
||||
"pusher-js": "^8.4.0",
|
||||
"sass-embedded": "^1.83.4",
|
||||
"tailwindcss": "^3.2.1",
|
||||
"typescript": "^5.6.3",
|
||||
@ -956,20 +959,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz",
|
||||
"integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==",
|
||||
"version": "3.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.4.tgz",
|
||||
"integrity": "sha512-fNGO9fjjSLns87tlcto106enQQLycCKR4DPNpgq3djP5IdcPFdPAmaKjsgzIeRhH7hWrELgW12hYnRthS5kLUw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.11.3.tgz",
|
||||
"integrity": "sha512-BVZ00i5XBucetRj2doVd32jOPtJthvZSVJvx9GL4gSQsyngliSCtzlP1Op7TFrEtmebRKT8QUQE1tRhOQzWecQ==",
|
||||
"version": "3.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.4.tgz",
|
||||
"integrity": "sha512-1fPrd3hE1SS4R/9JbX1AlzueY4duCK7ixuLcMW5GMnk9N6WbLo9MioNKiv22V+UaXKOLNy8tLdzT8NYerOFTOQ==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.11.3"
|
||||
"@tanstack/virtual-core": "3.13.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@ -2239,6 +2242,15 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-echo": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.0.2.tgz",
|
||||
"integrity": "sha512-Ciai6hA7r35MFqNRb8G034cvm9WiveSTFQQKRGJhWtZGbng7C8BBa5QvqDxk/Mw5GeJ+q19jrEwQhf7r1b1lcg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz",
|
||||
@ -2487,6 +2499,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz",
|
||||
"integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@ -2700,6 +2717,15 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
|
||||
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@ -2847,6 +2873,37 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.1.0.tgz",
|
||||
"integrity": "sha512-w4kEDEyXhIqv4QeFJeiuBc4mQP37hH/UTRpEb9dMbPdR49JG5TcV/s0+ntNRONUUW4LDLX7E1ZPcwBw5hnu0yw==",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@floating-ui/vue": "^1.1.6",
|
||||
"@internationalized/date": "^3.5.0",
|
||||
"@internationalized/number": "^3.5.0",
|
||||
"@tanstack/vue-virtual": "^3.12.0",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/shared": "^12.5.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.4",
|
||||
"ohash": "^1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui/node_modules/@vueuse/shared": {
|
||||
"version": "12.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
|
||||
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@ -3697,6 +3754,12 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
|
@ -24,12 +24,14 @@
|
||||
"vue-tsc": "^2.0.24"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"lucide-vue-next": "^0.474.0",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.1.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
|
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.2
|
||||
version: 8.21.2(vue@3.5.13(typescript@5.7.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^12.5.0
|
||||
version: 12.7.0(typescript@5.7.3)
|
||||
@ -26,6 +29,9 @@ importers:
|
||||
radix-vue:
|
||||
specifier: ^1.9.13
|
||||
version: 1.9.16(vue@3.5.13(typescript@5.7.3))
|
||||
reka-ui:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
@ -425,9 +431,19 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.21.2':
|
||||
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.2':
|
||||
resolution: {integrity: sha512-Qzz4EgzMbO5gKrmqUondCjiHcuu4B1ftHb0pjCut661lXZdGoHeze9f/M8iwsK1t5LGR6aNuNGU7mxkowaW6RQ==}
|
||||
|
||||
'@tanstack/vue-table@8.21.2':
|
||||
resolution: {integrity: sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.2':
|
||||
resolution: {integrity: sha512-z4swzjdhzCh95n9dw9lTvw+t3iwSkYRlVkYkra3C9mul/m5fTzHR7KmtkwH4qXMTXGJUbngtC/bz2cHQIHkO8g==}
|
||||
peerDependencies:
|
||||
@ -952,6 +968,9 @@ packages:
|
||||
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ohash@1.1.6:
|
||||
resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
@ -1054,6 +1073,11 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
reka-ui@2.1.0:
|
||||
resolution: {integrity: sha512-w4kEDEyXhIqv4QeFJeiuBc4mQP37hH/UTRpEb9dMbPdR49JG5TcV/s0+ntNRONUUW4LDLX7E1ZPcwBw5hnu0yw==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -1681,8 +1705,15 @@ snapshots:
|
||||
mini-svg-data-uri: 1.4.4
|
||||
tailwindcss: 3.4.17
|
||||
|
||||
'@tanstack/table-core@8.21.2': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.2': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.2(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.2
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.2(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.2
|
||||
@ -2224,6 +2255,8 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.3: {}
|
||||
|
||||
ohash@1.1.6: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
@ -2321,6 +2354,23 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
reka-ui@2.1.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.13
|
||||
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3))
|
||||
'@internationalized/date': 3.7.0
|
||||
'@internationalized/number': 3.6.0
|
||||
'@tanstack/vue-virtual': 3.13.2(vue@3.5.13(typescript@5.7.3))
|
||||
'@vueuse/core': 12.7.0(typescript@5.7.3)
|
||||
'@vueuse/shared': 12.7.0(typescript@5.7.3)
|
||||
aria-hidden: 1.2.4
|
||||
defu: 6.1.4
|
||||
ohash: 1.1.6
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
resolve@1.22.10:
|
||||
|
@ -18,7 +18,7 @@ const jobInfoType = props.jobInfo.job_info_type.name;
|
||||
<div>
|
||||
<Label :for="'' + jobInfo.id" class="text">{{ jobInfo.name }}<span v-if="jobInfo.is_required" class="cursor-help" title="Requis" aria-label="Requis">*</span></Label>
|
||||
<Description>{{ jobInfo.description }}</Description>
|
||||
<Input v-if="jobInfoType != 'checkbox'" :type="jobInfoType" :id="'' + jobInfo.id" :name="'' + jobInfo.id" :placeholder="jobInfo.placeholder" v-model="jobInfo.value as string" :required="jobInfo.is_required" />
|
||||
<Input v-if="['text', 'email', 'password', 'url', 'number'].includes(jobInfoType)" :type="jobInfoType" :id="'' + jobInfo.id" :name="'' + jobInfo.id" :placeholder="jobInfo.placeholder" v-model="jobInfo.value as string" :required="jobInfo.is_required" />
|
||||
<VModelCheckbox v-else :id="'' + jobInfo.id" :class="''" v-model="jobInfo.value as boolean" />
|
||||
</div>
|
||||
|
||||
|
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import Separator from "@/Components/ui/separator/Separator.vue";
|
||||
import { JobRunArtifact } from "@/types/Jobs/job";
|
||||
|
||||
defineProps<{
|
||||
jobRun: JobRunArtifact;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
<li v-for="artifact in jobRun.artifacts" :key="artifact.id">
|
||||
<p>{{ artifact.name }}</p>
|
||||
<p class="italic">{{ artifact.content }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
list-style-type: circle;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
28
resources/js/Components/Layout/Job/JobRuns/JobRunItem.vue
Normal file
28
resources/js/Components/Layout/Job/JobRuns/JobRunItem.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import AccordionContent from "@/Components/ui/accordion/AccordionContent.vue";
|
||||
import AccordionItem from "@/Components/ui/accordion/AccordionItem.vue";
|
||||
import AccordionTrigger from "@/Components/ui/accordion/AccordionTrigger.vue";
|
||||
import { JobRunArtifact } from "@/types/Jobs/job";
|
||||
import JobRunArtifacts from "./JobRunArtifacts.vue";
|
||||
|
||||
defineProps<{
|
||||
jobRun: JobRunArtifact;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem :value="''+jobRun.id" :class="[jobRun.success ? 'bg-green-100' : 'bg-red-200', 'first:rounded-t last:rounded-b', 'px-3']">
|
||||
<AccordionTrigger>
|
||||
{{ new Date(Date.parse(jobRun.created_at)).toLocaleTimeString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<JobRunArtifacts :jobRun="jobRun" />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</template>
|
30
resources/js/Components/Layout/Job/JobRuns/JobRuns.vue
Normal file
30
resources/js/Components/Layout/Job/JobRuns/JobRuns.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { Job, JobRunArtifact } from "@/types/Jobs/job";
|
||||
import JobRunItem from "./JobRunItem.vue";
|
||||
import Accordion from "@/Components/ui/accordion/Accordion.vue";
|
||||
import ScrollArea from "@/Components/ui/scroll-area/ScrollArea.vue";
|
||||
|
||||
defineProps<{
|
||||
job: Job;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="job.job_runs.length > 0">
|
||||
<h2>Ancien jobs</h2>
|
||||
<ScrollArea class="min-h-[300px] max-h-[20vh] overflow-auto pr-2">
|
||||
<Accordion type="multiple" collapsible>
|
||||
<JobRunItem
|
||||
:jobRun="jobRun"
|
||||
v-for="jobRun in job.job_runs.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.created_at).getTime() -
|
||||
new Date(a.created_at).getTime()
|
||||
);
|
||||
})"
|
||||
:key="jobRun.id"
|
||||
/>
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</template>
|
19
resources/js/Components/ui/accordion/Accordion.vue
Normal file
19
resources/js/Components/ui/accordion/Accordion.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AccordionRoot,
|
||||
type AccordionRootEmits,
|
||||
type AccordionRootProps,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AccordionRoot>
|
||||
</template>
|
24
resources/js/Components/ui/accordion/AccordionContent.vue
Normal file
24
resources/js/Components/ui/accordion/AccordionContent.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AccordionContent, type AccordionContentProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
24
resources/js/Components/ui/accordion/AccordionItem.vue
Normal file
24
resources/js/Components/ui/accordion/AccordionItem.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
39
resources/js/Components/ui/accordion/AccordionTrigger.vue
Normal file
39
resources/js/Components/ui/accordion/AccordionTrigger.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
type AccordionTriggerProps,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
4
resources/js/Components/ui/accordion/index.ts
Normal file
4
resources/js/Components/ui/accordion/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Accordion } from './Accordion.vue'
|
||||
export { default as AccordionContent } from './AccordionContent.vue'
|
||||
export { default as AccordionItem } from './AccordionItem.vue'
|
||||
export { default as AccordionTrigger } from './AccordionTrigger.vue'
|
35
resources/js/Components/ui/separator/Separator.vue
Normal file
35
resources/js/Components/ui/separator/Separator.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator, type SeparatorProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<
|
||||
SeparatorProps & { class?: HTMLAttributes['class'], label?: string }
|
||||
>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border relative',
|
||||
props.orientation === 'vertical' ? 'w-px h-full' : 'h-px w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="props.label"
|
||||
:class="cn('text-xs text-muted-foreground bg-background absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex justify-center items-center',
|
||||
props.orientation === 'vertical' ? 'w-[1px] px-1 py-2' : 'h-[1px] py-1 px-2',
|
||||
)"
|
||||
>{{ props.label }}</span>
|
||||
</Separator>
|
||||
</template>
|
1
resources/js/Components/ui/separator/index.ts
Normal file
1
resources/js/Components/ui/separator/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import JobForm from '../Components/Layout/Job/JobForm.vue'
|
||||
import JobCard from '../Components/Layout/Job/JobCard.vue'
|
||||
import JobRuns from '../Components/Layout/Job/JobRuns/JobRuns.vue';
|
||||
import { Job } from "@/types/Jobs/job";
|
||||
import { Head } from "@inertiajs/vue3";
|
||||
|
||||
@ -16,4 +17,6 @@ defineProps<{
|
||||
<JobCard :job="job" />
|
||||
|
||||
<JobForm :job="job" :error="error" />
|
||||
|
||||
<JobRuns :job="job" />
|
||||
</template>
|
||||
|
@ -1,8 +1,17 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { Updater } from '@tanstack/vue-table'
|
||||
import type { Ref } from 'vue'
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
|
||||
ref.value
|
||||
= typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
|
||||
export async function httpApi<T>(route: string): Promise<T> {
|
||||
|
14
resources/js/types/Jobs/job.d.ts
vendored
14
resources/js/types/Jobs/job.d.ts
vendored
@ -5,8 +5,9 @@ export type Job = {
|
||||
is_active: boolean;
|
||||
|
||||
job_infos: JobInfo[];
|
||||
job_runs: JobRunArtifact[];
|
||||
|
||||
created_at: Date;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type JobInfo = {
|
||||
@ -26,16 +27,23 @@ export type JobInfo = {
|
||||
export type JobInfoType = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type JobRunArtifact = {
|
||||
jobId: number;
|
||||
id: number;
|
||||
job_id: number;
|
||||
artifacts: JobArtifact[];
|
||||
success: boolean;
|
||||
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type JobArtifact = {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
|
||||
created_at: string;
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ Route::get('/jobs', function (Request $request) {
|
||||
|
||||
Route::get('/test/{id}', function (Request $request, $id, BrowserJobsInstances $BrowserJobsInstances) {
|
||||
$log = $BrowserJobsInstances->getJobInstance($id)->execute();
|
||||
return response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]);
|
||||
dump($log);
|
||||
dump(response()->json(['message' => 'Job ' . $id . ' ran', 'jobRun' => $log->load('artifacts')]));
|
||||
});
|
||||
|
||||
Route::get('jobs/{job}/test', [JobController::class, 'test'])->name('jobs.test');
|
||||
|
@ -1,6 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Browser\Jobs\Hellcase\HellcaseJob;
|
||||
use App\Browser\Jobs\HellcaseBattles\HellcaseBattlesJob;
|
||||
use App\Browser\Jobs\InstagramRepost\InstagramNotificationHandlingJob;
|
||||
use App\Browser\Jobs\InstagramRepost\InstagramRepostJob;
|
||||
use App\Jobs\PruneOldJobRuns;
|
||||
use App\Services\BrowserJobsInstances;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
@ -21,3 +24,6 @@ Schedule::job(new PruneOldJobRuns)->monthly()->onOneServer()->withoutOverlapping
|
||||
// Jobs
|
||||
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('Instagram reposts job');
|
||||
Schedule::job(new InstagramNotificationHandlingJob)->hourly()->onOneServer()->withoutOverlapping()->name('instagram_reposts_notifications')->description('Instagram reposts notification handling 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);
|
||||
}
|
||||
}
|
3
todo.md
3
todo.md
@ -13,6 +13,9 @@
|
||||
- Websocket installé
|
||||
- Serveur php plus propre (nginx, apache, n'importe)
|
||||
- Epic games
|
||||
Pas l'air possible avec cloudflare
|
||||
- Petit bug, quand l'on enregistre un formulaire avec une erreur, l'url a un argument GET ?error=mon%24erreur
|
||||
Du coup dans la nav le job actuel n'est plus reconnu
|
||||
|
||||
## Pour deploy Lama
|
||||
|
||||
|
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
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user