#!/usr/bin/env python3 """ Unified Beatport Scraper - Reliable Artist & Track Name Extraction Focused on extracting clean artist and track names for virtual playlists """ import requests from bs4 import BeautifulSoup import json import logging import os import time import re from urllib.parse import urljoin from typing import Dict, List, Optional import concurrent.futures from threading import Lock from utils.logging_config import get_logger logger = get_logger("beatport_scraper") _BEATPORT_LOGGING_ENABLED = os.environ.get("SOULSYNC_BEATPORT_SCRAPER_LOGS", "").lower() in ("1", "true", "yes", "on") if _BEATPORT_LOGGING_ENABLED: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.CRITICAL + 1) def _beatport_log(message: str): """Route scraper output through logging when explicitly enabled.""" if not _BEATPORT_LOGGING_ENABLED: return text = str(message) stripped = text.strip() if not stripped: return lowered = stripped.lower() level = logging.DEBUG if lowered.startswith("error") or " exception" in lowered or lowered.startswith("failed") or " failed" in lowered: level = logging.ERROR elif lowered.startswith("could not") or lowered.startswith("couldn't"): level = logging.WARNING elif lowered.startswith("no ") and "found" not in lowered: level = logging.WARNING logger.log(level, text) class BeatportUnifiedScraper: def __init__(self): self.base_url = "https://beatport.com" self.session = requests.Session() self.session.headers.update({ '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' }) self.results_lock = Lock() # Dynamic genres - will be populated by scraping homepage self.all_genres = [] # Current Beatport genres with correct URLs and IDs (updated from live site) self.fallback_genres = [ {'name': '140 / Deep Dubstep / Grime', 'slug': '140-deep-dubstep-grime', 'id': '95', 'url': f'{self.base_url}/genre/140-deep-dubstep-grime/95'}, {'name': 'Afro House', 'slug': 'afro-house', 'id': '89', 'url': f'{self.base_url}/genre/afro-house/89'}, {'name': 'Amapiano', 'slug': 'amapiano', 'id': '98', 'url': f'{self.base_url}/genre/amapiano/98'}, {'name': 'Ambient / Experimental', 'slug': 'ambient-experimental', 'id': '100', 'url': f'{self.base_url}/genre/ambient-experimental/100'}, {'name': 'Bass / Club', 'slug': 'bass-club', 'id': '85', 'url': f'{self.base_url}/genre/bass-club/85'}, {'name': 'Bass House', 'slug': 'bass-house', 'id': '91', 'url': f'{self.base_url}/genre/bass-house/91'}, {'name': 'Brazilian Funk', 'slug': 'brazilian-funk', 'id': '101', 'url': f'{self.base_url}/genre/brazilian-funk/101'}, {'name': 'Breaks / Breakbeat / UK Bass', 'slug': 'breaks-breakbeat-uk-bass', 'id': '9', 'url': f'{self.base_url}/genre/breaks-breakbeat-uk-bass/9'}, {'name': 'Dance / Pop', 'slug': 'dance-pop', 'id': '39', 'url': f'{self.base_url}/genre/dance-pop/39'}, {'name': 'Deep House', 'slug': 'deep-house', 'id': '12', 'url': f'{self.base_url}/genre/deep-house/12'}, {'name': 'DJ Tools', 'slug': 'dj-tools', 'id': '16', 'url': f'{self.base_url}/genre/dj-tools/16'}, {'name': 'Downtempo', 'slug': 'downtempo', 'id': '63', 'url': f'{self.base_url}/genre/downtempo/63'}, {'name': 'Drum & Bass', 'slug': 'drum-bass', 'id': '1', 'url': f'{self.base_url}/genre/drum-bass/1'}, {'name': 'Dubstep', 'slug': 'dubstep', 'id': '18', 'url': f'{self.base_url}/genre/dubstep/18'}, {'name': 'Electro (Classic / Detroit / Modern)', 'slug': 'electro-classic-detroit-modern', 'id': '94', 'url': f'{self.base_url}/genre/electro-classic-detroit-modern/94'}, {'name': 'Electronica', 'slug': 'electronica', 'id': '3', 'url': f'{self.base_url}/genre/electronica/3'}, {'name': 'Funky House', 'slug': 'funky-house', 'id': '81', 'url': f'{self.base_url}/genre/funky-house/81'}, {'name': 'Hard Dance / Hardcore / Neo Rave', 'slug': 'hard-dance-hardcore-neo-rave', 'id': '8', 'url': f'{self.base_url}/genre/hard-dance-hardcore-neo-rave/8'}, {'name': 'Hard Techno', 'slug': 'hard-techno', 'id': '2', 'url': f'{self.base_url}/genre/hard-techno/2'}, {'name': 'House', 'slug': 'house', 'id': '5', 'url': f'{self.base_url}/genre/house/5'}, {'name': 'Indie Dance', 'slug': 'indie-dance', 'id': '37', 'url': f'{self.base_url}/genre/indie-dance/37'}, {'name': 'Jackin House', 'slug': 'jackin-house', 'id': '97', 'url': f'{self.base_url}/genre/jackin-house/97'}, {'name': 'Mainstage', 'slug': 'mainstage', 'id': '96', 'url': f'{self.base_url}/genre/mainstage/96'}, {'name': 'Melodic House & Techno', 'slug': 'melodic-house-techno', 'id': '90', 'url': f'{self.base_url}/genre/melodic-house-techno/90'}, {'name': 'Minimal / Deep Tech', 'slug': 'minimal-deep-tech', 'id': '14', 'url': f'{self.base_url}/genre/minimal-deep-tech/14'}, {'name': 'Nu Disco / Disco', 'slug': 'nu-disco-disco', 'id': '50', 'url': f'{self.base_url}/genre/nu-disco-disco/50'}, {'name': 'Organic House', 'slug': 'organic-house', 'id': '93', 'url': f'{self.base_url}/genre/organic-house/93'}, {'name': 'Progressive House', 'slug': 'progressive-house', 'id': '15', 'url': f'{self.base_url}/genre/progressive-house/15'}, {'name': 'Psy-Trance', 'slug': 'psy-trance', 'id': '13', 'url': f'{self.base_url}/genre/psy-trance/13'}, {'name': 'Tech House', 'slug': 'tech-house', 'id': '11', 'url': f'{self.base_url}/genre/tech-house/11'}, {'name': 'Techno (Peak Time / Driving)', 'slug': 'techno-peak-time-driving', 'id': '6', 'url': f'{self.base_url}/genre/techno-peak-time-driving/6'}, {'name': 'Techno (Raw / Deep / Hypnotic)', 'slug': 'techno-raw-deep-hypnotic', 'id': '92', 'url': f'{self.base_url}/genre/techno-raw-deep-hypnotic/92'}, {'name': 'Trance (Main Floor)', 'slug': 'trance-main-floor', 'id': '7', 'url': f'{self.base_url}/genre/trance-main-floor/7'}, {'name': 'Trance (Raw / Deep / Hypnotic)', 'slug': 'trance-raw-deep-hypnotic', 'id': '99', 'url': f'{self.base_url}/genre/trance-raw-deep-hypnotic/99'}, {'name': 'Trap / Future Bass', 'slug': 'trap-future-bass', 'id': '38', 'url': f'{self.base_url}/genre/trap-future-bass/38'}, {'name': 'UK Garage / Bassline', 'slug': 'uk-garage-bassline', 'id': '86', 'url': f'{self.base_url}/genre/uk-garage-bassline/86'}, # Additional genres from current Beatport {'name': 'African', 'slug': 'african', 'id': '102', 'url': f'{self.base_url}/genre/african/102'}, {'name': 'Caribbean', 'slug': 'caribbean', 'id': '103', 'url': f'{self.base_url}/genre/caribbean/103'}, {'name': 'Hip-Hop', 'slug': 'hip-hop', 'id': '105', 'url': f'{self.base_url}/genre/hip-hop/105'}, {'name': 'Latin', 'slug': 'latin', 'id': '106', 'url': f'{self.base_url}/genre/latin/106'}, {'name': 'Pop', 'slug': 'pop', 'id': '107', 'url': f'{self.base_url}/genre/pop/107'}, {'name': 'R&B', 'slug': 'rb', 'id': '108', 'url': f'{self.base_url}/genre/rb/108'} ] def clean_text(self, text): """Clean and normalize text from HTML elements""" if not text: return text # Fix common spacing issues text = re.sub(r'([a-z$!@#%&*])([A-Z])', r'\1 \2', text) # Add space between lowercase/symbols and uppercase text = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', text) # Add space between letter and number text = re.sub(r'(\d)([a-zA-Z])', r'\1 \2', text) # Add space between number and letter text = re.sub(r'([a-zA-Z]),([a-zA-Z])', r'\1, \2', text) # Add space after comma text = re.sub(r'([a-zA-Z])Mix\b', r'\1 Mix', text) # Fix "hitMix" -> "hit Mix" text = re.sub(r'([a-zA-Z])Remix\b', r'\1 Remix', text) # Fix "hitRemix" -> "hit Remix" text = re.sub(r'([a-zA-Z])Extended\b', r'\1 Extended', text) # Fix "hitExtended" -> "hit Extended" text = re.sub(r'([a-zA-Z])Version\b', r'\1 Version', text) # Fix "hitVersion" -> "hit Version" text = re.sub(r'\s+', ' ', text) # Collapse multiple spaces text = text.strip() return text def _is_valid_genre_name(self, name: str) -> bool: """Check if a name is a valid genre name and not a section title""" # Filter out common section titles section_titles = { 'open format', 'electronic', 'genres', 'browse', 'charts', 'new releases', 'trending', 'featured', 'popular', 'top', 'main', 'explore', 'discover', 'all genres' } name_lower = name.lower().strip() # Reject if it's a section title if name_lower in section_titles: return False # Reject if it's too short or too generic if len(name_lower) < 3: return False # Reject if it contains only common words common_words = {'the', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for'} words = name_lower.split() if len(words) == 1 and words[0] in common_words: return False # Accept everything else return True def get_page(self, url: str) -> Optional[BeautifulSoup]: """Fetch and parse a page with error handling""" try: response = self.session.get(url, timeout=15) response.raise_for_status() return BeautifulSoup(response.content, 'html.parser') except requests.RequestException as e: _beatport_log(f"Error fetching {url}: {e}") return None def clean_artist_track_data(self, raw_artist: str, raw_title: str) -> Dict[str, str]: """Clean and separate artist and track data reliably""" if not raw_artist or not raw_title: return {'artist': raw_artist or 'Unknown Artist', 'title': raw_title or 'Unknown Title'} # Clean artist name - remove extra whitespace and common artifacts artist = re.sub(r'\s+', ' ', raw_artist.strip()) # Clean title and properly format mix information title = raw_title.strip() # Fix common concatenation issues in titles concatenation_fixes = [ (r'(.+?)(Extended Mix?)$', r'\1 (\2)'), (r'(.+?)(Original Mix?)$', r'\1 (\2)'), (r'(.+?)(Radio Edit?)$', r'\1 (\2)'), (r'(.+?)(Club Mix?)$', r'\1 (\2)'), (r'(.+?)(Vocal Mix?)$', r'\1 (\2)'), (r'(.+?)(Instrumental?)$', r'\1 (\2)'), (r'(.+?)(Remix?)$', r'\1 (\2)'), (r'(.+?)(Edit?)$', r'\1 (\2)'), (r'(.+?)(Extended)$', r'\1 (\2 Mix)'), (r'(.+?)(Version)$', r'\1 (\2)') ] for pattern, replacement in concatenation_fixes: match = re.match(pattern, title, re.IGNORECASE) if match: title = re.sub(pattern, replacement, title, flags=re.IGNORECASE) break # Remove duplicate spaces title = re.sub(r'\s+', ' ', title) return { 'artist': artist, 'title': title } def discover_genres_from_homepage(self) -> List[Dict]: """Dynamically discover all genres from Beatport homepage dropdown""" _beatport_log("Discovering genres from Beatport homepage...") try: soup = self.get_page(self.base_url) if not soup: _beatport_log("Could not fetch homepage") return self.fallback_genres genres = [] # Method 1: Look for the specific genres dropdown menu structure genres_dropdown = soup.find('div', {'id': 'genres-dropdown-menu'}) if genres_dropdown: _beatport_log("Found genres-dropdown-menu") # Look for the two main div containers as described genre_containers = genres_dropdown.find_all('div', recursive=False) _beatport_log(f"Found {len(genre_containers)} top-level containers in dropdown") for container_idx, container in enumerate(genre_containers): _beatport_log(f"Processing container {container_idx + 1}") # Look specifically for .dropdown_menu classes dropdown_menus = container.find_all(class_='dropdown_menu') if not dropdown_menus: # Fallback: Look for any element with class containing 'dropdown' and 'menu' dropdown_menus = container.find_all(class_=re.compile(r'dropdown.*menu', re.I)) if not dropdown_menus: _beatport_log(f"No .dropdown_menu found in container {container_idx + 1}") continue for menu_idx, menu in enumerate(dropdown_menus): _beatport_log(f"Processing dropdown_menu {menu_idx + 1} in container {container_idx + 1}") # Look for