From 78e7d2b12e62faeae5036d3e265e33f0a29dec7c Mon Sep 17 00:00:00 2001 From: Erol Date: Thu, 5 Mar 2026 05:18:03 +0100 Subject: [PATCH] =?UTF-8?q?Dagens=20=C3=B8kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/__pycache__/main.cpython-311.pyc | Bin 14340 -> 19570 bytes backend/main.py | 105 ++++++++++-- backend/requirements.txt | 2 +- backend/scrape_status.py | 161 ++++++++++++++++-- backend/test_gemini.py | 116 +++++++++++++ docker-compose.yml | 8 +- frontend/Dockerfile | 5 +- frontend/src/app/admin/page.tsx | 137 +++++++++++++-- .../src/components/ScrapeMethodSelect.tsx | 71 ++++++++ frontend/src/middleware.ts | 6 +- 10 files changed, 564 insertions(+), 47 deletions(-) create mode 100644 backend/test_gemini.py create mode 100644 frontend/src/components/ScrapeMethodSelect.tsx diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc index 0226cfe7a38f1dfdde8df6e6ec7fb593c1ae7ae5..841e055af9c3a95d05a6c6fcf2253467bd23becb 100644 GIT binary patch delta 7123 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 delta 2277 zcma)7UrZcD7~k33z2p9HcToQS-Gfp%C~y@;N~y7xYp+y`0c`A6d!Dy*u&{f3&Mvg{ z0ct{`F{v#XA58kvRHa%m(OjZ$#u$?}zKBU<%)S{P`l899Mw`Y5zu7CaLfdq5^V|9U z&i8%u&Du)1F(u&9)7QE5X=NSpsWbsl?K1)fU?bj$Cfp2*&x+px@Jhy_1|$eFZ5sN-gXO zbG2?ogur1I2jal8l`ikB?5NW`VF`1r)=SbSJ|;lTJr zd`vnX9~(b1ddx@H#UuIa!dZme^q_l)UT~kLZJw|4Bfg|i8vwV&4`6#$)5sV^Pg%Ch zve>47DO3 zy%9Rk7u@i9m;M@#*>Fc9T!(NZl9lcV_?wI7LSKg~ApLn^)2d=d>&w{))Z zAgGfr2Fxuv#NaT!B<+zBY#9Ww-8h>z6kIHUd)7*4A-7Wco79NLs4vpgKEhPf0Hy~( zDvn}cqGn90S&{?(0v(Mcnw!ME1@7TMzl>ag@|!J;)k3Jn8Tr5^s!A^CZLzreNGYmou1P1pV3?mf=7yue&-mr4EFflsuBAH}t=QO9- z%FjS$PzqrCOl&37hB2e!XbBl({5QC25O{K_dCf>EWP7O9F)9Che?Q7E9Q+)i1=>4YpIQKsEn-3p^5DH~ z4?cs_vlb@Kh3N+UyGaptEjDvvegacCrCC$S^b}6dkW0W?=kiyFZz0r6FC2=|?+(>1 zdYPgxZDcKc#?sW+3Ht*j!wlH7N8V&|-Y|^x1*2@BKkIym>$X&pP1OPMCv_uO?w z!pzXE7#7aXkuryXLNMYv=J#&r?SN&5(X5cpV?qY$wZnCbQAV?C>@-60$zVrBSjJ8j zorLU?WHOu5aWZLpX6MwjL9(`s%x1|uOzk`a)>(H-hIB)j)$lXqGa%VtmiaF{XayHS zsK9%XP~aF;acC5+amUu8Fdy$n7ZO+K%SQ&g;JFRezpY&!U2A>vo2s>7;>Td(W-xKi zUl96HX@T2>9HMVyEwk_CTb|~jGdsjMlPWk^*yf$ES3KN$FUoL diff --git a/backend/main.py b/backend/main.py index d57f6e1..04665c8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ """ -TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION +TEE OFF BACKEND API v3.6.9 - KOBLET PÅ ADMIN KJØR-KNAPP --------------------------------------------------------------------------- REGEL 1: Bruk str (ikke string) for type-hinting. REGEL 2: Inkluder alle subqueries for banestatus og hull-data. @@ -9,7 +9,7 @@ LOV: Aldri trunker eller slett logikk for "effektivitet". --------------------------------------------------------------------------- """ -from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request +from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import asyncpg @@ -21,6 +21,11 @@ from jose import jwt, JWTError from passlib.context import CryptContext from dotenv import load_dotenv +# NYE IMPORTER FOR ADMIN PANELET OG BAKGRUNNSJOBBER +from pydantic import BaseModel +from typing import Optional, List +import subprocess + load_dotenv() # --- KONFIGURASJON --- @@ -28,9 +33,19 @@ DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_pass SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production") ALGORITHM = "HS256" -# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") +# --- PYDANTIC MODELLER --- +class ScrapeSettingsUpdate(BaseModel): + scrape_method: Optional[str] = None + scrape_status_url: Optional[str] = None + scrape_status_selector: Optional[str] = None + +# NY MODELL FOR Å TA IMOT IDER FOR SCRAPING +class ScrapeRunRequest(BaseModel): + facility_ids: List[int] + +# --- FUNKSJONER --- def format_row(row): """ Vasker data fra databasen: @@ -43,12 +58,10 @@ def format_row(row): d = dict(row) - # 1. Håndter dato- og tidsformater for JSON-serialisering for key in ['status_updated_at', 'created_at']: if isinstance(d.get(key), (date, datetime)): d[key] = d[key].isoformat() - # 2. Definer alle felter som inneholder JSON-data json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes' @@ -57,7 +70,6 @@ def format_row(row): 'amenities', 'vtg', 'nsg_data', 'golfamore_data' ] - # Vask list-felter for field in json_list_fields: if field in d: val = d[field] @@ -71,7 +83,6 @@ def format_row(row): elif not isinstance(val, list): d[field] = [] - # Vask objekt-felter for field in json_dict_fields: if field in d: val = d[field] @@ -87,6 +98,32 @@ def format_row(row): return d +# --- BAKGRUNNSARBEIDER: FUNKSJON SOM KJØRER SKRAPEREN I BAKGRUNNEN --- +def run_scrape_worker(facility_ids: List[int]): + """ + Kjører selve skraping-scriptet i bakgrunnen. + Slik kan frontenden få et umiddelbart svar, mens skraperen jobber. + """ + print(f"🔄 STARTER BAKGRUNNSSKRAPING FOR FØLGENDE IDER: {facility_ids}") + + # Her kjører vi skraping-scriptet ditt via et system-kall (subprocess) + # Dette er den tryggeste måten å starte et annet script på uten å forstyrre API-et. + try: + # Konverterer listen med IDer til en streng som vi kan sende som argument + ids_arg = ",".join(map(str, facility_ids)) + + # Vi antar at scrape_status.py ligger i samme mappe som main.py + # Slett /dev/null hvis du vil ha logg-utskrifter i terminalen. + command = f"python scrape_status.py --ids {ids_arg} > /dev/null 2>&1" + subprocess.run(command, shell=True, check=True) + + print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}") + except subprocess.CalledProcessError as e: + print(f"❌ FEIL UNDER BAKGRUNNSSKRAPING: {e}") + except Exception as e: + print(f"🔥 UFORUTSETT FEIL UNDER BAKGRUNNSSKRAPING: {e}") + + @asynccontextmanager async def lifespan(app: FastAPI): # Opprett database-pool ved start @@ -106,7 +143,7 @@ async def lifespan(app: FastAPI): # Lukk pool ved avslutning await app.state.pool.close() -app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan) +app = FastAPI(title="TeeOff API v3.6.9", lifespan=lifespan) # CORS - Tillater både lokal utvikling og produksjonsdomene app.add_middleware( @@ -141,14 +178,12 @@ async def login(data: dict): h = admin['password_hash'] print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)") - # FIKS: Vi pakker KUN selve verify-sjekken inn i try/except try: is_valid = pwd_context.verify(data.get('password'), h) except Exception as e: print(f" - 🔥 FEIL VED LESING AV HASH: {e}") raise HTTPException(status_code=500, detail="Internt problem med passord-format") - # FIKS: 401 kastes nå UTENFOR try-blokken, slik at vi unngår 500-krasj if not is_valid: print(" - ❌ Passordet samsvarer ikke med hashen") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") @@ -238,6 +273,56 @@ async def get_facility(slug: str): return format_row(row) +# --- ADMIN ENDPOINTS --- + +@app.patch("/api/admin/facilities/{facility_id}/scrape-settings") +async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate): + """Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL).""" + async with app.state.pool.acquire() as conn: + try: + # Sjekk først at anlegget eksisterer + facility = await conn.fetchrow("SELECT id FROM facilities WHERE id = $1", facility_id) + if not facility: + raise HTTPException(status_code=404, detail="Anlegget finnes ikke.") + + # Oppdater verdiene i databasen + await conn.execute(""" + UPDATE facilities + SET scrape_method = $1, + scrape_status_url = $2, + scrape_status_selector = $3 + WHERE id = $4 + """, + settings.scrape_method, + settings.scrape_status_url, + settings.scrape_status_selector, + facility_id) + + return {"status": "success", "message": f"Skrapeinnstillinger for anlegg ID {facility_id} ble oppdatert."} + + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=str(e)) + +# --- NYTT ADMIN ENDPOINT: KJØRER SKRAPEREN FOR VALGTE IDER --- +@app.post("/api/admin/run-scraper") +async def run_scraper_endpoint(request: ScrapeRunRequest, background_tasks: BackgroundTasks): + """ + Tar imot IDer for skraping, og starter en bakgrunnsjobb. + Gir et umiddelbart svar tilbake til frontenden slik at den slipper å vente. + """ + if not request.facility_ids: + raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") + + print(f"📡 API mottok forespørsel om å kjøre skraping for IDer: {request.facility_ids}") + + # Her starter vi selve magien: Vi legger jobben i FastAPIs BackgroundTasks + background_tasks.add_task(run_scrape_worker, request.facility_ids) + + return {"status": "queued", "message": f"Skraping 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.""" diff --git a/backend/requirements.txt b/backend/requirements.txt index 97d1232..f9986c5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,4 +11,4 @@ python-dotenv python-jose[cryptography] passlib[bcrypt] pyotp -google-generativeai \ No newline at end of file +google-genai \ No newline at end of file diff --git a/backend/scrape_status.py b/backend/scrape_status.py index 0a18990..68d9701 100644 --- a/backend/scrape_status.py +++ b/backend/scrape_status.py @@ -3,6 +3,7 @@ import os import asyncpg import smtplib import re +import argparse from datetime import datetime from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart @@ -12,12 +13,77 @@ try: except ImportError: from playwright_stealth import stealth as apply_stealth +from google import genai from dotenv import load_dotenv load_dotenv() -DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") +# ========================================== +# KONFIGURERER GEMINI AI (NY SDK) +# ========================================== +# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din +client = genai.Client() + +async def ask_llm_status(text, course_name, is_single_course): + """Sender teksten til Gemini og ber om ett enkelt status-ord tilbake.""" + + # 1. Dynamisk instruks basert på antall baner + if is_single_course: + bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane." + else: + bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".' + + # 2. Selve promptet + prompt = f""" + Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus. + {bane_instruks} + Svar KUN med nøyaktig ETT av disse ordene: + - aapen (hvis banen er åpen/sommergreener) + - stengt (hvis banen er lukket/stengt/frost/snø) + - aapen_med_vintergreener (hvis det spilles på vintergreener) + - aapner_snart (hvis den åpner om kort tid) + - stenger_snart (hvis den stenger for sesongen om kort tid) + - under_utvikling (hvis den er under utvikling) + - nedlagt (hvis den er nedlagt) + - ukjent (hvis du ikke finner noe info om banen i teksten) + + Tekst fra nettsiden: + {text[:15000]} + """ + + try: + response = await client.aio.models.generate_content( + model='gemini-2.5-flash', + contents=prompt + ) + svar = response.text.strip().lower() + + # 3. Sikkerhetsfilteret som matcher ordene i promptet + gyldige_svar = [ + "aapen", + "stengt", + "aapen_med_vintergreener", + "aapner_snart", + "stenger_snart", + "under_utvikling", + "nedlagt", + "ukjent" + ] + + for gyldig in gyldige_svar: + if gyldig in svar: + return gyldig + return "ukjent" + except Exception as e: + print(f"❌ Gemini Feil: {e}") + return "ukjent" + + +# ========================================== +# EKSISTERENDE LOGIKK FOR MANUELL SCRAPING +# ========================================== def clean_text(text): return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower() @@ -48,6 +114,7 @@ def interpret_status(text, keyword=None): def send_report(changes, warnings, successes): if not changes and not warnings and not successes: return subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}" + body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n" if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\n\n" @@ -67,11 +134,33 @@ def send_report(changes, warnings, successes): except Exception as e: print(f"❌ E-post feil: {e}") -async def run_daily_scraping(): + +# ========================================== +# HOVEDMOTOR +# ========================================== +async def run_daily_scraping(facility_ids=None): print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...") conn = await asyncpg.connect(DB_URL) - facilities = await conn.fetch("SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL") + # --- NYTT: Filtrerer basert på valgte IDer fra Admin-panelet --- + if facility_ids: + print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}") + facilities = await conn.fetch( + "SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL AND id = ANY($1::int[])", + facility_ids + ) + else: + print("🌍 Kjører skraping for ALLE anlegg med scrape_status_url...") + facilities = await conn.fetch( + "SELECT id, name, scrape_status_url, scrape_status_selector, scrape_method FROM facilities WHERE scrape_status_url IS NOT NULL" + ) + + if not facilities: + print("⚠️ Fant ingen anlegg å skrape.") + await conn.close() + return + # ---------------------------------------------------------------- + changes, warnings, successes = [], [], [] async with async_playwright() as p: @@ -84,8 +173,7 @@ async def run_daily_scraping(): except: pass try: - print(f"🔍 Besøker {f['name']}...") - # Endret fra networkidle til domcontentloaded for å unngå Arendal-timeout + print(f"🔍 Besøker {f['name']} (Metode: {f.get('scrape_method') or 'css_selector'})...") await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded") await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden @@ -108,26 +196,20 @@ async def run_daily_scraping(): full_text = await element.inner_text() elif method == 'click_then_css': - # Vi forventer formatet: "knappe_selector||tekst_selector" parts = f['scrape_status_selector'].split('||') if len(parts) != 2: warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)") continue btn_selector, text_selector = parts - - # 1. Finn og klikk på knappen btn = page.locator(btn_selector).first if await btn.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'") continue await btn.click() - - # 2. Vent 2 sekunder så animasjonen (sidepanelet) rekker å bli ferdig await asyncio.sleep(2) - # 3. Les av teksten element = page.locator(text_selector).first if await element.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk") @@ -135,6 +217,32 @@ async def run_daily_scraping(): full_text = await element.inner_text() + # NY METODE: LLM PARSE (GEMINI) + elif method == 'llm_parse': + # --- AUTO-KLIKKER --- + print(" 🖱️ Leter etter 'banestatus'-knapper å klikke på...") + knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all() + + for knapp in knapper: + try: + if await knapp.is_visible(): + await knapp.click(timeout=3000) + print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...") + await asyncio.sleep(2) + break + except Exception: + pass + # -------------------- + + # Kopierer all synlig tekst fra hele nettsiden + element = page.locator("body").first + if await element.count() == 0: + warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning") + continue + råtekst = await element.inner_text() + # Fjerner overflødige linjeskift for å komprimere teksten før sending til Gemini + full_text = " ".join(råtekst.split()) + else: warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'") continue @@ -142,11 +250,21 @@ async def run_daily_scraping(): await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id']) courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id']) + + # Sjekk om anlegget kun har én bane + is_single_course = len(courses) == 1 + for c in courses: - new_status = interpret_status(full_text, c['scrape_keyword']) + + # HENTER STATUS VIA AI ELLER GAMMEL METODE + if method == 'llm_parse': + print(f" 🤖 Spør Gemini om status for '{c['name']}' (Singelbane: {is_single_course})...") + new_status = await ask_llm_status(full_text, c['name'], is_single_course) + else: + new_status = interpret_status(full_text, c['scrape_keyword']) if new_status == "NOT_FOUND": - warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten på siden.") + warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.") continue old_status = c['status'] or "ukjent" @@ -159,11 +277,11 @@ async def run_daily_scraping(): print(f" - {c['name']}: Ingen endring ({new_status.upper()})") except Exception as e: - # Trekker ut kun første linje av feilmeldingen for å unngå massiv og stygg tekst i e-posten err_msg = str(e).split('\n')[0] warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}") finally: await page.close() + await browser.close() await conn.close() @@ -171,4 +289,17 @@ async def run_daily_scraping(): print("🏁 Ferdig.") if __name__ == "__main__": - asyncio.run(run_daily_scraping()) \ No newline at end of file + # --- NYTT: Tar imot argumenter fra main.py (Background Task) --- + parser = argparse.ArgumentParser(description="TeeOff Status Scraper") + parser.add_argument("--ids", type=str, help="Kommaseparert liste med anleggs-IDer", default=None) + args = parser.parse_args() + + facility_ids_list = None + if args.ids: + try: + facility_ids_list = [int(id_str.strip()) for id_str in args.ids.split(",") if id_str.strip()] + except ValueError: + print("❌ Feil format på --ids. Må være kommaseparerte tall, f.eks: 1,4,12") + exit(1) + + asyncio.run(run_daily_scraping(facility_ids_list)) \ No newline at end of file diff --git a/backend/test_gemini.py b/backend/test_gemini.py new file mode 100644 index 0000000..1141ed3 --- /dev/null +++ b/backend/test_gemini.py @@ -0,0 +1,116 @@ +import asyncio +import os +import re +from playwright.async_api import async_playwright +from google import genai +from dotenv import load_dotenv + +load_dotenv() + +# Den nye pakken henter automatisk GEMINI_API_KEY fra .env-filen din +client = genai.Client() + +async def ask_llm_status(text, course_name, is_single_course): + if is_single_course: + bane_instruks = "Finn den generelle banestatusen for dette golfanlegget. Se bort fra spesifikke banenavn, da anlegget kun har én bane." + else: + bane_instruks = f'Finn banestatusen SPESIFIKT for banen som heter/omtales som: "{course_name}".' + + prompt = f""" + Du er en ekspert på å lese norske golfklubbers nettsider for å finne banestatus. + {bane_instruks} + Svar KUN med nøyaktig ETT av disse ordene: + - aapen (hvis banen er åpen/sommergreener) + - stengt (hvis banen er lukket/stengt/frost/snø) + - aapen_med_vintergreener (hvis det spilles på vintergreener) + - aapner_snart (hvis den åpner om kort tid) + - stenger_snart (hvis den stenger for sesongen om kort tid) + - under_utvikling (hvis den er under utvikling) + - nedlagt (hvis den er nedlagt) + - ukjent (hvis du ikke finner noe info om banen i teksten) + + Tekst fra nettsiden: + {text[:15000]} + """ + + try: + # Ny måte å kalle modellen asynkront på med google-genai + response = await client.aio.models.generate_content( + model='gemini-2.5-flash', + contents=prompt + ) + svar = response.text.strip().lower() + + gyldige_svar = [ + "aapen", "stengt", "aapen_med_vintergreener", + "aapner_snart", "stenger_snart", "under_utvikling", + "nedlagt", "ukjent" + ] + + for gyldig in gyldige_svar: + if gyldig in svar: + return gyldig + return "ukjent" + except Exception as e: + print(f"❌ Gemini Feil: {e}") + return "ukjent" + +async def run_test(): + print("\n" + "="*50) + print(" 🧪 TEE OFF: GEMINI TEST-VERKTØY (MED AUTO-KLIKKER)") + print("="*50) + + url = input("🌐 Skriv inn URL til golfklubben (f.eks. https://oslogk.no): ").strip() + if not url.startswith("http"): + url = "https://" + url + + course_name = input("⛳ Skriv inn banenavn (eller trykk ENTER hvis anlegget kun har 1 bane): ").strip() + is_single = len(course_name) == 0 + + print("\n⏳ 1. Starter nettleser og besøker siden...") + + full_text = "" + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + page = await browser.new_page() + try: + await page.goto(url, timeout=30000, wait_until="domcontentloaded") + await asyncio.sleep(3) # Vent på animasjoner og iframes + + # --- NY LOGIKK: AUTO-KLIKKER --- + print("🖱️ Leter etter 'banestatus'-knapper å klikke på...") + # Vi leter etter tekst som inneholder "banestatus" (ignorerer store/små bokstaver) + knapper = await page.get_by_text(re.compile(r"banestatus", re.IGNORECASE)).all() + + for knapp in knapper: + try: + if await knapp.is_visible(): + await knapp.click(timeout=3000) + print(" 🎯 Klikket på en banestatus-knapp! Venter 2 sekunder...") + await asyncio.sleep(2) # Venter på at modalen/pop-upen åpner seg + break # Vi trenger bare å klikke på den første vi finner + except Exception as e: + # Ignorerer hvis knappen ikke er klikkbar, prøver neste + pass + # -------------------------------- + + element = page.locator("body").first + råtekst = await element.inner_text() + full_text = " ".join(råtekst.split()) + print(f"✅ Hentet {len(full_text)} tegn med tekst fra nettsiden.") + + except Exception as e: + print(f"❌ Feil ved innlasting av side: {e}") + await browser.close() + return + await browser.close() + + print("🧠 2. Sender teksten til Gemini for analyse...") + status = await ask_llm_status(full_text, course_name, is_single) + + print("\n" + "="*50) + print(f"🎯 GEMINI SITT SVAR: {status.upper()}") + print("="*50 + "\n") + +if __name__ == "__main__": + asyncio.run(run_test()) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1bb8f59..2ce36ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,7 @@ services: - "8001:8000" volumes: - ./backend:/app - # Denne linjen sørger for at bilder lastet ned av import_wp.py - # lagres direkte i frontendens public-mappe på serveren din: + # Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe: - ./frontend/public/media:/app/public/media depends_on: - db @@ -29,10 +28,11 @@ services: frontend: build: ./frontend container_name: teeoff_frontend + # NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting + command: sh -c "npm run build && npm start" ports: - "3000:3000" - volumes: - - ./frontend:/app + # VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet depends_on: - api restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 40c8070..6308ff9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,6 +9,5 @@ RUN npm install # Kopier resten av koden COPY . . -# Vi starter serveren i "dev"-modus (utviklingsmodus). -# Dette gjør at vi kan se endringer live mens vi koder! -CMD ["npm", "run", "dev"] +# Vi starter IKKE serveren i "dev"-modus (utviklingsmodus). +CMD ["sh", "-c", "npm run build && npm start"] diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 0b49133..dd969a8 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,20 +1,26 @@ "use client"; /** - * TEE OFF ADMIN DASHBOARD v1.1 + * TEE OFF ADMIN DASHBOARD v1.4 - LIVE PROGRESSION * --------------------------------------------------------------------------- * PLASSERING: frontend/src/app/admin/page.tsx - * FUNKSJON: Monitorering av banestatus og administrasjon. + * FUNKSJON: Starter bakgrunnsjobber og oppdaterer tabellen live. * --------------------------------------------------------------------------- */ import { useState, useEffect } from 'react'; import { API_URL } from "@/config/constants"; +import ScrapeMethodSelect from "@/components/ScrapeMethodSelect"; export default function AdminDashboard() { - const [facilities, setFacilities] = useState([]); + const [facilities, setFacilities] = useState([]); const [loading, setLoading] = useState(true); + const [selectedFacilities, setSelectedFacilities] = useState([]); + + // NYTT: Holder styr på om en skraping pågår akkurat nå + const [isScraping, setIsScraping] = useState(false); - useEffect(() => { + // Henter data fra databasen + const fetchFacilities = () => { fetch(`${API_URL}/facilities`) .then(res => res.json()) .then(data => { @@ -22,13 +28,69 @@ export default function AdminDashboard() { setLoading(false); }) .catch(() => setLoading(false)); + }; + + // Hent data ved første innlasting + useEffect(() => { + fetchFacilities(); }, []); + // NYTT: Hvis skraping pågår, oppdater tabellen hvert 10. sekund! + useEffect(() => { + let interval: NodeJS.Timeout; + if (isScraping) { + interval = setInterval(() => { + fetchFacilities(); + }, 10000); + } + return () => clearInterval(interval); + }, [isScraping]); + + const handleSelectAll = (e: React.ChangeEvent) => { + if (e.target.checked) { + setSelectedFacilities(facilities.map(f => f.id)); + } else { + setSelectedFacilities([]); + } + }; + + const handleSelectOne = (id: number, checked: boolean) => { + if (checked) { + setSelectedFacilities([...selectedFacilities, id]); + } else { + setSelectedFacilities(selectedFacilities.filter(facilityId => facilityId !== id)); + } + }; + + // NYTT: Sender IDene til API-et og starter auto-oppdatering + const handleRunScrapers = async () => { + setIsScraping(true); + try { + const response = await fetch(`${API_URL}/admin/run-scraper`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ facility_ids: selectedFacilities }) + }); + + if (!response.ok) throw new Error("Kunne ikke starte skraping"); + + // Valgfritt: Fjern avhukingene når jobben har startet + setSelectedFacilities([]); + + // Stopper auto-oppdateringen etter 10 minutter (for sikkerhets skyld) + setTimeout(() => setIsScraping(false), 10 * 60 * 1000); + + } catch (error) { + console.error(error); + alert("Feil ved start av skraperen."); + setIsScraping(false); + } + }; + if (loading) return
LASTER DASHBORD...
; return (
- {/* SIDEBAR (22%) */}
- {/* HOVEDINNHOLD (78%) */}
@@ -49,33 +110,87 @@ export default function AdminDashboard() {

Scraping Monitor

Sjekker status på {facilities.length} anlegg

- + + {/* NYTT: Knappen endrer utseende når skraping pågår */} +
- + + + - + + {facilities.map((f: any) => ( - + + + diff --git a/frontend/src/components/ScrapeMethodSelect.tsx b/frontend/src/components/ScrapeMethodSelect.tsx new file mode 100644 index 0000000..8b93e85 --- /dev/null +++ b/frontend/src/components/ScrapeMethodSelect.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from 'react'; + +// Tilpass interface til de dataene du allerede har i frontend +interface Facility { + id: number; + scrape_method?: string; + scrape_status_url?: string; + scrape_status_selector?: string; +} + +export default function ScrapeMethodSelect({ facility }: { facility: Facility }) { + // Setter standardverdi til 'css_selector' hvis den er tom i databasen + const [method, setMethod] = useState(facility.scrape_method || 'css_selector'); + const [isLoading, setIsLoading] = useState(false); + const [statusColor, setStatusColor] = useState('bg-transparent'); // For å gi visuell feedback + + const handleMethodChange = async (newMethod: string) => { + setMethod(newMethod); + setIsLoading(true); + setStatusColor('bg-yellow-200'); // Lyser gult mens den lagrer + + try { + // Husk å endre URL-en hvis API-et ditt ligger på et annet domene + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/api/admin/facilities/${facility.id}/scrape-settings`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + // Hvis du bruker JWT i headers i stedet for cookies, legg det til her: + // 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + scrape_method: newMethod, + scrape_status_url: facility.scrape_status_url, // Beholder eksisterende + scrape_status_selector: facility.scrape_status_selector // Beholder eksisterende + }) + }); + + if (!response.ok) { + throw new Error('Feil ved lagring'); + } + + // Suksess! Lyser grønt et kort sekund + setStatusColor('bg-green-300'); + setTimeout(() => setStatusColor('bg-transparent'), 2000); + + } catch (error) { + console.error(error); + setStatusColor('bg-red-300'); // Lyser rødt ved feil + alert("Kunne ikke oppdatere skrapemetode."); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index dc05080..f01dfac 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,13 +1,13 @@ /** - * TEE OFF SECURITY MIDDLEWARE v1.0 + * TEE OFF SECURITY MIDDLEWARE v1.1 * --------------------------------------------------------------------------- * REGEL: Beskytter alle ruter under /admin (unntatt /admin/login). * FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler. + * RETTING: Flyttet NextRequest til next/server for å fikse build-error. * --------------------------------------------------------------------------- */ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/request'; +import { NextResponse, type NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
Anlegg + 0} + onChange={handleSelectAll} + /> + Anlegg KonfigurasjonMetode Siste SjekkStatusBanestatusHandling
+ + handleSelectOne(f.id, e.target.checked)} + /> +
{f.name}
{f.city}
-
{f.scrape_status_url}
+
{f.scrape_status_url}
{f.scrape_status_selector}
+ + {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'} +
+ {f.course_statuses && f.course_statuses.map((cs: any, idx: number) => { + let badgeColor = "bg-gray-100 text-gray-500"; + if (cs.status === "aapen") badgeColor = "bg-green-100 text-green-700"; + if (cs.status === "stengt" || cs.status === "nedlagt") badgeColor = "bg-red-100 text-red-700"; + if (cs.status === "aapen_med_vintergreener" || cs.status === "aapner_snart") badgeColor = "bg-yellow-100 text-yellow-700"; + + return ( +
+ + {cs.name} + + + {cs.status || 'UKJENT'} + +
+ ) + })} +
+