first commit
This commit is contained in:
commit
fd0917950d
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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user