ibombonato commited on
Commit
94a7d52
·
verified ·
1 Parent(s): 3cdec01

Add Mercado Livre support (#4)

Browse files

- Add Mercado Livre support (472175aa7e87eff9693e48929495ceaa655412d3)

.clinerules ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copilot Instructions for the Project
2
+
3
+ ## Ignore files and folders
4
+
5
+ - You should ignore the files and folder bellow:
6
+ - .venv/
7
+ - .vscode/
8
+ - __pycache__/
9
+ - build/
10
+ - chroma.db/
11
+ - vibe_dspy.egg-info/
12
+ - Ignore files that are mentioned inside .gitignore
13
+
14
+ ## Environment and Secrets
15
+
16
+ - All credentials and config are loaded from `.env` using `python-dotenv`.
17
+ - For DB access, use `DB_USER`, `DB_PASS_ENCODED`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`.
18
+ - For LLMs, use `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`.
19
+
20
+ ## Conventions and Workflows
21
+
22
+ - Always use environment variables for secrets and config.
23
+ - Always use `uv run ` to run code and code tools
24
+ - Always use `pytest` for unit tests and run then with `uv run pytest`.
25
+ - Always use `uv` and `uv add` to manage dependencies.
26
+
27
+ ## References
28
+
29
+ - See `README.md` for high-level goals and links.
30
+
31
+
32
+ ## Security and Environment File Handling
33
+
34
+ - **Never read, modify, index, or delete any `.env` files.**
35
+ - Do not access, print, or manipulate environment variable files (e.g., `.env`) in any way.
36
+ - All environment configuration is managed outside of AI agent operations for security and compliance.
37
+
38
+ ## Commit Message Guidelines (Conventional Commits)
39
+
40
+ All commit messages should adhere to the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). This provides a standardized format for commit messages, making it easier to understand the purpose of a commit and to automate changelog generation.
41
+
42
+ The basic structure of a commit message is:
43
+
44
+ ```
45
+ <type>[optional scope]: <description>
46
+
47
+ [optional body]
48
+
49
+ [optional footer(s)]
50
+ ```
51
+
52
+ ### Type
53
+
54
+ The `type` is a mandatory prefix that indicates the kind of change introduced by the commit. Common types include:
55
+
56
+ * `feat`: A new feature
57
+ * `fix`: A bug fix
58
+ * `docs`: Documentation only changes
59
+ * `style`: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
60
+ * `refactor`: A code change that neither fixes a bug nor adds a feature
61
+ * `perf`: A code change that improves performance
62
+ * `test`: Adding missing tests or correcting existing tests
63
+ * `build`: Changes that affect the build system or external dependencies (example scopes: npm, gulp, broccoli, make)
64
+ * `ci`: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
65
+ * `chore`: Other changes that don't modify src or test files
66
+ * `revert`: Reverts a previous commit
67
+
68
+ ### Scope (Optional)
69
+
70
+ The `scope` provides additional contextual information about the change. It is enclosed in parentheses after the `type`. For example, `feat(parser): add ability to parse arrays`.
71
+
72
+ ### Description
73
+
74
+ The `description` is a concise, imperative, present-tense summary of the change. It should not be capitalized and should not end with a period.
75
+
76
+ ### Body (Optional)
77
+
78
+ The `body` provides a longer, more detailed explanation of the commit's changes. It should be separated from the description by a blank line.
79
+
80
+ ### Footer(s) (Optional)
81
+
82
+ The `footer` can contain information about breaking changes, references to issues, or other metadata. Breaking changes should start with `BREAKING CHANGE:` followed by a description.
83
+
84
+ ### Examples
85
+
86
+ * `feat: add new user authentication module`
87
+ * `fix(auth): correct password validation bug`
88
+ * `docs: update README with installation instructions`
89
+ * `refactor(api): simplify error handling logic`
90
+ * `BREAKING CHANGE: refactor(core): remove old API endpoint`
91
+ `The /api/v1/old-endpoint has been removed. Use /api/v1/new-endpoint instead.`
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/Montserrat-Bold.ttf filter=lfs diff=lfs merge=lfs -text
37
+ assets/Montserrat-Regular.ttf filter=lfs diff=lfs merge=lfs -text
38
+ assets/template_natura_empty.jpg filter=lfs diff=lfs merge=lfs -text
GEMINI.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copilot Instructions for the Project
2
+
3
+ ## Ignore files and folders
4
+
5
+ - You should ignore the files and folder bellow:
6
+ - .venv/
7
+ - .vscode/
8
+ - __pycache__/
9
+ - build/
10
+ - chroma.db/
11
+ - vibe_dspy.egg-info/
12
+ - Ignore files that are mentioned inside .gitignore
13
+
14
+ ## Environment and Secrets
15
+
16
+ - All credentials and config are loaded from `.env` using `python-dotenv`.
17
+ - For DB access, use `DB_USER`, `DB_PASS_ENCODED`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`.
18
+ - For LLMs, use `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT_NAME`.
19
+
20
+ ## Conventions and Workflows
21
+
22
+ - Always use environment variables for secrets and config.
23
+ - Always use `uv run ` to run code and code tools
24
+ - Always use `pytest` for unit tests and run then with `uv run pytest`.
25
+ - Always use `uv` and `uv add` to manage dependencies.
26
+
27
+ ## References
28
+
29
+ - See `README.md` for high-level goals and links.
30
+
31
+
32
+ ## Security and Environment File Handling
33
+
34
+ - **Never read, modify, index, or delete any `.env` files.**
35
+ - Do not access, print, or manipulate environment variable files (e.g., `.env`) in any way.
36
+ - All environment configuration is managed outside of AI agent operations for security and compliance.
37
+
38
+ ## Commit Message Guidelines (Conventional Commits)
39
+
40
+ All commit messages should adhere to the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). This provides a standardized format for commit messages, making it easier to understand the purpose of a commit and to automate changelog generation.
41
+
42
+ The basic structure of a commit message is:
43
+
44
+ ```
45
+ <type>[optional scope]: <description>
46
+
47
+ [optional body]
48
+
49
+ [optional footer(s)]
50
+ ```
51
+
52
+ ### Type
53
+
54
+ The `type` is a mandatory prefix that indicates the kind of change introduced by the commit. Common types include:
55
+
56
+ * `feat`: A new feature
57
+ * `fix`: A bug fix
58
+ * `docs`: Documentation only changes
59
+ * `style`: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
60
+ * `refactor`: A code change that neither fixes a bug nor adds a feature
61
+ * `perf`: A code change that improves performance
62
+ * `test`: Adding missing tests or correcting existing tests
63
+ * `build`: Changes that affect the build system or external dependencies (example scopes: npm, gulp, broccoli, make)
64
+ * `ci`: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
65
+ * `chore`: Other changes that don't modify src or test files
66
+ * `revert`: Reverts a previous commit
67
+
68
+ ### Scope (Optional)
69
+
70
+ The `scope` provides additional contextual information about the change. It is enclosed in parentheses after the `type`. For example, `feat(parser): add ability to parse arrays`.
71
+
72
+ ### Description
73
+
74
+ The `description` is a concise, imperative, present-tense summary of the change. It should not be capitalized and should not end with a period.
75
+
76
+ ### Body (Optional)
77
+
78
+ The `body` provides a longer, more detailed explanation of the commit's changes. It should be separated from the description by a blank line.
79
+
80
+ ### Footer(s) (Optional)
81
+
82
+ The `footer` can contain information about breaking changes, references to issues, or other metadata. Breaking changes should start with `BREAKING CHANGE:` followed by a description.
83
+
84
+ ### Examples
85
+
86
+ * `feat: add new user authentication module`
87
+ * `fix(auth): correct password validation bug`
88
+ * `docs: update README with installation instructions`
89
+ * `refactor(api): simplify error handling logic`
90
+ * `BREAKING CHANGE: refactor(core): remove old API endpoint`
91
+ `The /api/v1/old-endpoint has been removed. Use /api/v1/new-endpoint instead.`
README.md CHANGED
@@ -9,7 +9,7 @@ This project leverages AI agents to automatically generate social media ad copy
9
 
10
  ## How it Works
11
 
12
- The system uses a Gradio interface (`app.py`) with two main tabs:
13
 
14
  1. **Social Media Ad Generator:** This tab takes product URLs and other parameters as input. Behind the scenes, a "crew" of AI agents, each with a specific role, processes this information:
15
  * **Product Analyst:** This agent scrapes a product URL to extract key information like the product name, features, price, and any available discounts. It also uses a tool to shorten the URL.
@@ -19,6 +19,12 @@ The system uses a Gradio interface (`app.py`) with two main tabs:
19
  * **Expert Perfume Analyst and Web Data Extractor:** This agent extracts detailed perfume information (notes, accords, longevity, sillage, similar fragrances, reviews) from the Fragrantica page.
20
  * **Fragrance Expert Woman and Perfume Analysis Reporter:** This agent synthesizes the extracted data into a human-friendly report, including graded evaluations and personalized recommendations.
21
 
 
 
 
 
 
 
22
  ## Setup and Usage
23
 
24
  1. **Prerequisites:**
@@ -33,7 +39,7 @@ The system uses a Gradio interface (`app.py`) with two main tabs:
33
  ```
34
  * Run the Docker container, mapping port 7860 and passing API keys as environment variables:
35
  ```bash
36
- docker run -p 7860:7860 -e OPENAI_API_KEY="your_openai_api_key" -e NATURA_API_TOKEN="your_natura_api_token" -e OPENAI_BASE_URL="your_openai_base_url" -e OPENAI_MODEL_NAME="your_openai_model_name" natura-ads
37
  ```
38
  * Access the Gradio interface in your web browser at `http://localhost:7860`.
39
 
@@ -42,6 +48,8 @@ The system uses a Gradio interface (`app.py`) with two main tabs:
42
  * `app.py`: The Gradio application that provides the user interface.
43
  * `social_media_crew.py`: Defines the AI agents and their tasks for social media ad generation.
44
  * `fragrantica_crew.py`: Defines the AI agents and their tasks for Fragrantica website analysis.
 
 
45
  * `stealth_scrape_tool.py`: A custom tool for stealthy web scraping using Playwright.
46
  * `shortener_tool.py`: A custom tool for shortening URLs.
47
  * `Dockerfile`: Defines the Docker image for deploying the application.
@@ -53,3 +61,6 @@ The system uses a Gradio interface (`app.py`) with two main tabs:
53
 
54
  - [x] Add support for any model/api key supported by LiteLLM.
55
  - [x] Add Fragrantica support, where user will input a Fragrantica URL and the agent will extract and generate a Perfume Analysis report.
 
 
 
 
9
 
10
  ## How it Works
11
 
12
+ The system uses a Gradio interface (`app.py`) with three main tabs:
13
 
14
  1. **Social Media Ad Generator:** This tab takes product URLs and other parameters as input. Behind the scenes, a "crew" of AI agents, each with a specific role, processes this information:
15
  * **Product Analyst:** This agent scrapes a product URL to extract key information like the product name, features, price, and any available discounts. It also uses a tool to shorten the URL.
 
19
  * **Expert Perfume Analyst and Web Data Extractor:** This agent extracts detailed perfume information (notes, accords, longevity, sillage, similar fragrances, reviews) from the Fragrantica page.
20
  * **Fragrance Expert Woman and Perfume Analysis Reporter:** This agent synthesizes the extracted data into a human-friendly report, including graded evaluations and personalized recommendations.
21
 
22
+ 3. **Image Ad Generator:** This tab allows users to generate a promotional image for a product. It takes the product name, original price, final price, a coupon code, and a product image URL as input. The tool then generates a promotional image with this information, based on a template.
23
+
24
+ ## Merchant Support
25
+
26
+ The application now supports generating ad copy for both **Natura** and **Mercado Livre** products. The `merchs/merch.py` file defines a `Merchant` class with two subclasses: `NaturaMerchant` and `MercadoLivreMerchant`. This allows the application to use different templates and URL shorteners for each merchant.
27
+
28
  ## Setup and Usage
29
 
30
  1. **Prerequisites:**
 
39
  ```
40
  * Run the Docker container, mapping port 7860 and passing API keys as environment variables:
41
  ```bash
42
+ docker run --rm -p 7860:7860 -e OPENAI_API_KEY="your_openai_api_key" -e NATURA_API_TOKEN="your_natura_api_token" -e OPENAI_BASE_URL="your_openai_base_url" -e OPENAI_MODEL_NAME="your_openai_model_name" natura-ads
43
  ```
44
  * Access the Gradio interface in your web browser at `http://localhost:7860`.
45
 
 
48
  * `app.py`: The Gradio application that provides the user interface.
49
  * `social_media_crew.py`: Defines the AI agents and their tasks for social media ad generation.
50
  * `fragrantica_crew.py`: Defines the AI agents and their tasks for Fragrantica website analysis.
51
+ * `merchs/merch.py`: Defines the merchant-specific logic for Natura and Mercado Livre.
52
+ * `generate_image_tool.py`: A tool to generate promotional images for products.
53
  * `stealth_scrape_tool.py`: A custom tool for stealthy web scraping using Playwright.
54
  * `shortener_tool.py`: A custom tool for shortening URLs.
55
  * `Dockerfile`: Defines the Docker image for deploying the application.
 
61
 
62
  - [x] Add support for any model/api key supported by LiteLLM.
63
  - [x] Add Fragrantica support, where user will input a Fragrantica URL and the agent will extract and generate a Perfume Analysis report.
64
+ - [x] Support Mercado Livre Merchant
65
+ - [wip] Add image templates
66
+ - [] Create carroussel images for Fragrantica post
app.py CHANGED
@@ -2,95 +2,13 @@ import gradio as gr
2
  import os
3
  import requests
4
  from crewai import Agent, Task, Crew, Process, LLM
5
- from crewai_tools import ScrapeWebsiteTool
6
- from crewai.tools import BaseTool
7
  from dotenv import load_dotenv
8
  from stealth_scrape_tool import StealthScrapeTool
 
 
9
 
10
- load_dotenv()
11
-
12
- class ShortenerTool(BaseTool):
13
- name: str = "URL Shortener Tool"
14
- description: str = "Generates a short version of a given URL using an external API."
15
- natura_api_token: str
16
-
17
- def _run(self, original_url: str) -> str:
18
- api_url = "https://sales-mgmt-cb-bff-apigw.prd.naturacloud.com/cb-bff-cms/cms/shortener"
19
- headers = {"authorization": f"Bearer {self.natura_api_token}", "content-type": "application/json"}
20
- payload = {"url": original_url}
21
-
22
- try:
23
- response = requests.post(api_url, headers=headers, json=payload)
24
- response.raise_for_status()
25
- short_url_data = response.json()
26
- return short_url_data.get("short", original_url)
27
- except requests.exceptions.RequestException as e:
28
- print(f"Warning: Error generating short URL: {e}. Returning original URL.")
29
- return original_url
30
- except ValueError:
31
- print("Warning: Invalid JSON response from shortener API. Returning original URL.")
32
- return original_url
33
-
34
- class CalculateDiscountedPriceTool(BaseTool):
35
- """
36
- A tool to calculate the final price of an item after a discount is applied.
37
- """
38
- name: str = "Calculate Discounted Price Tool"
39
- description: str = "Calculates the price after applying a given discount percentage."
40
-
41
- def _run(self, original_price: float, discount_percentage: float) -> float:
42
-
43
- """Calculates the discounted price and the total discount amount.
44
-
45
- This method takes an original price and a discount percentage, validates
46
- the inputs, and then computes the final price after the discount is
47
- applied, as well as the amount saved.
48
-
49
- Args:
50
- original_price: The initial price of the item as a float or integer.
51
 
52
- Returns:
53
- float:
54
- - The final discounted price, rounded to 2 decimal places.
55
- """
56
-
57
- if not isinstance(original_price, (int, float)) or not isinstance(discount_percentage, (int, float)):
58
- raise ValueError("Both original_price and discount_percentage must be numbers.")
59
- if discount_percentage < 0 or discount_percentage > 100:
60
- raise ValueError("Discount percentage must be between 0 and 100.")
61
-
62
- discount_amount = original_price * (discount_percentage / 100)
63
- discounted_price = original_price - discount_amount
64
- return round(discounted_price, 2)
65
-
66
- class CalculateDiscountValueTool(BaseTool):
67
- """
68
- A tool to calculate the final discount value of an item after comparing the original value and the final value.
69
- """
70
- name: str = "Calculate Discount Value Tool"
71
- description: str = "Calculates the discount value after comparing two values."
72
-
73
- def _run(self, original_price: float, final_price: float) -> float:
74
-
75
- """Calculates the total discounted amount give the original and final price.
76
-
77
- This method takes an original price and a final price, validates
78
- the inputs, and then computes the final discounted value.
79
-
80
- Args:
81
- original_price: The initial price of the item as a float or integer.
82
- final_price: The final price after discount as a float or integer.
83
-
84
- Returns:
85
- float:
86
- - The final discount value, rounded to 0 decimal places.
87
- """
88
- if not isinstance(original_price, (int, float)) or not isinstance(final_price, (int, float)):
89
- raise ValueError("Both original_price and final_price must be numbers.")
90
-
91
- discount_value = original_price - final_price
92
- discount_percentage = (discount_value / original_price) * 100
93
- return round(discount_percentage, 0)
94
 
95
  class SocialMediaCrew:
96
  def __init__(self, openai_api_key: str, natura_api_token: str, openai_base_url: str, openai_model_name: str):
@@ -99,9 +17,10 @@ class SocialMediaCrew:
99
  self.openai_base_url = openai_base_url
100
  self.openai_model_name = openai_model_name
101
  self.scrape_tool = StealthScrapeTool() #ScrapeWebsiteTool()
102
- self.shortener_tool = ShortenerTool(natura_api_token=self.natura_api_token)
103
  self.calculate_discounted_price_tool = CalculateDiscountedPriceTool()
104
  self.calculate_discount_value_tool = CalculateDiscountValueTool()
 
 
105
 
106
  print("Initializing SocialMediaCrew with BASE URL:", self.openai_base_url)
107
  print("Using OpenAI Model:", self.openai_model_name)
@@ -119,7 +38,6 @@ class SocialMediaCrew:
119
  backstory=("You are an expert in analyzing product pages and extracting the most important information. You can identify the product name, its main features, and the target audience."),
120
  verbose=True,
121
  tools=[self.scrape_tool,
122
- self.shortener_tool,
123
  self.calculate_discounted_price_tool,
124
  self.calculate_discount_value_tool],
125
  allow_delegation=False,
@@ -132,18 +50,19 @@ class SocialMediaCrew:
132
  goal='Create a compelling social media post in Portuguese to sell the product',
133
  backstory=("You are a creative copywriter specialized in the beauty and fragrance market. You know how to craft posts that are engaging, persuasive, and tailored for a Portuguese-speaking audience. You are an expert in using emojis and hashtags to increase engagement."),
134
  verbose=True,
 
135
  allow_delegation=False,
136
  llm=llm,
137
  max_retries=3
138
  )
139
 
140
- def run_crew(self, product_url: str, main_cupom: str, main_cupom_discount_percentage: float, cupom_1: str, cupom_2: str) -> str:
141
  headers = {
142
  "accept": "*/*",
143
  "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
144
- "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"' ,
145
  "sec-ch-ua-mobile": "?0",
146
- "sec-ch-ua-platform": '"Windows"' ,
147
  "sec-fetch-dest": "empty",
148
  "sec-fetch-mode": "cors",
149
  "sec-fetch-site": "cross-site",
@@ -153,48 +72,58 @@ class SocialMediaCrew:
153
  response = requests.get(product_url, headers=headers)
154
  response.raise_for_status()
155
  if '<template data-dgst="NEXT_NOT_FOUND">' in response.text:
156
- return "INVALID_URL"
 
157
  except requests.exceptions.RequestException as e:
158
  print(f"Error checking URL: {e}")
159
- return "INVALID_URL"
160
-
161
- analyze_product_task = Task(
162
- description=(f"1. Scrape the content of the URL: {product_url} using the 'scrape_tool' with css_element = '.product-detail-banner'.\n2. Identify and extract the original product price and the final discounted price if existing. IGNORE any price breakdowns like 'produto' or 'consultoria'.\n3. Extract the product name, key characteristics, and any other relevant DISCOUNT available.\n4. Use the 'Calculate Discounted Price Tool' with the extracted final best price and the provided DISCOUNT PERCENTAGE ({main_cupom_discount_percentage}) to get the CUPOM DISCOUNTED PRICE.\n4.1 Use the 'Calculate Discount Value Tool' with ORIGINAL PRICE and CUPOM DISCOUNTED PRICE to get the TOTAL DISCOUNT PERCENTAGE.\n5. Use the 'URL Shortener Tool' to generate a short URL for {product_url}. If the shortener tool returns an error, use the original URL.\n6. Provide all this information, including the product name, ORIGINAL PRICE, DISCOUNTED PRICE (the one used as the input in the tool 'Calculate Discounted Price Tool'), 2) CUPOM DISCOUNTED PRICE, and the generated short URL (or the original if the shortener failed). If any of this information cannot be extracted, you MUST return 'MISSING_PRODUCT_INFO'."),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  agent=self.product_analyst,
164
- expected_output="A concise summary of the product including its name, key features, unique selling points, ORIGINAL PRICE, DISCOUNTED PRICE (the one used as the input in the tool 'Calculate Discounted Price Tool'), CUPOM DISCOUNTED PRICE, TOTAL DISCOUNT PERCENTAGE, and the SHORT SHAREABLE URL (or the original if the shortener failed), OR 'MISSING_PRODUCT_INFO' if essential product details are not found."
165
  )
166
 
167
- create_post_task = Task(
168
- description=(f"""Based on the product analysis, create a CONCISE and DIRECT social media post in Portuguese, suitable for a WhatsApp group.
169
- If the input you receive is 'INVALID_URL' or 'MISSING_PRODUCT_INFO', you MUST stop and output only that same message.
170
- The post should strictly follow this template:
171
- ###Template:
172
- {{Title}}
173
-
174
- {{Description}}
175
-
176
- De ~~{{ORIGINAL PRICE}}~~
177
- 🔥Por {{CUPOM DISCOUNTED PRICE}} 🔥
178
-
179
- 🔥 {{TOTAL DISCOUNT PERCENTAGE}}% OFF!
180
-
181
- 🎟️ USE O CUPOM >>> {main_cupom}
182
-
183
- 🛒 Link >>> {{short_url}}
184
-
185
- `🎟️ *Cupom válido para a primeira compra no link Minha Loja Natura, mesmo se já comprou no app ou link antigo. Demais compras ou app, use o cupom {cupom_1} ou {cupom_2} (o desconto é um pouco menor)`
186
-
187
- `‼️ Faça login nesse link com o mesmo email e senha que já usa pra comprar Natura!`
188
- ###End Template
189
-
190
- Ensure a URL is always present in the output. Include a clear call to action and a MAXIMUM of 2 relevant emojis. DO NOT include hashtags. Keep it short and impactful and does not forget to include the backticks around the last paragraph.
191
-
192
- If the input you receive is 'INVALID_URL', you MUST stop and output only 'INVALID_URL'."""),
193
  agent=self.social_media_copywriter,
194
  expected_output="A short, direct, and impactful social media post in Portuguese for WhatsApp, strictly following the provided template, including the FINAL PRICE, any DISCOUNT, the SHORT SHAREABLE URL, a call to action, and up to 2 emojis, one in the Title and another in the Description. No hashtags should be present. A URL must always be present in the final output, OR the message 'INVALID_URL' or 'MISSING_PRODUCT_INFO' if the page was not found or product info is missing.",
195
  context=[analyze_product_task]
196
  )
197
 
 
 
 
 
 
 
 
 
 
198
  crew = Crew(
199
  agents=[self.product_analyst, self.social_media_copywriter],
200
  tasks=[analyze_product_task, create_post_task],
@@ -212,19 +141,25 @@ def clean_env_vars():
212
  os.environ.pop("OPENAI_MODEL_NAME", None)
213
 
214
  # --- Gradio Interface ---
215
- def generate_ad(product_url: str, main_cupom: str, main_cupom_discount_percentage: float, cupom_1: str, cupom_2: str, openai_api_key: str, natura_api_token: str, openai_base_url: str, openai_model_name: str):
 
 
216
  if not openai_api_key or not natura_api_token or not openai_model_name or not openai_base_url:
217
- return "Please configure your API keys in the settings section below."
 
 
 
 
218
 
219
  social_media_crew = SocialMediaCrew(openai_api_key, natura_api_token, openai_base_url, openai_model_name)
220
- result = social_media_crew.run_crew(product_url, main_cupom, main_cupom_discount_percentage, cupom_1, cupom_2)
221
 
222
  if result == "INVALID_URL":
223
- return "❌ The provided URL is invalid or the product page could not be found."
224
  elif result == "MISSING_PRODUCT_INFO":
225
- return "⚠️ Could not extract all required product information from the URL. Please check the URL or try a different one."
226
  else:
227
- return result.raw
228
 
229
  with gr.Blocks() as demo:
230
  gr.Markdown("# 🚀 Social Media Ad Generator")
@@ -232,12 +167,16 @@ with gr.Blocks() as demo:
232
 
233
  with gr.Tab("Generate Ad"):
234
  url_input = gr.Textbox(label="Product URL", placeholder="Enter product URL here...")
 
235
 
236
  main_cupom_input = gr.Textbox(label="Main Cupom (e.g., PRIMEIRACOMPRA)", value="PRIMEIRACOMPRA")
237
- main_cupom_discount_percentage_input = gr.Number(label="Main Cupom Discount Percentage (e.g., 20 for 20%)", value=20, minimum=0, maximum=100)
238
  cupom_1_input = gr.Textbox(label="Cupom 1 (e.g., AMIGO15)", placeholder="Enter first coupon code...")
239
- cupom_2_input = gr.Textbox(label="Cupom 2 (e.g., JULHOA)", placeholder="Enter second coupon code...")
240
- generate_button = gr.Button("Generate Ad")
 
 
 
241
  ad_output = gr.Markdown(label="Your Generated Ad", show_copy_button=True)
242
 
243
  with gr.Tab("Fragrantica"):
@@ -258,8 +197,12 @@ with gr.Blocks() as demo:
258
  # No save button needed as keys are passed directly
259
  gr.Markdown("API keys are used directly from these fields when you click 'Generate Ad'. They are not saved persistently.")
260
 
261
- generate_button.click(generate_ad, inputs=[url_input, main_cupom_input, main_cupom_discount_percentage_input, cupom_1_input, cupom_2_input, openai_key_input, natura_token_input, openai_base_url_input, openai_model_name_input], outputs=ad_output)
262
-
 
 
 
 
263
  # Placeholder for Fragrantica analysis function
264
  def analyze_fragrantica_url(url, openai_api_key, natura_api_token, openai_base_url, openai_model_name):
265
  if not openai_api_key or not openai_model_name or not openai_base_url:
 
2
  import os
3
  import requests
4
  from crewai import Agent, Task, Crew, Process, LLM
 
 
5
  from dotenv import load_dotenv
6
  from stealth_scrape_tool import StealthScrapeTool
7
+ from image_generator_tool import GenerateImageTool
8
+ from utils_tools import CalculateDiscountedPriceTool, CalculateDiscountValueTool, GetImageUrlTool, MerchantSelectorTool
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ load_dotenv()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  class SocialMediaCrew:
14
  def __init__(self, openai_api_key: str, natura_api_token: str, openai_base_url: str, openai_model_name: str):
 
17
  self.openai_base_url = openai_base_url
18
  self.openai_model_name = openai_model_name
19
  self.scrape_tool = StealthScrapeTool() #ScrapeWebsiteTool()
 
20
  self.calculate_discounted_price_tool = CalculateDiscountedPriceTool()
21
  self.calculate_discount_value_tool = CalculateDiscountValueTool()
22
+ self.image_generator_tool = GenerateImageTool()
23
+ self.merchant_selector_tool = MerchantSelectorTool(natura_api_token=self.natura_api_token)
24
 
25
  print("Initializing SocialMediaCrew with BASE URL:", self.openai_base_url)
26
  print("Using OpenAI Model:", self.openai_model_name)
 
38
  backstory=("You are an expert in analyzing product pages and extracting the most important information. You can identify the product name, its main features, and the target audience."),
39
  verbose=True,
40
  tools=[self.scrape_tool,
 
41
  self.calculate_discounted_price_tool,
42
  self.calculate_discount_value_tool],
43
  allow_delegation=False,
 
50
  goal='Create a compelling social media post in Portuguese to sell the product',
51
  backstory=("You are a creative copywriter specialized in the beauty and fragrance market. You know how to craft posts that are engaging, persuasive, and tailored for a Portuguese-speaking audience. You are an expert in using emojis and hashtags to increase engagement."),
52
  verbose=True,
53
+ tools=[self.image_generator_tool],
54
  allow_delegation=False,
55
  llm=llm,
56
  max_retries=3
57
  )
58
 
59
+ def _validate_url(self, product_url: str) -> bool:
60
  headers = {
61
  "accept": "*/*",
62
  "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
63
+ "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
64
  "sec-ch-ua-mobile": "?0",
65
+ "sec-ch-ua-platform": '"Windows"',
66
  "sec-fetch-dest": "empty",
67
  "sec-fetch-mode": "cors",
68
  "sec-fetch-site": "cross-site",
 
72
  response = requests.get(product_url, headers=headers)
73
  response.raise_for_status()
74
  if '<template data-dgst="NEXT_NOT_FOUND">' in response.text:
75
+ return False
76
+ return True
77
  except requests.exceptions.RequestException as e:
78
  print(f"Error checking URL: {e}")
79
+ return False
80
+
81
+ def _prepare_merchant(self, product_url: str):
82
+ merchant = self.merchant_selector_tool.run(product_url)
83
+ css_selector = merchant.get_css_selector()
84
+ short_url = merchant.shorten_url(product_url)
85
+ return merchant, css_selector, short_url
86
+
87
+ def _create_analyze_product_task(self, product_url: str, css_selector: str, main_cupom_discount_percentage: float, short_url: str, original_price: float, discounted_price: float) -> Task:
88
+ task_description = (f"1. Scrape the content of the URL: {product_url} using the 'scrape_tool' with css_element = '{css_selector}'.\n"
89
+ "2. Extract the product name, key characteristics, and any other relevant DISCOUNT available.\n")
90
+
91
+ if original_price is not None and original_price > 0 and discounted_price is not None and discounted_price > 0:
92
+ task_description += (f"3. The user has provided the prices. Use ORIGINAL PRICE = {original_price} and DISCOUNTED PRICE = {discounted_price}.\n")
93
+ final_best_price_source = str(discounted_price)
94
+ else:
95
+ task_description += ("3. Identify and extract the original product price and the final discounted price if existing from the scraped content. "
96
+ "IGNORE any price breakdowns like 'produto' or 'consultoria'.\n")
97
+ final_best_price_source = "the extracted final best price"
98
+
99
+ task_description += (f"4. Use the 'Calculate Discounted Price Tool' with {final_best_price_source} and the provided DISCOUNT PERCENTAGE ({main_cupom_discount_percentage}) to get the CUPOM DISCOUNTED PRICE.\n"
100
+ "4.1 Use the 'Calculate Discount Value Tool' with ORIGINAL PRICE and CUPOM DISCOUNTED PRICE to get the TOTAL DISCOUNT PERCENTAGE.\n"
101
+ f"5. Provide all this information, including the product name, ORIGINAL PRICE, DISCOUNTED PRICE (the one from step 3), CUPOM DISCOUNTED PRICE, and the generated short URL ({short_url}). If any of this information cannot be extracted, you MUST return 'MISSING_PRODUCT_INFO'.")
102
+
103
+ return Task(
104
+ description=task_description,
105
  agent=self.product_analyst,
106
+ expected_output="A concise summary of the product including its name, key features, unique selling points, ORIGINAL PRICE, DISCOUNTED PRICE (the one used as the input in the tool 'Calculate Discounted Price Tool'), CUPOM DISCOUNTED PRICE, TOTAL DISCOUNT PERCENTAGE, and the SHORT SHAREABLE URL ({short_url}), OR 'MISSING_PRODUCT_INFO' if essential product details are not found."
107
  )
108
 
109
+ def _create_post_task(self, analyze_product_task: Task, merchant, main_cupom: str, cupom_1: str, store_name: str) -> Task:
110
+ template = merchant.get_template(main_cupom, cupom_1, store=store_name)
111
+ return Task(
112
+ description=(f"Based on the product analysis, create a CONCISE and DIRECT social media post in Portuguese, suitable for a WhatsApp group. \n If the input you receive is 'INVALID_URL' or 'MISSING_PRODUCT_INFO', you MUST stop and output only that same message.\n The post should strictly follow this template:\n {template}\n\nEnsure a URL is always present in the output. Include a clear call to action and a MAXIMUM of 2 relevant emojis. DO NOT include hashtags. Keep it short and impactful and does not forget to include the backticks around the last paragraph.\n\n If the input you receive is 'INVALID_URL', you MUST stop and output only 'INVALID_URL'."),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  agent=self.social_media_copywriter,
114
  expected_output="A short, direct, and impactful social media post in Portuguese for WhatsApp, strictly following the provided template, including the FINAL PRICE, any DISCOUNT, the SHORT SHAREABLE URL, a call to action, and up to 2 emojis, one in the Title and another in the Description. No hashtags should be present. A URL must always be present in the final output, OR the message 'INVALID_URL' or 'MISSING_PRODUCT_INFO' if the page was not found or product info is missing.",
115
  context=[analyze_product_task]
116
  )
117
 
118
+ def run_crew(self, product_url: str, store_name: str, main_cupom: str, main_cupom_discount_percentage: float, cupom_1: str, original_price: float, discounted_price: float) -> str:
119
+ if not self._validate_url(product_url):
120
+ return "INVALID_URL"
121
+
122
+ merchant, css_selector, short_url = self._prepare_merchant(product_url)
123
+
124
+ analyze_product_task = self._create_analyze_product_task(product_url, css_selector, main_cupom_discount_percentage, short_url, original_price, discounted_price)
125
+ create_post_task = self._create_post_task(analyze_product_task, merchant, main_cupom, cupom_1, store_name)
126
+
127
  crew = Crew(
128
  agents=[self.product_analyst, self.social_media_copywriter],
129
  tasks=[analyze_product_task, create_post_task],
 
141
  os.environ.pop("OPENAI_MODEL_NAME", None)
142
 
143
  # --- Gradio Interface ---
144
+ def generate_ad(product_url: str, store_name: str, main_cupom: str, main_cupom_discount_percentage: float, cupom_1: str, original_price: float, discounted_price: float, openai_api_key: str, natura_api_token: str, openai_base_url: str, openai_model_name: str):
145
+ yield gr.update(interactive=False, value="Generating..."), gr.Markdown(value="⏳ Generating ad... Please wait.")
146
+
147
  if not openai_api_key or not natura_api_token or not openai_model_name or not openai_base_url:
148
+ yield gr.update(interactive=True, value="Generate Ad"), gr.Markdown(value="Please configure your API keys in the settings section below.")
149
+ return
150
+
151
+ original_price = original_price if original_price is not None else 0
152
+ discounted_price = discounted_price if discounted_price is not None else 0
153
 
154
  social_media_crew = SocialMediaCrew(openai_api_key, natura_api_token, openai_base_url, openai_model_name)
155
+ result = social_media_crew.run_crew(product_url, store_name, main_cupom, main_cupom_discount_percentage, cupom_1, original_price, discounted_price)
156
 
157
  if result == "INVALID_URL":
158
+ yield gr.update(interactive=True, value="Generate Ad"), gr.Markdown(value="❌ The provided URL is invalid or the product page could not be found.")
159
  elif result == "MISSING_PRODUCT_INFO":
160
+ yield gr.update(interactive=True, value="Generate Ad"), gr.Markdown(value="⚠️ Could not extract all required product information from the URL. Please check the URL or try a different one.")
161
  else:
162
+ yield gr.update(interactive=True, value="Generate Ad"), gr.Markdown(value=result.raw)
163
 
164
  with gr.Blocks() as demo:
165
  gr.Markdown("# 🚀 Social Media Ad Generator")
 
167
 
168
  with gr.Tab("Generate Ad"):
169
  url_input = gr.Textbox(label="Product URL", placeholder="Enter product URL here...")
170
+ store_name_input = gr.Textbox(label="Store Name (e.g., O Boticário)", placeholder="Enter store name...")
171
 
172
  main_cupom_input = gr.Textbox(label="Main Cupom (e.g., PRIMEIRACOMPRA)", value="PRIMEIRACOMPRA")
173
+ main_cupom_discount_percentage_input = gr.Number(label="Main Cupom Discount Percentage (e.g., 20 for 20%)", value=15, minimum=0, maximum=100)
174
  cupom_1_input = gr.Textbox(label="Cupom 1 (e.g., AMIGO15)", placeholder="Enter first coupon code...")
175
+ original_price_input = gr.Number(label="Original Price (Optional)", value=0, minimum=0)
176
+ discounted_price_input = gr.Number(label="Discounted Price (Optional)", value=0, minimum=0)
177
+ with gr.Row():
178
+ generate_button = gr.Button("Generate Ad")
179
+ clear_button = gr.Button("Clear")
180
  ad_output = gr.Markdown(label="Your Generated Ad", show_copy_button=True)
181
 
182
  with gr.Tab("Fragrantica"):
 
197
  # No save button needed as keys are passed directly
198
  gr.Markdown("API keys are used directly from these fields when you click 'Generate Ad'. They are not saved persistently.")
199
 
200
+ def clear_fields():
201
+ return "", 0, 0
202
+
203
+ generate_button.click(generate_ad, inputs=[url_input, store_name_input, main_cupom_input, main_cupom_discount_percentage_input, cupom_1_input, original_price_input, discounted_price_input, openai_key_input, natura_token_input, openai_base_url_input, openai_model_name_input], outputs=[generate_button, ad_output])
204
+ clear_button.click(clear_fields, inputs=[], outputs=[url_input, original_price_input, discounted_price_input])
205
+
206
  # Placeholder for Fragrantica analysis function
207
  def analyze_fragrantica_url(url, openai_api_key, natura_api_token, openai_base_url, openai_model_name):
208
  if not openai_api_key or not openai_model_name or not openai_base_url:
assets/Montserrat-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:846d5823e5c909a5aad49efbd71dd5f3320a8640fff86840bf7d529c8d8660a5
3
+ size 335788
assets/Montserrat-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f5a3f02c4a72f1da11c6dadf4fd78c07b2f145a34ed46eb875ed0da28cbd348c
3
+ size 330948
assets/template_natura_empty.jpg ADDED

Git LFS Details

  • SHA256: 43b40b4ee2cfc678845fceecec9f1f2610ed25685d937dd5c5c52ea9eeac77c1
  • Pointer size: 131 Bytes
  • Size of remote file: 235 kB
generate_image_tool.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from PIL import Image, ImageDraw, ImageFont
3
+ from io import BytesIO
4
+ import uuid
5
+ # --- 1. SETUP: Define your data ---
6
+
7
+ # Input and Output files
8
+ template_path = 'template_natura_empty.jpg'
9
+
10
+ output_path = f'{uuid.uuid4()}.png'
11
+
12
+ # Image to place on the template
13
+ # NOTE: Replace this with parameters
14
+ product_image_url = 'https://production.na01.natura.com/on/demandware.static/-/Sites-natura-br-storefront-catalog/default/dw68595724/NATBRA-89834_1.jpg'
15
+ product_name = "Homem Cor.agio"
16
+ original_price = "De: R$ 399,00"
17
+ final_price = "Por: R$ 167,92"
18
+ coupon_code = "AGOSTOA"
19
+
20
+ # --- 2. IMAGE PROCESSING ---
21
+
22
+ try:
23
+ # Load the base template image
24
+ template_image = Image.open(template_path).convert("RGBA")
25
+
26
+ # Fetch the product image from the URL
27
+ response = requests.get(product_image_url)
28
+ product_image_data = BytesIO(response.content)
29
+ product_image = Image.open(product_image_data).convert("RGBA")
30
+
31
+ # Define the position and size for the product image placeholder
32
+ # These coordinates were estimated from your template (width, height)
33
+ box_size = (442, 353)
34
+ box_position = (140, 280) # (x, y) from top-left corner
35
+
36
+
37
+ # --- KEY CHANGE 1: Resize image while preserving aspect ratio ---
38
+ # The thumbnail method resizes the image to fit within the box_size
39
+ # without changing its aspect ratio. It modifies the image in-place.
40
+ product_image_resized = product_image.copy() # Work on a copy
41
+ product_image_resized.thumbnail(box_size)
42
+
43
+ # --- KEY CHANGE 2: Calculate position to center the image ---
44
+ # Find the top-left corner to paste the image so it's centered in the box
45
+ paste_x = box_position[0] + (box_size[0] - product_image_resized.width) // 2
46
+ paste_y = box_position[1] + (box_size[1] - product_image_resized.height) // 2
47
+ paste_position = (paste_x, paste_y)
48
+
49
+ # Paste the resized product image onto the template
50
+ template_image.paste(product_image_resized, paste_position, product_image_resized)
51
+
52
+ # --- 3. TEXT DRAWING ---
53
+
54
+ # Create a drawing context
55
+ draw = ImageDraw.Draw(template_image)
56
+
57
+ # Define fonts. For best results, download a font like 'Montserrat' and provide the path.
58
+ # Using a default font if a specific one isn't found.
59
+ try:
60
+ font_name = ImageFont.truetype("Montserrat-Bold.ttf", 47)
61
+ font_price_from = ImageFont.truetype("Montserrat-Regular.ttf", 28)
62
+ font_price = ImageFont.truetype("Montserrat-Bold.ttf", 47)
63
+ font_cupom = ImageFont.truetype("Montserrat-Bold.ttf", 33)
64
+ except IOError:
65
+ print("Arial font not found. Using default font.")
66
+ font_bold = ImageFont.load_default()
67
+ font_regular = ImageFont.load_default()
68
+ font_price = ImageFont.load_default()
69
+ font_cupom = ImageFont.load_default()
70
+
71
+ # Define text colors
72
+ white_color = "#FFFFFF"
73
+ yellow_color = "#FEE161" # A yellow sampled from your design
74
+ black_color = "#000000"
75
+
76
+ # Add text to the image
77
+ # The 'anchor="ms"' centers the text horizontally at the given x-coordinate
78
+
79
+ # 1. Product Name
80
+ draw.text((360, 710), product_name, font=font_name, fill=white_color, anchor="ms")
81
+
82
+ # 2. Original Price
83
+ draw.text((360, 800), original_price, font=font_price_from, fill=white_color, anchor="ms")
84
+
85
+ # 3. Final Price
86
+ draw.text((360, 860), final_price, font=font_price, fill=yellow_color, anchor="ms")
87
+
88
+ # 4. Coupon Code
89
+ draw.text((360, 993), coupon_code, font=font_cupom, fill=black_color, anchor="ms")
90
+
91
+ # --- 4. SAVE THE FINAL IMAGE ---
92
+
93
+ # Save the result as a PNG to preserve quality
94
+ template_image.save(output_path)
95
+
96
+ print(f"✨ Success! Image saved as '{output_path}'")
97
+
98
+ except FileNotFoundError:
99
+ print(f"Error: The template file '{template_path}' was not found.")
100
+ except Exception as e:
101
+ print(f"An error occurred: {e}")
image_generator_tool.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from crewai.tools import BaseTool
2
+ from pydantic import BaseModel, Field
3
+ from PIL import Image, ImageDraw, ImageFont
4
+ import requests
5
+ from io import BytesIO
6
+ import uuid
7
+
8
+ class GenerateImageToolInput(BaseModel):
9
+ """Input for the Generate Image Tool."""
10
+ product_image_url: str = Field(..., description="URL of the product image to be placed on the template.")
11
+ product_name: str = Field(..., description="Name of the product.")
12
+ original_price: str = Field(..., description="Original price of the product.")
13
+ final_price: str = Field(..., description="Final price of the product.")
14
+ coupon_code: str = Field(..., description="Coupon code to be displayed on the image.")
15
+
16
+ import tempfile
17
+ import os
18
+
19
+ class GenerateImageTool(BaseTool):
20
+ name: str = "Generate Image Tool"
21
+ description: str = "Generates a promotional image for a product using a template."
22
+ args_schema = GenerateImageToolInput
23
+
24
+ def _run(self, product_image_url: str, product_name: str, original_price: str, final_price: str, coupon_code: str) -> str:
25
+
26
+ template_path = 'assets/template_natura_empty.jpg'
27
+ temp_dir = tempfile.gettempdir()
28
+ output_path = os.path.join(temp_dir, f'{uuid.uuid4()}.png')
29
+
30
+ try:
31
+ template_image = Image.open(template_path).convert("RGBA")
32
+ response = requests.get(product_image_url)
33
+ product_image_data = BytesIO(response.content)
34
+ product_image = Image.open(product_image_data).convert("RGBA")
35
+
36
+ box_size = (442, 353)
37
+ box_position = (140, 280)
38
+
39
+ product_image_resized = product_image.copy()
40
+ product_image_resized.thumbnail(box_size)
41
+
42
+ paste_x = box_position[0] + (box_size[0] - product_image_resized.width) // 2
43
+ paste_y = box_position[1] + (box_size[1] - product_image_resized.height) // 2
44
+ paste_position = (paste_x, paste_y)
45
+
46
+ template_image.paste(product_image_resized, paste_position, product_image_resized)
47
+
48
+ draw = ImageDraw.Draw(template_image)
49
+
50
+ try:
51
+ font_name = ImageFont.truetype("assets/Montserrat-Bold.ttf", 47)
52
+ font_price_from = ImageFont.truetype("assets/Montserrat-Regular.ttf", 28)
53
+ font_price = ImageFont.truetype("assets/Montserrat-Bold.ttf", 47)
54
+ font_cupom = ImageFont.truetype("assets/Montserrat-Bold.ttf", 33)
55
+ except IOError:
56
+ print("Arial font not found. Using default font.")
57
+ font_name = ImageFont.load_default()
58
+ font_price_from = ImageFont.load_default()
59
+ font_price = ImageFont.load_default()
60
+ font_cupom = ImageFont.load_default()
61
+
62
+ white_color = "#FFFFFF"
63
+ yellow_color = "#FEE161"
64
+ black_color = "#000000"
65
+
66
+ draw.text((360, 710), product_name, font=font_name, fill=white_color, anchor="ms")
67
+ draw.text((360, 800), original_price, font=font_price_from, fill=white_color, anchor="ms")
68
+ draw.text((360, 860), final_price, font=font_price, fill=yellow_color, anchor="ms")
69
+ draw.text((360, 993), coupon_code, font=font_cupom, fill=black_color, anchor="ms")
70
+
71
+ template_image.save(output_path)
72
+
73
+ return output_path
74
+
75
+ except FileNotFoundError:
76
+ return f"Error: The template file '{template_path}' was not found."
77
+ except Exception as e:
78
+ return f"An error occurred: {e}"
main.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ import os
4
+ import csv
5
+ from dotenv import load_dotenv
6
+ from social_media_crew import SocialMediaCrew
7
+ import mlflow
8
+
9
+ mlflow.crewai.autolog()
10
+
11
+ # Optional: Set a tracking URI and an experiment name if you have a tracking server
12
+ mlflow.set_tracking_uri("http://localhost:5000")
13
+ mlflow.set_experiment("CrewAI")
14
+
15
+ # Load environment variables from .env file
16
+ load_dotenv()
17
+
18
+ # Now you can safely access the API key
19
+ # Make sure your .env file has OPENAI_API_KEY="your_key_here"
20
+ api_key = os.getenv("OPENAI_API_KEY")
21
+ natura_api_token = os.getenv("NATURA_API_TOKEN")
22
+
23
+ if not api_key:
24
+ raise ValueError("OPENAI_API_KEY not found in .env file or environment variables.")
25
+
26
+ if not natura_api_token:
27
+ raise ValueError("NATURA_API_TOKEN not found in .env file or environment variables. Please set it for the URL shortener tool.")
28
+
29
+ CSV_FILE = 'urls.csv'
30
+ MARKDOWN_FILE = 'social_media_ads.md'
31
+ all_results = []
32
+
33
+ # Initialize the SocialMediaCrew
34
+ social_media_crew = SocialMediaCrew()
35
+
36
+ try:
37
+ with open(CSV_FILE, mode='r', encoding='utf-8') as file:
38
+ reader = csv.DictReader(file)
39
+ for row in reader:
40
+ current_url = row['url']
41
+
42
+ # Run the crew for the current URL
43
+ result = social_media_crew.run_crew(current_url)
44
+
45
+ print("######################")
46
+ print("Crew work finished for URL:", current_url)
47
+ print("Final result:")
48
+ print(result)
49
+ print("######################\n")
50
+
51
+ all_results.append({'url': current_url, 'ad': result})
52
+
53
+ # Write all results to a Markdown file
54
+ with open(MARKDOWN_FILE, mode='w', encoding='utf-8') as md_file:
55
+ md_file.write("# Social Media Ads for Perfumes\n\n")
56
+ for item in all_results:
57
+ md_file.write(f"## URL: {item['url']}\n\n")
58
+ md_file.write(f"{item['ad']}\n\n---\n\n")
59
+ print(f"All social media ads have been written to '{MARKDOWN_FILE}'")
60
+
61
+ except FileNotFoundError:
62
+ print(f"Error: The CSV file '{CSV_FILE}' was not found. Please create it with a 'url' column.")
63
+ except KeyError:
64
+ print(f"Error: The CSV file '{CSV_FILE}' must contain a 'url' column.")
65
+ except Exception as e:
66
+ print(f"An unexpected error occurred: {e}")
merchs/merch.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from shortener_tool import ShortenerTool
3
+
4
+ class Merchant():
5
+ def __init__(self):
6
+ pass
7
+
8
+ @abstractmethod
9
+ def get_template(self, main_cupom, cupom_1, store = None) -> str:
10
+ pass
11
+
12
+ def get_css_selector(self) -> str:
13
+ return "body"
14
+
15
+ @abstractmethod
16
+ def shorten_url(self, url: str) -> str:
17
+ pass
18
+
19
+ class NaturaMerchant(Merchant):
20
+
21
+ def __init__(self, natura_api_token: str):
22
+ super().__init__()
23
+ self.shortener_tool = ShortenerTool()
24
+
25
+ def get_template(self, main_cupom, cupom_1, store = None) -> str:
26
+ return f"""
27
+ ###Template:
28
+ {{Title}}
29
+
30
+ {{Description}}
31
+
32
+ Preço original: ~~{{ORIGINAL PRICE}}~~
33
+ **HOJE: {{CUPOM DISCOUNTED PRICE}} — {{TOTAL DISCOUNT PERCENTAGE}}% OFF**
34
+
35
+ 🎟️ CUPOM: {main_cupom} {'ou ' + cupom_1 if cupom_1 else ''}
36
+ 🛒 Compre aqui: {{short_url}}
37
+
38
+ `⚠️ Faça login com o mesmo email e senha que já usa para comprar na Natura!`
39
+ ###End Template
40
+ """
41
+
42
+ def get_css_selector(self) -> str:
43
+ return ".product-detail-banner"
44
+
45
+ def shorten_url(self, url: str) -> str:
46
+ return self.shortener_tool.run(url)
47
+
48
+ class MercadoLivreMerchant(Merchant):
49
+
50
+ def get_template(self, main_cupom, cupom_1, store = None) -> str:
51
+ return f"""
52
+ ###Template:
53
+ {{Title}}
54
+
55
+ (MERCADO LIVRE - {store.upper()} OFICIAL)
56
+
57
+ {{Description}}
58
+
59
+ Preço original: ~~{{ORIGINAL PRICE}}~~
60
+ **HOJE: {{CUPOM DISCOUNTED PRICE}} — {{TOTAL DISCOUNT PERCENTAGE}}% OFF**
61
+
62
+ 🎟️ CUPOM: {main_cupom}
63
+ 🛒 Compre aqui: {{short_url}}
64
+
65
+ `⚠️ Selecione a loja oficial {store.upper()}`
66
+ ###End Template
67
+ """
68
+
69
+ def get_css_selector(self) -> str:
70
+ return ".rl-card-featured"
71
+
72
+ def shorten_url(self, url: str) -> str:
73
+ return url
pyproject.toml CHANGED
@@ -10,6 +10,19 @@ dependencies = [
10
  "crewai-tools>=0.55.0",
11
  "gradio>=5.38.0",
12
  "litellm>=1.72.6",
 
13
  "playwright>=1.53.0",
14
  "playwright-stealth>=2.0.0",
 
15
  ]
 
 
 
 
 
 
 
 
 
 
 
 
10
  "crewai-tools>=0.55.0",
11
  "gradio>=5.38.0",
12
  "litellm>=1.72.6",
13
+ "pillow>=11.3.0",
14
  "playwright>=1.53.0",
15
  "playwright-stealth>=2.0.0",
16
+ "requests>=2.32.4",
17
  ]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8.4.1",
22
+ ]
23
+
24
+ [tool.pytest.ini_options]
25
+ pythonpath = [
26
+ ".",
27
+ ]
28
+ testpaths = ["tests"]
scrape.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from playwright.async_api import async_playwright
3
+ from playwright_stealth.stealth import Stealth
4
+ from bs4 import BeautifulSoup
5
+
6
+ async def main():
7
+ url = "https://www.fragrantica.com.br/perfume/Natura/Frescor-de-Cacau-25963.html"
8
+
9
+ async with Stealth().use_async(async_playwright()) as p:
10
+ browser = await p.chromium.launch(headless=True)
11
+
12
+ # Create the page from the stealthy context
13
+ page = await browser.new_page()
14
+
15
+ try:
16
+ print("Navigating to page with corrected stealth logic...")
17
+ await page.goto(url, timeout=120000)
18
+
19
+ print("Waiting for Cloudflare check/content load...")
20
+ main_content_selector = 'h1[itemprop="name"]'
21
+ await page.wait_for_selector(main_content_selector, timeout=60000)
22
+ print("✅ Cloudflare passed! Main content is visible.")
23
+
24
+ await page.screenshot(path='success_screenshot.png')
25
+
26
+ html_content = await page.content()
27
+ soup = BeautifulSoup(html_content, 'html.parser')
28
+
29
+ target_div = soup.find('div', class_='grid-x grid-margin-x')
30
+ if target_div:
31
+ div_string = target_div.prettify()
32
+ print("\n--- Targeted Div HTML Content ---")
33
+ print(div_string)
34
+ else:
35
+ print("❌ Could not find the <div class=\"grid-x grid-margin-x\"> tag.")
36
+
37
+ except Exception as e:
38
+ print(f"An error occurred: {e}")
39
+ await page.screenshot(path='error_screenshot.png')
40
+ print("Saved 'error_screenshot.png' for debugging.")
41
+ finally:
42
+ await browser.close()
43
+ print("\nBrowser closed.")
44
+
45
+ if __name__ == "__main__":
46
+ asyncio.run(main())
stealth_scrape_tool.py CHANGED
@@ -16,8 +16,16 @@ class StealthScrapeTool(BaseTool):
16
 
17
  await page.goto(website_url, timeout=120000)
18
 
19
- # Wait for the specific element to be present
20
- await page.wait_for_selector(css_element, timeout=60000)
 
 
 
 
 
 
 
 
21
 
22
  html_content = await page.content()
23
  soup = BeautifulSoup(html_content, 'html.parser')
 
16
 
17
  await page.goto(website_url, timeout=120000)
18
 
19
+ try:
20
+ # Wait for the specific element to be present
21
+ await page.wait_for_selector(css_element, timeout=30000)
22
+ except Exception as e:
23
+ # If timeout error, try again with "body" as css_element
24
+ if "Timeout" in str(e) and css_element != "body":
25
+ await page.wait_for_selector("body", timeout=60000)
26
+ css_element = "body"
27
+ else:
28
+ raise e
29
 
30
  html_content = await page.content()
31
  soup = BeautifulSoup(html_content, 'html.parser')
test_tool.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from image_generator_tool import GenerateImageTool
2
+
3
+ tool = GenerateImageTool()
4
+
5
+ result = tool._run(
6
+ product_image_url='https://production.na01.natura.com/on/demandware.static/-/Sites-natura-br-storefront-catalog/default/dw68595724/NATBRA-89834_1.jpg',
7
+ product_name="Homem Cor.agio",
8
+ original_price="De: R$ 399,00",
9
+ final_price="Por: R$ 167,92",
10
+ coupon_code="AGOSTOA"
11
+ )
12
+
13
+ print(result)
tests/test_utils_url_tool.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from utils_tools import GetImageUrlTool
3
+
4
+ def test_get_image_url_tool_success():
5
+ """Test that GetImageUrlTool correctly extracts product_id and returns the expected image URL."""
6
+ tool = GetImageUrlTool()
7
+
8
+ sample_url = "https://minhaloja.natura.com/p/refil-shampoo-mamae-e-bebe/NATBRA-92791?product=refil-shampoo-mamae-e-bebe&productId=NATBRA-92791&consultoria=lidimelocosmeticos&marca=natura"
9
+ expected_image_url = "https://production.na01.natura.com/on/demandware.static/-/Sites-natura-br-storefront-catalog/default/dw68595724/NATBRA-92791_1.jpg"
10
+
11
+ result = tool._run(sample_url)
12
+ assert result == expected_image_url
13
+
14
+ def test_get_image_url_tool_different_id():
15
+ """Test with a different product_id to ensure extraction works generally."""
16
+ tool = GetImageUrlTool()
17
+
18
+ sample_url = "https://minhaloja.natura.com/p/some-product/NATBRA-12345?productId=NATBRA-12345"
19
+ expected_image_url = "https://production.na01.natura.com/on/demandware.static/-/Sites-natura-br-storefront-catalog/default/dw68595724/NATBRA-12345_1.jpg"
20
+
21
+ result = tool._run(sample_url)
22
+ assert result == expected_image_url
23
+
24
+ def test_get_image_url_tool_invalid_input_type():
25
+ """Test that a ValueError is raised if product_url is not a string."""
26
+ tool = GetImageUrlTool()
27
+
28
+ with pytest.raises(ValueError, match="product_url must be a string."):
29
+ tool._run(12345) # type: ignore
30
+
31
+ def test_get_image_url_tool_missing_pattern():
32
+ """Test that a ValueError is raised if NATBRA-<digits> pattern is not found."""
33
+ tool = GetImageUrlTool()
34
+
35
+ invalid_url = "https://minhaloja.natura.com/p/some-product/INVALID-12345?productId=INVALID-12345"
36
+
37
+ with pytest.raises(ValueError, match="Could not extract product_id from the provided URL."):
38
+ tool._run(invalid_url)
utils_tools.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from crewai.tools import BaseTool
3
+ import re
4
+ from typing import Any
5
+ from merchs.merch import Merchant, NaturaMerchant, MercadoLivreMerchant
6
+
7
+ class CalculateDiscountedPriceTool(BaseTool):
8
+ """
9
+ A tool to calculate the final price of an item after a discount is applied.
10
+ """
11
+ name: str = "Calculate Discounted Price Tool"
12
+ description: str = "Calculates the price after applying a given discount percentage."
13
+
14
+ def _run(self, original_price: float, discount_percentage: float) -> float:
15
+
16
+ """Calculates the discounted price and the total discount amount.
17
+
18
+ This method takes an original price and a discount percentage, validates
19
+ the inputs, and then computes the final price after the discount is
20
+ applied, as well as the amount saved.
21
+
22
+ Args:
23
+ original_price: The initial price of the item as a float or integer.
24
+
25
+ Returns:
26
+ float:
27
+ - The final discounted price, rounded to 2 decimal places.
28
+ """
29
+
30
+ if not isinstance(original_price, (int, float)) or not isinstance(discount_percentage, (int, float)):
31
+ raise ValueError("Both original_price and discount_percentage must be numbers.")
32
+ if discount_percentage < 0 or discount_percentage > 100:
33
+ raise ValueError("Discount percentage must be between 0 and 100.")
34
+
35
+ discount_amount = original_price * (discount_percentage / 100)
36
+ discounted_price = original_price - discount_amount
37
+ return round(discounted_price, 2)
38
+
39
+ class CalculateDiscountValueTool(BaseTool):
40
+ """
41
+ A tool to calculate the final discount value of an item after comparing the original value and the final value.
42
+ """
43
+ name: str = "Calculate Discount Value Tool"
44
+ description: str = "Calculates the discount value after comparing two values."
45
+
46
+ def _run(self, original_price: float, final_price: float) -> float:
47
+
48
+ """Calculates the total discounted amount give the original and final price.
49
+
50
+ This method takes an original price and a final price, validates
51
+ the inputs, and then computes the final discounted value.
52
+
53
+ Args:
54
+ original_price: The initial price of the item as a float or integer.
55
+ final_price: The final price after discount as a float or integer.
56
+
57
+ Returns:
58
+ float:
59
+ - The final discount value, rounded to 0 decimal places.
60
+ """
61
+ if not isinstance(original_price, (int, float)) or not isinstance(final_price, (int, float)):
62
+ raise ValueError("Both original_price and final_price must be numbers.")
63
+
64
+ discount_value = original_price - final_price
65
+ discount_percentage = (discount_value / original_price) * 100
66
+ return round(discount_percentage, 0)
67
+
68
+ class GetImageUrlTool(BaseTool):
69
+ """
70
+ A tool to retrieve the image URL for a given product URL.
71
+ """
72
+ name: str = "Get Image URL Tool"
73
+ description: str = "Retrieves the image URL for a given product URL."
74
+
75
+ def _run(self, product_url: str) -> str:
76
+ """
77
+ Retrieves the image URL for a given product URL.
78
+
79
+ Example:
80
+ product_url = (
81
+ "https://minhaloja.natura.com/p/refil-shampoo-mamae-e-bebe/"
82
+ "NATBRA-92791?product=refil-shampoo-mamae-e-bebe&productId=NATBRA-92791"
83
+ "&consultoria=lidimelocosmeticos&marca=natura"
84
+ )
85
+ image_url = GetImageUrlTool()._run(product_url)
86
+ # Returns:
87
+ # "https://production.na01.natura.com/on/demandware.static/-/Sites-natura-br-storefront-catalog/default/dw68595724/NATBRA-92791_1.jpg"
88
+ """
89
+ if not isinstance(product_url, str):
90
+ raise ValueError("product_url must be a string.")
91
+
92
+ # Extract the numeric part after "NATBRA-" using a regular expression.
93
+ match = re.search(r"NATBRA-(\d+)", product_url)
94
+ if not match:
95
+ raise ValueError(
96
+ "Could not extract product_id from the provided URL. "
97
+ "Expected a pattern like 'NATBRA-<digits>'."
98
+ )
99
+ product_id = match.group(1)
100
+
101
+ # Build the final image URL.
102
+ image_url = (
103
+ f"https://production.na01.natura.com/on/demandware.static/-/Sites-natura-br-storefront-catalog/"
104
+ f"default/dw68595724/NATBRA-{product_id}_1.jpg"
105
+ )
106
+ return image_url
107
+
108
+ class MerchantSelectorTool(BaseTool):
109
+ name: str = "Merchant Selector Tool"
110
+ description: str = "Selects the merchant based on url."
111
+ natura_api_token: str
112
+
113
+ def _run(self, original_url: str) -> Merchant:
114
+ if "mercadolivre" in original_url or "ml.com.br" in original_url:
115
+ return MercadoLivreMerchant()
116
+ elif "natura.com" in original_url:
117
+ return NaturaMerchant(natura_api_token=self.natura_api_token)
118
+ else:
119
+ raise ValueError("Unsupported merchant in URL.")
uv.lock CHANGED
@@ -574,8 +574,15 @@ dependencies = [
574
  { name = "crewai-tools" },
575
  { name = "gradio" },
576
  { name = "litellm" },
 
577
  { name = "playwright" },
578
  { name = "playwright-stealth" },
 
 
 
 
 
 
579
  ]
580
 
581
  [package.metadata]
@@ -585,10 +592,15 @@ requires-dist = [
585
  { name = "crewai-tools", specifier = ">=0.55.0" },
586
  { name = "gradio", specifier = ">=5.38.0" },
587
  { name = "litellm", specifier = ">=1.72.6" },
 
588
  { name = "playwright", specifier = ">=1.53.0" },
589
  { name = "playwright-stealth", specifier = ">=2.0.0" },
 
590
  ]
591
 
 
 
 
592
  [[package]]
593
  name = "crewai-tools"
594
  version = "0.55.0"
@@ -1261,6 +1273,15 @@ wheels = [
1261
  { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" },
1262
  ]
1263
 
 
 
 
 
 
 
 
 
 
1264
  [[package]]
1265
  name = "instructor"
1266
  version = "1.10.0"
@@ -2430,6 +2451,15 @@ wheels = [
2430
  { url = "https://files.pythonhosted.org/packages/b9/4e/c37ac19cea166a97de3a9690ad5ba340b3f4f4fcd5bf8237cedb2c2c7076/playwright_stealth-2.0.0-py3-none-any.whl", hash = "sha256:9eb3af1fd21619aac9fdd13a4a08141ed67159ac6310a94f7d2f758ba0cbe179", size = 32466, upload-time = "2025-06-18T03:54:53.394Z" },
2431
  ]
2432
 
 
 
 
 
 
 
 
 
 
2433
  [[package]]
2434
  name = "portalocker"
2435
  version = "3.2.0"
@@ -2803,6 +2833,22 @@ wheels = [
2803
  { url = "https://files.pythonhosted.org/packages/48/0a/c99fb7d7e176f8b176ef19704a32e6a9c6aafdf19ef75a187f701fc15801/pysbd-0.3.4-py3-none-any.whl", hash = "sha256:cd838939b7b0b185fcf86b0baf6636667dfb6e474743beeff878e9f42e022953", size = 71082, upload-time = "2021-02-11T16:36:33.351Z" },
2804
  ]
2805
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2806
  [[package]]
2807
  name = "python-dateutil"
2808
  version = "2.9.0.post0"
 
574
  { name = "crewai-tools" },
575
  { name = "gradio" },
576
  { name = "litellm" },
577
+ { name = "pillow" },
578
  { name = "playwright" },
579
  { name = "playwright-stealth" },
580
+ { name = "requests" },
581
+ ]
582
+
583
+ [package.dev-dependencies]
584
+ dev = [
585
+ { name = "pytest" },
586
  ]
587
 
588
  [package.metadata]
 
592
  { name = "crewai-tools", specifier = ">=0.55.0" },
593
  { name = "gradio", specifier = ">=5.38.0" },
594
  { name = "litellm", specifier = ">=1.72.6" },
595
+ { name = "pillow", specifier = ">=11.3.0" },
596
  { name = "playwright", specifier = ">=1.53.0" },
597
  { name = "playwright-stealth", specifier = ">=2.0.0" },
598
+ { name = "requests", specifier = ">=2.32.4" },
599
  ]
600
 
601
+ [package.metadata.requires-dev]
602
+ dev = [{ name = "pytest", specifier = ">=8.4.1" }]
603
+
604
  [[package]]
605
  name = "crewai-tools"
606
  version = "0.55.0"
 
1273
  { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" },
1274
  ]
1275
 
1276
+ [[package]]
1277
+ name = "iniconfig"
1278
+ version = "2.1.0"
1279
+ source = { registry = "https://pypi.org/simple" }
1280
+ sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
1281
+ wheels = [
1282
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
1283
+ ]
1284
+
1285
  [[package]]
1286
  name = "instructor"
1287
  version = "1.10.0"
 
2451
  { url = "https://files.pythonhosted.org/packages/b9/4e/c37ac19cea166a97de3a9690ad5ba340b3f4f4fcd5bf8237cedb2c2c7076/playwright_stealth-2.0.0-py3-none-any.whl", hash = "sha256:9eb3af1fd21619aac9fdd13a4a08141ed67159ac6310a94f7d2f758ba0cbe179", size = 32466, upload-time = "2025-06-18T03:54:53.394Z" },
2452
  ]
2453
 
2454
+ [[package]]
2455
+ name = "pluggy"
2456
+ version = "1.6.0"
2457
+ source = { registry = "https://pypi.org/simple" }
2458
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
2459
+ wheels = [
2460
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
2461
+ ]
2462
+
2463
  [[package]]
2464
  name = "portalocker"
2465
  version = "3.2.0"
 
2833
  { url = "https://files.pythonhosted.org/packages/48/0a/c99fb7d7e176f8b176ef19704a32e6a9c6aafdf19ef75a187f701fc15801/pysbd-0.3.4-py3-none-any.whl", hash = "sha256:cd838939b7b0b185fcf86b0baf6636667dfb6e474743beeff878e9f42e022953", size = 71082, upload-time = "2021-02-11T16:36:33.351Z" },
2834
  ]
2835
 
2836
+ [[package]]
2837
+ name = "pytest"
2838
+ version = "8.4.1"
2839
+ source = { registry = "https://pypi.org/simple" }
2840
+ dependencies = [
2841
+ { name = "colorama", marker = "sys_platform == 'win32'" },
2842
+ { name = "iniconfig" },
2843
+ { name = "packaging" },
2844
+ { name = "pluggy" },
2845
+ { name = "pygments" },
2846
+ ]
2847
+ sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
2848
+ wheels = [
2849
+ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
2850
+ ]
2851
+
2852
  [[package]]
2853
  name = "python-dateutil"
2854
  version = "2.9.0.post0"