410 lines
14 KiB
Python
410 lines
14 KiB
Python
"""
|
|
Target.com scraper
|
|
"""
|
|
|
|
import re
|
|
import json
|
|
import logging
|
|
from typing import List, Optional
|
|
from bs4 import BeautifulSoup
|
|
|
|
from .base import BaseScraper, Product
|
|
from src.browser import get_browser
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _find_image_in_json(data, depth=0) -> Optional[str]:
|
|
"""Recursively search a JSON dict for a Target image URL"""
|
|
if depth > 6:
|
|
return None
|
|
if isinstance(data, str):
|
|
if 'scene7.com' in data and not data.endswith('/'):
|
|
return data
|
|
return None
|
|
if isinstance(data, dict):
|
|
# Check known Target image keys first
|
|
for key in ('primary_image_url', 'base_url', 'url', 'src'):
|
|
val = data.get(key)
|
|
if isinstance(val, str) and 'scene7.com' in val:
|
|
return val
|
|
# Recurse, prioritizing enrichment/images paths
|
|
for key in ('enrichment', 'images', 'image'):
|
|
if key in data:
|
|
result = _find_image_in_json(data[key], depth + 1)
|
|
if result:
|
|
return result
|
|
for val in data.values():
|
|
result = _find_image_in_json(val, depth + 1)
|
|
if result:
|
|
return result
|
|
if isinstance(data, list) and data:
|
|
return _find_image_in_json(data[0], depth + 1)
|
|
return None
|
|
|
|
|
|
def _extract_img_url(img_tag) -> Optional[str]:
|
|
"""Extract real image URL from an img tag, skipping lazy-load placeholders"""
|
|
if not img_tag:
|
|
return None
|
|
for attr in ("src", "data-src", "data-lazy-src", "data-srcset", "srcset"):
|
|
val = img_tag.get(attr, "")
|
|
if not val:
|
|
continue
|
|
# srcset may contain multiple URLs - take the first
|
|
url = val.split()[0].rstrip(",")
|
|
if url and not url.startswith("data:") and len(url) > 20:
|
|
return url
|
|
return None
|
|
|
|
|
|
class TargetScraper(BaseScraper):
|
|
"""Scraper for Target.com"""
|
|
|
|
site_name = "target"
|
|
base_url = "https://www.target.com"
|
|
|
|
def scrape_category_page(self, url: str, max_pages: int = 1) -> List[Product]:
|
|
"""
|
|
Scrape a Target search/category page for all products
|
|
|
|
Args:
|
|
url: Search/category page URL
|
|
max_pages: Maximum number of pages to scrape (default 1)
|
|
|
|
Returns:
|
|
List of Product objects
|
|
"""
|
|
browser = get_browser()
|
|
all_products = []
|
|
|
|
# Add sort by newest if not already in URL
|
|
if "sortBy=" not in url:
|
|
separator = "&" if "?" in url else "?"
|
|
url = f"{url}{separator}sortBy=newest"
|
|
|
|
for page_num in range(1, max_pages + 1):
|
|
products = []
|
|
page_url = url if page_num == 1 else f"{url}&Nao={24 * (page_num - 1)}"
|
|
|
|
try:
|
|
page, html = browser.get_page_content(
|
|
page_url,
|
|
wait_for_selector=None,
|
|
timeout=60000,
|
|
)
|
|
|
|
# Save debug screenshot
|
|
try:
|
|
page.screenshot(path="debug_target.png")
|
|
logger.info("Saved debug screenshot to debug_target.png")
|
|
except:
|
|
pass
|
|
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
# Try to find product data in page scripts (Target uses React/hydration)
|
|
scripts = soup.find_all("script", type="application/json")
|
|
for script in scripts:
|
|
try:
|
|
data = json.loads(script.string)
|
|
# Look for product data in the JSON
|
|
products.extend(self._extract_products_from_json(data))
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
|
|
# Parse product links from the page
|
|
product_links = soup.select("a[href*='/p/']")
|
|
logger.info(f"Found {len(product_links)} product links")
|
|
|
|
# Group links by href and pick the best one (with aria-label or text)
|
|
href_to_links = {}
|
|
for link in product_links:
|
|
href = link.get("href", "")
|
|
if not href:
|
|
continue
|
|
if href not in href_to_links:
|
|
href_to_links[href] = []
|
|
href_to_links[href].append(link)
|
|
|
|
logger.info(f"Found {len(href_to_links)} unique hrefs")
|
|
|
|
for href, links in href_to_links.items():
|
|
# Find the best link (one with aria-label or text content)
|
|
best_link = None
|
|
for link in links:
|
|
aria = link.get("aria-label", "")
|
|
text = link.get_text(strip=True)
|
|
if aria or (text and len(text) > 10):
|
|
best_link = link
|
|
break
|
|
if not best_link:
|
|
best_link = links[0] # Fallback to first link
|
|
|
|
product = self._parse_product_link(best_link, soup)
|
|
if product:
|
|
products.append(product)
|
|
|
|
page.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error scraping Target page {page_num}: {e}")
|
|
if page_num == 1:
|
|
raise
|
|
break
|
|
|
|
all_products.extend(products)
|
|
|
|
# Stop if no products found on this page
|
|
if not products:
|
|
break
|
|
|
|
# Remove duplicates
|
|
seen_urls = set()
|
|
unique_products = []
|
|
for p in all_products:
|
|
if p.url not in seen_urls:
|
|
seen_urls.add(p.url)
|
|
unique_products.append(p)
|
|
|
|
logger.info(f"Scraped {len(unique_products)} unique products from Target")
|
|
return unique_products
|
|
|
|
def _extract_products_from_json(self, data, depth=0) -> List[Product]:
|
|
"""Recursively search JSON for product data"""
|
|
products = []
|
|
if depth > 10: # Prevent infinite recursion
|
|
return products
|
|
|
|
if isinstance(data, dict):
|
|
# Check if this looks like a product
|
|
if "tcin" in data or ("title" in data and "price" in data):
|
|
product = self._parse_product_json(data)
|
|
if product:
|
|
products.append(product)
|
|
|
|
# Recurse into nested objects
|
|
for value in data.values():
|
|
products.extend(self._extract_products_from_json(value, depth + 1))
|
|
|
|
elif isinstance(data, list):
|
|
for item in data:
|
|
products.extend(self._extract_products_from_json(item, depth + 1))
|
|
|
|
return products
|
|
|
|
def _parse_product_json(self, data: dict) -> Optional[Product]:
|
|
"""Parse a product from Target's JSON data"""
|
|
try:
|
|
name = data.get("title") or data.get("product_description", {}).get("title", "")
|
|
if not name:
|
|
return None
|
|
|
|
# Build URL
|
|
tcin = data.get("tcin", "")
|
|
slug = data.get("url_slug", name.lower().replace(" ", "-"))
|
|
url = f"{self.base_url}/p/{slug}/-/A-{tcin}" if tcin else ""
|
|
if not url:
|
|
return None
|
|
|
|
# Get price
|
|
price_data = data.get("price", {})
|
|
price = None
|
|
if isinstance(price_data, dict):
|
|
price = price_data.get("formatted_current_price") or price_data.get("current_retail")
|
|
elif isinstance(price_data, (int, float)):
|
|
price = f"${price_data:.2f}"
|
|
|
|
# Check availability
|
|
availability = data.get("availability_status", "")
|
|
fulfillment = data.get("fulfillment", {})
|
|
in_stock = availability not in ["OUT_OF_STOCK", "UNAVAILABLE"]
|
|
if fulfillment:
|
|
in_stock = fulfillment.get("is_out_of_stock_in_all_store_locations", True) is False
|
|
|
|
# Get image - search all known Target JSON paths
|
|
image_url = _find_image_in_json(data)
|
|
|
|
return Product(
|
|
name=name,
|
|
url=url,
|
|
price=str(price) if price else None,
|
|
in_stock=in_stock,
|
|
image_url=image_url,
|
|
site=self.site_name,
|
|
product_id=tcin,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error parsing Target product JSON: {e}")
|
|
return None
|
|
|
|
def _parse_product_link(self, link, soup) -> Optional[Product]:
|
|
"""Parse a product from a product link element"""
|
|
try:
|
|
href = link.get("href", "")
|
|
if not href:
|
|
return None
|
|
|
|
url = href if href.startswith("http") else f"{self.base_url}{href}"
|
|
|
|
# Get name - prefer aria-label as it's usually clean
|
|
name = link.get("aria-label", "")
|
|
|
|
# If no aria-label, try link text
|
|
if not name or len(name) < 5:
|
|
name = link.get_text(strip=True)
|
|
|
|
# Clean up the name
|
|
if name:
|
|
# Remove rating text patterns
|
|
name = re.sub(r'\d+\.?\d*\s*out of \d+ stars.*$', '', name, flags=re.IGNORECASE)
|
|
name = re.sub(r'\s*\d+ ratings?.*$', '', name, flags=re.IGNORECASE)
|
|
name = re.sub(r'\s*\d+ reviews?.*$', '', name, flags=re.IGNORECASE)
|
|
name = name.strip()
|
|
|
|
# Skip empty/short names
|
|
if not name or len(name) < 10:
|
|
return None
|
|
|
|
# Skip if name looks like navigation/rating text only
|
|
lower_name = name.lower()
|
|
if any(lower_name.startswith(skip) for skip in ['rating', 'stars', 'review', 'filter', 'sort']):
|
|
return None
|
|
|
|
# Extract product ID from URL (A-12345678)
|
|
product_id = ""
|
|
match = re.search(r"/A-(\d+)", url)
|
|
if match:
|
|
product_id = match.group(1)
|
|
|
|
# Try to find price near this link
|
|
parent = link.find_parent()
|
|
price = None
|
|
for _ in range(5): # Go up 5 levels max
|
|
if parent:
|
|
price_text = parent.get_text()
|
|
price_match = re.search(r"\$[\d,]+\.?\d*", price_text)
|
|
if price_match:
|
|
price = price_match.group()
|
|
break
|
|
parent = parent.find_parent()
|
|
|
|
# Try to find image - check inside link first, then walk up to parents
|
|
image_url = _extract_img_url(link.select_one("img"))
|
|
if not image_url:
|
|
parent = link.find_parent()
|
|
for _ in range(4):
|
|
if parent:
|
|
image_url = _extract_img_url(parent.select_one("img"))
|
|
if image_url:
|
|
break
|
|
parent = parent.find_parent()
|
|
|
|
# Check stock (assume in stock unless we see otherwise)
|
|
in_stock = True
|
|
parent = link.find_parent()
|
|
for _ in range(5):
|
|
if parent:
|
|
text = parent.get_text().lower()
|
|
if "out of stock" in text or "unavailable" in text:
|
|
in_stock = False
|
|
break
|
|
parent = parent.find_parent()
|
|
|
|
return Product(
|
|
name=name,
|
|
url=url,
|
|
price=price,
|
|
in_stock=in_stock,
|
|
image_url=image_url,
|
|
site=self.site_name,
|
|
product_id=product_id,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error parsing Target product link: {e}")
|
|
return None
|
|
|
|
def _parse_product_card(self, card) -> Optional[Product]:
|
|
"""Parse a product card element"""
|
|
try:
|
|
# Find link
|
|
link = card.select_one("a[href*='/p/']")
|
|
if not link:
|
|
return None
|
|
|
|
href = link.get("href", "")
|
|
url = href if href.startswith("http") else f"{self.base_url}{href}"
|
|
|
|
# Get name
|
|
name_elem = card.select_one("[data-test='product-title']") or card.select_one("a")
|
|
name = name_elem.get_text(strip=True) if name_elem else "Unknown"
|
|
|
|
# Get price
|
|
price_elem = card.select_one("[data-test='current-price']") or card.select_one("[class*='price']")
|
|
price = None
|
|
if price_elem:
|
|
price_match = re.search(r"\$[\d,]+\.?\d*", price_elem.get_text())
|
|
if price_match:
|
|
price = price_match.group()
|
|
|
|
# Check stock
|
|
card_text = card.get_text().lower()
|
|
out_of_stock = "out of stock" in card_text or "unavailable" in card_text
|
|
in_stock = not out_of_stock
|
|
|
|
# Get image
|
|
image_url = _extract_img_url(card.select_one("img"))
|
|
|
|
# Extract product ID from URL
|
|
product_id = ""
|
|
match = re.search(r"/A-(\d+)", url)
|
|
if match:
|
|
product_id = match.group(1)
|
|
|
|
return Product(
|
|
name=name,
|
|
url=url,
|
|
price=price,
|
|
in_stock=in_stock,
|
|
image_url=image_url,
|
|
site=self.site_name,
|
|
product_id=product_id,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error parsing Target product card: {e}")
|
|
return None
|
|
|
|
def check_product_stock(self, product_url: str) -> tuple[bool, Optional[str]]:
|
|
"""Check if a specific product is in stock"""
|
|
browser = get_browser()
|
|
|
|
try:
|
|
page, html = browser.get_page_content(product_url, timeout=30000)
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
page_text = soup.get_text().lower()
|
|
|
|
out_of_stock = any(
|
|
phrase in page_text
|
|
for phrase in ["out of stock", "unavailable", "not available"]
|
|
)
|
|
|
|
in_stock = not out_of_stock
|
|
|
|
# Get price
|
|
price = None
|
|
price_elem = soup.select_one("[data-test='product-price']")
|
|
if price_elem:
|
|
price_match = re.search(r"\$[\d,]+\.?\d*", price_elem.get_text())
|
|
if price_match:
|
|
price = price_match.group()
|
|
|
|
page.close()
|
|
return in_stock, price
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking Target product stock: {e}")
|
|
return False, None
|