168 lines
7 KiB
Text
168 lines
7 KiB
Text
|
|
"""
|
|||
|
|
TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON)
|
|||
|
|
---------------------------------------------------------------------------
|
|||
|
|
Går til oppgitte medlemskaps-URLer (støtter flere URLer adskilt med komma),
|
|||
|
|
henter ut tekst, og bruker Gemini til å summere og finne 'Standard' og
|
|||
|
|
'Rimeligste' medlemskap.
|
|||
|
|
---------------------------------------------------------------------------
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
load_dotenv()
|
|||
|
|
|
|||
|
|
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
|
|||
|
|
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:
|
|||
|
|
"""Bruker Playwright for å hente all synlig tekst fra EN nettside."""
|
|||
|
|
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()
|
|||
|
|
|
|||
|
|
text = soup.get_text(separator=' ', strip=True)
|
|||
|
|
return text
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f" ❌ Feil ved lasting av {url}: {e}")
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
def analyze_with_gemini(text: str, club_name: str) -> dict:
|
|||
|
|
"""Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser."""
|
|||
|
|
print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...")
|
|||
|
|
|
|||
|
|
prompt = f"""
|
|||
|
|
Du er en ekspert på norske golfklubber. Din oppgave er å lese teksten hentet fra nettsidene til "{club_name}" og finne to spesifikke priser.
|
|||
|
|
|
|||
|
|
VIKTIG REGEL OM NORSK GOLF:
|
|||
|
|
Mange steder er "Klubbkontingent/Medlemskap" og "Spillerett/Årskort" to forskjellige ting.
|
|||
|
|
For å få spille ubegrenset (Fritt spill) MÅ man betale BEGGE DELER. Hvis du ser at prisene for kontingent og spillerett er oppgitt hver for seg, SKAL DU SUMMERE disse to summene og bruke totalen som "Standard pris".
|
|||
|
|
|
|||
|
|
ALDERSPREMISS FOR BEGGE PRISER:
|
|||
|
|
Vi forutsetter at personen som skal ha medlemskap er en VOKSEN GOLFER PÅ MINST 35 ÅR. Du må ALDRI velge priser som gjelder for barn, junior, ung voksen (f.eks. 20-29 år), student eller senior/pensjonist.
|
|||
|
|
|
|||
|
|
DEFINISJONER DU MÅ FØLGE STRENGT:
|
|||
|
|
1. "Standard medlemskap": Hva er TOTALPRISEN (inkludert evt. spillerett/årskort) for en voksen person (35+ år) for å spille SÅ MYE VEDKOMMENDE ØNSKER (Fritt spill) i år?
|
|||
|
|
2. "Rimeligste alternativ": Det absolutt billigste alternativet FOR EN VOKSEN PERSON (35+ år) som gir medlemskap i klubben (golfkortet), forutsatt at man betaler greenfee for hver runde. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem, eller kun "Klubbkontingent for voksne" uten spillerett).
|
|||
|
|
|
|||
|
|
TEKST FRA NETTSIDEN(E):
|
|||
|
|
{text}
|
|||
|
|
|
|||
|
|
OPPGAVE:
|
|||
|
|
Returner KUN et gyldig JSON-objekt med følgende struktur:
|
|||
|
|
{{
|
|||
|
|
"foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)",
|
|||
|
|
"foreslatt_standard_pris": 1234,
|
|||
|
|
"foreslatt_standard_kommentar": "Kort kommentar (eks: Måtte summere kontingent på 900 og årskort på 5000)",
|
|||
|
|
"foreslatt_rimeligste_navn": "Navn (eks: Greenfeemedlemskap Voksen)",
|
|||
|
|
"foreslatt_rimeligste_pris": 500,
|
|||
|
|
"ai_begrunnelse": "Kort forklaring på utregningen din."
|
|||
|
|
}}
|
|||
|
|
Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke finner det.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
response = model.generate_content(prompt)
|
|||
|
|
raw_response = response.text.strip()
|
|||
|
|
|
|||
|
|
if raw_response.startswith("```json"):
|
|||
|
|
raw_response = raw_response[7:]
|
|||
|
|
if raw_response.endswith("```"):
|
|||
|
|
raw_response = raw_response[:-3]
|
|||
|
|
|
|||
|
|
return json.loads(raw_response.strip())
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f" ❌ AI-analyse feilet: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def run_scraper(facility_ids=None):
|
|||
|
|
print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...")
|
|||
|
|
conn = await asyncpg.connect(DB_URL)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''"
|
|||
|
|
if facility_ids:
|
|||
|
|
query += f" AND id IN ({','.join(map(str, facility_ids))})"
|
|||
|
|
|
|||
|
|
facilities = await conn.fetch(query)
|
|||
|
|
print(f"📋 Fant {len(facilities)} anlegg å skrape.")
|
|||
|
|
|
|||
|
|
async with async_playwright() as p:
|
|||
|
|
browser = await p.chromium.launch(headless=True)
|
|||
|
|
|
|||
|
|
for facility in facilities:
|
|||
|
|
fac_id = facility['id']
|
|||
|
|
name = facility['name']
|
|||
|
|
urls_raw = facility['medlemskap_url']
|
|||
|
|
|
|||
|
|
print(f"\n▶️ Behandler: {name} (ID: {fac_id})")
|
|||
|
|
|
|||
|
|
# Sjekker om det er flere URL-er adskilt med komma
|
|||
|
|
urls = [u.strip() for u in urls_raw.split(',')]
|
|||
|
|
combined_text = ""
|
|||
|
|
|
|||
|
|
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.")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks)
|
|||
|
|
draft_data = analyze_with_gemini(combined_text[:25000], name)
|
|||
|
|
|
|||
|
|
if not draft_data:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}")
|
|||
|
|
|
|||
|
|
await conn.execute("""
|
|||
|
|
UPDATE facilities
|
|||
|
|
SET membership_draft = $1::jsonb
|
|||
|
|
WHERE id = $2
|
|||
|
|
""", json.dumps(draft_data), fac_id)
|
|||
|
|
|
|||
|
|
print(" 💾 Utkast lagret i databasen!")
|
|||
|
|
|
|||
|
|
await browser.close()
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
await conn.close()
|
|||
|
|
print("\n🏁 Skraping fullført.")
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
parser = argparse.ArgumentParser(description="Skrap medlemskapspriser 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_scraper(ids_to_scrape))
|