Spaces:
Running
Running
Add Mercado Livre support (#4)
Browse files- Add Mercado Livre support (472175aa7e87eff9693e48929495ceaa655412d3)
- .clinerules +91 -0
- .gitattributes +3 -0
- GEMINI.md +91 -0
- README.md +13 -2
- app.py +76 -133
- assets/Montserrat-Bold.ttf +3 -0
- assets/Montserrat-Regular.ttf +3 -0
- assets/template_natura_empty.jpg +3 -0
- generate_image_tool.py +101 -0
- image_generator_tool.py +78 -0
- main.py +66 -0
- merchs/merch.py +73 -0
- pyproject.toml +13 -0
- scrape.py +46 -0
- stealth_scrape_tool.py +10 -2
- test_tool.py +13 -0
- tests/test_utils_url_tool.py +38 -0
- utils_tools.py +119 -0
- uv.lock +46 -0
.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
|
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 |
-
|
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
|
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
|
|
|
157 |
except requests.exceptions.RequestException as e:
|
158 |
print(f"Error checking URL: {e}")
|
159 |
-
return
|
160 |
-
|
161 |
-
|
162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
165 |
)
|
166 |
|
167 |
-
|
168 |
-
|
169 |
-
|
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,
|
|
|
|
|
216 |
if not openai_api_key or not natura_api_token or not openai_model_name or not openai_base_url:
|
217 |
-
|
|
|
|
|
|
|
|
|
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,
|
221 |
|
222 |
if result == "INVALID_URL":
|
223 |
-
|
224 |
elif result == "MISSING_PRODUCT_INFO":
|
225 |
-
|
226 |
else:
|
227 |
-
|
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=
|
238 |
cupom_1_input = gr.Textbox(label="Cupom 1 (e.g., AMIGO15)", placeholder="Enter first coupon code...")
|
239 |
-
|
240 |
-
|
|
|
|
|
|
|
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 |
-
|
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
|
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 |
-
|
20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|