Spaces:
Sleeping
Sleeping
| # ================================================================= | |
| # ๐ Extra.com โ VortexCommerce | Full Product Scraper | |
| # ================================================================= | |
| import asyncio, re, os, uuid, sqlite3, logging, zipfile, random, json, requests, time | |
| from datetime import datetime, timezone | |
| from typing import List, Optional, Dict, Any | |
| # Kaggle Notebook - ู ูู ูุงุญุฏ ุดุงู ู: | |
| # โ ุฌูุจ ุงูู ูุชุฌุงุช ู ู extra.com (EN + AR) | |
| # โ ุฒูุงุฑุฉ ุตูุญุฉ ูู ู ูุชุฌ ููุญุตูู ุนูู: | |
| # - ุนุฏุฉ ุตูุฑ ุญููููุฉ (Image Gallery) | |
| # - ุงูู ูุงุตูุงุช ุงูุชูุตูููุฉ (Specs) | |
| # - ุงูุงุณู ุจุงูุนุฑุจูุฉ ูุงูุฅูุฌููุฒูุฉ | |
| # - ุงูุชูููู ูุนุฏุฏ ุงูู ุฑุงุฌุนุงุช | |
| # โ ุชุตุฏูุฑ JSON ุจููููุฉ VortexCommerce | |
| # ================================================================= | |
| # ================= CONFIGURATION ================= | |
| CATEGORIES_TO_SCRAPE = { | |
| "smartphones": "/en-sa/mobiles-tablets/mobiles/smartphone/c/2-212-3/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "laptops": "/en-sa/computer/laptops/c/3-303/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "tablets": "/en-sa/mobiles-tablets/tablets/c/2-217/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "tvs": "/en-sa/electronics/television/c/1-109/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "headphones": "/en-sa/mobiles-tablets/portable-audio/c/2-623/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "smartwatches": "/en-sa/smart-watches/c/8126/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "gaming": "/en-sa/electronic-games/c/13/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "small_appliances": "/en-sa/small-appliances/c/5/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "large_appliances": "/en-sa/white-goods/c/4/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| "cameras": "/en-sa/computer/cameras/c/3-603/facet/?q=%3Arelevance%3AinStock%3Atrue%3Atype%3APRODUCT&text=&pg={page}&pageSize=24&sort=relevance", | |
| } | |
| TARGET_PRODUCTS_PER_CATEGORY = 100 # Default target per category | |
| DATABASE_NAME = "extra_catalog.db" | |
| # ููู ุฒุงู ูุฉ ุงูู ุจุงุดุฑุฉ ู ุน PostgreSQL (Supabase): | |
| # SUPABASE_DATABASE_URL = "postgresql://user:pass@host:port/dbname" | |
| DATABASE_URL = os.environ.get("DATABASE_URL", None) | |
| # ================================================= | |
| # ----------------------------------------------- | |
| # 1. ุชุซุจูุช Playwright | |
| # ----------------------------------------------- | |
| try: | |
| import playwright | |
| except ImportError: | |
| print("๐ฆ Installing Playwright & Dependencies...") | |
| !pip install playwright | |
| !playwright install chromium | |
| !playwright install-deps | |
| from playwright.async_api import async_playwright | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| logger = logging.getLogger("ExtraScraper") | |
| # ----------------------------------------------- | |
| # 2. ุงูุซูุงุจุช ูุงููููููุฉ ุงูุงุญุชุฑุงููุฉ (Professional Hierarchy) | |
| # ----------------------------------------------- | |
| # ุงููุฆุงุช ุงูุฃุณุงุณูุฉ ูุงููุฑุนูุฉ ู ุทุงุจูุฉ ูุชูุฒูุน ุงูุณุชุฑุง ูุชุตู ูู VortexCommerce | |
| CATEGORIES_HIERARCHY = { | |
| "ุงูุฃุฌูุฒุฉ ุงูุฅููุชุฑูููุฉ": { | |
| "en": "Electronics", | |
| "icon": "๐ฑ", | |
| "subs": { | |
| "smartphones": {"en": "Smartphones", "ar": "ุฌูุงูุงุช"}, | |
| "laptops": {"en": "Laptops", "ar": "ูุงุจุชูุจ"}, | |
| "tablets": {"en": "Tablets", "ar": "ุฃุฌูุฒุฉ ููุญูุฉ"}, | |
| "tvs": {"en": "TVs", "ar": "ุชููุฒูููุงุช"}, | |
| "cameras": {"en": "Cameras", "ar": "ูุงู ูุฑุงุช"}, | |
| "printers": {"en": "Printers", "ar": "ุทุงุจุนุงุช"} | |
| } | |
| }, | |
| "ู ูุญูุงุช ูุงูุณุณูุงุฑุงุช": { | |
| "en": "Accessories", | |
| "icon": "๐", | |
| "subs": { | |
| "headphones": {"en": "Audio & Headphones", "ar": "ุฃุฌูุฒุฉ ุงูุตูุช ูุงูุณู ุงุนุงุช"}, | |
| "smartwatches": {"en": "Smartwatches", "ar": "ุณุงุนุงุช ุฐููุฉ"} | |
| } | |
| }, | |
| "ุฃูุนุงุจ ุฌูู ูุฌ": { | |
| "en": "Gaming", | |
| "icon": "๐ฎ", | |
| "subs": { | |
| "gaming": {"en": "Gaming Consoles", "ar": "ุฃุฌูุฒุฉ ุฃูุนุงุจ"} | |
| } | |
| }, | |
| "ุงูุฃุฌูุฒุฉ ุงูู ูุฒููุฉ": { | |
| "en": "Home Appliances", | |
| "icon": "๐ ", | |
| "subs": { | |
| "refrigerators": {"en": "Refrigerators", "ar": "ุซูุงุฌุงุช"}, | |
| "washing-machines": {"en": "Washing Machines", "ar": "ุบุณุงูุงุช ูู ุฌููุงุช"}, | |
| "air-conditioners": {"en": "Air Conditioners", "ar": "ู ูููุงุช"}, | |
| "small_appliances": {"en": "Small Appliances", "ar": "ุฃุฌูุฒุฉ ู ูุฒููุฉ ุตุบูุฑุฉ"}, | |
| "large_appliances": {"en": "Large Appliances", "ar": "ุฃุฌูุฒุฉ ู ูุฒููุฉ ูุจูุฑุฉ"} | |
| } | |
| } | |
| } | |
| # Reverse map for quick parent lookup based on scraped category key | |
| EXTRA_CAT_TO_PARENT = {} | |
| for p_ar, info in CATEGORIES_HIERARCHY.items(): | |
| for sub_key in info["subs"].keys(): | |
| EXTRA_CAT_TO_PARENT[sub_key] = p_ar | |
| KNOWN_BRANDS = [ | |
| 'Apple', 'Samsung', 'Huawei', 'Xiaomi', 'Honor', 'Sony', 'HP', 'Dell', | |
| 'Lenovo', 'Asus', 'Acer', 'Microsoft', 'Nikon', 'Canon', 'Bose', | |
| 'Logitech', 'LG', 'Panasonic', 'Philips', 'Dyson', 'Bosch', 'Hisense', | |
| 'TCL', 'Class Pro', 'JBL', 'Anker', 'Oppo', 'Motorola', 'Tecno', | |
| 'Nokia', 'Google', 'Nothing', 'Realme', 'MSI', 'Razer', 'SteelSeries', | |
| 'HyperX', 'Corsair', 'PlayStation', 'Xbox', 'Nintendo', | |
| 'GoPro', 'DJI', 'Fujifilm', 'Garmin', 'Fitbit', 'Amazfit', 'Beats', | |
| 'Sennheiser', 'Jabra', 'Marshall', 'Harman Kardon', | |
| 'BISSELL', 'Gree', 'Haier', 'Midea', 'Toshiba', 'Whirlpool', | |
| 'Electrolux', 'Braun', 'Tefal', 'Kenwood', 'Moulinex', 'Black+Decker', | |
| 'Infinix', 'REDMI', 'POCO', 'OnePlus', 'EPSON', 'Brother', | |
| 'Skyworth', 'ASUS ROG', 'Bang & Olufsen', | |
| ] | |
| COLOR_MAP = { | |
| 'Black': 'ุฃุณูุฏ', 'White': 'ุฃุจูุถ', 'Silver': 'ูุถู', 'Gold': 'ุฐูุจู', | |
| 'Blue': 'ุฃุฒุฑู', 'Red': 'ุฃุญู ุฑ', 'Green': 'ุฃุฎุถุฑ', 'Grey': 'ุฑู ุงุฏู', | |
| 'Gray': 'ุฑู ุงุฏู', 'Titanium': 'ุชูุชุงูููู ', 'Purple': 'ุฃุฑุฌูุงูู', | |
| 'Pink': 'ูุฑุฏู', 'Orange': 'ุจุฑุชูุงูู', 'Yellow': 'ุฃุตูุฑ', | |
| 'Rose Gold': 'ุฐูุจู ูุฑุฏู', 'Space Gray': 'ุฑู ุงุฏู ูููู', | |
| 'Midnight': 'ู ูุชุตู ุงูููู', 'Starlight': 'ุณุชุงุฑูุงูุช', | |
| 'Cream': 'ูุฑูู ู', 'Lavender': 'ูุงููุฏุฑ', 'Mint': 'ูุนูุงุนู', | |
| 'Graphite': 'ุฑุตุงุตู ุบุงู ู', 'Burgundy': 'ุนูุงุจู', 'Navy': 'ูุญูู', | |
| 'Cobalt': 'ููุจุงูุช', 'Coral': 'ู ุฑุฌุงูู', 'Icyblue': 'ุฃุฒุฑู ุซูุฌู', | |
| 'Glacier Silver': 'ูุถู ุฌููุฏู', 'Sky Blue': 'ุฃุฒุฑู ุณู ุงูู', | |
| } | |
| BRAND_AR = { | |
| 'Apple': 'ุขุจู', 'Samsung': 'ุณุงู ุณููุฌ', 'Huawei': 'ููุงูู', | |
| 'Xiaomi': 'ุดุงูู ู', 'Honor': 'ูููุฑ', 'Sony': 'ุณููู', | |
| 'HP': 'ุฅุชุด ุจู', 'Dell': 'ุฏูู', 'Lenovo': 'ูููููู', | |
| 'Asus': 'ุฃุณูุณ', 'Acer': 'ุฃูุณุฑ', 'Microsoft': 'ู ุงููุฑูุณููุช', | |
| 'LG': 'ุฅู ุฌู', 'Panasonic': 'ุจุงูุงุณูููู', 'Philips': 'ูููุจุณ', | |
| 'Dyson': 'ุฏุงูุณูู', 'Bosch': 'ุจูุด', 'Canon': 'ูุงููู', | |
| 'Nikon': 'ููููู', 'JBL': 'ุฌู ุจู ุฅู', 'Bose': 'ุจูุฒ', | |
| 'Logitech': 'ููุฌูุชูู', 'Oppo': 'ุฃูุจู', 'Motorola': 'ู ูุชูุฑููุง', | |
| 'Nokia': 'ููููุง', 'Google': 'ุฌูุฌู', 'Beats': 'ุจูุชุณ', | |
| 'Garmin': 'ุฌุงุฑู ูู', 'Hisense': 'ูุงูุณูุณ', 'TCL': 'ุชู ุณู ุฅู', | |
| 'PlayStation': 'ุจูุงูุณุชูุดู', 'Xbox': 'ุฅูุณ ุจููุณ', 'Nintendo': 'ูููุชูุฏู', | |
| 'Tecno': 'ุชููู', 'Infinix': 'ุฅููููููุณ', 'Realme': 'ุฑููู ู', | |
| 'Class Pro': 'ููุงุณ ุจุฑู', 'GoPro': 'ุฌู ุจุฑู', 'DJI': 'ุฏู ุฌู ุขู', | |
| 'MSI': 'ุฅู ุฅุณ ุขู', 'Razer': 'ุฑูุฒุฑ', 'Nothing': 'ูุงุซููู', | |
| } | |
| # ----------------------------------------------- | |
| # 3. ูุงุนุฏุฉ ุงูุจูุงูุงุช | |
| # ----------------------------------------------- | |
| class Database: | |
| def __init__(self, path_or_url): | |
| self.is_pg = "postgresql" in path_or_url if path_or_url else False | |
| self.path_or_url = path_or_url or DATABASE_NAME | |
| if self.is_pg: | |
| try: | |
| import psycopg2 | |
| self.conn = psycopg2.connect(self.path_or_url) | |
| self.placeholder = "%s" | |
| except ImportError: | |
| print("๐ฆ Installing psycopg2...") | |
| import subprocess | |
| subprocess.check_call(["pip", "install", "psycopg2-binary"]) | |
| import psycopg2 | |
| self.conn = psycopg2.connect(self.path_or_url) | |
| self.placeholder = "%s" | |
| else: | |
| self.conn = sqlite3.connect(self.path_or_url) | |
| self.placeholder = "?" | |
| self._ensure_tables() | |
| def _ensure_tables(self): | |
| cursor = self.conn.cursor() | |
| create_sql = f'''CREATE TABLE IF NOT EXISTS products ( | |
| id TEXT PRIMARY KEY, pid TEXT UNIQUE, | |
| title_en TEXT NOT NULL, title_ar TEXT, | |
| description_en TEXT, description_ar TEXT, | |
| price REAL NOT NULL, old_price REAL, | |
| currency TEXT DEFAULT 'SAR', | |
| images TEXT, product_url TEXT, | |
| category TEXT NOT NULL, brand TEXT, | |
| specs TEXT, rating REAL, rating_count INTEGER, | |
| created_at TEXT)''' | |
| cursor.execute(create_sql) | |
| self.conn.commit() | |
| def save_products(self, products): | |
| cursor = self.conn.cursor() | |
| saved = 0 | |
| for p in products: | |
| try: | |
| now = datetime.now(timezone.utc).isoformat() | |
| pid = p.get('pid') | |
| images_json = json.dumps(p.get('images', []), ensure_ascii=False) | |
| specs_json = json.dumps(p.get('specs', {}), ensure_ascii=False) | |
| cursor.execute(f"SELECT id FROM products WHERE pid = {self.placeholder}", (pid,)) | |
| existing = cursor.fetchone() | |
| if existing: | |
| cursor.execute(f'''UPDATE products SET | |
| title_en={self.placeholder}, title_ar={self.placeholder}, | |
| description_en={self.placeholder}, description_ar={self.placeholder}, | |
| price={self.placeholder}, old_price={self.placeholder}, | |
| images={self.placeholder}, product_url={self.placeholder}, | |
| brand={self.placeholder}, specs={self.placeholder}, | |
| rating={self.placeholder}, rating_count={self.placeholder} | |
| WHERE id={self.placeholder}''', | |
| (p['title_en'], p.get('title_ar',''), p.get('description_en',''), p.get('description_ar',''), | |
| p['price'], p.get('old_price'), images_json, p['product_url'], | |
| p.get('brand'), specs_json, p.get('rating'), p.get('rating_count'), existing[0])) | |
| else: | |
| cursor.execute(f'''INSERT INTO products ( | |
| id, pid, title_en, title_ar, description_en, description_ar, | |
| price, old_price, currency, images, product_url, | |
| category, brand, specs, rating, rating_count, created_at) | |
| VALUES ({self.placeholder},{self.placeholder},{self.placeholder},{self.placeholder}, | |
| {self.placeholder},{self.placeholder},{self.placeholder},{self.placeholder}, | |
| {self.placeholder},{self.placeholder},{self.placeholder},{self.placeholder}, | |
| {self.placeholder},{self.placeholder},{self.placeholder},{self.placeholder},{self.placeholder})''', | |
| (str(uuid.uuid4()), pid, p['title_en'], p.get('title_ar',''), | |
| p.get('description_en',''), p.get('description_ar',''), | |
| p['price'], p.get('old_price'), 'SAR', | |
| images_json, p['product_url'], | |
| p['category'], p.get('brand'), | |
| specs_json, p.get('rating'), p.get('rating_count'), now)) | |
| saved += 1 | |
| except Exception as e: | |
| logger.error(f"Error saving {p.get('title_en','?')}: {e}") | |
| self.conn.commit() | |
| return saved | |
| def close(self): | |
| self.conn.close() | |
| # ----------------------------------------------- | |
| # 4. ุงูุณูุฑุงุจุฑ ุงูุฑุฆูุณู | |
| # ----------------------------------------------- | |
| # ุงูุงุณุชุฑุงุชูุฌูุฉ: | |
| # ุฎุทูุฉ 1: ุฌูุจ ูุงุฆู ุฉ ุงูู ูุชุฌุงุช ู ู ุตูุญุฉ ุงููุฆุฉ (PID + URL + ุงูุณุนุฑ) | |
| # ุฎุทูุฉ 2: ุฒูุงุฑุฉ ูู ุตูุญุฉ ู ูุชุฌ EN ูุฌูุจ (ุงูุนููุงู EN + ุงูุตูุฑ + ุงูู ูุงุตูุงุช) | |
| # ุฎุทูุฉ 3: ุฒูุงุฑุฉ ููุณ ุงูู ูุชุฌ AR ูุฌูุจ (ุงูุนููุงู AR + ุงููุตู AR + ุงูู ูุงุตูุงุช AR) | |
| # ----------------------------------------------- | |
| # === JavaScript ูุงุณุชุฎุฑุงุฌ ูุงุฆู ุฉ ุงูู ูุชุฌุงุช ู ู ุตูุญุฉ ุงููุฆุฉ === | |
| LISTING_JS = r'''() => { | |
| const products = []; | |
| const seenPids = new Set(); | |
| // ุงูู ุญุฏุฏ ุงูุฃุณุงุณู ุงูู ุคูุฏ: a.product-tile-content-wrapper[data-product-id] | |
| const cards = document.querySelectorAll('a.product-tile-content-wrapper, [data-product-id]'); | |
| cards.forEach(card => { | |
| try { | |
| const pid = card.getAttribute('data-product-id') || card.getAttribute('data-sku') || ''; | |
| if (!pid || seenPids.has(pid)) return; | |
| seenPids.add(pid); | |
| let url = ''; | |
| if (card.tagName === 'A') url = card.href || ''; | |
| else { | |
| const a = card.querySelector('a[href*="/p/"]'); | |
| url = a ? a.href : ''; | |
| } | |
| if (!url || !url.includes('/p/')) return; | |
| // ุงูุณุนุฑ: .price-container strong | |
| let price = 0, oldPrice = 0; | |
| const priceContainers = card.querySelectorAll('.price-container, [class*="price"]'); | |
| priceContainers.forEach(pc => { | |
| pc.querySelectorAll('strong').forEach(s => { | |
| const val = parseFloat((s.textContent || '').replace(/[^0-9.]/g, '')); | |
| if (val > 0 && price === 0) price = val; | |
| }); | |
| }); | |
| const striked = card.querySelector('.striked-off, [class*="striked"], del, s'); | |
| if (striked) { | |
| const val = parseFloat((striked.textContent || '').replace(/[^0-9.]/g, '')); | |
| if (val > 0) oldPrice = val; | |
| } | |
| if (price === 0) { | |
| const allText = card.textContent || ''; | |
| const m = allText.match(/(\d[\d,]*\.?\d*)\s*(?:SAR|ุฑ\.ุณ)/); | |
| if (m) price = parseFloat(m[1].replace(/,/g, '')); | |
| } | |
| if (price <= 0) return; | |
| if (oldPrice > 0 && oldPrice < price) [price, oldPrice] = [oldPrice, price]; | |
| // ุงูุนูุงู ุฉ ุงูุชุฌุงุฑูุฉ | |
| let brand = ''; | |
| const brandEl = card.querySelector('[class*="brand"]'); | |
| if (brandEl) brand = brandEl.textContent.trim(); | |
| if (!brand) { | |
| const bm = (card.textContent || '').match(/^[\s\n]*([A-Z][A-Z\s]+)\s*โข/); | |
| if (bm) brand = bm[1].trim(); | |
| } | |
| products.push({ pid, product_url: url, price, old_price: oldPrice || null, brand }); | |
| } catch(e) {} | |
| }); | |
| // Fallback: ุจุญุซ ูู ุงูุฑูุงุจุท | |
| if (products.length === 0) { | |
| document.querySelectorAll('a[href*="/p/"]').forEach(link => { | |
| const pidM = link.href.match(/\/p\/([A-Z0-9]+)/); | |
| if (!pidM || seenPids.has(pidM[1])) return; | |
| seenPids.add(pidM[1]); | |
| const parent = link.closest('li, [class*="product"], [class*="tile"]') || link.parentElement; | |
| let price = 0; | |
| const t = (parent?.textContent || ''); | |
| const pm = t.match(/(\d{3,6})/); | |
| if (pm) price = parseInt(pm[1]); | |
| if (price > 0) { | |
| products.push({ pid: pidM[1], product_url: link.href, price, old_price: null, brand: '' }); | |
| } | |
| }); | |
| } | |
| return products; | |
| }''' | |
| # === JavaScript ูุงุณุชุฎุฑุงุฌ ุงูุชูุงุตูู ู ู ุตูุญุฉ ุงูู ูุชุฌ ุงูุฅูุฌููุฒูุฉ === | |
| DETAIL_EN_JS = r'''() => { | |
| const result = { title: '', description: '', images: [], specs: {}, rating: 0, rating_count: 0, brand: '', variants: [], categories: [] }; | |
| // 0. ุงุณุชุฎุฑุงุฌ ุงูุฃูุณุงู ู ู Breadcrumbs ุฃู URL | |
| const paths = window.location.pathname.split('/').filter(p => p && p !== 'en-sa' && p !== 'p'); | |
| result.categories = paths.slice(0, 3); | |
| // 1. JSON-LD - ุงูู ุตุฏุฑ ุงูุฃุบูู ุจุงูุจูุงูุงุช (ุงูุนููุงูุ ุงููุตูุ ุงูุจุฑุงูุฏุ ุงูู ูุงุตูุงุชุ ุงูุชูููู ) | |
| document.querySelectorAll('script[type="application/ld+json"]').forEach(script => { | |
| try { | |
| const data = JSON.parse(script.textContent); | |
| if (data['@type'] === 'Product') { | |
| result.title = data.name || ''; | |
| result.description = data.description || ''; | |
| result.brand = data.brand?.name || ''; | |
| if (data.additionalProperty) { | |
| data.additionalProperty.forEach(prop => { | |
| if (prop.name && prop.value) { | |
| result.specs[prop.name] = prop.value; | |
| } | |
| }); | |
| } | |
| if (data.aggregateRating) { | |
| result.rating = parseFloat(data.aggregateRating.ratingValue) || 0; | |
| result.rating_count = parseInt(data.aggregateRating.reviewCount || data.aggregateRating.ratingCount) || 0; | |
| } | |
| } | |
| } catch(e) {} | |
| }); | |
| // 2. Fallback: H1 ููุนููุงู | |
| if (!result.title) { | |
| const h1 = document.querySelector('h1'); | |
| if (h1) result.title = h1.textContent.trim(); | |
| } | |
| // 3. ุงูุตูุฑ: ูุนุชู ุฏ ุนูู Python-side probing ุจุฏูุงู ู ู DOM scraping | |
| // ูุฃู ุงูุตูุฑ lazy-loaded ูุบูุฑ ู ูุฌูุฏุฉ ูููุง ูู DOM | |
| // ุณูุชุฑู images ูุงุฑุบุฉ ูุณูุชู ู ูุคูุง ู ู Python ุนุจุฑ PID pattern | |
| result.images = []; | |
| // 4. ุงุณุชุฎุฑุงุฌ ุงูุฎูุงุฑุงุช (Variants) | |
| const variantContainers = document.querySelectorAll('.same-sku-container, [class*="variant"], [class*="sku-container"]'); | |
| variantContainers.forEach(container => { | |
| const label = (container.querySelector('[class*="title"], [class*="label"]')?.textContent || '').trim(); | |
| const options = container.querySelectorAll('.tag, [class*="option"], [class*="item"]'); | |
| options.forEach(opt => { | |
| const name = (opt.querySelector('.text, [class*="value"]')?.textContent || opt.textContent || '').trim(); | |
| if (name) { | |
| result.variants.push({ | |
| type: label || 'Option', | |
| name: name, | |
| selected: opt.classList.contains('selected') || opt.getAttribute('aria-selected') === 'true' | |
| }); | |
| } | |
| }); | |
| }); | |
| if (result.variants.length === 0) { | |
| const storageSpec = result.specs['Memory (internal)'] || result.specs['Storage']; | |
| if (storageSpec) { | |
| result.variants.push({ type: 'Storage', name: storageSpec, selected: true }); | |
| } | |
| } | |
| return result; | |
| }''' | |
| # === JavaScript ูุงุณุชุฎุฑุงุฌ ุงูุนููุงู ูุงููุตู ูุงูู ูุงุตูุงุช ุงูุนุฑุจูุฉ === | |
| DETAIL_AR_JS = r'''() => { | |
| const result = { title_ar: '', description_ar: '', specs_ar: {}, variants_ar: [] }; | |
| // JSON-LD ุจุงูุนุฑุจู | |
| document.querySelectorAll('script[type="application/ld+json"]').forEach(script => { | |
| try { | |
| const data = JSON.parse(script.textContent); | |
| if (data['@type'] === 'Product') { | |
| result.title_ar = data.name || ''; | |
| result.description_ar = data.description || ''; | |
| if (data.additionalProperty) { | |
| data.additionalProperty.forEach(prop => { | |
| if (prop.name && prop.value) { | |
| result.specs_ar[prop.name] = prop.value; | |
| } | |
| }); | |
| } | |
| } | |
| } catch(e) {} | |
| }); | |
| // ุงุณุชุฎุฑุงุฌ ุงูุฎูุงุฑุงุช ุจุงูุนุฑุจู | |
| const variantContainers = document.querySelectorAll('.same-sku-container, [class*="variant"]'); | |
| variantContainers.forEach(container => { | |
| const label = (container.querySelector('[class*="title"], [class*="label"]')?.textContent || '').trim(); | |
| const options = container.querySelectorAll('.tag, [class*="option"]'); | |
| options.forEach(opt => { | |
| const name = (opt.querySelector('.text, [class*="value"]')?.textContent || opt.textContent || '').trim(); | |
| if (name) { | |
| result.variants_ar.push({ | |
| type: label || 'ุฎูุงุฑ', | |
| name: name, | |
| selected: opt.classList.contains('selected') | |
| }); | |
| } | |
| }); | |
| }); | |
| // Fallback: H1 | |
| if (!result.title_ar) { | |
| const h1 = document.querySelector('h1'); | |
| if (h1) result.title_ar = h1.textContent.trim(); | |
| } | |
| return result; | |
| }''' | |
| class ExtraScraper: | |
| async def _go_safe(self, page, url, retries=3): | |
| """ุชุญู ูู ุตูุญุฉ ู ุน ุฅุนุงุฏุฉ ุงูู ุญุงููุฉ""" | |
| for attempt in range(retries): | |
| try: | |
| await page.goto(url, wait_until="domcontentloaded", timeout=45000) | |
| return True | |
| except Exception as e: | |
| logger.warning(f"โ ๏ธ Attempt {attempt+1} for {url[:80]}...: {e}") | |
| await asyncio.sleep(2) | |
| return False | |
| async def scrape_listing(self, page, category_key, url_template, target_count, current_total=0): | |
| """ุงูุฎุทูุฉ 1: ุฌูุจ PIDs ูุงูุฃุณุนุงุฑ ู ู ุตูุญุงุช ุงูููุงุฆู ุจุดูู ุฐูู""" | |
| listing = [] | |
| p_num = 0 | |
| while len(listing) < target_count: | |
| target = f"https://www.extra.com{url_template.format(page=p_num)}" | |
| logger.info(f"๐ [{category_key}] Listing page {p_num + 1} (Found: {len(listing)}/{target_count})") | |
| if not await self._go_safe(page, target): | |
| break | |
| await page.wait_for_timeout(3000) | |
| for y in [800, 2000, 4000]: | |
| await page.evaluate(f"window.scrollTo(0, {y})") | |
| await page.wait_for_timeout(200) | |
| items = await page.evaluate(LISTING_JS) | |
| if not items: | |
| logger.warning(f"โ ๏ธ No products on page {p_num+1}, stopping category") | |
| break | |
| # ุฅุถุงูุฉ ุงูู ูุชุฌุงุช ุงูุฌุฏูุฏุฉ ุงูุชู ูู ูุชุฌุงูุฒ ุจูุง ุงูุญุฏ ุงูู ุทููุจ | |
| for it in items: | |
| if len(listing) < target_count: | |
| it['category'] = category_key | |
| listing.append(it) | |
| else: | |
| break | |
| logger.info(f" โ {len(items)} products on this page | Total for category: {len(listing)}") | |
| p_num += 1 | |
| await asyncio.sleep(random.uniform(0.5, 1.5)) | |
| # ุฅุฐุง ูู ูุญุตู ุนูู ุฃู ู ูุชุฌุงุช ุฌุฏูุฏุฉ ุฃู ุนุฏุฏ ูููู ุฌุฏุงู ูุฏ ูุนูู ููุงูุฉ ุงูุฃูุณุงู | |
| if len(items) < 5: | |
| break | |
| return listing | |
| # ุฃูู ุงุท URL ุงูู ุชุนุฏุฏุฉ ุงูุชู ูุณุชุฎุฏู ูุง media.extra.com | |
| # ุงุณุชุฎุฏุงู fmt=png ูุถู ุงู ุงูุชูุงูู ู ุน Meta Catalog ู Snapchat Catalog | |
| IMAGE_URL_PATTERNS = [ | |
| "https://media.extra.com/i/aurora/{pid}_100_{nn}?fmt=png&w=800", # ุงููู ุท ุงูุฃุณุงุณู (ุงูุฃูุซุฑ ุดููุนุงู) | |
| "https://media.extra.com/i/aurora/{pid}_{nn}?fmt=png&w=800", # ูู ุท ุจุฏูู ุจุฏูู _100_ | |
| "https://media.extra.com/i/aurora/{pid}-{nn}?fmt=png&w=800", # ูู ุท ุจุฏูู ุจุดุฑุทุฉ | |
| ] | |
| # ุญุฌู placeholder ุงูู ุนุฑูู ู ู extra.com (ููุฑุฌุน 200 OK ู ุน ุตูุฑุฉ ุจุฏููุฉ "ุงูุตูุฑุฉ ุบูุฑ ู ุชููุฑุฉ") | |
| # ุญุฌู ุงูู placeholder ุงูู ูุชุดู ุจุฏูุฉ ูู 12.6 kB (ุญูุงูู 12600 ุฅูู 12750 ุจุงูุช) | |
| PLACEHOLDER_SIZE_WINDOW = (12500, 12850) | |
| def _check_image_real(self, url, headers, session): | |
| """ูุญุต ุตูุฑุฉ ูุงุญุฏุฉ: ูู ูู ุญููููุฉ ุฃู placeholderุ | |
| ูุณุชุฎุฏู GET+stream ุจุฏู HEAD ูุถู ุงู ุงูุญุตูู ุนูู Content-Length. | |
| ููุฑุฃ ุฃูู chunk ูุชุญุฏูุฏ ุงูุญุฌู ุงูุญูููู ุฅุฐุง ูู ููุฌุฏ Content-Length. | |
| """ | |
| try: | |
| response = session.get(url, headers=headers, timeout=8, allow_redirects=True, stream=True) | |
| if response.status_code != 200: | |
| response.close() | |
| return None, 0 | |
| # ู ุญุงููุฉ ุงูุญุตูู ุนูู ุงูุญุฌู ู ู ุงูููุฏุฑ | |
| size = int(response.headers.get('Content-Length', 0)) | |
| if size == 0: | |
| # Content-Length ุบุงุฆุจ โ ููุฑุฃ ุฃูู 32KB ูุชุญุฏูุฏ ุงูุญุฌู | |
| chunk = response.content[:32768] if not response.headers.get('Transfer-Encoding') else b'' | |
| if not chunk: | |
| # ูู Transfer-Encoding: chunkedุ ููุฑุฃ ุงููู | |
| chunk = b'' | |
| for part in response.iter_content(chunk_size=8192): | |
| chunk += part | |
| if len(chunk) > 32768: | |
| break | |
| size = len(chunk) | |
| response.close() | |
| else: | |
| response.close() | |
| return url, size | |
| except Exception: | |
| return None, 0 | |
| def _discover_images_by_pid(self, pid): | |
| """ุงูุชุดุงู ุตูุฑ ุงูู ูุชุฌ ุนุจุฑ ุฃูู ุงุท URL ู ุชุนุฏุฏุฉ ู ุน ุญู ุงูุฉ ู ู Rate Limiting | |
| ุงูุชุญุณููุงุช: | |
| 1. GET+stream ุจุฏู HEAD โ ูุถู ู Content-Length ุฃู ููุฑุฃ ุงูุญูููู | |
| 2. ุฃูู ุงุท URL ู ุชุนุฏุฏุฉ ูู fallback | |
| 3. delay ุจูู ุงูุทูุจุงุช ูุชุฌูุจ Rate Limiting ุนูู Kaggle | |
| 4. ุญุฏ ูุดู ุฃุนูู (5 ุจุฏู 3) | |
| 5. ูุดู placeholder ุนุจุฑ ุงูุญุฌู ุงูุซุงุจุช ุงูู ุนุฑูู | |
| """ | |
| headers = { | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", | |
| "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", | |
| "Referer": "https://www.extra.com/" | |
| } | |
| max_probe = 25 | |
| max_images = 20 | |
| max_consecutive_fail = 5 # ุฑูุน ุงูุญุฏ ู ู 3 ุฅูู 5 | |
| # ุฅูุดุงุก session ูุฅุนุงุฏุฉ ุงุณุชุฎุฏุงู ุงูุงุชุตุงู (ุฃุณุฑุน + ุฃูู rate limiting) | |
| session = requests.Session() | |
| session.headers.update(headers) | |
| # ุชุฌุฑุจุฉ ูู ูู ุท URL ุญุชู ูุฌุฏ ุตูุฑุงู | |
| for pattern in self.IMAGE_URL_PATTERNS: | |
| valid_images = [] | |
| consecutive_failures = 0 | |
| last_sizes = [] | |
| placeholder_size = None # ุญุฌู ุงูู placeholder ููุฐุง ุงูู ูุชุฌ (ูููุชุดู ุฏููุงู ูููุงู) | |
| for i in range(1, max_probe + 1): | |
| nn = f"{i:02d}" | |
| img_url = pattern.format(pid=pid, nn=nn) | |
| url, size = self._check_image_real(img_url, headers, session) | |
| if url and size > 0: | |
| # ูุดู placeholder: | |
| # 1. ุงูููู ุฉ ุงูู ูุชุดูุฉ ู ู ูุจู ุงูู ุณุชุฎุฏู (12.6 kB) | |
| is_placeholder = (self.PLACEHOLDER_SIZE_WINDOW[0] <= size <= self.PLACEHOLDER_SIZE_WINDOW[1]) | |
| if is_placeholder: | |
| # ูุฐุง placeholder ู ุคูุฏ -> ูุฐุง ูุนูู ุงูุชูุงุก ุตูุฑ ุงูู ูุชุฌ ุงูุญููููุฉ | |
| # ูุชููู ููุฑุงู ุนู ู ุญุงููุฉ ุฌูุจ ุงูุตูุฑ ุงูุชุงููุฉ ููุฐุง ุงูู ูุชุฌ | |
| logger.info(f" ๐ Placeholder detected ({size} bytes) for {pid}. Stopping probe.") | |
| break | |
| if size < 20000: # ุตูุฑ ุงูู ูุชุฌ ุงูุญููููุฉ ุบุงูุจุงู ุฃูุจุฑ ู ู 20KBุ ููููุง ููุจู ุงูุฃุตุบุฑ ุฅุฐุง ูู ุชูู ู ูุฑุฑุฉ | |
| if placeholder_size is not None and abs(size - placeholder_size) < 100: | |
| consecutive_failures += 1 | |
| continue | |
| last_sizes.append(size) | |
| if len(last_sizes) >= 3: | |
| recent = last_sizes[-3:] | |
| if recent[0] == recent[1] == recent[2]: | |
| placeholder_size = recent[0] | |
| valid_images = valid_images[:-2] # ุฅุฒุงูุฉ ุงูุตูุฑ ุงูุชู ุงุชุถุญ ุฃููุง placeholder | |
| break | |
| valid_images.append(img_url) | |
| consecutive_failures = 0 | |
| else: | |
| consecutive_failures += 1 | |
| if consecutive_failures >= max_consecutive_fail: | |
| break | |
| if len(valid_images) >= max_images: | |
| break | |
| # ุชุฃุฎูุฑ ุจุณูุท ุจูู ุงูุทูุจุงุช ูุชุฌูุจ Rate Limiting | |
| time.sleep(random.uniform(0.05, 0.15)) | |
| # ุฅุฐุง ูุฌุฏูุง ุตูุฑุงู ุจูุฐุง ุงููู ุท โ ูุฑุฌุนูุง | |
| if valid_images: | |
| session.close() | |
| return valid_images | |
| # Fallback ุฃุฎูุฑ: ู ุญุงููุฉ ุงูุฑุงุจุท ุงูู ุจุงุดุฑ ุจุฏูู ุชุฑููู (png) | |
| fallback_urls = [ | |
| f"https://media.extra.com/i/aurora/{pid}_100_01?fmt=png&w=800", | |
| f"https://media.extra.com/i/aurora/{pid}?fmt=png&w=800" | |
| ] | |
| for f_url in fallback_urls: | |
| url, size = self._check_image_real(f_url, headers, session) | |
| if url and size > 0: | |
| is_placeholder = (self.PLACEHOLDER_SIZE_WINDOW[0] <= size <= self.PLACEHOLDER_SIZE_WINDOW[1]) | |
| if not is_placeholder: | |
| session.close() | |
| return [f_url] | |
| session.close() | |
| return [] | |
| async def scrape_detail_en(self, page, product_url): | |
| """ุงูุฎุทูุฉ 2: ุฌูุจ ุชูุงุตูู ุงูู ูุชุฌ ุจุงูุฅูุฌููุฒูุฉ (ุตูุฑ + ู ูุงุตูุงุช + ุนููุงู)""" | |
| if not await self._go_safe(page, product_url): | |
| return None | |
| await page.wait_for_timeout(2000) | |
| # ุชู ุฑูุฑ ูุชุญู ูู ุงูู ุญุชูู | |
| for y in [500, 1000]: | |
| await page.evaluate(f"window.scrollTo(0, {y})") | |
| await page.wait_for_timeout(200) | |
| # ุงุณุชุฎุฑุงุฌ ุงูุจูุงูุงุช ุงููุตูุฉ ู ู ุงูุตูุญุฉ (ุงูุนููุงูุ ุงูู ูุงุตูุงุชุ ุงูุชูููู ) | |
| detail = await page.evaluate(DETAIL_EN_JS) | |
| if not detail: | |
| return None | |
| # --- ุงูุชุดุงู ุงูุตูุฑ ุงูุญููููุฉ ุนุจุฑ PID Pattern (Python-side) --- | |
| pid_match = re.search(r'/p/([A-Z0-9]+)', product_url) | |
| if pid_match: | |
| pid = pid_match.group(1) | |
| logger.info(f"๐ผ๏ธ Discovering images for PID={pid} via URL pattern probing...") | |
| discovered_images = self._discover_images_by_pid(pid) | |
| detail['images'] = discovered_images | |
| logger.info(f" โ Found {len(discovered_images)} real product images for PID={pid}") | |
| else: | |
| logger.warning(f" โ ๏ธ Could not extract PID from URL: {product_url}") | |
| detail['images'] = [] | |
| return detail | |
| async def scrape_detail_ar(self, page, product_url): | |
| """ุงูุฎุทูุฉ 3: ุฌูุจ ุงูุนููุงู ูุงููุตู ูุงูู ูุงุตูุงุช ุจุงูุนุฑุจูุฉ""" | |
| ar_url = product_url.replace('/en-sa/', '/ar-sa/') | |
| if not await self._go_safe(page, ar_url): | |
| return None | |
| await page.wait_for_timeout(1000) | |
| detail = await page.evaluate(DETAIL_AR_JS) | |
| return detail | |
| async def scrape(self, category_key, url_template, max_products=None, global_seen_pids=None): | |
| """ุงูุณูุฑุงุจุฑ ุงููุงู ู: ูุงุฆู ุฉ โ ุชูุงุตูู EN โ ุชูุงุตูู AR""" | |
| results = [] | |
| if global_seen_pids is None: | |
| global_seen_pids = set() | |
| async with async_playwright() as pw: | |
| browser = await pw.chromium.launch(headless=True) | |
| context = await browser.new_context( | |
| user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", | |
| locale="en-SA", | |
| extra_http_headers={"Accept-Language": "en-US,en;q=0.9,ar;q=0.8"} | |
| ) | |
| page = await context.new_page() | |
| # === ุฎุทูุฉ 1: ุฌูุจ ุงููุงุฆู ุฉ === | |
| listing = await self.scrape_listing(page, category_key, url_template, max_products, current_total=len(results)) | |
| logger.info(f"๐ [{category_key}] Total listings found: {len(listing)}") | |
| # === ุฎุทูุฉ 2 + 3: ุฒูุงุฑุฉ ูู ู ูุชุฌ === | |
| for i, item in enumerate(listing): | |
| pid = item['pid'] | |
| url = item['product_url'] | |
| # --- ุงูููุชุฑุฉ ุงูู ุณุจูุฉ ุงูููุฑูุฉ (Early Deduplication) --- | |
| if pid in global_seen_pids: | |
| logger.info(f" โฉ Skipping PID={pid} (Already processed previously)") | |
| continue | |
| if max_products and len(results) >= max_products: | |
| logger.info(f" ๐ฏ Reached quota for category: {category_key}") | |
| break | |
| logger.info(f" ๐ [{category_key}] Product {len(results)+1}/{max_products or '?'}: PID={pid}") | |
| # --- ุชูุงุตูู ุฅูุฌููุฒูุฉ --- | |
| en_detail = await self.scrape_detail_en(page, url) | |
| if not en_detail or not en_detail.get('title'): | |
| logger.warning(f" โ ๏ธ Skipping PID={pid} (no EN detail)") | |
| continue | |
| # --- ุชูุงุตูู ุนุฑุจูุฉ --- | |
| ar_detail = await self.scrape_detail_ar(page, url) | |
| # --- ุฏู ุฌ ุงูุจูุงูุงุช --- | |
| product = { | |
| 'pid': pid, | |
| 'category': item['category'], | |
| 'product_url': url, | |
| 'price': item['price'], | |
| 'old_price': item.get('old_price'), | |
| 'brand': en_detail.get('brand') or item.get('brand', ''), | |
| 'title_en': en_detail['title'], | |
| 'title_ar': ar_detail.get('title_ar', '') if ar_detail else '', | |
| 'description_en': en_detail.get('description', ''), | |
| 'description_ar': ar_detail.get('description_ar', '') if ar_detail else '', | |
| 'images': en_detail.get('images', []), | |
| 'specs_en': en_detail.get('specs', {}), | |
| 'specs_ar': ar_detail.get('specs_ar', {}) if ar_detail else {}, | |
| 'variants_en': en_detail.get('variants', []), | |
| 'variants_ar': ar_detail.get('variants_ar', []) if ar_detail else [], | |
| 'rating_count': en_detail.get('rating_count', 0), | |
| 'raw_categories': en_detail.get('categories', []), | |
| } | |
| results.append(product) | |
| global_seen_pids.add(pid) # ุฅุถุงูุฉ ุงูู ูุชุฌ ููุงุฆู ุฉ ุงูู ุนุงูุฌูู | |
| logger.info(f" โ {product['title_en'][:50]}... | {len(product['images'])} imgs | {len(product['specs_en'])} specs") | |
| await asyncio.sleep(random.uniform(0.1, 0.4)) | |
| await browser.close() | |
| return results | |
| # ----------------------------------------------- | |
| # 5. ุฏูุงู ุงูุชุญููู ุฅูู VortexCommerce | |
| # ----------------------------------------------- | |
| def extract_brand(title, raw_brand=''): | |
| if raw_brand: | |
| for b in KNOWN_BRANDS: | |
| if b.upper() == raw_brand.upper() or b.lower() == raw_brand.lower(): | |
| return b | |
| for brand in KNOWN_BRANDS: | |
| if brand.lower() in title.lower(): | |
| return brand | |
| if 'rog' in title.lower(): | |
| return 'ASUS ROG' | |
| return raw_brand.strip().title() if raw_brand else "" | |
| def extract_details(title): | |
| details = {"storage": "", "color": "", "color_ar": ""} | |
| m = re.search(r'(\d+)\s*(GB|TB|ุฌูุฌุง|ุชูุฑุง)', title, re.IGNORECASE) | |
| if m: | |
| details["storage"] = m.group(0) | |
| for en, ar in COLOR_MAP.items(): | |
| if en.lower() in title.lower() or ar in title: | |
| details["color"] = en | |
| details["color_ar"] = ar | |
| break | |
| return details | |
| def make_options(raw_product, images): | |
| """ุฅูุดุงุก ุฎูุงุฑุงุช/ู ุชุบูุฑุงุช ุงุญุชุฑุงููุฉ ุจูุงุกู ุนูู ุงูุจูุงูุงุช ุงูู ุณุญูุจุฉ""" | |
| opts = [] | |
| base_price = raw_product['price'] | |
| variants_en = raw_product.get('variants_en', []) | |
| variants_ar = raw_product.get('variants_ar', []) | |
| img1 = images[0] if images else '' | |
| img2 = images[1] if len(images) > 1 else img1 | |
| # 1. ุฅุฐุง ูุฌุฏูุง ุฎูุงุฑุงุช ุญููููุฉ ู ุณุญูุจุฉ ู ู ุงูุตูุญุฉ | |
| if variants_en: | |
| # ูููู ุจุชุฌู ูุน ุงูุฎูุงุฑุงุช ุญุณุจ ุงูููุน (ู ุซูุงู ุชุฌู ูุน ุงูุณุนุงุช ู ุนุงู) | |
| # ููุชุณุฌูู ุงูุญุงููุ ุณูุฃุฎุฐ ุฃูู ู ุฌู ูุนุฉ ุฎูุงุฑุงุช ูุฌุฏูุง (ุบุงูุจุงู ุงูุญุฌู ) | |
| unique_variants = {} | |
| for i, ven in enumerate(variants_en): | |
| name_en = ven['name'] | |
| name_ar = variants_ar[i]['name'] if i < len(variants_ar) else name_en | |
| if name_en not in unique_variants: | |
| unique_variants[name_en] = { | |
| "name_en": name_en, | |
| "name_ar": name_ar, | |
| "selected": ven.get('selected', False) | |
| } | |
| def to_gb(t): | |
| m = re.search(r'(\d+)\s*(GB|TB|ุฌูุฌุง|ุชูุฑุง)', t, re.IGNORECASE) | |
| if not m: return 0 | |
| v = int(m.group(1)) | |
| u = m.group(2).upper() | |
| return v * 1024 if ('TB' in u or 'ุชูุฑุง' in u) else v | |
| for i, (name, vdata) in enumerate(unique_variants.items()): | |
| # Realistic Price Adjustment for Storage (SAR Deltas) | |
| # Instead of simple ratios, we use standard industry jumps for premium devices | |
| brand = (raw_product.get('brand') or "").upper() | |
| is_premium = brand in ["APPLE", "SAMSUNG", "GOOGLE"] or base_price > 3000 | |
| price_delta = 0 | |
| if "GB" in name or "TB" in name: | |
| val = to_gb(name) | |
| current_val = to_gb(raw_product['title_en']) | |
| if is_premium: | |
| # Premium tier offsets (Approximate market standard) | |
| # 128->256 (+500), 256->512 (+1000), 512->1TB (+1000), 1TB->2TB (+2000) | |
| tiers = {128: 0, 256: 500, 512: 1500, 1024: 2500, 2048: 4500} | |
| target_offset = 0 | |
| current_offset = 0 | |
| # Find closest tiers | |
| for t_size, t_off in sorted(tiers.items()): | |
| if val >= t_size: target_offset = t_off | |
| if current_val >= t_size: current_offset = t_off | |
| price_delta = target_offset - current_offset | |
| else: | |
| # Generic devices use simpler ratio | |
| ratio = val / current_val if current_val > 0 else 1 | |
| if ratio > 1: price_delta = base_price * (min(ratio - 1, 1.0) * 0.2) | |
| elif ratio < 1: price_delta = base_price * (max(ratio - 1, -0.4)) | |
| opts.append({ | |
| "sku": f"EXT-{uuid.uuid4().hex[:8]}", | |
| "name_en": vdata["name_en"], | |
| "name_ar": vdata["name_ar"], | |
| "price": round(base_price + price_delta, 2) if not vdata["selected"] else base_price, | |
| "stock": random.randint(5, 50), | |
| "image": img1 if vdata["selected"] else img2, | |
| }) | |
| # 2. ุฅุฐุง ูู ูุฌุฏ ุฎูุงุฑุงุชุ ูุถุน ุฎูุงุฑุงู ุงูุชุฑุงุถูุงู ูุงุญุฏุงู | |
| if not opts: | |
| opts.append({ | |
| "sku": f"EXT-{uuid.uuid4().hex[:8]}", | |
| "name_en": "Standard Edition", | |
| "name_ar": "ุงูุฅุตุฏุงุฑ ุงูููุงุณู", | |
| "price": base_price, | |
| "stock": random.randint(10, 100), | |
| "image": img1, | |
| }) | |
| return opts | |
| def transform_to_vortex(raw_products): | |
| """ุงูุชุญููู ุงูููุงุฆู ุฅูู ููููุฉ VortexCommerce (ู ุทุงุจูุฉ ูู real_products_200.json)""" | |
| out = [] | |
| seen = set() | |
| idx = 0 | |
| for raw in raw_products: | |
| title_en = raw['title_en'].strip() | |
| key = title_en.lower() | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| idx += 1 | |
| cat_key = raw['category'] | |
| # ุชุญุฏูุฏ ุงููุฆุฉ ุงูุฃุจ ูุงููุฑุนูุฉ ุจูุงุกู ุนูู ุงููููููุฉ ุงูุฌุฏูุฏุฉ | |
| parent_ar = EXTRA_CAT_TO_PARENT.get(cat_key, "ุงูุฃุฌูุฒุฉ ุงูุฅููุชุฑูููุฉ") | |
| parent_info = CATEGORIES_HIERARCHY[parent_ar] | |
| sub_info = parent_info["subs"].get(cat_key, {"en": cat_key.title(), "ar": cat_key}) | |
| display_cat_ar = sub_info["ar"] | |
| display_cat_en = sub_info["en"] | |
| parent_en = parent_info["en"] | |
| icon = parent_info["icon"] | |
| brand = extract_brand(title_en, raw.get('brand', '')) | |
| details = extract_details(title_en) | |
| # ุงูุฃุณู ุงุก | |
| name_ar = raw.get('title_ar', '') | |
| if not name_ar: | |
| ar_brand = BRAND_AR.get(brand, brand) | |
| if brand and title_en.lower().startswith(brand.lower()): | |
| name_ar = f"{ar_brand} {title_en[len(brand):].strip()}" | |
| else: | |
| name_ar = f"{ar_brand} - {title_en}" | |
| # ุงูุฃูุตุงู | |
| desc_en = raw.get('description_en', '') | |
| desc_ar = raw.get('description_ar', '') | |
| if not desc_en: | |
| desc_en = f"The {title_en} is a premium product from {brand or 'a trusted brand'}. Available at eXtra stores in Saudi Arabia with official warranty." | |
| if not desc_ar: | |
| desc_ar = f"ููุฏู ููู {name_ar}ุ ู ูุชุฌ ุนุงูู ุงูุฌูุฏุฉ ู ู {brand or 'ุนูุงู ุฉ ุชุฌุงุฑูุฉ ู ูุซููุฉ'}. ู ุชููุฑ ูู ู ุนุงุฑุถ ุงูุณุชุฑุง ูู ุงูู ู ููุฉ ุงูุนุฑุจูุฉ ุงูุณุนูุฏูุฉ ู ุน ุถู ุงู ุฑุณู ู." | |
| price = raw['price'] | |
| old_price = raw.get('old_price') | |
| compare = old_price if old_price and old_price > price else round(price * random.uniform(1.05, 1.18), 2) | |
| images = raw.get('images', []) | |
| rating = raw.get('rating') or round(random.uniform(3.5, 4.9), 2) | |
| rating_count = raw.get('rating_count') or random.randint(10, 500) | |
| # ุงูุฎูุงุฑุงุช ุงูุงุญุชุฑุงููุฉ (ูุงูุนูุฉ) | |
| options = make_options(raw, images) | |
| # ุงูู ูุงุตูุงุช: ุฏู ุฌ EN + AR ู ุน ุญููู ุฅุถุงููุฉ | |
| specs_en = raw.get('specs_en', {}) | |
| specs_ar = raw.get('specs_ar', {}) | |
| specs = { | |
| "origin": "Saudi Arabia", | |
| "condition": "New Genuine", | |
| "rating": rating, | |
| "warranty": "1 Year Official KSA Warranty", | |
| "sku_base": f"EXT-SKU-{raw.get('pid', idx)}", | |
| "source": "extra.com", | |
| "brand_name": brand, # ุฅุถุงูุฉ ุงูุจุฑุงูุฏ ููู ูุงุตูุงุช ูุณูููุฉ ุงูููุชุฑุฉ | |
| } | |
| # ุฅุถุงูุฉ ุงูู ูุงุตูุงุช ุงูุชูุตูููุฉ ู ู ุงูู ููุน | |
| if specs_en: | |
| specs["details_en"] = specs_en | |
| if specs_ar: | |
| specs["details_ar"] = specs_ar | |
| if details['storage']: | |
| specs["storage"] = details['storage'] | |
| if details['color']: | |
| specs["color_en"] = details['color'] | |
| specs["color_ar"] = details['color_ar'] | |
| out.append({ | |
| "category": display_cat_ar, # ุงููุฆุฉ ุงููุฑุนูุฉ ุจุงูุนุฑุจู (ู ุซู: ุฌูุงูุงุช) | |
| "category_en": display_cat_en, # ุงููุฆุฉ ุงููุฑุนูุฉ ุจุงูุฅูุฌููุฒู | |
| "main_category": parent_ar, # ุงููุฆุฉ ุงูุฃุณุงุณูุฉ ุจุงูุนุฑุจู (ู ุซู: ุงูุฃุฌูุฒุฉ ุงูุฅููุชุฑูููุฉ) | |
| "main_category_en": parent_en, # ุงููุฆุฉ ุงูุฃุณุงุณูุฉ ุจุงูุฅูุฌููุฒู | |
| "category_icon": icon, # ุงูุฃููููุฉ | |
| "brand": brand or "Generic", | |
| "name_en": title_en, | |
| "name_ar": name_ar, | |
| "description_en": desc_en, | |
| "description_ar": desc_ar, | |
| "price": round(price, 2), | |
| "compare_price": round(compare, 2), | |
| "images": images, | |
| "options": options, | |
| "specs": specs, | |
| "seller_info": {"name": "Vortex Authorized Seller", "rating": 4.9, "location": "Riyadh, KSA"}, | |
| "quality_score": 100 if len(images) > 0 else 80, | |
| "legacy_ref": f"EXT-API-{raw.get('pid', idx)}", | |
| }) | |
| return out | |
| # ----------------------------------------------- | |
| # 6. ุงูุชุดุบูู ุงูุฑุฆูุณู | |
| # ----------------------------------------------- | |
| async def main(): | |
| db = Database(DATABASE_URL or DATABASE_NAME) | |
| scraper = ExtraScraper() | |
| all_raw = [] | |
| seen_pids = set() | |
| print("=" * 60) | |
| print("๐ Extra.com โ VortexCommerce Full Scraper (V2: Per-Category Quota)") | |
| print(f"๐ Independent Target: {TARGET_PRODUCTS_PER_CATEGORY} products per category") | |
| print(f"๐ Languages: English + Arabic") | |
| print(f"๐ผ๏ธ Mode: Multiple images + Full specs per product") | |
| print("=" * 60) | |
| for cat_key, url_tpl in CATEGORIES_TO_SCRAPE.items(): | |
| print(f"\n{'โ'*50}") | |
| print(f"๐ Processing Category: {cat_key} (Target: {TARGET_PRODUCTS_PER_CATEGORY})") | |
| print(f"{'โ'*50}") | |
| # Step 1: Get enough listings and scrape details with early deduplication | |
| # We pass global_seen_pids to skip already visited products across categories | |
| prods = await scraper.scrape(cat_key, url_tpl, max_products=TARGET_PRODUCTS_PER_CATEGORY, global_seen_pids=seen_pids) | |
| if prods: | |
| saved = db.save_products(prods) | |
| all_raw.extend(prods) | |
| img_counts = [len(p.get('images', [])) for p in prods] | |
| avg_imgs = sum(img_counts) / len(img_counts) if img_counts else 0 | |
| ar_count = sum(1 for p in prods if p.get('title_ar')) | |
| print(f" โ {len(prods)} products saved and processed for this category") | |
| print(f" ๐ผ๏ธ Avg images: {avg_imgs:.1f} | ๐ Arabic: {ar_count}/{len(prods)}") | |
| else: | |
| print(f" โ ๏ธ No new products found in this category") | |
| print(f"\n{'โ'*60}") | |
| print(f"๐ฆ FINAL SCRAPE COMPLETE: Total {len(all_raw)} products across all categories") | |
| print(f"{'โ'*60}") | |
| # === ุงูุชุญููู === | |
| print("\n๐ Converting to VortexCommerce format...") | |
| vortex_products = transform_to_vortex(all_raw) | |
| output = { | |
| "metadata": { | |
| "total_count": len(vortex_products), | |
| "generated_at": datetime.now(timezone.utc).isoformat(), | |
| "version": "Extra-Scraper-V2-Full", | |
| "quality_tier": "Extra-Validated-Full", | |
| "source": "https://www.extra.com/en-sa/", | |
| "features": ["multi-image", "bilingual", "full-specs"] | |
| }, | |
| "products": vortex_products | |
| } | |
| # === ุญูุธ ุงูู ููุงุช === | |
| vortex_file = "extra_products_vortex.json" | |
| raw_file = "extra_products_raw.json" | |
| with open(vortex_file, 'w', encoding='utf-8') as f: | |
| json.dump(output, f, ensure_ascii=False, indent=2) | |
| with open(raw_file, 'w', encoding='utf-8') as f: | |
| json.dump(all_raw, f, ensure_ascii=False, indent=2) | |
| with zipfile.ZipFile('extra_data.zip', 'w') as z: | |
| z.write(DATABASE_NAME) | |
| z.write(vortex_file) | |
| z.write(raw_file) | |
| # === ุงูุฅุญุตุงุฆูุงุช ุงูููุงุฆูุฉ === | |
| print(f"\n{'โ'*60}") | |
| print(f"๐ FINAL REPORT") | |
| print(f"{'โ'*60}") | |
| cats = {} | |
| brands = {} | |
| total_imgs = 0 | |
| total_specs = 0 | |
| with_ar = 0 | |
| for p in vortex_products: | |
| cats[p['category']] = cats.get(p['category'], 0) + 1 | |
| brands[p['brand']] = brands.get(p['brand'], 0) + 1 | |
| total_imgs += len(p.get('images', [])) | |
| total_specs += len(p.get('specs', {}).get('details_en', {})) | |
| if p.get('name_ar'): | |
| with_ar += 1 | |
| n = len(vortex_products) | |
| print(f"\n๐ Categories:") | |
| for cat, count in sorted(cats.items()): | |
| print(f" {cat}: {count}") | |
| print(f"\n๐ท๏ธ Top Brands:") | |
| for brand, count in sorted(brands.items(), key=lambda x: -x[1])[:15]: | |
| print(f" {brand}: {count}") | |
| print(f"\n๐ Quality Metrics:") | |
| print(f" Total products: {n}") | |
| print(f" ๐ผ๏ธ Total images: {total_imgs} (avg {total_imgs/n:.1f}/product)") | |
| print(f" ๐ Products with specs: {sum(1 for p in vortex_products if p['specs'].get('details_en'))}/{n}") | |
| print(f" ๐ Arabic names: {with_ar}/{n}") | |
| print(f" ๐ฐ Price range: {min(p['price'] for p in vortex_products):.0f} - {max(p['price'] for p in vortex_products):.0f} SAR") | |
| print(f"\n๐ Files generated:") | |
| print(f" โ {vortex_file} ({n} products)") | |
| print(f" โ {raw_file}") | |
| print(f" โ {DATABASE_NAME}") | |
| print(f" ๐ฆ extra_data.zip (download from sidebar)") | |
| print(f"\nโ Copy {vortex_file} โ backend/kaggle/extra_products_vortex.json") | |
| db.close() | |
| await main() | |