ibombonato commited on
Commit
457afbf
·
verified ·
1 Parent(s): ab04a14

feat: new tool for new natura site and anti blocker from ml (#12)

Browse files

- feat: new tool for new natura site and anti blocker from ml (e3734a47395501e9a4bca4944237612ce52ac9aa)

Files changed (7) hide show
  1. advanced_scrape_tool.py +77 -0
  2. app.py +1 -132
  3. merchs/__init__.py +0 -0
  4. merchs/merch.py +14 -6
  5. pyproject.toml +1 -0
  6. social_media_crew.py +99 -25
  7. uv.lock +65 -1
advanced_scrape_tool.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # In custom_tools.py
2
+
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+ from pydantic import BaseModel, Field
6
+ from typing import List, Type, Optional
7
+
8
+ from crewai.tools import BaseTool
9
+ from playwright.sync_api import sync_playwright
10
+
11
+ class AdvancedScrapingToolSchema(BaseModel):
12
+ """Input schema for the AdvancedScrapingTool."""
13
+ website_url: str = Field(..., description="A URL completa do site para fazer o scrape.")
14
+ base_selector: str = Field(..., description="O seletor CSS principal para extrair o bloco de conteúdo inicial. Ex: '.page-content'")
15
+ keep_selectors: Optional[List[str]] = Field(None, description="Uma lista de seletores CSS para manter no resultado final. A extração será focada nestes elementos. Ex: ['.title', '.description']")
16
+ remove_selectors: Optional[List[str]] = Field(None, description="Uma lista de seletores CSS para remover do conteúdo extraído. Ex: ['.ads', '.hide']")
17
+
18
+ class AdvancedScrapingTool(BaseTool):
19
+ name: str = "Scrape and Filter Website Content"
20
+ description: str = "Uma ferramenta poderosa que extrai um bloco de conteúdo de um site usando um seletor base e, em seguida, filtra esse conteúdo, mantendo ou removendo elementos específicos. Lida com conteúdo dinâmico carregado por JavaScript."
21
+ args_schema: Type[BaseModel] = AdvancedScrapingToolSchema
22
+
23
+ def _run(self, website_url: str, base_selector: str = 'body', keep_selectors: Optional[List[str]] = None, remove_selectors: Optional[List[str]] = None) -> str:
24
+ try:
25
+ with sync_playwright() as p:
26
+ browser = p.chromium.launch(headless=True)
27
+
28
+ # --- START: NEW ANTI-BOT BYPASS LOGIC ---
29
+ # Create a browser context that looks like a real user's browser
30
+ context = browser.new_context(
31
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
32
+ viewport={"width": 1920, "height": 1080}
33
+ )
34
+ page = context.new_page()
35
+ # --- END: NEW ANTI-BOT BYPASS LOGIC ---
36
+
37
+ # Increase the default timeout for navigation to handle challenge pages
38
+ page.goto(website_url, wait_until="domcontentloaded", timeout=30000)
39
+
40
+ # Wait for the main container element to be ready
41
+ print(f"Waiting for base selector: '{base_selector}'")
42
+ page.wait_for_selector(base_selector, timeout=20000)
43
+
44
+ if keep_selectors:
45
+ print(f"Waiting for keep selectors: {keep_selectors}")
46
+ for selector in keep_selectors:
47
+ page.wait_for_selector(selector, timeout=15000)
48
+
49
+ html_content = page.content()
50
+ browser.close()
51
+
52
+ soup = BeautifulSoup(html_content, 'lxml')
53
+
54
+ base_content = soup.select_one(base_selector)
55
+ if not base_content:
56
+ return f"Erro: O seletor base '{base_selector}' não foi encontrado na página."
57
+
58
+ if remove_selectors:
59
+ for selector in remove_selectors:
60
+ for element in base_content.select(selector):
61
+ element.decompose()
62
+
63
+ if keep_selectors:
64
+ final_content = []
65
+ for selector in keep_selectors:
66
+ elements = base_content.select(selector)
67
+ for element in elements:
68
+ final_content.append(element.prettify())
69
+
70
+ if not final_content:
71
+ return "Nenhum dos 'keep_selectors' foi encontrado dentro do conteúdo extraído após a limpeza."
72
+ return "\n".join(final_content)
73
+ else:
74
+ return base_content.prettify()
75
+
76
+ except Exception as e:
77
+ return f"Ocorreu um erro inesperado com Playwright ou BeautifulSoup: {e}"
app.py CHANGED
@@ -1,143 +1,12 @@
1
  import gradio as gr
2
  import os
3
- import requests
4
  import base64
5
- from io import BytesIO
6
- from PIL import Image
7
- from crewai import Agent, Task, Crew, Process, LLM
8
- from crewai_tools import ScrapeElementFromWebsiteTool
9
  from dotenv import load_dotenv
10
- from stealth_scrape_tool import StealthScrapeTool
11
  from image_generator_tool import GenerateImageTool
12
- from utils_tools import CalculateDiscountedPriceTool, CalculateDiscountValueTool, GetImageUrlTool, MerchantSelectorTool
13
-
14
 
15
  load_dotenv()
16
 
17
- class SocialMediaCrew:
18
- def __init__(self, openai_api_key: str, natura_api_token: str, openai_base_url: str, openai_model_name: str):
19
- self.openai_api_key = openai_api_key
20
- self.natura_api_token = natura_api_token
21
- self.openai_base_url = openai_base_url
22
- self.openai_model_name = openai_model_name
23
- self.scrape_tool = ScrapeElementFromWebsiteTool()
24
- self.calculate_discounted_price_tool = CalculateDiscountedPriceTool()
25
- self.calculate_discount_value_tool = CalculateDiscountValueTool()
26
- self.image_generator_tool = GenerateImageTool()
27
- self.merchant_selector_tool = MerchantSelectorTool(natura_api_token=self.natura_api_token)
28
-
29
- print("Initializing SocialMediaCrew with BASE URL:", self.openai_base_url)
30
- print("Using OpenAI Model:", self.openai_model_name)
31
- print("Using OpenAI Key:", self.openai_api_key[:10])
32
-
33
- llm = LLM(
34
- api_key=self.openai_api_key,
35
- model=self.openai_model_name,
36
- base_url=self.openai_base_url
37
- )
38
-
39
- self.product_analyst = Agent(
40
- role='Product Analyst',
41
- goal='Analyze the provided URL and extract key product information',
42
- 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."),
43
- verbose=True,
44
- tools=[self.scrape_tool,
45
- self.calculate_discounted_price_tool,
46
- self.calculate_discount_value_tool],
47
- allow_delegation=False,
48
- llm=llm,
49
- max_retries=3
50
- )
51
-
52
- self.social_media_copywriter = Agent(
53
- role='Social Media Copywriter',
54
- goal='Create a compelling social media post in Portuguese to sell the product',
55
- 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."),
56
- verbose=True,
57
- tools=[self.image_generator_tool],
58
- allow_delegation=False,
59
- llm=llm,
60
- max_retries=3
61
- )
62
-
63
- def _validate_url(self, product_url: str) -> bool:
64
- headers = {
65
- "accept": "*/*",
66
- "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
67
- "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
68
- "sec-ch-ua-mobile": "?0",
69
- "sec-ch-ua-platform": '"Windows"',
70
- "sec-fetch-dest": "empty",
71
- "sec-fetch-mode": "cors",
72
- "sec-fetch-site": "cross-site",
73
- "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
74
- }
75
- try:
76
- response = requests.get(product_url, headers=headers)
77
- response.raise_for_status()
78
- if '<template data-dgst="NEXT_NOT_FOUND">' in response.text:
79
- return False
80
- return True
81
- except requests.exceptions.RequestException as e:
82
- print(f"Error checking URL: {e}")
83
- return False
84
-
85
- def _prepare_merchant(self, product_url: str):
86
- merchant = self.merchant_selector_tool.run(product_url)
87
- css_selector = merchant.get_css_selector()
88
- short_url = merchant.shorten_url(product_url)
89
- return merchant, css_selector, short_url
90
-
91
- 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:
92
- task_description = (f"1. Scrape the content of the URL: {product_url} using the 'scrape_tool' with param `css_element` as `{css_selector}`.\n"
93
- "2. Extract the product name, key characteristics, and any other relevant DISCOUNT available.\n")
94
-
95
- if original_price is not None and original_price > 0 and discounted_price is not None and discounted_price > 0:
96
- task_description += (f"3. The user has provided the prices. Use ORIGINAL PRICE = {original_price} and DISCOUNTED PRICE = {discounted_price}.\n")
97
- final_best_price_source = str(discounted_price)
98
- else:
99
- task_description += ("3. Identify and extract the original product price and the final discounted price if existing from the scraped content. "
100
- "IGNORE any price breakdowns like 'produto' or 'consultoria'.\n")
101
- final_best_price_source = "the extracted final best price"
102
-
103
- 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"
104
- "4.1 Use the 'Calculate Discount Value Tool' with ORIGINAL PRICE and CUPOM DISCOUNTED PRICE to get the TOTAL DISCOUNT PERCENTAGE.\n"
105
- 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'.")
106
-
107
- return Task(
108
- description=task_description,
109
- agent=self.product_analyst,
110
- 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."
111
- )
112
-
113
- def _create_post_task(self, analyze_product_task: Task, merchant, main_cupom: str, cupom_1: str, store_name: str) -> Task:
114
- template = merchant.get_template(main_cupom, cupom_1, store=store_name)
115
- return Task(
116
- description=(f"Based on the product analysis, create a CONCISE and DIRECT social media post in Portuguese, using Brazilian currency format, add one space after R$, like R$ 99,99, 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\n. Do not add backticks to response. Ensure a URL is always present in the output. Include a clear and short call to action and a MAXIMUM of 1 relevant emoji. 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'."),
117
- agent=self.social_media_copywriter,
118
- expected_output="A short, direct, and impactful social media post in Portuguese for WhatsApp, strictly following the provided template, dont include triple backticks, including the FINAL PRICE, any DISCOUNT with no decimal cases, the SHORT SHAREABLE URL, a call to action, and one emoji in the Title or in the Description. Description should be one sentence only. Description should stay before the title, as the template. 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.",
119
- context=[analyze_product_task]
120
- )
121
-
122
- 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:
123
- if not self._validate_url(product_url):
124
- return "INVALID_URL"
125
-
126
- merchant, css_selector, short_url = self._prepare_merchant(product_url)
127
-
128
- analyze_product_task = self._create_analyze_product_task(product_url, css_selector, main_cupom_discount_percentage, short_url, original_price, discounted_price)
129
- create_post_task = self._create_post_task(analyze_product_task, merchant, main_cupom, cupom_1, store_name)
130
-
131
- crew = Crew(
132
- agents=[self.product_analyst, self.social_media_copywriter],
133
- tasks=[analyze_product_task, create_post_task],
134
- process=Process.sequential
135
- )
136
-
137
- print(f"Crew is kicking off for URL: {product_url}")
138
- result = crew.kickoff()
139
- return result
140
-
141
  def clean_env_vars():
142
  os.environ.pop("OPENAI_API_KEY", None)
143
  os.environ.pop("NATURA_API_TOKEN", None)
 
1
  import gradio as gr
2
  import os
 
3
  import base64
 
 
 
 
4
  from dotenv import load_dotenv
 
5
  from image_generator_tool import GenerateImageTool
6
+ from social_media_crew import SocialMediaCrew
 
7
 
8
  load_dotenv()
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  def clean_env_vars():
11
  os.environ.pop("OPENAI_API_KEY", None)
12
  os.environ.pop("NATURA_API_TOKEN", None)
merchs/__init__.py ADDED
File without changes
merchs/merch.py CHANGED
@@ -1,5 +1,7 @@
1
  from abc import ABC, abstractmethod
2
  from shortener_tool import ShortenerTool
 
 
3
 
4
  class Merchant():
5
  def __init__(self):
@@ -9,12 +11,15 @@ class Merchant():
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
 
@@ -22,6 +27,9 @@ class NaturaMerchant(Merchant):
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:
@@ -39,8 +47,8 @@ Preço original: ~{{ORIGINAL PRICE}}~
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)
@@ -64,8 +72,8 @@ Preço original: ~{{ORIGINAL PRICE}}~
64
  ###End Template
65
  """
66
 
67
- def get_css_selector(self) -> str:
68
- return ".rl-card-featured"
69
 
70
  def shorten_url(self, url: str) -> str:
71
  return url
 
1
  from abc import ABC, abstractmethod
2
  from shortener_tool import ShortenerTool
3
+ from advanced_scrape_tool import AdvancedScrapingTool
4
+ from crewai_tools import ScrapeElementFromWebsiteTool
5
 
6
  class Merchant():
7
  def __init__(self):
 
11
  def get_template(self, main_cupom, cupom_1, store = None) -> str:
12
  pass
13
 
14
+ def keep_css_selectors(self) -> list[str]:
15
+ return ['body']
16
 
17
  @abstractmethod
18
  def shorten_url(self, url: str) -> str:
19
  pass
20
+
21
+ def get_scraper_tool(self):
22
+ return ScrapeElementFromWebsiteTool()
23
 
24
  class NaturaMerchant(Merchant):
25
 
 
27
  super().__init__()
28
  self.shortener_tool = ShortenerTool()
29
 
30
+ def get_scraper_tool(self):
31
+ return AdvancedScrapingTool()
32
+
33
  def get_template(self, main_cupom, cupom_1, store = None) -> str:
34
  return f"""
35
  ###Template:
 
47
  ###End Template
48
  """
49
 
50
+ def keep_css_selectors(self) -> list[str]:
51
+ return ['h1.text-2xl', '#product-price', '.pt-4']
52
 
53
  def shorten_url(self, url: str) -> str:
54
  return self.shortener_tool.run(url)
 
72
  ###End Template
73
  """
74
 
75
+ def keep_css_selectors(self) -> list[str]:
76
+ return ['.rl-card-featured']
77
 
78
  def shorten_url(self, url: str) -> str:
79
  return url
pyproject.toml CHANGED
@@ -10,6 +10,7 @@ dependencies = [
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",
 
10
  "crewai-tools>=0.55.0",
11
  "gradio>=5.38.0",
12
  "litellm>=1.72.6",
13
+ "lxml>=6.0.1",
14
  "pillow>=11.3.0",
15
  "playwright>=1.53.0",
16
  "playwright-stealth>=2.0.0",
social_media_crew.py CHANGED
@@ -1,50 +1,124 @@
1
- import os
2
- from crewai import Agent, Task, Crew, Process
3
- from crewai_tools import ScrapeWebsiteTool
4
- from shortener_tool import ShortenerTool
 
 
5
 
6
  class SocialMediaCrew:
7
- def __init__(self):
8
- self.scrape_tool = ScrapeWebsiteTool()
9
- self.shortener_tool = ShortenerTool()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  self.product_analyst = Agent(
12
  role='Product Analyst',
13
  goal='Analyze the provided URL and extract key product information',
14
- backstory=("""You are an expert in analyzing product pages and extracting the most important information.
15
- You can identify the product name, the price, discount if any, its main features, and the target audience."""),
16
  verbose=True,
17
- tools=[self.scrape_tool, self.shortener_tool],
 
 
 
 
 
18
  )
19
 
20
  self.social_media_copywriter = Agent(
21
  role='Social Media Copywriter',
22
  goal='Create a compelling social media post in Portuguese to sell the product',
23
- backstory=("""You are a creative copywriter specialized in the beauty and fragrance market.
24
- You know how to craft posts that are engaging, persuasive, and tailored for a Portuguese-speaking audience.
25
- You are an expert in using emojis and hashtags to increase engagement."""),
26
  verbose=True,
 
 
 
 
27
  )
28
 
29
- def run_crew(self, product_url: str) -> str:
30
- analyze_product_task = Task(
31
- description=(f"""Using the 'scrape_tool', scrape the content of the URL: {product_url} and provide a summary of the product.
32
- Focus on the product name, its key characteristics, the FINAL PRICE, any DISCOUNT available.
33
- Then, use the 'URL Shortener Tool' to generate a short URL for {product_url}. If the shortener tool returns an error, use the original URL.
34
- Finally, provide all this information, including the generated short URL (or the original if shortener failed)."""),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  agent=self.product_analyst,
36
- expected_output="A concise summary of the product including its name, key features, unique selling points, FINAL PRICE, any DISCOUNT available, and the SHORT SHAREABLE URL (or the original URL if shortener failed)."
37
  )
38
 
39
- create_post_task = Task(
40
- description=("""Based on the product analysis, create a CONCISE and DIRECT social media post in Portuguese, suitable for a WhatsApp group.
41
- The post should be exciting and highlight the main benefits of the perfume, including the FINAL PRICE, any DISCOUNT, and the SHORT SHAREABLE URL.
42
- 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."""),
43
  agent=self.social_media_copywriter,
44
- expected_output="A short, direct, and impactful social media post in Portuguese for WhatsApp, including the FINAL PRICE, any DISCOUNT, the SHORT SHAREABLE URL, a call to action, and up to 2 emojis. No hashtags should be present. A URL must always be present in the final output.",
45
  context=[analyze_product_task]
46
  )
47
 
 
 
 
 
 
 
 
 
 
48
  crew = Crew(
49
  agents=[self.product_analyst, self.social_media_copywriter],
50
  tasks=[analyze_product_task, create_post_task],
 
1
+ from crewai import Agent, Task, Crew, Process, LLM
2
+ from image_generator_tool import GenerateImageTool
3
+ from utils_tools import CalculateDiscountedPriceTool, CalculateDiscountValueTool, MerchantSelectorTool
4
+ from scrape_interest_tool import scrape_and_clean_website
5
+ import requests
6
+ from advanced_scrape_tool import AdvancedScrapingTool
7
 
8
  class SocialMediaCrew:
9
+ def __init__(self, openai_api_key: str, natura_api_token: str, openai_base_url: str, openai_model_name: str):
10
+ self.openai_api_key = openai_api_key
11
+ self.natura_api_token = natura_api_token
12
+ self.openai_base_url = openai_base_url
13
+ self.openai_model_name = openai_model_name
14
+ self.scrape_tool = AdvancedScrapingTool()
15
+ self.calculate_discounted_price_tool = CalculateDiscountedPriceTool()
16
+ self.calculate_discount_value_tool = CalculateDiscountValueTool()
17
+ self.image_generator_tool = GenerateImageTool()
18
+ self.merchant_selector_tool = MerchantSelectorTool(natura_api_token=self.natura_api_token)
19
+
20
+ print("Initializing SocialMediaCrew with BASE URL:", self.openai_base_url)
21
+ print("Using OpenAI Model:", self.openai_model_name)
22
+ print("Using OpenAI Key:", self.openai_api_key[:10])
23
+
24
+ llm = LLM(
25
+ api_key=self.openai_api_key,
26
+ model=self.openai_model_name,
27
+ base_url=self.openai_base_url
28
+ )
29
 
30
  self.product_analyst = Agent(
31
  role='Product Analyst',
32
  goal='Analyze the provided URL and extract key product information',
33
+ 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."),
 
34
  verbose=True,
35
+ tools=[self.scrape_tool,
36
+ self.calculate_discounted_price_tool,
37
+ self.calculate_discount_value_tool],
38
+ allow_delegation=False,
39
+ llm=llm,
40
+ max_retries=3
41
  )
42
 
43
  self.social_media_copywriter = Agent(
44
  role='Social Media Copywriter',
45
  goal='Create a compelling social media post in Portuguese to sell the product',
46
+ 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."),
 
 
47
  verbose=True,
48
+ tools=[self.image_generator_tool],
49
+ allow_delegation=False,
50
+ llm=llm,
51
+ max_retries=3
52
  )
53
 
54
+ def _validate_url(self, product_url: str) -> bool:
55
+ headers = {
56
+ "accept": "*/*",
57
+ "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
58
+ "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
59
+ "sec-ch-ua-mobile": "?0",
60
+ "sec-ch-ua-platform": '"Windows"',
61
+ "sec-fetch-dest": "empty",
62
+ "sec-fetch-mode": "cors",
63
+ "sec-fetch-site": "cross-site",
64
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
65
+ }
66
+ try:
67
+ response = requests.get(product_url, headers=headers)
68
+ response.raise_for_status()
69
+ if '<template data-dgst="NEXT_NOT_FOUND">' in response.text:
70
+ return False
71
+ return True
72
+ except requests.exceptions.RequestException as e:
73
+ print(f"Error checking URL: {e}")
74
+ return False
75
+
76
+ def _prepare_merchant(self, product_url: str):
77
+ merchant = self.merchant_selector_tool.run(product_url)
78
+ css_selector = merchant.keep_css_selectors()
79
+ short_url = merchant.shorten_url(product_url)
80
+ return merchant, css_selector, short_url
81
+
82
+ def _create_analyze_product_task(self, product_url: str, css_selector: list[str], main_cupom_discount_percentage: float, short_url: str, original_price: float, discounted_price: float) -> Task:
83
+ task_description = (f"1. Scrape the content of the URL: {product_url} using the 'scrape_tool' with param `keep_selectors` as `{css_selector}` and `base_selector` as `body`.\n"
84
+ "2. Extract the product name, key characteristics, and any other relevant DISCOUNT available.\n")
85
+
86
+ if original_price is not None and original_price > 0 and discounted_price is not None and discounted_price > 0:
87
+ task_description += (f"3. The user has provided the prices. Use ORIGINAL PRICE = {original_price} and DISCOUNTED PRICE = {discounted_price}.\n")
88
+ final_best_price_source = str(discounted_price)
89
+ else:
90
+ task_description += ("3. Identify and extract the original product price and the final discounted price if existing from the scraped content. "
91
+ "IGNORE any price breakdowns like 'produto' or 'consultoria'.\n")
92
+ final_best_price_source = "the extracted final best price"
93
+
94
+ 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"
95
+ "4.1 Use the 'Calculate Discount Value Tool' with ORIGINAL PRICE and CUPOM DISCOUNTED PRICE to get the TOTAL DISCOUNT PERCENTAGE.\n"
96
+ 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'.")
97
+
98
+ return Task(
99
+ description=task_description,
100
  agent=self.product_analyst,
101
+ 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."
102
  )
103
 
104
+ def _create_post_task(self, analyze_product_task: Task, merchant, main_cupom: str, cupom_1: str, store_name: str) -> Task:
105
+ template = merchant.get_template(main_cupom, cupom_1, store=store_name)
106
+ return Task(
107
+ description=(f"Based on the product analysis, create a CONCISE and DIRECT social media post in Portuguese, using Brazilian currency format, add one space after R$, like R$ 99,99, 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\n. Do not add backticks to response. Ensure a URL is always present in the output. Include a clear and short call to action and a MAXIMUM of 1 relevant emoji. 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'."),
108
  agent=self.social_media_copywriter,
109
+ expected_output="A short, direct, and impactful social media post in Portuguese for WhatsApp, strictly following the provided template, dont include triple backticks, including the FINAL PRICE, any DISCOUNT with no decimal cases, the SHORT SHAREABLE URL, a call to action, and one emoji in the Title or in the Description. Description should be one sentence only. Description should stay before the title, as the template. 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.",
110
  context=[analyze_product_task]
111
  )
112
 
113
+ 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:
114
+ if not self._validate_url(product_url):
115
+ return "INVALID_URL"
116
+
117
+ merchant, css_selector, short_url = self._prepare_merchant(product_url)
118
+
119
+ analyze_product_task = self._create_analyze_product_task(product_url, css_selector, main_cupom_discount_percentage, short_url, original_price, discounted_price)
120
+ create_post_task = self._create_post_task(analyze_product_task, merchant, main_cupom, cupom_1, store_name)
121
+
122
  crew = Crew(
123
  agents=[self.product_analyst, self.social_media_copywriter],
124
  tasks=[analyze_product_task, create_post_task],
uv.lock CHANGED
@@ -1,5 +1,5 @@
1
  version = 1
2
- revision = 2
3
  requires-python = ">=3.12"
4
  resolution-markers = [
5
  "python_full_version >= '3.13'",
@@ -574,6 +574,7 @@ dependencies = [
574
  { name = "crewai-tools" },
575
  { name = "gradio" },
576
  { name = "litellm" },
 
577
  { name = "pillow" },
578
  { name = "playwright" },
579
  { name = "playwright-stealth" },
@@ -592,6 +593,7 @@ requires-dist = [
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" },
@@ -1694,6 +1696,68 @@ wheels = [
1694
  { url = "https://files.pythonhosted.org/packages/96/c9/4aae0b77632279eef9716dbcb98edd8b36c08a9da070e2470ca9c410c0f8/litellm-1.72.6-py3-none-any.whl", hash = "sha256:e0ae98d25db4910e78b1a0a604f24c0d6875f6cdea02426b264a45d4fbdb8c46", size = 8302810, upload-time = "2025-06-14T21:43:08.628Z" },
1695
  ]
1696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1697
  [[package]]
1698
  name = "mako"
1699
  version = "1.3.10"
 
1
  version = 1
2
+ revision = 3
3
  requires-python = ">=3.12"
4
  resolution-markers = [
5
  "python_full_version >= '3.13'",
 
574
  { name = "crewai-tools" },
575
  { name = "gradio" },
576
  { name = "litellm" },
577
+ { name = "lxml" },
578
  { name = "pillow" },
579
  { name = "playwright" },
580
  { name = "playwright-stealth" },
 
593
  { name = "crewai-tools", specifier = ">=0.55.0" },
594
  { name = "gradio", specifier = ">=5.38.0" },
595
  { name = "litellm", specifier = ">=1.72.6" },
596
+ { name = "lxml", specifier = ">=6.0.1" },
597
  { name = "pillow", specifier = ">=11.3.0" },
598
  { name = "playwright", specifier = ">=1.53.0" },
599
  { name = "playwright-stealth", specifier = ">=2.0.0" },
 
1696
  { url = "https://files.pythonhosted.org/packages/96/c9/4aae0b77632279eef9716dbcb98edd8b36c08a9da070e2470ca9c410c0f8/litellm-1.72.6-py3-none-any.whl", hash = "sha256:e0ae98d25db4910e78b1a0a604f24c0d6875f6cdea02426b264a45d4fbdb8c46", size = 8302810, upload-time = "2025-06-14T21:43:08.628Z" },
1697
  ]
1698
 
1699
+ [[package]]
1700
+ name = "lxml"
1701
+ version = "6.0.1"
1702
+ source = { registry = "https://pypi.org/simple" }
1703
+ sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214, upload-time = "2025-08-22T10:37:53.525Z" }
1704
+ wheels = [
1705
+ { url = "https://files.pythonhosted.org/packages/b0/a9/82b244c8198fcdf709532e39a1751943a36b3e800b420adc739d751e0299/lxml-6.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c03ac546adaabbe0b8e4a15d9ad815a281afc8d36249c246aecf1aaad7d6f200", size = 8422788, upload-time = "2025-08-22T10:32:56.612Z" },
1706
+ { url = "https://files.pythonhosted.org/packages/c9/8d/1ed2bc20281b0e7ed3e6c12b0a16e64ae2065d99be075be119ba88486e6d/lxml-6.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33b862c7e3bbeb4ba2c96f3a039f925c640eeba9087a4dc7a572ec0f19d89392", size = 4593547, upload-time = "2025-08-22T10:32:59.016Z" },
1707
+ { url = "https://files.pythonhosted.org/packages/76/53/d7fd3af95b72a3493bf7fbe842a01e339d8f41567805cecfecd5c71aa5ee/lxml-6.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a3ec1373f7d3f519de595032d4dcafae396c29407cfd5073f42d267ba32440d", size = 4948101, upload-time = "2025-08-22T10:33:00.765Z" },
1708
+ { url = "https://files.pythonhosted.org/packages/9d/51/4e57cba4d55273c400fb63aefa2f0d08d15eac021432571a7eeefee67bed/lxml-6.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03b12214fb1608f4cffa181ec3d046c72f7e77c345d06222144744c122ded870", size = 5108090, upload-time = "2025-08-22T10:33:03.108Z" },
1709
+ { url = "https://files.pythonhosted.org/packages/f6/6e/5f290bc26fcc642bc32942e903e833472271614e24d64ad28aaec09d5dae/lxml-6.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:207ae0d5f0f03b30f95e649a6fa22aa73f5825667fee9c7ec6854d30e19f2ed8", size = 5021791, upload-time = "2025-08-22T10:33:06.972Z" },
1710
+ { url = "https://files.pythonhosted.org/packages/13/d4/2e7551a86992ece4f9a0f6eebd4fb7e312d30f1e372760e2109e721d4ce6/lxml-6.0.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:32297b09ed4b17f7b3f448de87a92fb31bb8747496623483788e9f27c98c0f00", size = 5358861, upload-time = "2025-08-22T10:33:08.967Z" },
1711
+ { url = "https://files.pythonhosted.org/packages/8a/5f/cb49d727fc388bf5fd37247209bab0da11697ddc5e976ccac4826599939e/lxml-6.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e18224ea241b657a157c85e9cac82c2b113ec90876e01e1f127312006233756", size = 5652569, upload-time = "2025-08-22T10:33:10.815Z" },
1712
+ { url = "https://files.pythonhosted.org/packages/ca/b8/66c1ef8c87ad0f958b0a23998851e610607c74849e75e83955d5641272e6/lxml-6.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a07a994d3c46cd4020c1ea566345cf6815af205b1e948213a4f0f1d392182072", size = 5252262, upload-time = "2025-08-22T10:33:12.673Z" },
1713
+ { url = "https://files.pythonhosted.org/packages/1a/ef/131d3d6b9590e64fdbb932fbc576b81fcc686289da19c7cb796257310e82/lxml-6.0.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:2287fadaa12418a813b05095485c286c47ea58155930cfbd98c590d25770e225", size = 4710309, upload-time = "2025-08-22T10:33:14.952Z" },
1714
+ { url = "https://files.pythonhosted.org/packages/bc/3f/07f48ae422dce44902309aa7ed386c35310929dc592439c403ec16ef9137/lxml-6.0.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4e597efca032ed99f418bd21314745522ab9fa95af33370dcee5533f7f70136", size = 5265786, upload-time = "2025-08-22T10:33:16.721Z" },
1715
+ { url = "https://files.pythonhosted.org/packages/11/c7/125315d7b14ab20d9155e8316f7d287a4956098f787c22d47560b74886c4/lxml-6.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9696d491f156226decdd95d9651c6786d43701e49f32bf23715c975539aa2b3b", size = 5062272, upload-time = "2025-08-22T10:33:18.478Z" },
1716
+ { url = "https://files.pythonhosted.org/packages/8b/c3/51143c3a5fc5168a7c3ee626418468ff20d30f5a59597e7b156c1e61fba8/lxml-6.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e4e3cd3585f3c6f87cdea44cda68e692cc42a012f0131d25957ba4ce755241a7", size = 4786955, upload-time = "2025-08-22T10:33:20.34Z" },
1717
+ { url = "https://files.pythonhosted.org/packages/11/86/73102370a420ec4529647b31c4a8ce8c740c77af3a5fae7a7643212d6f6e/lxml-6.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:45cbc92f9d22c28cd3b97f8d07fcefa42e569fbd587dfdac76852b16a4924277", size = 5673557, upload-time = "2025-08-22T10:33:22.282Z" },
1718
+ { url = "https://files.pythonhosted.org/packages/d7/2d/aad90afaec51029aef26ef773b8fd74a9e8706e5e2f46a57acd11a421c02/lxml-6.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f8c9bcfd2e12299a442fba94459adf0b0d001dbc68f1594439bfa10ad1ecb74b", size = 5254211, upload-time = "2025-08-22T10:33:24.15Z" },
1719
+ { url = "https://files.pythonhosted.org/packages/63/01/c9e42c8c2d8b41f4bdefa42ab05448852e439045f112903dd901b8fbea4d/lxml-6.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e9dc2b9f1586e7cd77753eae81f8d76220eed9b768f337dc83a3f675f2f0cf9", size = 5275817, upload-time = "2025-08-22T10:33:26.007Z" },
1720
+ { url = "https://files.pythonhosted.org/packages/bc/1f/962ea2696759abe331c3b0e838bb17e92224f39c638c2068bf0d8345e913/lxml-6.0.1-cp312-cp312-win32.whl", hash = "sha256:987ad5c3941c64031f59c226167f55a04d1272e76b241bfafc968bdb778e07fb", size = 3610889, upload-time = "2025-08-22T10:33:28.169Z" },
1721
+ { url = "https://files.pythonhosted.org/packages/41/e2/22c86a990b51b44442b75c43ecb2f77b8daba8c4ba63696921966eac7022/lxml-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:abb05a45394fd76bf4a60c1b7bec0e6d4e8dfc569fc0e0b1f634cd983a006ddc", size = 4010925, upload-time = "2025-08-22T10:33:29.874Z" },
1722
+ { url = "https://files.pythonhosted.org/packages/b2/21/dc0c73325e5eb94ef9c9d60dbb5dcdcb2e7114901ea9509735614a74e75a/lxml-6.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:c4be29bce35020d8579d60aa0a4e95effd66fcfce31c46ffddf7e5422f73a299", size = 3671922, upload-time = "2025-08-22T10:33:31.535Z" },
1723
+ { url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139, upload-time = "2025-08-22T10:33:34.09Z" },
1724
+ { url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954, upload-time = "2025-08-22T10:33:36.294Z" },
1725
+ { url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052, upload-time = "2025-08-22T10:33:38.19Z" },
1726
+ { url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885, upload-time = "2025-08-22T10:33:40.035Z" },
1727
+ { url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542, upload-time = "2025-08-22T10:33:41.896Z" },
1728
+ { url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303, upload-time = "2025-08-22T10:33:43.868Z" },
1729
+ { url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055, upload-time = "2025-08-22T10:33:45.784Z" },
1730
+ { url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719, upload-time = "2025-08-22T10:33:48.089Z" },
1731
+ { url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310, upload-time = "2025-08-22T10:33:49.852Z" },
1732
+ { url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024, upload-time = "2025-08-22T10:33:52.22Z" },
1733
+ { url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335, upload-time = "2025-08-22T10:33:54.041Z" },
1734
+ { url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864, upload-time = "2025-08-22T10:33:56.382Z" },
1735
+ { url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173, upload-time = "2025-08-22T10:33:58.698Z" },
1736
+ { url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896, upload-time = "2025-08-22T10:34:00.586Z" },
1737
+ { url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417, upload-time = "2025-08-22T10:34:02.719Z" },
1738
+ { url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051, upload-time = "2025-08-22T10:34:04.553Z" },
1739
+ { url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325, upload-time = "2025-08-22T10:34:06.24Z" },
1740
+ { url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443, upload-time = "2025-08-22T10:34:07.974Z" },
1741
+ { url = "https://files.pythonhosted.org/packages/38/e3/b7eb612ce07abe766918a7e581ec6a0e5212352194001fd287c3ace945f0/lxml-6.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:29b0e849ec7030e3ecb6112564c9f7ad6881e3b2375dd4a0c486c5c1f3a33859", size = 8426160, upload-time = "2025-08-22T10:34:10.154Z" },
1742
+ { url = "https://files.pythonhosted.org/packages/35/8f/ab3639a33595cf284fe733c6526da2ca3afbc5fd7f244ae67f3303cec654/lxml-6.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:02a0f7e629f73cc0be598c8b0611bf28ec3b948c549578a26111b01307fd4051", size = 4589288, upload-time = "2025-08-22T10:34:12.972Z" },
1743
+ { url = "https://files.pythonhosted.org/packages/2c/65/819d54f2e94d5c4458c1db8c1ccac9d05230b27c1038937d3d788eb406f9/lxml-6.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:beab5e54de016e730875f612ba51e54c331e2fa6dc78ecf9a5415fc90d619348", size = 4964523, upload-time = "2025-08-22T10:34:15.474Z" },
1744
+ { url = "https://files.pythonhosted.org/packages/5b/4a/d4a74ce942e60025cdaa883c5a4478921a99ce8607fc3130f1e349a83b28/lxml-6.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a08aefecd19ecc4ebf053c27789dd92c87821df2583a4337131cf181a1dffa", size = 5101108, upload-time = "2025-08-22T10:34:17.348Z" },
1745
+ { url = "https://files.pythonhosted.org/packages/cb/48/67f15461884074edd58af17b1827b983644d1fae83b3d909e9045a08b61e/lxml-6.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c8fa7e177649470bc3dcf7eae6bee1e4984aaee496b9ccbf30e97ac4127fa2", size = 5053498, upload-time = "2025-08-22T10:34:19.232Z" },
1746
+ { url = "https://files.pythonhosted.org/packages/b6/d4/ec1bf1614828a5492f4af0b6a9ee2eb3e92440aea3ac4fa158e5228b772b/lxml-6.0.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5d08e0f1af6916267bb7eff21c09fa105620f07712424aaae09e8cb5dd4164d1", size = 5351057, upload-time = "2025-08-22T10:34:21.143Z" },
1747
+ { url = "https://files.pythonhosted.org/packages/65/2b/c85929dacac08821f2100cea3eb258ce5c8804a4e32b774f50ebd7592850/lxml-6.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9705cdfc05142f8c38c97a61bd3a29581ceceb973a014e302ee4a73cc6632476", size = 5671579, upload-time = "2025-08-22T10:34:23.528Z" },
1748
+ { url = "https://files.pythonhosted.org/packages/d0/36/cf544d75c269b9aad16752fd9f02d8e171c5a493ca225cb46bb7ba72868c/lxml-6.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74555e2da7c1636e30bff4e6e38d862a634cf020ffa591f1f63da96bf8b34772", size = 5250403, upload-time = "2025-08-22T10:34:25.642Z" },
1749
+ { url = "https://files.pythonhosted.org/packages/c2/e8/83dbc946ee598fd75fdeae6151a725ddeaab39bb321354a9468d4c9f44f3/lxml-6.0.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e38b5f94c5a2a5dadaddd50084098dfd005e5a2a56cd200aaf5e0a20e8941782", size = 4696712, upload-time = "2025-08-22T10:34:27.753Z" },
1750
+ { url = "https://files.pythonhosted.org/packages/f4/72/889c633b47c06205743ba935f4d1f5aa4eb7f0325d701ed2b0540df1b004/lxml-6.0.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5ec101a92ddacb4791977acfc86c1afd624c032974bfb6a21269d1083c9bc49", size = 5268177, upload-time = "2025-08-22T10:34:29.804Z" },
1751
+ { url = "https://files.pythonhosted.org/packages/b0/b6/f42a21a1428479b66ea0da7bd13e370436aecaff0cfe93270c7e165bd2a4/lxml-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c17e70c82fd777df586c12114bbe56e4e6f823a971814fd40dec9c0de518772", size = 5094648, upload-time = "2025-08-22T10:34:31.703Z" },
1752
+ { url = "https://files.pythonhosted.org/packages/51/b0/5f8c1e8890e2ee1c2053c2eadd1cb0e4b79e2304e2912385f6ca666f48b1/lxml-6.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:45fdd0415a0c3d91640b5d7a650a8f37410966a2e9afebb35979d06166fd010e", size = 4745220, upload-time = "2025-08-22T10:34:33.595Z" },
1753
+ { url = "https://files.pythonhosted.org/packages/eb/f9/820b5125660dae489ca3a21a36d9da2e75dd6b5ffe922088f94bbff3b8a0/lxml-6.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d417eba28981e720a14fcb98f95e44e7a772fe25982e584db38e5d3b6ee02e79", size = 5692913, upload-time = "2025-08-22T10:34:35.482Z" },
1754
+ { url = "https://files.pythonhosted.org/packages/23/8e/a557fae9eec236618aecf9ff35fec18df41b6556d825f3ad6017d9f6e878/lxml-6.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8e5d116b9e59be7934febb12c41cce2038491ec8fdb743aeacaaf36d6e7597e4", size = 5259816, upload-time = "2025-08-22T10:34:37.482Z" },
1755
+ { url = "https://files.pythonhosted.org/packages/fa/fd/b266cfaab81d93a539040be699b5854dd24c84e523a1711ee5f615aa7000/lxml-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c238f0d0d40fdcb695c439fe5787fa69d40f45789326b3bb6ef0d61c4b588d6e", size = 5276162, upload-time = "2025-08-22T10:34:39.507Z" },
1756
+ { url = "https://files.pythonhosted.org/packages/25/6c/6f9610fbf1de002048e80585ea4719591921a0316a8565968737d9f125ca/lxml-6.0.1-cp314-cp314-win32.whl", hash = "sha256:537b6cf1c5ab88cfd159195d412edb3e434fee880f206cbe68dff9c40e17a68a", size = 3669595, upload-time = "2025-08-22T10:34:41.783Z" },
1757
+ { url = "https://files.pythonhosted.org/packages/72/a5/506775e3988677db24dc75a7b03e04038e0b3d114ccd4bccea4ce0116c15/lxml-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:911d0a2bb3ef3df55b3d97ab325a9ca7e438d5112c102b8495321105d25a441b", size = 4079818, upload-time = "2025-08-22T10:34:44.04Z" },
1758
+ { url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901, upload-time = "2025-08-22T10:34:45.799Z" },
1759
+ ]
1760
+
1761
  [[package]]
1762
  name = "mako"
1763
  version = "1.3.10"