Nye-TeeOff/backend/scrape_vtg.py

284 lines
12 KiB
Python

"""
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))