""" TEE OFF - VEIEN TIL GOLF (VTG) SKRAPER MED GEMINI AI --------------------------------------------------------------------------- Henter pris, beskrivelse (inkl. lånekøller/medlemskap/pakker) og kursdatoer fra VTG-sider. Støtter kommaseparerte URL-er. --------------------------------------------------------------------------- """ import asyncio import asyncpg import os import json import argparse from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() DB_URL = get_database_url() GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") if not GEMINI_API_KEY: raise ValueError("🚨 GEMINI_API_KEY mangler i .env filen!") genai.configure(api_key=GEMINI_API_KEY) model = genai.GenerativeModel('gemini-2.5-flash') async def fetch_page_text(url: str, browser) -> str: url = url.strip() if not url.startswith("http"): return "" print(f" 🌐 Laster inn: {url}") try: page = await browser.new_page() await page.goto(url, wait_until="domcontentloaded", timeout=15000) html_content = await page.content() await page.close() soup = BeautifulSoup(html_content, 'html.parser') for script in soup(["script", "style", "nav", "footer", "header"]): script.extract() return soup.get_text(separator=' ', strip=True) except Exception as e: print(f" ❌ Feil ved lasting av {url}: {e}") return "" def analyze_vtg_with_gemini(text: str, club_name: str) -> dict | None: print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...") prompt = f""" Du er en ekspert på norske golfklubber. Din oppgave er å lese en lang tekst fra nettsidene til "{club_name}" og koke dette ned til essensen om deres "Veien til Golf" (VTG) nybegynnerkurs. OPPGAVER: 1. Finn standardprisen for VTG-kurset for en vanlig voksen person. (Returner KUN tallet). VIKTIG PRISLOGIKK: - Hvis klubben tilbyr både "kun VTG-kurs" og en pakke som inkluderer medlemskap/spillerett, skal du velge prisen på KUN SELVE KURSET som foreslatt_vtg_pris. - Hvis klubben tilbyr et rimeligere kurs uten medlemskap, men også en dyrere pakke med medlemskap, er det alltid kursprisen uten medlemskap som skal returneres. - Hvis klubben BARE tilbyr en samlet pakke med VTG + medlemskap/spillerett, returnerer du pakkeprisen. - Ignorer medlemskapstilbud som ikke faktisk er knyttet til VTG-kurset. - Ignorer priser for barn, junior, student, familie eller andre spesialgrupper hvis det finnes en vanlig voksenpris. 2. Skriv en KOMPRIMERT, selgende beskrivelse (maks 3-4 setninger). Du MÅ inkludere informasjon om: - Inkluderer prisen et medlemskap/spillerett i klubben (og ev. for hvor lenge)? - Hvis klubben tilbyr både kurs uten medlemskap og en egen pakke med medlemskap, må du nevne dette eksplisitt og oppgi pakkeprisen hvis den finnes i teksten. - Hva er omfanget? (F.eks. "12 timer praksis pluss e-læring"). Ignorer uvesentlig støy og lange historiske utgreiinger. 3. Finn alle kommende kursdatoer. Finn startdato/sluttdato for hvert kurs, og noter status ("Ledig", "Fulltegnet", "Venteliste"). TEKST FRA NETTSIDEN: {text} OPPGAVE: Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur: {{ "foreslatt_vtg_pris": 1990, "foreslatt_vtg_beskrivelse": "Kurset går over 12 timer inkludert obligatorisk e-læring, og lån av golfkøller er inkludert. Selve VTG-kurset koster 1990 kroner. Klubben tilbyr også en pakke med kurs og medlemskap ut året til 3490 kroner.", "foreslatt_vtg_datoer": [ {{"dato": "12.-14. mai", "status": "Fulltegnet"}}, {{"dato": "5.-7. juni", "status": "Ledig"}} ], "ai_begrunnelse": "Fant voksenpris på 1990 kroner for selve VTG-kurset. Det stod også at klubben har en egen pakke med medlemskap til 3490 kroner, samt at lån av utstyr er inkludert." }} Merk: - Sett foreslatt_vtg_pris til null (null) hvis du ikke finner en tydelig voksenpris. - Hvis du ikke finner datoer, la listen være tom []. - Hvis prisen du returnerer faktisk er en pakkepris med medlemskap, må det sies tydelig i foreslatt_vtg_beskrivelse og ai_begrunnelse. """ try: response = model.generate_content(prompt) parsed = parse_llm_json(response.text) return parsed if isinstance(parsed, dict) else None except Exception as e: print(f" ❌ AI-analyse feilet: {e}") return None async def run_vtg_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None): print("🚀 Starter Veien til Golf (VTG) skraperen...") conn = await asyncpg.connect(DB_URL) facilities = [] analyzed_count = 0 saved_count = 0 skipped_count = 0 failed_count = 0 try: query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''" if facility_ids: query += f" AND id IN ({','.join(map(str, facility_ids))})" facilities = await conn.fetch(query) total_facilities = len(facilities) print(f"📋 Fant {total_facilities} anlegg å skrape.") await emit_progress( progress_callback, progress_total=total_facilities, progress_completed=0, progress_ok=0, progress_failed=0, progress_skipped=0, event=make_progress_event( facility_id=None, facility_name="VTG", outcome="info", message=f"Starter VTG-skraping for {total_facilities} anlegg.", processed=0, total=total_facilities, ), ) async with async_playwright() as p: browser = await p.chromium.launch(headless=True) for index, facility in enumerate(facilities, start=1): fac_id = facility['id'] name = facility['name'] urls_raw = facility['vtg_lenke'] print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})") await emit_progress( progress_callback, current_facility_id=fac_id, current_facility_name=name, event=make_progress_event( facility_id=fac_id, facility_name=name, outcome="info", message="Starter henting av VTG-sider.", processed=index - 1, total=total_facilities, ), ) urls = [u.strip() for u in urls_raw.split(',')] combined_text = "" try: for idx, url in enumerate(urls, 1): page_text = await fetch_page_text(url, browser) if page_text: combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" if len(combined_text) < 50: print(" ⚠️ Fant for lite tekst, hopper over.") skipped_count += 1 await emit_progress( progress_callback, progress_completed=index, progress_ok=saved_count, progress_failed=failed_count, progress_skipped=skipped_count, current_facility_id=fac_id, current_facility_name=name, event=make_progress_event( facility_id=fac_id, facility_name=name, outcome="warning", message="Hoppet over fordi det ble funnet for lite tekst på VTG-sidene.", processed=index, total=total_facilities, ), ) continue draft_data = analyze_vtg_with_gemini(combined_text[:25000], name) if not draft_data: failed_count += 1 await emit_progress( progress_callback, progress_completed=index, progress_ok=saved_count, progress_failed=failed_count, progress_skipped=skipped_count, current_facility_id=fac_id, current_facility_name=name, event=make_progress_event( facility_id=fac_id, facility_name=name, outcome="error", message="AI-analysen ga ikke et gyldig VTG-utkast.", processed=index, total=total_facilities, ), ) continue analyzed_count += 1 found_dates = len(draft_data.get('foreslatt_vtg_datoer', [])) print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {found_dates} datoer.") await conn.execute(""" UPDATE facilities SET vtg_draft = $1::jsonb WHERE id = $2 """, json.dumps(draft_data), fac_id) print(" 💾 VTG-utkast lagret i databasen!") saved_count += 1 await emit_progress( progress_callback, progress_completed=index, progress_ok=saved_count, progress_failed=failed_count, progress_skipped=skipped_count, current_facility_id=fac_id, current_facility_name=name, event=make_progress_event( facility_id=fac_id, facility_name=name, outcome="success", message=f"Utkast lagret med pris {draft_data.get('foreslatt_vtg_pris') or 'ukjent'} og {found_dates} kursdatoer.", processed=index, total=total_facilities, ), ) except Exception as e: failed_count += 1 print(f" ❌ Uventet feil for {name}: {e}") await emit_progress( progress_callback, progress_completed=index, progress_ok=saved_count, progress_failed=failed_count, progress_skipped=skipped_count, current_facility_id=fac_id, current_facility_name=name, event=make_progress_event( facility_id=fac_id, facility_name=name, outcome="error", message=f"Feilet under behandling: {str(e).splitlines()[0]}", processed=index, total=total_facilities, ), ) await browser.close() finally: await conn.close() print("\n🏁 Skraping fullført.") return { "processed_facilities": len(facilities), "analyzed_facilities": analyzed_count, "saved_drafts": saved_count, "skipped_facilities": skipped_count, "failed_facilities": failed_count, } if __name__ == "__main__": parser = argparse.ArgumentParser(description="Skrap VTG via AI.") parser.add_argument("--ids", type=str, help="Kommaseparert liste med facility IDs (eks: 1,5,12)") args = parser.parse_args() ids_to_scrape = None if args.ids: ids_to_scrape = [int(x.strip()) for x in args.ids.split(",")] asyncio.run(run_vtg_scraper(ids_to_scrape))