Remove all Dupliclick/Spesa integration; merge annual waste info into status line
This commit is contained in:
+2
-436
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user