| | import requests |
| | import json |
| | import re |
| | from bs4 import BeautifulSoup |
| | from typing import List, Dict, Any, Tuple |
| | from utils import clean_time |
| |
|
| | def scrape_workshops_from_squarespace(url: str) -> List[Dict[str, str]]: |
| | """ |
| | Extract workshops using our robust Squarespace JSON + HTML parsing system |
| | """ |
| | headers = { |
| | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' |
| | } |
| | |
| | try: |
| | |
| | json_url = f"{url}?format=json" |
| | print(f"π Trying Squarespace JSON API: {json_url}") |
| | |
| | response = requests.get(json_url, headers=headers, timeout=10) |
| | if response.status_code == 200: |
| | try: |
| | json_data = response.json() |
| | workshops = extract_workshops_from_json(json_data, json_url) |
| | if workshops: |
| | print(f"β
Extracted {len(workshops)} workshops from JSON API") |
| | return workshops |
| | else: |
| | print("β No workshops found in JSON, falling back to HTML") |
| | except json.JSONDecodeError: |
| | print("β Invalid JSON response, falling back to HTML") |
| | |
| | |
| | print(f"π Falling back to HTML scraping for {url}") |
| | response = requests.get(url, headers=headers, timeout=10) |
| | response.raise_for_status() |
| | |
| | soup = BeautifulSoup(response.content, 'html.parser') |
| | workshops = parse_workshops_from_html(soup, url) |
| | |
| | if workshops: |
| | print(f"β
Extracted {len(workshops)} workshops from HTML parsing") |
| | return workshops |
| | else: |
| | print("β No workshops found in HTML") |
| | return [] |
| | |
| | except Exception as e: |
| | print(f"β Error scraping workshops from {url}: {e}") |
| | return [] |
| |
|
| | def extract_workshops_from_json(data: Any, source_url: str) -> List[Dict[str, str]]: |
| | """Extract workshop information from Squarespace JSON data""" |
| | workshops = [] |
| | |
| | |
| | if isinstance(data, dict) and 'mainContent' in data: |
| | main_content_html = data['mainContent'] |
| | if isinstance(main_content_html, str): |
| | print(f"π― Found mainContent HTML! Length: {len(main_content_html)} characters") |
| | |
| | soup = BeautifulSoup(main_content_html, 'html.parser') |
| | workshops = parse_workshops_from_html(soup, source_url) |
| | |
| | if workshops: |
| | return workshops |
| | |
| | return workshops |
| |
|
| | def parse_workshops_from_html(soup, source_url: str) -> List[Dict[str, str]]: |
| | """Enhanced HTML parsing specifically for workshop content""" |
| | workshops = [] |
| | workshop_texts = set() |
| | |
| | print(f"π ENHANCED HTML PARSING:") |
| | |
| | |
| | potential_containers = soup.find_all(['div', 'section', 'article'], |
| | attrs={'class': re.compile(r'(item|card|product|workshop|class)', re.I)}) |
| | |
| | print(f" Found {len(potential_containers)} potential workshop containers") |
| | |
| | for container in potential_containers: |
| | workshop_text = container.get_text(strip=True) |
| | |
| | if len(workshop_text) < 30 or workshop_text in workshop_texts: |
| | continue |
| | |
| | if any(keyword in workshop_text.lower() for keyword in ['with', 'casting', 'director', 'agent', 'perfect submission', 'crush the callback', 'get scene']): |
| | workshop = extract_single_workshop_from_text(workshop_text, source_url) |
| | if workshop and not is_duplicate_workshop(workshop, workshops): |
| | workshops.append(workshop) |
| | workshop_texts.add(workshop_text) |
| | |
| | |
| | all_text = soup.get_text() |
| | |
| | workshop_patterns = [ |
| | |
| | r'((?:The\s+)?(?:Perfect\s+Submission|Crush\s+the\s+Callback|Get\s+Scene\s+360?))\s+with\s+((?:Casting\s+Director|DDO\s+Agent|Manager|Director|Producer|Agent|Acting\s+Coach|Talent\s+Agent|Executive\s+Casting\s+Producer)\s+[A-Za-z\s]+?)\s+on\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r'((?:Atlanta\s+Models\s+&\s+Talent\s+President|Talent\s+Agent|Casting\s+Director|Manager|Director|Producer|Agent)\s+[A-Za-z\s]+?),\s+((?:The\s+)?(?:Perfect\s+Submission|Crush\s+the\s+Callback|Get\s+Scene\s+360?))\s+on\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r'(Casting\s+Director)\s+([A-Za-z\s\-]+?),\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*(?:at\s+)?([0-9:]+\s*(?:AM|PM))?', |
| | ] |
| | |
| | for i, pattern in enumerate(workshop_patterns): |
| | matches = re.findall(pattern, all_text, re.IGNORECASE) |
| | for match in matches: |
| | workshop = parse_refined_workshop_match(match, i+1, source_url) |
| | if workshop and not is_duplicate_workshop(workshop, workshops): |
| | workshops.append(workshop) |
| | |
| | print(f"π― TOTAL UNIQUE WORKSHOPS FOUND: {len(workshops)}") |
| | return workshops |
| |
|
| | def extract_single_workshop_from_text(text: str, source_url: str) -> Dict[str, str]: |
| | """Extract workshop info from a single text block""" |
| | |
| | |
| | text = re.sub(r'\$[0-9,]+\.00', '', text) |
| | text = re.sub(r'Featured|Sold Out', '', text, flags=re.IGNORECASE) |
| | text = re.sub(r'\s+', ' ', text).strip() |
| | text = re.sub(r'\n+', ' ', text) |
| | |
| | patterns = [ |
| | |
| | r'((?:The\s+)?(?:Perfect\s+Submission|Crush\s+the\s+Callback|Get\s+Scene\s+360?))\s+with\s+((?:Casting\s+Director|CD|DDO\s+Agent|Manager|Director|Producer|Agent|Acting\s+Coach|Talent\s+Agent|Executive\s+Casting\s+Producer|Atlanta\s+Models\s+&\s+Talent\s+President)\s+[A-Za-z\s\-]+?)\s+on\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r'((?:Atlanta\s+Models\s+&\s+Talent\s+President|Talent\s+Agent|Casting\s+Director|Casting\s+Associate|Manager|Director|Producer|Agent|Executive\s+Casting\s+Producer)\s+[A-Za-z\s\-]+?),\s+((?:The\s+)?(?:Perfect\s+Submission|Crush\s+the\s+Callback|Get\s+Scene\s+360?))\s+on\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r'(Casting\s+Director|Casting\s+Associate)\s+([A-Za-z\s\-]+?),\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*(?:at\s+)?([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r"([A-Za-z']+\s+(?:Executive\s+Casting\s+Producer|Studios\s+Casting\s+Associate))\s+([A-Za-z\s]+?)\s+(?:on\s+)?(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?", |
| | |
| | |
| | r'([A-Za-z\s]+)\s+(Agent|Talent)\s+([A-Za-z\s]+?)\s+(?:on\s+)?(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r'([A-Za-z\s]+\s+Talent),\s+([A-Za-z\s\.]+?),\s+((?:The\s+)?(?:Perfect\s+Submission|Crush\s+the\s+Callback|Get\s+Scene\s+360?))\s+on\s+(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?', |
| | |
| | |
| | r'^([A-Za-z\s&\']{3,25}(?:Director|Agent|Manager|Producer|President|Coach))\s+([A-Za-z\s\-]{3,30}?)\s+(?:on\s+)?(\w+\s+\d+(?:st|nd|rd|th)?)\s*[@\s]*([0-9:]+\s*(?:AM|PM))?$' |
| | ] |
| | |
| | for i, pattern in enumerate(patterns): |
| | match = re.search(pattern, text, re.IGNORECASE) |
| | if match: |
| | return parse_pattern_match(match, i, source_url) |
| | |
| | return None |
| |
|
| | def parse_pattern_match(match, pattern_index: int, source_url: str) -> Dict[str, str]: |
| | """Parse a regex match or tuple based on pattern type""" |
| | |
| | def get_grp(m, idx): |
| | val = "" |
| | if hasattr(m, 'group'): |
| | try: |
| | val = m.group(idx) |
| | except IndexError: |
| | val = "" |
| | |
| | |
| | elif isinstance(m, (tuple, list)): |
| | if 0 <= idx-1 < len(m): |
| | val = m[idx-1] |
| | |
| | return val if val is not None else "" |
| |
|
| | |
| | workshop_title = "" |
| | instructor_title = "" |
| | instructor_name = "" |
| | date_str = "" |
| | time_str = "" |
| |
|
| | try: |
| | if pattern_index == 0: |
| | workshop_title = get_grp(match, 1).strip() |
| | professional_full = get_grp(match, 2).strip() |
| | date_str = get_grp(match, 3).strip() |
| | time_str = get_grp(match, 4).strip() |
| | |
| | if professional_full.startswith('CD '): |
| | professional_full = 'Casting Director ' + professional_full[3:] |
| | |
| | instructor_title, instructor_name = parse_professional_info(professional_full) |
| | |
| | elif pattern_index == 1: |
| | professional_full = get_grp(match, 1).strip() |
| | workshop_title = get_grp(match, 2).strip() |
| | date_str = get_grp(match, 3).strip() |
| | time_str = get_grp(match, 4).strip() |
| | |
| | instructor_title, instructor_name = parse_professional_info(professional_full) |
| | |
| | elif pattern_index == 2: |
| | instructor_title = get_grp(match, 1).strip() |
| | instructor_name = get_grp(match, 2).strip() |
| | date_str = get_grp(match, 3).strip() |
| | time_str = get_grp(match, 4).strip() |
| | workshop_title = "Casting Workshop" |
| | |
| | elif pattern_index == 3: |
| | instructor_title = get_grp(match, 1).strip() |
| | instructor_name = get_grp(match, 2).strip() |
| | date_str = get_grp(match, 3).strip() |
| | time_str = get_grp(match, 4).strip() |
| | workshop_title = "Industry Workshop" |
| | |
| | elif pattern_index == 4: |
| | company_name = get_grp(match, 1).strip() |
| | agent_type = get_grp(match, 2).strip() |
| | instructor_name = get_grp(match, 3).strip() |
| | date_str = get_grp(match, 4).strip() |
| | time_str = get_grp(match, 5).strip() |
| | |
| | instructor_title = f"{company_name} {agent_type}" |
| | workshop_title = "Industry Workshop" |
| | |
| | elif pattern_index == 5: |
| | company_name = get_grp(match, 1).strip() |
| | instructor_name = get_grp(match, 2).strip() |
| | workshop_title = get_grp(match, 3).strip() |
| | date_str = get_grp(match, 4).strip() |
| | time_str = get_grp(match, 5).strip() |
| | |
| | instructor_title = company_name |
| | |
| | else: |
| | professional_full = get_grp(match, 1).strip() + " " + get_grp(match, 2).strip() |
| | date_str = get_grp(match, 3).strip() |
| | time_str = get_grp(match, 4).strip() |
| | workshop_title = "Industry Workshop" |
| | |
| | if len(professional_full) > 50 or '\n' in professional_full: |
| | return None |
| | |
| | instructor_title, instructor_name = parse_professional_info(professional_full) |
| | |
| | if instructor_name and date_str: |
| | |
| | full_text = f"{workshop_title} with {instructor_title} {instructor_name}" |
| | if date_str: |
| | full_text += f" on {date_str}" |
| | if time_str: |
| | full_text += f" at {clean_time(time_str)}" |
| | |
| | return { |
| | 'title': workshop_title, |
| | 'instructor_name': instructor_name, |
| | 'instructor_title': instructor_title, |
| | 'date': date_str, |
| | 'time': clean_time(time_str), |
| | 'full_text': full_text, |
| | 'source_url': source_url |
| | } |
| | |
| | except Exception as e: |
| | print(f"Error parsing pattern match: {e}") |
| | |
| | return None |
| |
|
| | def parse_professional_info(professional_full: str) -> tuple: |
| | """Parse professional title and name from full string""" |
| | |
| | professional_full = re.sub(r'\s+', ' ', professional_full).strip() |
| | |
| | |
| | specific_titles = [ |
| | 'Atlanta Models & Talent President', |
| | 'Executive Casting Producer', |
| | 'Casting Director', |
| | 'Casting Associate', |
| | 'DDO Agent', |
| | 'Talent Agent', |
| | 'Acting Coach' |
| | ] |
| | |
| | for title in specific_titles: |
| | if title in professional_full: |
| | title_pos = professional_full.find(title) |
| | |
| | if title_pos == 0: |
| | name_part = professional_full[len(title):].strip() |
| | return title, name_part |
| | else: |
| | name_part = professional_full[:title_pos].strip().rstrip(',') |
| | return title, name_part |
| | |
| | |
| | single_word_titles = ['Manager', 'Director', 'Producer', 'Agent', 'Coach', 'President'] |
| | |
| | words = professional_full.split() |
| | for i, word in enumerate(words): |
| | if word in single_word_titles: |
| | if i > 0 and words[i-1] in ['Casting', 'Talent', 'Executive', 'DDO', 'Acting']: |
| | title = f"{words[i-1]} {word}" |
| | name_parts = words[:i-1] + words[i+1:] |
| | else: |
| | title = word |
| | name_parts = words[:i] + words[i+1:] |
| | |
| | name = ' '.join(name_parts).strip() |
| | return title, name |
| | |
| | |
| | if len(words) >= 2: |
| | return words[0], ' '.join(words[1:]) |
| | |
| | return '', professional_full |
| |
|
| | def parse_refined_workshop_match(match, pattern_num: int, source_url: str) -> Dict[str, str]: |
| | """Parse a regex match into a clean workshop dictionary""" |
| | return parse_pattern_match(match, pattern_num-1, source_url) |
| |
|
| | def is_duplicate_workshop(new_workshop: Dict, existing_workshops: List[Dict]) -> bool: |
| | """Enhanced duplicate detection""" |
| | for existing in existing_workshops: |
| | if (existing.get('instructor_name', '').strip().lower() == new_workshop.get('instructor_name', '').strip().lower() and |
| | existing.get('date', '').strip().lower() == new_workshop.get('date', '').strip().lower()): |
| | |
| | existing_title = existing.get('title', '').strip().lower() |
| | new_title = new_workshop.get('title', '').strip().lower() |
| | |
| | if (existing_title == new_title or |
| | 'workshop' in existing_title and 'workshop' in new_title or |
| | existing_title in new_title or new_title in existing_title): |
| | return True |
| | return False |
| |
|
| | def calculate_workshop_confidence(w: Dict) -> float: |
| | """Calculate confidence score of retrieved workshop data""" |
| | score = 0.0 |
| | if w.get('title'): score += 0.3 |
| | if w.get('instructor_name'): score += 0.3 |
| | if w.get('date'): score += 0.2 |
| | if w.get('time'): score += 0.1 |
| | if w.get('source_url'): score += 0.1 |
| | return round(score, 2) |