diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 020672b..3831787 100644 Binary files a/backend/__pycache__/main.cpython-311.pyc and b/backend/__pycache__/main.cpython-311.pyc differ diff --git a/backend/main.py b/backend/main.py index 502f1ef..35b6ee6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,6 +62,10 @@ class MembershipDraftApproval(BaseModel): class BulkApprovalRequest(BaseModel): approvals: List[MembershipDraftApproval] +class QuickEditRequest(BaseModel): + field: str + value: str + # --- FUNKSJONER --- def format_row(row): """ @@ -137,6 +141,17 @@ def run_scrape_worker(facility_ids: List[int]): except Exception as e: print(f"đ„ UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}") +def run_membership_worker(facility_ids: List[int]): + """KjĂžrer medlemskap-skraperen i bakgrunnen.""" + print(f"đ STARTER MEDLEMSKAP-SKRAPING FOR IDER: {facility_ids}") + try: + ids_arg = ",".join(map(str, facility_ids)) + command = f"python -u scrape_membership.py --ids {ids_arg}" + subprocess.run(command, shell=True, check=True) + print(f"â MEDLEMSKAP-SKRAPING FULLFĂRT FOR IDER: {facility_ids}") + except Exception as e: + print(f"đ„ FEIL UNDER MEDLEMSKAP-SKRAPING: {e}") + @asynccontextmanager async def lifespan(app: FastAPI): @@ -420,6 +435,17 @@ async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: Back return {"status": "queued", "message": f"Skraping for {len(request.facility_ids)} anlegg ble lagt i kĂž."} +@app.post("/api/admin/run-membership-scraper") +async def run_membership_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): + """Tar imot IDer for medlemskapsskraping og legger jobben i kĂž.""" + if not request.facility_ids: + raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") + + print(f"đĄ API mottok forespĂžrsel om medlemskapsskraping for IDer: {request.facility_ids}") + background_tasks.add_task(run_membership_worker, request.facility_ids) + + return {"status": "queued", "message": f"Medlemskapsskraping for {len(request.facility_ids)} anlegg ble lagt i kĂž."} + @app.get("/api/health") async def health_check(): """Enkel sjekk for Ă„ se at API og DB lever.""" @@ -473,6 +499,20 @@ async def approve_membership_bulk(request: BulkApprovalRequest): approval.facility_id) return {"status": "success", "message": f"{len(request.approvals)} anlegg ble oppdatert med nye priser!"} +@app.patch("/api/admin/facilities/{facility_id}/quick-edit") +async def quick_edit_facility(facility_id: int, request: QuickEditRequest): + """Lyn-redigering av enkle URL-felter fra admin-dashbordet.""" + # Sikkerhet: Tillat KUN disse tre feltene for hurtigredigering + allowed_fields = ['scrape_status_url', 'medlemskap_url', 'scrape_status_selector'] + if request.field not in allowed_fields: + raise HTTPException(status_code=400, detail="Ugyldig felt for hurtigredigering.") + + async with app.state.pool.acquire() as conn: + # F-string her er trygt fordi request.field er sjekket mot allowed_fields-listen + await conn.execute(f"UPDATE facilities SET {request.field} = $1 WHERE id = $2", + request.value, facility_id) + return {"status": "success"} + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index f9986c5..6204e3c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,5 @@ python-dotenv python-jose[cryptography] passlib[bcrypt] pyotp -google-genai \ No newline at end of file +google-genai +google-generativeai \ No newline at end of file diff --git a/backend/scrape_greenfee.py b/backend/scrape_greenfee.py new file mode 100644 index 0000000..edf796f --- /dev/null +++ b/backend/scrape_greenfee.py @@ -0,0 +1,173 @@ +""" +TEE OFF - GREENFEE-SKRAPER MED GEMINI AI +--------------------------------------------------------------------------- +Henter alle greenfee-varianter fra en (eller flere) URL-er og strukturerer +dem i en JSON-liste. Finner ogsĂ„ avtaleklubber/vennskapsklubber. +--------------------------------------------------------------------------- +""" + +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: + 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_greenfee_with_gemini(text: str, club_name: str) -> dict: + print(f" đ§ Sender {len(text)} tegn til Gemini for greenfee-analyse...") + + prompt = f""" +Du er en ekspert pĂ„ norske golfklubber og prissetting. +Din oppgave er Ă„ lese teksten hentet fra nettsidene til "{club_name}" og hente ut TO ting: +1. ALLE varianter av greenfee-priser. +2. Navn pĂ„ eventuelle vennskapsklubber/avtaleklubber (hvis nevnt). + +REGLER FOR GREENFEE: +- Trekk ut absolutt alle priskategorier du finner (f.eks. "Hverdag hĂžysesong", "Helg fĂžr kl 14", "Gjest av medlem", "9 hull kveld", osv.). +- Finn bĂ„de voksenpris og juniorpris for hver kategori. +- HVIS juniorpris er oppgitt som en regel (f.eks. "Juniorer betaler halv pris" eller "50% rabatt for junior"), MĂ du selv regne ut prisen og skrive inn heltallet. +- "banenavn": Bruk navnet pĂ„ banen hvis det er spesifisert (f.eks. "18-hullsbanen", "Korthullsbanen"). Hvis ikke spesifisert, bruk "{club_name}". +- Priser SKAL vĂŠre tall (integer). Sett pris til null (null) hvis den ikke finnes. + +REGLER FOR AVTALEKLUBBER: +- Let etter overskrifter som "Vennskapsklubber", "Avtaleklubber", "Gjestespill", "Samarbeidsklubber". +- Trekk ut kun navnene pĂ„ klubbene i en liste (f.eks. ["Haga GK", "Oslo GK"]). La listen vĂŠre tom hvis du ikke finner noen. + +TEKST FRA NETTSIDEN: +{text} + +OPPGAVE: +Returner KUN et gyldig JSON-objekt med nĂžyaktig fĂžlgende struktur: +{{ + "foreslatt_greenfee": [ + {{ + "banenavn": "Navn pĂ„ banen", + "priskategori": "F.eks: Hverdag Gjest av Medlem", + "pris_voksne": 600, + "pris_junior": 300 + }} + ], + "foreslatt_avtaleklubber": [ + "Klubb 1 GK", + "Klubb 2 GK" + ], + "ai_begrunnelse": "Kort forklaring, f.eks: 'Fant et komplekst prissystem for hĂžy/lavsesong. Regnet ut juniorpriser til 50% som angitt i teksten. Fant 3 samarbeidsklubber nederst.'" +}} +""" + + 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_greenfee_scraper(facility_ids=None): + print("đ Starter Greenfee-skraperen...") + conn = await asyncpg.connect(DB_URL) + + try: + query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_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['greenfee_url'] + + print(f"\nâ¶ïž Behandler Greenfee for: {name} (ID: {fac_id})") + + 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 + + draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name) + + if not draft_data: + continue + + funnet_priser = len(draft_data.get('foreslatt_greenfee', [])) + funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', [])) + print(f" â AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.") + + await conn.execute(""" + UPDATE facilities + SET greenfee_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" đŸ Greenfee-utkast lagret i databasen!") + + await browser.close() + + finally: + await conn.close() + print("\nđ Skraping fullfĂžrt.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Skrap greenfeepriser 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_greenfee_scraper(ids_to_scrape)) \ No newline at end of file diff --git a/backend/scrape_membership.py b/backend/scrape_membership.py index f177dcd..2fb99fe 100644 --- a/backend/scrape_membership.py +++ b/backend/scrape_membership.py @@ -1,9 +1,9 @@ """ -TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI +TEE OFF - MEDLEMSKAPSSKRAPER MED GEMINI AI (MULTI-URL VERSJON) --------------------------------------------------------------------------- -GĂ„r til oppgitte medlemskaps-URLer, henter ut tekst, og bruker Gemini til Ă„ -finne 'Standard' og 'Rimeligste' medlemskap basert pĂ„ TeeOffs definisjoner. -Lagrer resultatet som et utkast i databasen (membership_draft). +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. --------------------------------------------------------------------------- """ @@ -17,7 +17,6 @@ from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv -# Last inn miljĂžvariabler load_dotenv() DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") @@ -26,72 +25,70 @@ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") if not GEMINI_API_KEY: raise ValueError("đš GEMINI_API_KEY mangler i .env filen!") -# Konfigurer Gemini genai.configure(api_key=GEMINI_API_KEY) -model = genai.GenerativeModel('gemini-2.5-flash') # Eller gemini-1.5-pro avhengig av hva du har tilgang til +model = genai.GenerativeModel('gemini-2.5-flash') -async def fetch_page_text(url: str) -> str: - """Bruker Playwright for Ă„ hente all synlig tekst fra nettsiden.""" +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: - async with async_playwright() as p: - browser = await p.chromium.launch(headless=True) - page = await browser.new_page() - # Setter timeout til 15 sekunder - await page.goto(url, wait_until="domcontentloaded", timeout=15000) + 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() - # Hent hele HTML-innholdet - html_content = await page.content() - await browser.close() - - # Bruk BeautifulSoup til Ă„ renske ut bare den synlige teksten - soup = BeautifulSoup(html_content, 'html.parser') - # Fjern script og style tags - for script in soup(["script", "style", "nav", "footer", "header"]): - script.extract() - - text = soup.get_text(separator=' ', strip=True) - - # Begrens teksten slik at vi ikke sprenger token-grensen til AI (f.eks max 15000 tegn) - return text[:15000] + text = soup.get_text(separator=' ', strip=True) + return text except Exception as e: - print(f" â Feil ved lasting av side: {e}") + print(f" â Feil ved lasting av {url}: {e}") return "" def analyze_with_gemini(text: str, club_name: str) -> dict: - """Sender teksten til Gemini for Ă„ trekke ut priser.""" + """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 og medlemskap. -Din oppgave er Ă„ lese teksten hentet fra nettsiden til "{club_name}" og trekke ut to spesifikke medlemskapspriser. +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 vil det koste for en gjennomsnittsgolfer (voksen over 25/30 Ă„r, ikke student/senior) Ă„ spille SĂ RYE VEDKOMMENDE ĂNSKER (Fritt spill) pĂ„ denne banen i Ă„r? -2. "Rimeligste alternativ": Det absolutt billigste medlemskapet som gir medlemskap i klubben (golfkortet), forutsatt at man aksepterer Ă„ mĂ„tte betale greenfee for hver runde man spiller. (Ofte kalt Greenfeemedlem, Postkassemedlem, Fjernmedlem el.l.) +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: +TEKST FRA NETTSIDEN(E): {text} OPPGAVE: -Returner KUN et gyldig JSON-objekt med fĂžlgende struktur (og ingenting annet, ingen markdown): +Returner KUN et gyldig JSON-objekt med fĂžlgende struktur: {{ - "foreslatt_standard_navn": "Navnet pĂ„ medlemskapet (eks: Hovedmedlem Voksen)", + "foreslatt_standard_navn": "Navn (eks: Hovedmedlem Voksen inkl. spillerett)", "foreslatt_standard_pris": 1234, - "foreslatt_standard_kommentar": "Kort evt kommentar (eks: Inkluderer ikke 500kr i dugnadsavgift)", - "foreslatt_rimeligste_navn": "Navnet (eks: Greenfeemedlemskap)", + "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Ă„ hvorfor du valgte disse to, f.eks: 'Valgte Hovedmedlem for fritt spill og Greenfeemedlem fordi...'." + "ai_begrunnelse": "Kort forklaring pĂ„ utregningen din." }} - -Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Prisen SKAL vĂŠre et tall (integer), ikke en tekststreng (bruk 6500, ikke "6 500"). +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() - # Rensker vekk eventuell markdown-formatering som ```json if raw_response.startswith("```json"): raw_response = raw_response[7:] if raw_response.endswith("```"): @@ -103,13 +100,10 @@ Merk: Hvis prisene mangler, sett pris til null og skriv "Fant ikke" i navnet. Pr return None async def run_scraper(facility_ids=None): - """Hovedfunksjon som henter fra DB, skraper, og lagrer utkast.""" - print("đ Starter Medlemskaps-skraperen...") - + print("đ Starter Medlemskaps-skraperen (StĂžtter multi-URL)...") conn = await asyncpg.connect(DB_URL) try: - # Hent anlegg som har en url for medlemskap 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))})" @@ -117,35 +111,46 @@ async def run_scraper(facility_ids=None): facilities = await conn.fetch(query) print(f"đ Fant {len(facilities)} anlegg Ă„ skrape.") - for facility in facilities: - fac_id = facility['id'] - name = facility['name'] - url = facility['medlemskap_url'] + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) - print(f"\nâ¶ïž Behandler: {name} (ID: {fac_id})") - - # 1. Hent tekst - page_text = await fetch_page_text(url) - if not page_text or len(page_text) < 50: - print(" â ïž Fant for lite tekst pĂ„ siden, hopper over.") - continue + for facility in facilities: + fac_id = facility['id'] + name = facility['name'] + urls_raw = facility['medlemskap_url'] - # 2. Analyser med Gemini - draft_data = analyze_with_gemini(page_text, name) - - if not draft_data: - continue + print(f"\nâ¶ïž Behandler: {name} (ID: {fac_id})") - # 3. Lagre i databasen som utkast - 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!") + # 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() diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index cd39d83..808f333 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,9 +1,9 @@ "use client"; /** - * TEE OFF ADMIN DASHBOARD v1.9 - RESPONSIVT MED AI-HVISKER, KILL SWITCH, FILTER & FULL EDIT + * TEE OFF ADMIN DASHBOARD v2.1 - MISSION CONTROL + INLINE EDIT * --------------------------------------------------------------------------- - * PLASSERING: frontend/src/app/admin/page.tsx - * FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering. + * FUNKSJON: "Mission Control" med faner for Banestatus og Medlemskap, + * og klikk-for-Ă„-redigere URL-er direkte i tabellen! * --------------------------------------------------------------------------- */ @@ -12,6 +12,49 @@ import { API_URL } from "@/config/constants"; import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; import Link from 'next/link'; +// KOMPONENT FOR HURTIGREDIGERING AV URL-ER +const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => { + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(initialValue || ''); + + const handleSave = () => { + setIsEditing(false); + if (value !== initialValue) { + onSave(facilityId, field, value); + } + }; + + if (isEditing) { + return ( +