import os import re import json import hashlib import requests from urllib.parse import urljoin, urlparse from bs4 import BeautifulSoup from sqlalchemy.orm import Session from exa_py import Exa from firecrawl import Firecrawl from PIL import Image from app.config import settings from app.models.project import Project, ProjectStatus from app.models.asset import Asset, AssetType from app.services import r2_storage # Browser headers for image downloads and fallback scraping _BROWSER_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate", } # Minimum chars to consider a scrape successful _MIN_CONTENT_LENGTH = 50 # ─── Main entry point ───────────────────────────────────── def scrape_blog(project: Project, db: Session) -> Project: """ Scrape blog content and images from the project's blog_url. Scraping chain (first success wins): 1. Firecrawl (default — renders JS, handles SPAs) 2. requests + BeautifulSoup (free fallback for static sites) 3. Exa with livecrawl (last resort, headless browser) """ url = project.blog_url text = "" image_urls: list[str] = [] # ── Step 1: Firecrawl (default — handles JS/SPA sites) ── if settings.FIRECRAWL_API_KEY: try: text, image_urls = _scrape_with_firecrawl(url) if text and len(text.strip()) >= _MIN_CONTENT_LENGTH: print(f"[SCRAPER] Firecrawl succeeded ({len(text)} chars, {len(image_urls)} images)") else: print(f"[SCRAPER] Firecrawl returned thin content ({len(text.strip())} chars), trying requests...") text = "" except Exception as e: print(f"[SCRAPER] Firecrawl failed: {e}, trying requests...") # ── Step 2: requests + BeautifulSoup (free fallback) ── if not text or len(text.strip()) < _MIN_CONTENT_LENGTH: try: req_text, req_images = _scrape_with_requests(url) if req_text and len(req_text.strip()) >= _MIN_CONTENT_LENGTH: text = req_text image_urls = req_images print(f"[SCRAPER] requests succeeded ({len(text)} chars, {len(image_urls)} images)") else: print(f"[SCRAPER] requests returned thin content ({len(req_text.strip())} chars), trying Exa...") except Exception as e: print(f"[SCRAPER] requests failed: {e}, trying Exa...") # ── Step 3: Exa with livecrawl (last resort) ── if (not text or len(text.strip()) < _MIN_CONTENT_LENGTH) and settings.EXA_API_KEY: try: exa_text, exa_images = _scrape_with_exa(url) if exa_text and len(exa_text.strip()) >= _MIN_CONTENT_LENGTH: text = exa_text image_urls = exa_images print(f"[SCRAPER] Exa succeeded ({len(text)} chars, {len(image_urls)} images)") except Exception as e: print(f"[SCRAPER] Exa failed: {e}") if not text or len(text.strip()) < _MIN_CONTENT_LENGTH: raise ValueError( "Could not extract meaningful content from the URL. " "The site may require JavaScript rendering or the page may be empty." ) # Download images (only from the original blog page — no external sources) _download_images(project.user_id, project.id, image_urls, db) # Update project project.blog_content = text project.status = ProjectStatus.SCRAPED db.commit() db.refresh(project) return project # ─── Exa API scraping ───────────────────────────────────── def _scrape_with_exa(url: str) -> tuple[str, list[str]]: """ Use Exa API to get clean text content, HTML for code blocks, and image URLs from a URL. Exa handles Medium/Substack/paywalled sites. The hero/OG image is always first in the returned list. """ exa = Exa(api_key=settings.EXA_API_KEY) # Request HTML-tagged text (for code blocks + inline images) plus image_links. # Use livecrawl="preferred" so Exa tries a fresh headless-browser crawl first # (which executes JS → Medium/Substack images become visible), falling back # to cached content if the live crawl fails. result = exa.get_contents( urls=[url], text={"include_html_tags": True, "max_characters": 50000}, extras={"image_links": 40}, livecrawl="preferred", livecrawl_timeout=15000, # 15s timeout for live crawl ) if not result.results: raise ValueError("Exa returned no results") page = result.results[0] html_text = page.text or "" # If Exa returned very little content, the page might be paywalled/JS-rendered. # Retry with livecrawl="always" to force a fresh headless crawl. if len(html_text.strip()) < 500: print(f"[SCRAPER] Exa returned thin content ({len(html_text)} chars), retrying with forced livecrawl...") try: result2 = exa.get_contents( urls=[url], text={"include_html_tags": True, "max_characters": 50000}, extras={"image_links": 40}, livecrawl="always", livecrawl_timeout=30000, ) if result2.results and len((result2.results[0].text or "").strip()) > len(html_text.strip()): page = result2.results[0] html_text = page.text or "" print(f"[SCRAPER] Forced livecrawl got {len(html_text)} chars (better)") except Exception as e2: print(f"[SCRAPER] Forced livecrawl failed (using cached): {e2}") # --- Parse Exa's HTML (already scoped to article content) --- soup_exa = BeautifulSoup(html_text, "lxml") if "<" in html_text else None # Extract code blocks code_blocks: list[dict] = [] if soup_exa and (" bool: """Add an image URL if not duplicate. Returns True if added. trust_source=True skips _is_blog_image for Exa-curated results. """ if not img_url or img_url in seen_images: return False # Upgrade Medium URLs to max resolution if is_medium or "miro.medium.com" in img_url or "cdn-images" in img_url: img_url = _upgrade_medium_image_url(img_url) # Deduplicate by Medium image ID (same image at different sizes) mid = _extract_medium_image_id(img_url) if mid and mid in seen_image_ids: return False # For Exa-curated image_links, only reject obvious non-content if trust_source: if not _is_content_image_light_filter(img_url): return False else: if not _is_blog_image(img_url): return False if img_url in seen_images: return False image_urls.append(img_url) seen_images.add(img_url) if mid: seen_image_ids.add(mid) return True # 0. Medium JSON API — most reliable source for Medium images (no JS needed) if is_medium: medium_json_images = _extract_medium_images_via_json(url) for img_url in medium_json_images: _add_image(img_url, trust_source=True) # 1. Hero / OG image from Exa if hasattr(page, "image") and page.image: _add_image(page.image, trust_source=True) # 2. Exa extras.image_links — BEST source for Medium/Substack. # Exa's headless browser renders JS and extracts content images, # so these are already curated. Trust them with light filtering. if hasattr(page, "extras") and page.extras: exa_images = page.extras.get("image_links") or page.extras.get("imageLinks") or [] for img_url in exa_images: if isinstance(img_url, str): _add_image(img_url, trust_source=True) # 3. Inline images from Exa's article HTML if soup_exa: for img_url in _extract_all_image_srcs(soup_exa, url): _add_image(img_url, trust_source=True) # 4. Fallback: direct HTML fetch — for any images Exa missed # Always try for Medium since their images require JS rendering if len(image_urls) < 5 or is_medium: try: resp = requests.get(url, headers=_BROWSER_HEADERS, timeout=15) if resp.status_code == 200: soup = BeautifulSoup(resp.text, "lxml") # Get hero image if we still don't have one if not image_urls: og_img = _extract_og_image_from_soup(soup, url) if og_img: _add_image(og_img, trust_source=True) # Extract images from the article body body_images = _extract_article_image_urls(soup, url) for img_url in body_images: _add_image(img_url) # Medium-specific: also try
and