import logging import os import re import shutil import time from datetime import datetime from pathlib import Path from bs4 import BeautifulSoup from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait from webdriver_manager.chrome import ChromeDriverManager import requests from journeyfm.paths import data_path logger = logging.getLogger(__name__) DEBUG_SCRAPE_DIR = data_path("debug_scrapes") def detect_chrome_binary(): env_binary = os.getenv("CHROME_BINARY", "").strip() if env_binary and os.path.exists(env_binary): return env_binary candidates = [] if os.name == "nt": local_app_data = os.getenv("LOCALAPPDATA", "") program_files = os.getenv("PROGRAMFILES", "") program_files_x86 = os.getenv("PROGRAMFILES(X86)", "") candidates.extend([ os.path.join(local_app_data, "Google", "Chrome", "Application", "chrome.exe"), os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe"), os.path.join(program_files_x86, "Google", "Chrome", "Application", "chrome.exe"), os.path.join(program_files, "Chromium", "Application", "chrome.exe"), os.path.join(program_files_x86, "Chromium", "Application", "chrome.exe"), ]) else: candidates.extend([ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/opt/google/chrome/chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", ]) for binary in candidates: if binary and os.path.exists(binary): return binary for name in ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"]: found = shutil.which(name) if found: return found return None def detect_chromedriver_path(): env_driver = os.getenv("CHROMEDRIVER_PATH", "").strip() if env_driver and os.path.exists(env_driver): return env_driver for candidate in ["/usr/bin/chromedriver", "/usr/local/bin/chromedriver"]: if os.path.exists(candidate): return candidate return shutil.which("chromedriver") def _write_debug_payload(station_key, payload, extension): DEBUG_SCRAPE_DIR.mkdir(exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") file_path = DEBUG_SCRAPE_DIR / f"{station_key}_{timestamp}.{extension}" with open(file_path, "w", encoding="utf-8") as file_handle: file_handle.write(payload) latest_path = DEBUG_SCRAPE_DIR / f"{station_key}_latest.{extension}" with open(latest_path, "w", encoding="utf-8") as file_handle: file_handle.write(payload) return str(file_path) class JourneyFMScraper: station_key = "journey_fm" display_name = "Journey FM" url = "https://www.myjourneyfm.com/recently-played/" SELECTOR_PATTERNS = [ { "name": "rp-item", "container": ("div", "rp-item"), "title": ("h5", "song-title"), "artist": ("p", "song-artist"), }, { "name": "top-rp-list", "container": ("div", "song-item"), "title": ("div", "title-artist"), "artist": ("span", None), }, ] @classmethod def _extract_with_pattern(cls, soup, pattern): songs = [] container_tag, container_class = pattern["container"] title_tag, title_class = pattern["title"] artist_tag, artist_class = pattern["artist"] for item in soup.find_all(container_tag, class_=container_class): if title_class: title_elem = item.find(title_tag, class_=title_class) else: title_elem = item.find(title_tag) if not title_elem: continue if pattern["name"] == "top-rp-list": paragraph = title_elem.find("p") if not paragraph: continue combined = paragraph.get_text(" ", strip=True) parts = re.split(r"\s+by\s+", combined, maxsplit=1, flags=re.IGNORECASE) if len(parts) != 2: continue title = parts[0].strip() artist = parts[1].strip() else: if artist_class: artist_elem = item.find(artist_tag, class_=artist_class) else: artist_elem = item.find(artist_tag) if not artist_elem: continue title = title_elem.get_text(" ", strip=True).strip() artist = artist_elem.get_text(" ", strip=True).strip() if title and artist: songs.append({"title": title, "artist": artist, "source": cls.display_name}) return songs @classmethod def parse_html_with_telemetry(cls, html): soup = BeautifulSoup(html, "html.parser") for pattern in cls.SELECTOR_PATTERNS: songs = cls._extract_with_pattern(soup, pattern) if songs: return songs, pattern["name"] return [], "none" @staticmethod def parse_html(html): songs, _pattern = JourneyFMScraper.parse_html_with_telemetry(html) return songs @classmethod def scrape(cls, driver): driver.get(cls.url) WebDriverWait(driver, 15).until( lambda _driver: ( bool(_driver.find_elements(By.CSS_SELECTOR, "div.rp-item")) or bool(_driver.find_elements(By.CSS_SELECTOR, "div.song-item")) ) ) try: more_button = WebDriverWait(driver, 5).until(EC.element_to_be_clickable((By.ID, "moreSongs"))) more_button.click() time.sleep(2) except Exception: pass html = driver.page_source raw_path = _write_debug_payload(cls.station_key, html, "html") songs, parse_pattern = cls.parse_html_with_telemetry(html) return songs, raw_path, parse_pattern @classmethod def scrape_without_driver(cls): response = requests.get(cls.url, timeout=20) response.raise_for_status() html = response.text raw_path = _write_debug_payload(cls.station_key, html, "html") songs, parse_pattern = cls.parse_html_with_telemetry(html) return songs, raw_path, f"{parse_pattern}-requests-fallback" class SpiritFMScraper: station_key = "spirit_fm" display_name = "Spirit FM" url = "https://spiritfm.com/ajax/now_playing_history.txt" @staticmethod def parse_text(text): songs = [] for line in text.splitlines(): line = line.strip() if not line: continue match = re.match(r"^\w+\s+\d+:\d+[AP]M\s+(.+?)\s+-\s+(.+)$", line) if not match: continue artist = match.group(1).strip().replace("&", "&") title = match.group(2).strip().replace("&", "&") if title and artist: songs.append({"title": title, "artist": artist, "source": SpiritFMScraper.display_name}) return songs @classmethod def scrape(cls, driver): driver.get(cls.url) time.sleep(2) text = BeautifulSoup(driver.page_source, "html.parser").get_text() raw_path = _write_debug_payload(cls.station_key, text, "txt") return cls.parse_text(text), raw_path, "plain-text-regex" @classmethod def scrape_without_driver(cls): response = requests.get(cls.url, timeout=20) response.raise_for_status() text = response.text raw_path = _write_debug_payload(cls.station_key, text, "txt") return cls.parse_text(text), raw_path, "plain-text-regex-requests-fallback" class KLOVEScraper: station_key = "klove" display_name = "K-LOVE" url = "https://www.klove.com/music/songs" # K-LOVE is a JS-rendered React app; Selenium is the primary path. # The requests fallback may still return partial SSR content worth parsing. _LOAD_SELECTOR = "a[href*='/music/artists/']" @staticmethod def parse_html(html): songs, _pattern = KLOVEScraper.parse_html_with_telemetry(html) return songs @staticmethod def parse_html_with_telemetry(html): soup = BeautifulSoup(html, "html.parser") songs = [] seen = set() # Collect every anchor that points at /music/artists/{artist}/{song} # The page renders each card as two anchors (art + title) — use hrefs # as a dedup key so we only process each song once. processed_hrefs = set() for anchor in soup.find_all("a", href=re.compile(r"/music/artists/[^/]+/[^/]+")): href = anchor.get("href", "").strip().rstrip("/") parts = href.strip("/").split("/") # Expect: music / artists / artist-slug / song-slug if len(parts) < 4: continue title_text = anchor.get_text(" ", strip=True) if not title_text or len(title_text) < 2: # Image-only anchor (cover art link) — skip it; the title anchor # for this same href will appear later in the DOM with real text. continue # Now that we have a text-bearing anchor, dedup by href if href in processed_hrefs: continue processed_hrefs.add(href) # Skip navigation / album-only links (e.g. artist page without song) song_slug = parts[-1] artist_slug = parts[-2] if len(parts) >= 4 else "" # Find "By {artist}" text in nearby DOM — check siblings and parent artist_text = "" parent = anchor.parent if parent: full_text = parent.get_text(" ", strip=True) by_match = re.search(r"\bBy\s+(.+)", full_text) if by_match: artist_text = by_match.group(1).strip() if not artist_text and parent and parent.parent: grandparent_text = parent.parent.get_text(" ", strip=True) by_match = re.search(r"\bBy\s+(.+?)(?:\bBy\b|$)", grandparent_text) if by_match: artist_text = by_match.group(1).strip() if not artist_text: # Derive artist from URL slug as last resort artist_text = " ".join(w.capitalize() for w in artist_slug.split("-")) # Strip leftover boilerplate that sometimes bleeds in artist_text = re.sub(r"\s*(feat\.|ft\.|featuring|Play Sample|Image).*$", "", artist_text, flags=re.IGNORECASE).strip() if title_text and artist_text: key = (title_text.lower(), artist_text.lower()) if key not in seen: seen.add(key) songs.append({"title": title_text, "artist": artist_text, "source": KLOVEScraper.display_name}) if songs: return songs, "klove-anchor-by" return [], "none" @classmethod def scrape(cls, driver): driver.get(cls.url) # Wait for song anchor links to appear (JS-rendered) WebDriverWait(driver, 20).until( EC.presence_of_element_located((By.CSS_SELECTOR, cls._LOAD_SELECTOR)) ) time.sleep(2) # Allow lazy-load tiles to settle html = driver.page_source raw_path = _write_debug_payload(cls.station_key, html, "html") songs, parse_pattern = cls.parse_html_with_telemetry(html) return songs, raw_path, parse_pattern @classmethod def scrape_without_driver(cls): 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" ) } response = requests.get(cls.url, headers=headers, timeout=20) response.raise_for_status() html = response.text raw_path = _write_debug_payload(cls.station_key, html, "html") songs, parse_pattern = cls.parse_html_with_telemetry(html) return songs, raw_path, f"{parse_pattern}-requests-fallback" SCRAPERS = { JourneyFMScraper.station_key: JourneyFMScraper, SpiritFMScraper.station_key: SpiritFMScraper, KLOVEScraper.station_key: KLOVEScraper, } def build_driver(): options = Options() options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-gpu") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("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") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) chrome_binary = detect_chrome_binary() if chrome_binary: options.binary_location = chrome_binary chromedriver_path = detect_chromedriver_path() if chromedriver_path: service = Service(chromedriver_path) else: service = Service(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=options) def scrape_recently_played(selected_stations=None): selected_stations = selected_stations or ["journey_fm", "spirit_fm"] driver = None driver_start_error = "" all_songs = [] seen = set() station_results = [] try: try: driver = build_driver() except Exception as exc: driver = None driver_start_error = str(exc) logger.warning("Browser scraper unavailable; using fallback mode: %s", exc) for station_key in selected_stations: scraper_cls = SCRAPERS.get(station_key) if scraper_cls is None: station_results.append({ "station": station_key, "display_name": station_key, "success": False, "error": "unsupported-station", "scraped_count": 0, "raw_path": "", }) continue try: if driver is not None: try: songs, raw_path, parse_pattern = scraper_cls.scrape(driver) except Exception as scrape_error: logger.warning( "Selenium scrape failed for %s, retrying fallback mode: %s", scraper_cls.display_name, scrape_error, ) songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver() else: songs, raw_path, parse_pattern = scraper_cls.scrape_without_driver() raw_payload_bytes = 0 if raw_path and os.path.exists(raw_path): try: raw_payload_bytes = os.path.getsize(raw_path) except Exception: raw_payload_bytes = 0 added_for_station = 0 for song in songs: dedupe_key = (song["title"].lower(), song["artist"].lower()) if dedupe_key in seen: continue seen.add(dedupe_key) all_songs.append(song) added_for_station += 1 logger.info(" Found: %s by %s", song["title"], song["artist"]) station_results.append({ "station": station_key, "display_name": scraper_cls.display_name, "success": True, "error": "", "scraped_count": added_for_station, "raw_path": raw_path, "raw_payload_bytes": raw_payload_bytes, "parse_pattern": parse_pattern, }) except Exception as exc: combined_error = str(exc) if driver is None and driver_start_error: combined_error = f"{combined_error}; browser init: {driver_start_error}" station_results.append({ "station": station_key, "display_name": scraper_cls.display_name, "success": False, "error": combined_error, "scraped_count": 0, "raw_path": "", "raw_payload_bytes": 0, "parse_pattern": "error", }) logger.error("Error scraping %s: %s", scraper_cls.display_name, exc) finally: if driver is not None: driver.quit() logger.info("Total unique songs found: %s", len(all_songs)) return {"songs": all_songs, "station_results": station_results}