first commit
This commit is contained in:
1
backend/.env
Normal file
1
backend/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
OPENAI_API_KEY=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
|
||||||
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
258
backend/main.py
Normal file
258
backend/main.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
from fastapi import FastAPI, Form
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from playwright.sync_api import sync_playwright, TimeoutError
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def frontend():
|
||||||
|
return """
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial; margin: 40px;">
|
||||||
|
<h2>🔍 Product Search</h2>
|
||||||
|
<form action="/search" method="post">
|
||||||
|
<label>Descrizione:</label><br>
|
||||||
|
<input name="descrizione" style="width:300px"><br><br>
|
||||||
|
<label>Marca:</label><br>
|
||||||
|
<input name="brand" style="width:300px"><br><br>
|
||||||
|
<label>EAN:</label><br>
|
||||||
|
<input name="ean" style="width:300px"><br><br>
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_fanola(descrizione: str, brand: str):
|
||||||
|
query = quote_plus(f"{descrizione} {brand}")
|
||||||
|
search_url = f"https://www.fanola.it/catalogsearch/result/?q={query}"
|
||||||
|
|
||||||
|
def extract_table_value(page, label):
|
||||||
|
try:
|
||||||
|
el = page.locator(
|
||||||
|
f"//th[contains(translate(text(),'abcdefghijklmnopqrstuvwxyz','ABCDEFGHIJKLMNOPQRSTUVWXYZ'),"
|
||||||
|
f"'{label.upper()}')]/following-sibling::td"
|
||||||
|
).first
|
||||||
|
return el.inner_text().strip()
|
||||||
|
except:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def extract_ingredients(page, description):
|
||||||
|
|
||||||
|
selectors = [
|
||||||
|
".ingredients",
|
||||||
|
".inci",
|
||||||
|
"#ingredients",
|
||||||
|
"div[data-role='content']:has-text('INGREDIENTI')",
|
||||||
|
"div:has-text('INGREDIENTI')"
|
||||||
|
]
|
||||||
|
|
||||||
|
for sel in selectors:
|
||||||
|
el = page.query_selector(sel)
|
||||||
|
if el:
|
||||||
|
txt = el.inner_text().strip()
|
||||||
|
if "," in txt and len(txt) > 50:
|
||||||
|
return txt.replace("INGREDIENTI:", "").strip()
|
||||||
|
|
||||||
|
if "INGREDIENTI:" in description.upper():
|
||||||
|
return description.split("INGREDIENTI:", 1)[-1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
html = page.content()
|
||||||
|
import re
|
||||||
|
match = re.search(r"(Aqua\s*\(.*?\).{100,2000})", html, re.I | re.S)
|
||||||
|
return match.group(1).strip() if match else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page()
|
||||||
|
|
||||||
|
page.goto(search_url, wait_until="networkidle", timeout=20000)
|
||||||
|
page.wait_for_selector("a.product-item-link", timeout=10000)
|
||||||
|
|
||||||
|
product_elem = page.query_selector("a.product-item-link")
|
||||||
|
if not product_elem:
|
||||||
|
browser.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
product_url = product_elem.get_attribute("href")
|
||||||
|
page.goto(product_url, wait_until="networkidle", timeout=20000)
|
||||||
|
|
||||||
|
|
||||||
|
tabs = page.query_selector_all(
|
||||||
|
".product.data.items .item.title, .tabs .item.title"
|
||||||
|
)
|
||||||
|
for t in tabs:
|
||||||
|
try:
|
||||||
|
t.click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
title_el = page.query_selector("span.base") or page.query_selector("h1.page-title span")
|
||||||
|
product_title = title_el.inner_text().strip() if title_el else descrizione
|
||||||
|
|
||||||
|
desc_el = page.query_selector("div.product.attribute.description")
|
||||||
|
description = desc_el.inner_text().strip() if desc_el else ""
|
||||||
|
|
||||||
|
|
||||||
|
ingredients = extract_ingredients(page, description)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
img_el = page.query_selector("img.fotorama__img")
|
||||||
|
product_image = img_el.get_attribute("src") if img_el else ""
|
||||||
|
except:
|
||||||
|
product_image = ""
|
||||||
|
|
||||||
|
|
||||||
|
sku = extract_table_value(page, "SKU")
|
||||||
|
barcode = extract_table_value(page, "EAN")
|
||||||
|
famiglia = extract_table_value(page, "Famiglia")
|
||||||
|
s_famiglia = extract_table_value(page, "Sottofamiglia")
|
||||||
|
ss_famiglia = extract_table_value(page, "SSottofamiglia")
|
||||||
|
|
||||||
|
|
||||||
|
gruppo = linea = s_linea = ss_linea = ""
|
||||||
|
try:
|
||||||
|
crumbs = page.locator("ul.items li").all_inner_texts()
|
||||||
|
crumbs = [c.strip() for c in crumbs if c.strip()]
|
||||||
|
if len(crumbs) > 1:
|
||||||
|
gruppo = crumbs[1]
|
||||||
|
if len(crumbs) > 2:
|
||||||
|
linea = crumbs[2]
|
||||||
|
if len(crumbs) > 3:
|
||||||
|
s_linea = crumbs[3]
|
||||||
|
if len(crumbs) > 4:
|
||||||
|
ss_linea = crumbs[4]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": product_url,
|
||||||
|
"codice": sku,
|
||||||
|
"descrizione": product_title,
|
||||||
|
"marca": brand.title() if brand else "",
|
||||||
|
"linea": linea,
|
||||||
|
"s_linea": s_linea,
|
||||||
|
"ss_linea": ss_linea,
|
||||||
|
"gruppo": gruppo,
|
||||||
|
"s_gruppo": "",
|
||||||
|
"famiglia": famiglia,
|
||||||
|
"s_famiglia": s_famiglia,
|
||||||
|
"ss_famiglia": ss_famiglia,
|
||||||
|
"descrizione_articolo": description,
|
||||||
|
"ingredienti": ingredients,
|
||||||
|
"barcode": barcode,
|
||||||
|
"product_link": product_url,
|
||||||
|
"image_70x70": product_image
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("Scraper error:", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clean_newlines(product_data):
|
||||||
|
for key in ["descrizione_articolo", "ingredienti"]:
|
||||||
|
if product_data.get(key):
|
||||||
|
product_data[key] = product_data[key].replace("\n", "<br>")
|
||||||
|
return product_data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_product_json(scraped):
|
||||||
|
schema = {
|
||||||
|
"codice": "",
|
||||||
|
"descrizione": "",
|
||||||
|
"marca": "",
|
||||||
|
"linea": "",
|
||||||
|
"s_linea": "",
|
||||||
|
"ss_linea": "",
|
||||||
|
"gruppo": "",
|
||||||
|
"s_gruppo": "",
|
||||||
|
"famiglia": "",
|
||||||
|
"s_famiglia": "",
|
||||||
|
"ss_famiglia": "",
|
||||||
|
"descrizione_articolo": "",
|
||||||
|
"ingredienti": "",
|
||||||
|
"barcode": "",
|
||||||
|
"product_link": "",
|
||||||
|
"image_70x70": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
You are a data normalization system. Use ONLY the scraped data. Do NOT invent anything.
|
||||||
|
|
||||||
|
DATI ESTRATTI:
|
||||||
|
{json.dumps(scraped, indent=2)}
|
||||||
|
|
||||||
|
RISPONDI CON SOLO JSON valido che segue esattamente questo schema:
|
||||||
|
{json.dumps(schema, indent=2)}
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = requests.post(
|
||||||
|
"http://192.168.2.207:8080/",
|
||||||
|
model="llama3.1:latest",
|
||||||
|
messages=[{"role": "user", "content": prompt}]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.raise_for_status()
|
||||||
|
content = res.json().get("message", {}).get("content", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = content.split("```")[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
start = content.find("{")
|
||||||
|
end = content.rfind("}") + 1
|
||||||
|
json_str = content[start:end]
|
||||||
|
|
||||||
|
return json.loads(json_str)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": "Invalid JSON", "raw": content, "details": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search_product(descrizione: str = Form(...),
|
||||||
|
brand: str = Form(...),
|
||||||
|
ean: str = Form("")):
|
||||||
|
|
||||||
|
scraped = search_fanola(descrizione, brand)
|
||||||
|
|
||||||
|
if not scraped:
|
||||||
|
return {
|
||||||
|
"requested": {"descrizione": descrizione, "brand": brand, "ean": ean},
|
||||||
|
"product_data": {"error": "Product not found"},
|
||||||
|
"search_url": f"https://www.fanola.it/catalogsearch/result/?q={quote_plus(descrizione + ' ' + brand)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
product_data = extract_product_json(scraped)
|
||||||
|
|
||||||
|
if product_data and "error" not in product_data:
|
||||||
|
if "descrizione_articolo" in product_data and product_data["descrizione_articolo"]:
|
||||||
|
product_data["descrizione_articolo"] = product_data["descrizione_articolo"].replace("\n", "<br>")
|
||||||
|
if "ingredienti" in product_data and product_data["ingredienti"]:
|
||||||
|
product_data["ingredienti"] = product_data["ingredienti"].replace("\n", "<br>")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"requested": {"descrizione": descrizione, "brand": brand, "ean": ean},
|
||||||
|
"product_data": product_data,
|
||||||
|
"search_url": f"https://www.fanola.it/catalogsearch/result/?q={quote_plus(descrizione + ' ' + brand)}"
|
||||||
|
}
|
||||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-multipart
|
||||||
|
openai
|
||||||
|
requests
|
||||||
23
static/index.html
Normal file
23
static/index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Product Search</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h2>Product Search</h2>
|
||||||
|
|
||||||
|
<form id="searchForm">
|
||||||
|
<input id="descrizione" placeholder="Descrizione" required>
|
||||||
|
<input id="marca" placeholder="Marca" required>
|
||||||
|
<input id="ean" placeholder="EAN">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="results"></div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
static/script.js
Normal file
61
static/script.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const form = document.getElementById('searchForm');
|
||||||
|
const resultsDiv = document.getElementById('results');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const descrizione = document.getElementById('descrizione').value;
|
||||||
|
const marca = document.getElementById('marca').value;
|
||||||
|
const ean = document.getElementById('ean').value;
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = "<p>Loading...</p>";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:8000/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ descrizione, brand: marca, ean })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.product_data.error) {
|
||||||
|
resultsDiv.innerHTML = `<p style="color:red;">${data.product_data.error}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = data.product_data;
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="product-card">
|
||||||
|
<img src="${p.image_70x70 || ''}" alt="Product Image">
|
||||||
|
<div class="product-info">
|
||||||
|
<table>
|
||||||
|
<tr><th>Codice</th><td>${p.codice}</td></tr>
|
||||||
|
<tr><th>Descrizione</th><td>${p.descrizione}</td></tr>
|
||||||
|
<tr><th>Marca</th><td>${p.marca}</td></tr>
|
||||||
|
<tr><th>Linea</th><td>${p.linea}</td></tr>
|
||||||
|
<tr><th>S Linea</th><td>${p.s_linea}</td></tr>
|
||||||
|
<tr><th>SS Linea</th><td>${p.ss_linea}</td></tr>
|
||||||
|
<tr><th>Gruppo</th><td>${p.gruppo}</td></tr>
|
||||||
|
<tr><th>S Gruppo</th><td>${p.s_gruppo}</td></tr>
|
||||||
|
<tr><th>Famiglia</th><td>${p.famiglia}</td></tr>
|
||||||
|
<tr><th>S Famiglia</th><td>${p.s_famiglia}</td></tr>
|
||||||
|
<tr><th>SS Famiglia</th><td>${p.ss_famiglia}</td></tr>
|
||||||
|
<tr><th>Barcode</th><td>${p.barcode}</td></tr>
|
||||||
|
<tr><th>Link</th><td><a href="${p.product_link}" target="_blank">Open Product Page</a></td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="ingredients">
|
||||||
|
<strong>Ingredienti:</strong><br>
|
||||||
|
${p.ingredienti || 'N/A'}
|
||||||
|
</div>
|
||||||
|
<div class="description">
|
||||||
|
<strong>Descrizione Articolo:</strong><br>
|
||||||
|
${p.descrizione_articolo ? p.descrizione_articolo.replace(/\n/g, "<br>") : 'N/A'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch(err) {
|
||||||
|
resultsDiv.innerHTML = `<p style="color:red;">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
75
static/style.css
Normal file
75
static/style.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button {
|
||||||
|
padding: 8px;
|
||||||
|
width: 300px;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 150px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
#results {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0px 1px 5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card img {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-right: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info th, .product-info td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info th {
|
||||||
|
width: 130px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredients, .description {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user