Remove all Dupliclick/Spesa integration; merge annual waste info into status line

This commit is contained in:
dadaloop82
2026-04-29 16:52:36 +00:00
parent 9c1346019c
commit 3c9fe7dfea
7 changed files with 8 additions and 1227 deletions
+2 -436
View File
@@ -2,7 +2,7 @@
/**
* EverShelf - Main API Router
* Handles all CRUD operations for products, inventory, shopping lists,
* AI-powered features (Gemini), and third-party integrations (Bring!, DupliClick).
* AI-powered features (Gemini), and third-party integrations (Bring!).
*
* @author Stimpfl Daniel <evershelfproject@gmail.com>
* @license MIT
@@ -65,7 +65,7 @@ function checkRateLimit(string $action): void {
// Determine limit based on action
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping'];
$loginActions = ['dupliclick_login'];
$loginActions = [];
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
if (in_array($action, $aiActions)) {
@@ -284,25 +284,6 @@ try {
migrateUnitsToBase($db);
break;
// ===== SPESA ONLINE =====
case 'dupliclick_login':
dupliclickLogin();
break;
case 'dupliclick_search':
dupliclickSearch();
break;
case 'dupliclick_status':
$tokenFile = __DIR__ . '/../data/dupliclick_token.json';
if (file_exists($tokenFile)) {
$td = json_decode(file_get_contents($tokenFile), true);
echo json_encode(['logged_in' => !empty($td['token']), 'email' => $td['email'] ?? '']);
} else {
echo json_encode(['logged_in' => false]);
}
break;
// ===== SHARED APP DATA =====
case 'app_settings_get':
appSettingsGet($db);
@@ -1963,8 +1944,6 @@ function getServerSettings(): void {
'camera_facing' => env('CAMERA_FACING', 'environment'),
'scale_enabled' => env('SCALE_ENABLED', 'false') === 'true',
'scale_gateway_url' => env('SCALE_GATEWAY_URL', ''),
'spesa_provider' => env('SPESA_PROVIDER', 'bring'),
'spesa_ai_prompt' => env('SPESA_AI_PROMPT', ''),
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
]);
}
@@ -1988,8 +1967,6 @@ function saveSettings(): void {
'camera_facing' => 'CAMERA_FACING',
'dietary' => 'DIETARY',
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
'spesa_provider' => 'SPESA_PROVIDER',
'spesa_ai_prompt' => 'SPESA_AI_PROMPT',
];
// Boolean keys
$boolMap = [
@@ -5194,417 +5171,6 @@ function bringSuggestItems(PDO $db): void {
], JSON_UNESCAPED_UNICODE);
}
// ===== DUPLICLICK (GRUPPO POLI) =====
function dupliclickLogin(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['error' => 'POST required']);
return;
}
$input = json_decode(file_get_contents('php://input'), true);
$email = $input['email'] ?? '';
$password = $input['password'] ?? '';
if (empty($email) || empty($password)) {
echo json_encode(['error' => 'Email e password sono obbligatori']);
return;
}
$postData = http_build_query([
'login' => $email,
'password' => $password,
'remember_me' => 'true',
'show_sectors' => 'false'
]);
$ch = curl_init('https://www.dupliclick.it/ebsn/api/auth/login');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
'Accept: application/json',
'Origin: https://www.dupliclick.it',
'Referer: https://www.dupliclick.it/',
'x-ebsn-client: production',
'x-ebsn-client-redirect: production',
'x-ebsn-client-uuid: 64b2d6318bb8f97bb1aba47dd8af38f6',
'x-ebsn-version: 2.0.7'
],
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
echo json_encode(['error' => 'Errore connessione: ' . curl_error($ch)]);
curl_close($ch);
return;
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headerStr = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
curl_close($ch);
// Extract JWT token from x-ebsn-account header
$token = '';
foreach (explode("\r\n", $headerStr) as $line) {
if (stripos($line, 'x-ebsn-account:') === 0) {
$token = trim(substr($line, strlen('x-ebsn-account:')));
break;
}
}
// The response body may have leading whitespace/newlines - trim it
$body = trim($body);
$bodyData = json_decode($body, true);
// Check login success: status is at response.status (not root level)
if ($bodyData === null) {
echo json_encode(['error' => 'Risposta non valida dal server DupliClick', 'http_code' => $httpCode, 'raw' => substr($body, 0, 500)]);
return;
}
$respStatus = $bodyData['response']['status'] ?? ($bodyData['status'] ?? -1);
if ($respStatus !== 0) {
$errors = $bodyData['response']['errors'] ?? $bodyData['errors'] ?? [];
$errMsg = $errors[0]['error'] ?? $bodyData['message'] ?? 'Credenziali non valide';
echo json_encode(['error' => $errMsg, 'status' => $respStatus]);
return;
}
// User data is at root level, not inside data.user
$userData = $bodyData['data']['user'] ?? $bodyData['user'] ?? null;
$cartId = $bodyData['data']['cartId'] ?? $bodyData['cartId'] ?? null;
// Save token to file for later use
$tokenData = [
'token' => $token,
'email' => $email,
'logged_at' => date('c'),
'user' => $userData,
'cart_id' => $cartId,
];
file_put_contents(__DIR__ . '/../data/dupliclick_token.json', json_encode($tokenData, JSON_PRETTY_PRINT));
echo json_encode([
'success' => true,
'token' => !empty($token) ? substr($token, 0, 20) . '...' : '(non trovato)',
'token_full' => $token,
'http_code' => $httpCode,
'data' => $bodyData['data'] ?? null,
'user' => $userData,
'response_status' => $respStatus,
'infos' => $bodyData['response']['infos'] ?? [],
]);
}
// ===== DUPLICLICK PRODUCT SEARCH =====
function dupliclickSearch(): void {
$query = $_GET['q'] ?? '';
$spec = $_GET['spec'] ?? '';
$aiPrompt = $_GET['prompt'] ?? '';
if (empty($query)) {
echo json_encode(['error' => 'Parametro q obbligatorio']);
return;
}
// Load saved token
$tokenFile = __DIR__ . '/../data/dupliclick_token.json';
if (!file_exists($tokenFile)) {
echo json_encode(['error' => 'Non sei loggato a DupliClick. Vai in Configurazione > Spesa Online.']);
return;
}
$tokenData = json_decode(file_get_contents($tokenFile), true);
$token = $tokenData['token'] ?? '';
if (empty($token)) {
echo json_encode(['error' => 'Token DupliClick non trovato. Effettua il login.']);
return;
}
$baseHeaders = [
'Accept: application/json',
'Origin: https://www.dupliclick.it',
'Referer: https://www.dupliclick.it/',
'x-ebsn-client: production',
'x-ebsn-client-uuid: 64b2d6318bb8f97bb1aba47dd8af38f6',
'x-ebsn-version: 2.0.7',
'x-ebsn-account: ' . $token,
];
// Search catalog by item name only first
$searchResults = dupliclickCatalogSearch($query, $baseHeaders);
if ($searchResults === null) {
echo json_encode(['error' => 'Errore nella ricerca']);
return;
}
$products = $searchResults['products'];
$total = $searchResults['total'];
if (empty($products)) {
// Fallback: try searching with spec keywords appended
$specKeywords = dupliclickExtractSpecKeywords($spec);
if ($specKeywords) {
$searchResults = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders);
if ($searchResults && !empty($searchResults['products'])) {
$products = $searchResults['products'];
$total = $searchResults['total'];
}
}
if (empty($products)) {
echo json_encode(['success' => true, 'query' => $query, 'product' => null, 'total' => 0]);
return;
}
}
// Format top 10 products
$topProducts = array_slice($products, 0, 10);
$formatted = array_map('formatDupliclickProduct', $topProducts);
// If multiple results, use AI to pick the best match
$bestProduct = $formatted[0];
$aiUsed = false;
if (count($formatted) > 1) {
$aiResult = aiSelectBestProduct($query, $spec, $formatted, $aiPrompt);
if ($aiResult !== null) {
$bestProduct = $aiResult;
$aiUsed = true;
} elseif ($aiResult === null && !empty($spec)) {
// AI said no match — try refined search with spec keywords
$specKeywords = dupliclickExtractSpecKeywords($spec);
if ($specKeywords) {
$refined = dupliclickCatalogSearch($query . ' ' . $specKeywords, $baseHeaders);
if ($refined && !empty($refined['products'])) {
$refinedFormatted = array_map('formatDupliclickProduct', array_slice($refined['products'], 0, 10));
$aiResult2 = aiSelectBestProduct($query, $spec, $refinedFormatted, $aiPrompt);
if ($aiResult2 !== null) {
$bestProduct = $aiResult2;
$aiUsed = true;
} else {
$bestProduct = $refinedFormatted[0];
}
}
}
}
}
echo json_encode([
'success' => true,
'query' => $query,
'product' => $bestProduct,
'total' => $total,
'ai_used' => $aiUsed,
]);
}
/**
* Search DupliClick catalog and return raw products array
*/
function dupliclickCatalogSearch(string $query, array $headers): ?array {
$url = 'https://www.dupliclick.it/ebsn/api/products?' . http_build_query([
'q' => $query,
'page' => 1,
'order_by' => 'search_score desc'
]);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
if (curl_errno($ch)) { curl_close($ch); return null; }
curl_close($ch);
$data = json_decode(trim($response), true);
if (!$data || ($data['response']['status'] ?? -1) !== 0) return null;
return [
'products' => $data['data']['products'] ?? [],
'total' => $data['data']['page']['totItems'] ?? 0,
];
}
/**
* Extract meaningful product keywords from a Bring specification string,
* stripping quantities, emojis, and noise words.
*/
function dupliclickExtractSpecKeywords(string $spec): string {
if (empty($spec)) return '';
// Remove priority emojis
$clean = preg_replace('/[\x{1F534}\x{1F7E1}\x{1F7E2}]/u', '', $spec);
// Remove quantities (150g, 500ml, 2x, 1 flacone, etc.)
$clean = preg_replace('/\d+\s*(g|kg|ml|l|pz|pezzi|conf|flacon[ei]|x)\b/i', '', $clean);
$clean = preg_replace('/\d+x\d*/i', '', $clean);
// Remove standalone numbers
$clean = preg_replace('/\b\d+\b/', '', $clean);
// Remove noise words
$noise = ['senza', 'con', 'più', 'meno', 'circa', 'tipo', 'lidl', 'coop', 'conad', 'esselunga'];
$clean = preg_replace('/\b(' . implode('|', $noise) . ')\b/i', '', $clean);
// Remove commas and extra spaces
$clean = preg_replace('/[,+]/', ' ', $clean);
$clean = preg_replace('/\s+/', ' ', trim($clean));
return $clean;
}
/**
* Pick the best product from search results using offline text-scoring (no AI needed).
* Returns null when nothing matches well enough (triggers refined search with spec keywords).
*/
function aiSelectBestProduct(string $itemName, string $spec, array $products, string $customPrompt = ''): ?array {
if (empty($products)) return null;
if (count($products) === 1) return $products[0];
$stop = ['di','del','della','dei','degli','dalle','delle','da','in','con','per','su',
'a','e','il','lo','la','i','gli','le','un','uno','una','al','alle','agli',
'allo','gr','kg','ml','lt','cl','pz','conf','pack'];
$tokenize = function(string $s) use ($stop): array {
$clean = mb_strtolower(preg_replace('/[^\p{L}0-9\s]/u', ' ', $s), 'UTF-8');
return array_values(array_filter(
preg_split('/\s+/', trim($clean)),
fn($t) => mb_strlen($t, 'UTF-8') > 2 && !in_array($t, $stop)
));
};
$queryTokens = $tokenize($itemName);
$specTokens = $tokenize($spec);
if (empty($queryTokens)) return $products[0];
// Variant conflict pairs: if spec says X, penalise products containing opposite
$variantConflicts = [
'cubetti' => ['fette','affettata','intera','arrotolata'],
'fette' => ['cubetti','dadini'],
'cotto' => ['crudo','stagionato'],
'crudo' => ['cotto'],
'intero' => ['macinato','tritato','cubetti','fette'],
'macinato' => ['intero'],
'biologico' => [],
];
// Category mismatch: if query implies a category, penalise products from the wrong one
$categoryGuards = [
['query' => ['frutta','mele','pere','pesche','fragole','uva','arance','limoni','banane','kiwi'],
'exclude' => ['succo','succhi','nettare','sciroppo','aranciata','bevanda','bibita']],
['query' => ['verdura','spinaci','zucchine','carote','finocchio','sedano','broccoli'],
'exclude' => ['surgelat','succo']],
['query' => ['formaggio','mozzarella','parmigiano','ricotta','pecorino'],
'exclude' => ['ravioli','tortellini','cannelloni','lasagne','pizza']],
['query' => ['pasta','spaghetti','penne','fusilli','rigatoni','tagliatelle'],
'exclude' => ['insalata','minestra','zuppa','brodo']],
];
$scores = [];
foreach ($products as $idx => $product) {
$productName = $product['name'] ?? '';
$productBrand = $product['brand'] ?? '';
$productTokens = $tokenize($productName . ' ' . $productBrand);
$nameLower = mb_strtolower($productName, 'UTF-8');
$score = 0;
// --- Token overlap: query vs product ---
foreach ($queryTokens as $qt) {
foreach ($productTokens as $pt) {
if ($qt === $pt) { $score += 6; break; }
if (str_contains($pt, $qt) || str_contains($qt, $pt)) { $score += 2; break; }
}
}
// --- Spec tokens get extra weight (user specified variant) ---
foreach ($specTokens as $st) {
foreach ($productTokens as $pt) {
if ($st === $pt) { $score += 8; break; }
if (str_contains($pt, $st) || str_contains($st, $pt)) { $score += 3; break; }
}
}
// --- First-token anchor bonus ---
if (!empty($queryTokens) && !empty($productTokens) && $queryTokens[0] === $productTokens[0]) {
$score += 10;
}
// --- Category mismatch penalty ---
foreach ($categoryGuards as $guard) {
if (!empty(array_intersect($queryTokens, $guard['query']))) {
foreach ($guard['exclude'] as $exc) {
if (str_contains($nameLower, $exc)) { $score -= 50; break; }
}
}
}
// --- Variant conflict penalty ---
foreach ($specTokens as $st) {
if (isset($variantConflicts[$st])) {
foreach ($variantConflicts[$st] as $conflict) {
if (str_contains($nameLower, $conflict)) { $score -= 20; }
}
}
}
$scores[$idx] = $score;
}
arsort($scores);
reset($scores);
$topIdx = key($scores);
$topScore = current($scores);
next($scores);
$secondScore = current($scores) ?: 0;
// Return null (triggers spec-refined search) only when spec is given and no product
// matches well, so the caller can retry with more specific keywords.
if (!empty($spec) && $topScore < 4) return null;
// Otherwise return the best scoring result (fallback to first if score is 0)
return $products[$topIdx];
}
function formatDupliclickProduct(array $p): array {
$promo = $p['warehousePromo'] ?? null;
$result = [
'productId' => $p['productId'] ?? $p['id'] ?? null,
'name' => $p['name'] ?? '',
'brand' => $p['shortDescr'] ?? '',
'price' => $p['price'] ?? 0,
'priceDisplay' => $p['priceDisplay'] ?? $p['price'] ?? 0,
'priceUm' => $p['priceStandardUmDisplay'] ?? null,
'weightUnit' => $p['weightUnitDisplay'] ?? '',
'packageDescr' => $p['productInfos']['PACKAGE_DESCR'] ?? '',
'barcode' => $p['barcode'] ?? '',
'imageUrl' => $p['mediaURL'] ?? '',
'slug' => $p['slug'] ?? '',
'itemUrl' => $p['itemUrl'] ?? '',
'url' => 'https://www.dupliclick.it' . ($p['itemUrl'] ?? ''),
'available' => $p['available'] ?? 0,
];
if ($promo) {
$result['promo'] = [
'discount' => $promo['discount'] ?? 0,
'discountPerc' => $promo['discountPerc'] ?? 0,
'originalPrice' => round(($p['price'] ?? 0) + ($promo['discount'] ?? 0), 2),
'validFrom' => $promo['validityDate'] ?? '',
'validTo' => $promo['expireDate'] ?? '',
'label' => $promo['view']['body'] ?? 'OFFERTA',
'type' => $promo['promoType'] ?? '',
];
}
return $result;
}
// ===== SHARED APP DATA FUNCTIONS =====
function appSettingsGet(PDO $db): void {