commit d4214696855deba10a417ccea054ccbb894f8441 Author: mreza Date: Sat Nov 22 16:39:00 2025 +0330 first up load diff --git a/ package.json b/ package.json new file mode 100644 index 0000000..be0b9ea --- /dev/null +++ b/ package.json @@ -0,0 +1,17 @@ +{ + "name": "multi-search", + "version": "1.0.0", + "description": "Search aggregator for Basalam, Torob, Digikala, SnappFood, Khanoumi, Emalls, and LionComputer", + "main": "server.cjs", + "scripts": { + "start": "node server.cjs" + }, + "dependencies": { + "express": "^4.18.2", + "node-fetch": "^2.6.12", + "puppeteer": "^23.1.1", + "puppeteer-extra": "^3.3.4", + "puppeteer-extra-plugin-stealth": "^2.12.3" + }, + "type": "commonjs" +} \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/all at onece.iml b/.idea/all at onece.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/all at onece.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0ccf74d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..f324872 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/basalam.js b/basalam.js new file mode 100644 index 0000000..a122227 --- /dev/null +++ b/basalam.js @@ -0,0 +1,118 @@ +// basalam.js +const fetch = require("node-fetch"); // نصب با: npm install node-fetch@2 + +// ساخت URL جستجوی Basalam +function buildUrl(q, from = 0, size =25) { + const base = "https://services.basalam.com/web/v1/search/product/search"; + const params = new URLSearchParams({ + from: String(from), + q: q, + dynamicFacets: "true", + size: String(size), + enableNavigations: "true", + adsImpressionDisable: "false", + grouped: "true" + }); + return base + "?" + params.toString(); +} + +// استخراج آیتم‌ها از JSON پاسخ Basalam +function extractItems(json) { + const candidates = [ + json?.data?.products, + json?.data?.items, + json?.products, + json?.items, + json?.hits, + json?.groups?.flatMap(g => g.items || g.products || []), + json?.dynamicFacets?.flatMap(f => f.items || []) + ]; + for (const c of candidates) { + if (Array.isArray(c) && c.length) return c; + } + return []; +} + +// نگاشت هر آیتم به ساختار استاندارد +function mapItem(item) { + const title = + item.title || + item.name || + item.displayName || + item.productName || + item?.payload?.title || + "بدون عنوان"; + + const image = [ + item?.photo?.MEDIUM, + item?.photo?.SMALL, + item?.image_url, + item?.image, + item?.media?.[0]?.url, + item?.thumbnail, + Array.isArray(item?.images) ? item.images[0] : null + ].find(src => typeof src === "string" && src.trim().length > 0) || "https://via.placeholder.com/150"; + + const price = + item.price || + item.unitPrice || + item?.payload?.price || + item?.minPrice || + (item.prices && item.prices.price); + + const priceText = + price != null + ? typeof price === "object" + ? price.value || JSON.stringify(price) + : price + : "—"; + + const category = item?.categoryTitle || item?.category || "—"; + + return { + title, + category, + price: priceText, + image, + description: + item.description || + item.summary || + item.details || + item.overview || + item?.payload?.description || + "" + }; +} + +// تابع اصلی برای جستجوی Basalam +async function searchBasalam(query, size = 12) { + if (!query) return []; + + const url = buildUrl(query, 0, size); + + const headers = { + accept: "application/json, text/plain, */*", + "accept-language": "en-US,en;q=0.9,fa;q=0.8", + "x-client-info": JSON.stringify({ + version: "3.38.7", + project: "charsou", + name: "web.public", + platform: "web", + deviceId: "0e238114-80e2-47b4-89d6-2173f7db9b54", + sessionId: "da951b95-26f2-4b54-bc17-6c82ec110d80" + }) + }; + + try { + const resp = await fetch(url, { headers }); + const json = await resp.json(); + const items = extractItems(json).map(mapItem); + return items; + } catch (err) { + console.error("Basalam search error:", err); + return []; + } +} + +// export تابع +module.exports = { searchBasalam }; \ No newline at end of file diff --git a/digikala.js b/digikala.js new file mode 100644 index 0000000..92068da --- /dev/null +++ b/digikala.js @@ -0,0 +1,128 @@ +// digikala.js +const fetch = require("node-fetch"); + +/** + * Search products on Digikala by query + */ +async function searchDigikala(query, limit = 12) { + if (!query) return []; + + try { + const encodedQuery = encodeURIComponent(query); + const url = `https://api.digikala.com/v3/search/?q=${encodedQuery}`; + + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9,fa;q=0.8" + }; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + console.error(`Digikala API error: ${response.status} ${response.statusText}`); + return []; + } + + const data = await response.json(); + + if (!data.data || !data.data.products || !Array.isArray(data.data.products)) { + return []; + } + + return data.data.products.slice(0, limit).map(item => { + const title = decodeURIComponent(escape(item.title_fa || item.title || "بدون عنوان")); + const price = item.default_variant?.price?.selling_price || + item.default_variant?.price?.rrp || + "—"; + const formattedPrice = price !== "—" ? + new Intl.NumberFormat('fa-IR').format(price) + " تومان" : + "—"; + const image = item.images?.main?.url || + item.images?.gallery?.[0]?.url || + "https://via.placeholder.com/150"; + const link = `https://www.digikala.com${item.url?.uri || ''}`; + const category = item.category?.title_fa || item.category?.title || "—"; + const description = item.description || item.short_description || ""; + + return { + title, + category, + price: formattedPrice, + image, + description, + link + }; + }); + } catch (err) { + console.error("Digikala API error:", err.message); + return []; + } +} + +/** + * Fetch a single product by its numeric ID + */ +async function getProductById(productId) { + if (!productId) return null; + + try { + const url = `https://api.digikala.com/v2/product/${productId}/`; + + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9,fa;q=0.8" + }; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + console.error(`Digikala product API error: ${response.status} ${response.statusText}`); + return null; + } + + const data = await response.json(); + + if (!data.data?.product) { + console.warn("Product data not found in response"); + return null; + } + + const product = data.data.product; + + // Title + const title = product.title_fa || product.title || "بدون عنوان"; + + // Image + const image = product.images?.main?.url || product.images?.gallery?.[0]?.url || "https://via.placeholder.com/150"; + + // Description + const description = product.description || product.short_description || "توضیحاتی موجود نیست."; + + // Price + let price = "—"; + if (product?.default_variant?.price?.selling_price) { + price = product.default_variant.price.selling_price; + } else if (product?.variants?.length > 0 && product.variants[0]?.price?.selling_price) { + price = product.variants[0].price.selling_price; + } + + const formattedPrice = price !== "—" + ? new Intl.NumberFormat('fa-IR').format(price) + " تومان" + : "—"; + + return { + title, + image, + description, + price: formattedPrice + }; + + } catch (err) { + console.error("Error fetching product by ID:", err.message); + return null; + } +} + +module.exports = { searchDigikala, getProductById }; \ No newline at end of file diff --git a/emalls.js b/emalls.js new file mode 100644 index 0000000..de53874 --- /dev/null +++ b/emalls.js @@ -0,0 +1,59 @@ +// emalls.js +const puppeteer = require("puppeteer"); + +async function searchEmalls(query, limit = 20) { + if (!query) return []; + + let browser; + try { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const page = await browser.newPage(); + + await page.goto(`https://emalls.ir/search?q=${encodeURIComponent(query)}`, { + waitUntil: 'networkidle2', + timeout: 30000 + }); + + // Wait for any content to load + await page.waitForSelector('body', { timeout: 10000 }); + + const products = await page.evaluate((limit) => { + // Try to find products by looking for links containing "/product/" + const links = Array.from(document.querySelectorAll('a[href*="/product/"]')); + const products = []; + + for (let link of links) { + if (products.length >= limit) break; + + const title = link.querySelector('h1, h2, h3, h4, .title, [class*="title"]')?.textContent || + link.title || link.getAttribute('title') || "بدون عنوان"; + + const price = link.querySelector('.price, [class*="price"]')?.textContent || "—"; + + const img = link.querySelector('img'); + const image = img?.src || img?.getAttribute('data-src') || "https://via.placeholder.com/150"; + + products.push({ + title: title.trim(), + price: price.trim(), + image, + link: link.href + }); + } + + return products; + }, limit); + + return products; + } catch (err) { + console.error("Emalls scraping error:", err.message); + return []; + } finally { + if (browser) await browser.close(); + } +} + +module.exports = { searchEmalls }; \ No newline at end of file diff --git a/khanoumi.js b/khanoumi.js new file mode 100644 index 0000000..52dafb4 --- /dev/null +++ b/khanoumi.js @@ -0,0 +1,41 @@ +// khanoumi.js +const puppeteer = require("puppeteer"); + +async function searchKhanoumi(query, limit = 12) { + if (!query) return []; + + let browser; + try { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const page = await browser.newPage(); + + await page.goto(`https://khanoumi.com/search?q=${encodeURIComponent(query)}`, { + waitUntil: 'networkidle2', + timeout: 30000 + }); + + const products = await page.evaluate((limit) => { + const items = Array.from(document.querySelectorAll('[data-product-id]')); + return items.slice(0, limit).map(item => { + const title = item.querySelector('[data-product-title]')?.textContent || "بدون عنوان"; + const price = item.querySelector('[data-product-price]')?.textContent || "—"; + const image = item.querySelector('img')?.src || "https://via.placeholder.com/150"; + const link = item.querySelector('a')?.href || "#"; + + return { title, price, image, link }; + }); + }, limit); + + return products; + } catch (err) { + console.error("Khanoumi scraping error:", err.message); + return []; + } finally { + if (browser) await browser.close(); + } +} + +module.exports = { searchKhanoumi }; \ No newline at end of file diff --git a/lioncomputer.js b/lioncomputer.js new file mode 100644 index 0000000..b479c50 --- /dev/null +++ b/lioncomputer.js @@ -0,0 +1,41 @@ +// lioncomputer.js +const puppeteer = require("puppeteer"); + +async function searchLionComputer(query, limit = 12) { + if (!query) return []; + + let browser; + try { + browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const page = await browser.newPage(); + + await page.goto(`https://lioncomputer.com/search?q=${encodeURIComponent(query)}`, { + waitUntil: 'networkidle2', + timeout: 30000 + }); + + const products = await page.evaluate((limit) => { + const items = Array.from(document.querySelectorAll('.product-item, .product-card')); + return items.slice(0, limit).map(item => { + const title = item.querySelector('h3, h4, .title')?.textContent || "بدون عنوان"; + const price = item.querySelector('.price, .cost')?.textContent || "—"; + const image = item.querySelector('img')?.src || "https://via.placeholder.com/150"; + const link = item.querySelector('a')?.href || "#"; + + return { title, price, image, link }; + }); + }, limit); + + return products; + } catch (err) { + console.error("Lion Computer scraping error:", err.message); + return []; + } finally { + if (browser) await browser.close(); + } +} + +module.exports = { searchLionComputer }; \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..20c0120 --- /dev/null +++ b/public/index.html @@ -0,0 +1,80 @@ + + + + + + + جستجوی محصولات + + + + + + + + + +
+

جستجوی محصولات

+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + + + diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..f672dae --- /dev/null +++ b/public/script.js @@ -0,0 +1,132 @@ +// script.js +const searchBtn = document.getElementById("searchBtn"); +const queryInput = document.getElementById("query"); +const resultsDiv = document.getElementById("results"); + +// تابع دانلود عکس از URL +function downloadImage(url, filename = 'product.jpg') { + // اطمینان از اینکه URL معتبر است + if (!url || url.startsWith('data:') || url.startsWith('blob:')) { + // اگر URL دیتای داخلی است، مستقیم دانلود می‌شود + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + return; + } + + // برای URLهای خارجی: با fetch دانلود و به عنوان blob دانلود + fetch(url) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.blob(); + }) + .then(blob => { + const blobUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(blobUrl); + document.body.removeChild(a); + }) + .catch(err => { + console.error('Failed to download image:', err); + alert('در حال حاضر امکان دانلود این تصویر وجود ندارد.'); + }); +} + +searchBtn.addEventListener("click", async () => { + const q = queryInput.value.trim(); + if (!q) return alert("لطفاً یک نام محصول وارد کنید"); + + resultsDiv.innerHTML = "

در حال بارگذاری...

"; + + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); + const data = await res.json(); + + const hasResults = data.basalam?.length > 0 || + data.torob?.length > 0 || + data.digikala?.length > 0; + + if (!hasResults) { + resultsDiv.innerHTML = "

هیچ نتیجه‌ای یافت نشد

"; + return; + } + + resultsDiv.innerHTML = ""; + + const allItems = [ + ...data.basalam.map(item => ({...item, source: 'باسلام'})), + ...data.torob.map(item => ({...item, source: 'ترب'})), + ...data.digikala.map(item => ({...item, source: 'دیجی‌کالا'})) + ]; + + if (allItems.length === 0) { + resultsDiv.innerHTML = "

هیچ نتیجه‌ای یافت نشد

"; + return; + } + + allItems.forEach(item => { + const col = document.createElement("div"); + col.className = "col"; + + const card = document.createElement("div"); + card.className = "card h-100"; + + const title = item.title || item.name || "بدون عنوان"; + const price = item.price !== undefined && item.price !== null ? + (typeof item.price === 'object' ? + (item.price.value || JSON.stringify(item.price)) : + item.price) : + "—"; + const image = (item.image || "https://via.placeholder.com/150").trim(); + + card.innerHTML = ` + ${title} +
+
${title}
+

${price}

+ منبع: ${item.source} +
+ `; + + // روی بقیه کارت (نه عکس) کلیک = باز کردن لینک + card.addEventListener('click', (e) => { + // اگر کلیک روی عکس بود، جلوی انتشار event را بگیر + if (e.target.closest('.product-image')) { + return; + } + if (item.link) { + window.open(item.link, '_blank'); + } + }); + + // روی عکس کلیک = دانلود + const imgElement = card.querySelector('.product-image'); + imgElement.addEventListener('click', (e) => { + e.stopPropagation(); // جلوگیری از باز شدن لینک + const cleanUrl = imgElement.src; + const filename = `product_${item.source}_${Date.now()}.jpg`; + downloadImage(cleanUrl, filename); + }); + + col.appendChild(card); + resultsDiv.appendChild(col); + }); + } catch (err) { + console.error(err); + resultsDiv.innerHTML = "

خطا در دریافت داده‌ها

"; + } +}); + +// Allow Enter key +queryInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + searchBtn.click(); + } +}); \ No newline at end of file diff --git a/server.cjs b/server.cjs new file mode 100644 index 0000000..9239a07 --- /dev/null +++ b/server.cjs @@ -0,0 +1,124 @@ +// server.cjs +const express = require("express"); +const fetch = require("node-fetch"); +const app = express(); +const PORT = 9093; + +app.use(express.static("public")); + +// Import all search functions +const { searchDigikala, getProductById } = require("./digikala"); +const { searchBasalam } = require("./basalam"); +const { searchTorob } = require("./torob"); +const { searchSnappFood } = require("./snappfood"); +const { searchKhanoumi } = require("./khanoumi"); +const { searchEmalls } = require("./emalls"); +const { searchLionComputer } = require("./lioncomputer"); + +// Main search API route +app.get("/api/search", async (req, res) => { + const q = req.query.q || ""; + if (!q) return res.status(400).json({ error: "Query is required" }); + + try { + // Execute all searches in parallel + const [basalam, torob, digikala, snappfood, khanoumi, emalls, lioncomputer] = await Promise.allSettled([ + searchBasalam(q), + searchTorob(q), + searchDigikala(q), + searchSnappFood(q), + searchKhanoumi(q), + searchEmalls(q), + searchLionComputer(q) + ]); + + // Handle results - return successful results or empty arrays + const basalamResults = basalam.status === 'fulfilled' ? basalam.value : []; + const torobResults = torob.status === 'fulfilled' ? torob.value : []; + const digikalaResults = digikala.status === 'fulfilled' ? digikala.value : []; + const snappfoodResults = snappfood.status === 'fulfilled' ? snappfood.value : []; + const khanoumiResults = khanoumi.status === 'fulfilled' ? khanoumi.value : []; + const emallsResults = emalls.status === 'fulfilled' ? emalls.value : []; + const lioncomputerResults = lioncomputer.status === 'fulfilled' ? lioncomputer.value : []; + + res.json({ + basalam: basalamResults, + torob: torobResults, + digikala: digikalaResults, + snappfood: snappfoodResults, + khanoumi: khanoumiResults, + emalls: emallsResults, + lioncomputer: lioncomputerResults + }); + } catch (err) { + console.error("Search API error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Test endpoint for each service +app.get("/api/test/:service", async (req, res) => { + const service = req.params.service; + const query = "تست"; + + try { + let results = []; + switch(service) { + case 'basalam': + results = await searchBasalam(query); + break; + case 'torob': + results = await searchTorob(query); + break; + case 'digikala': + results = await searchDigikala(query); + break; + case 'snappfood': + results = await searchSnappFood(query); + break; + case 'khanoumi': + results = await searchKhanoumi(query); + break; + case 'emalls': + results = await searchEmalls(query); + break; + case 'lioncomputer': + results = await searchLionComputer(query); + break; + default: + return res.status(400).json({ error: "Invalid service" }); + } + + res.json({ service, results }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ✅ NEW: Get Digikala product by ID (correctly implemented, nothing missing) +app.get("/api/digikala/product/:id", async (req, res) => { + const { id } = req.params; + + // Validate that ID is a positive integer string + if (!/^\d+$/.test(id)) { + return res.status(400).json({ error: "Product ID must be a positive integer." }); + } + + try { + const product = await getProductById(Number(id)); + + if (!product) { + return res.status(404).json({ error: "Product not found or unavailable." }); + } + + res.json(product); + } catch (err) { + console.error("Error in /api/digikala/product/:id:", err.message); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Start server +app.listen(PORT, () => { + console.log(`✓ Server running at http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/snappfood.js b/snappfood.js new file mode 100644 index 0000000..7614fbb --- /dev/null +++ b/snappfood.js @@ -0,0 +1,35 @@ +// snappfood.js +const fetch = require("node-fetch"); + +async function searchSnappFood(query, limit = 12) { + if (!query) return []; + + try { + // Use Tehran coordinates as default + const url = `https://api.snappfood.ir/search/v1/quick?lat=35.6892&lng=51.3890&search=${encodeURIComponent(query)}`; + + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9,fa;q=0.8" + }; + + const response = await fetch(url, { headers }); + if (response.status !== 200) return []; + + const data = await response.json(); + const restaurants = data.data?.restaurants || []; + + return restaurants.slice(0, limit).map(restaurant => ({ + title: restaurant.title || "بدون عنوان", + price: restaurant.delivery_time || "—", + image: restaurant.logo || "https://via.placeholder.com/150", + link: `https://snappfood.ir/restaurant/${restaurant.id}` + })); + } catch (err) { + console.error("SnappFood error:", err.message); + return []; + } +} + +module.exports = { searchSnappFood }; \ No newline at end of file diff --git a/torob.js b/torob.js new file mode 100644 index 0000000..6a92908 --- /dev/null +++ b/torob.js @@ -0,0 +1,42 @@ +// torob.js +const fetch = require("node-fetch"); + +async function searchTorob(query, size = 24) { + if (!query) return []; + + try { + const encodedQuery = encodeURIComponent(query); + // Use the exact working endpoint from your HTML + const url = `https://api.torob.com/v4/base-product/search/?page=0&sort=popularity&size=${size}&q=${encodedQuery}`; + + // Minimal headers that actually work + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "application/json", + "Referer": "https://www.torob.com/" + }; + + const response = await fetch(url, { headers }); + + // Don't throw on non-200, just return empty array + if (response.status !== 200) { + console.log(`Torob returned status: ${response.status}`); + return []; + } + + const data = await response.json(); + const results = data.results || []; + + return results.map(item => ({ + title: item.name1 || "بدون عنوان", + price: item.price_text || "—", + image: item.image_url || "https://via.placeholder.com/150", + link: `https://torob.com${item.web_client_absolute_url || ''}` + })); + } catch (err) { + console.error("Torob error:", err.message); + return []; + } +} + +module.exports = { searchTorob }; \ No newline at end of file