Files
pokemon-stock-checker/scrapers/target.py
T
2026-04-10 10:04:20 -04:00

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