Dagens økt

This commit is contained in:
Erol 2026-03-05 05:18:03 +01:00
parent 1ca1868e11
commit 78e7d2b12e
10 changed files with 564 additions and 47 deletions

View file

@ -1,5 +1,5 @@
""" """
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION TEE OFF BACKEND API v3.6.9 - KOBLET ADMIN KJØR-KNAPP
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting. REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data. 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 fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import asyncpg import asyncpg
@ -21,6 +21,11 @@ from jose import jwt, JWTError
from passlib.context import CryptContext from passlib.context import CryptContext
from dotenv import load_dotenv 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() load_dotenv()
# --- KONFIGURASJON --- # --- 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") SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
ALGORITHM = "HS256" ALGORITHM = "HS256"
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") 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): def format_row(row):
""" """
Vasker data fra databasen: Vasker data fra databasen:
@ -43,12 +58,10 @@ def format_row(row):
d = dict(row) d = dict(row)
# 1. Håndter dato- og tidsformater for JSON-serialisering
for key in ['status_updated_at', 'created_at']: for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)): if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat() d[key] = d[key].isoformat()
# 2. Definer alle felter som inneholder JSON-data
json_list_fields = [ json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee', 'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes' 'faqs', 'shotzoom', 'social_links', 'holes'
@ -57,7 +70,6 @@ def format_row(row):
'amenities', 'vtg', 'nsg_data', 'golfamore_data' 'amenities', 'vtg', 'nsg_data', 'golfamore_data'
] ]
# Vask list-felter
for field in json_list_fields: for field in json_list_fields:
if field in d: if field in d:
val = d[field] val = d[field]
@ -71,7 +83,6 @@ def format_row(row):
elif not isinstance(val, list): elif not isinstance(val, list):
d[field] = [] d[field] = []
# Vask objekt-felter
for field in json_dict_fields: for field in json_dict_fields:
if field in d: if field in d:
val = d[field] val = d[field]
@ -87,6 +98,32 @@ def format_row(row):
return d 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 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Opprett database-pool ved start # Opprett database-pool ved start
@ -106,7 +143,7 @@ async def lifespan(app: FastAPI):
# Lukk pool ved avslutning # Lukk pool ved avslutning
await app.state.pool.close() 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 # CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware( app.add_middleware(
@ -141,14 +178,12 @@ async def login(data: dict):
h = admin['password_hash'] h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)") print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
# FIKS: Vi pakker KUN selve verify-sjekken inn i try/except
try: try:
is_valid = pwd_context.verify(data.get('password'), h) is_valid = pwd_context.verify(data.get('password'), h)
except Exception as e: except Exception as e:
print(f" - 🔥 FEIL VED LESING AV HASH: {e}") print(f" - 🔥 FEIL VED LESING AV HASH: {e}")
raise HTTPException(status_code=500, detail="Internt problem med passord-format") 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: if not is_valid:
print(" - ❌ Passordet samsvarer ikke med hashen") print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord") raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
@ -238,6 +273,56 @@ async def get_facility(slug: str):
return format_row(row) 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") @app.get("/api/health")
async def health_check(): async def health_check():
"""Enkel sjekk for å se at API og DB lever.""" """Enkel sjekk for å se at API og DB lever."""

View file

@ -11,4 +11,4 @@ python-dotenv
python-jose[cryptography] python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
pyotp pyotp
google-generativeai google-genai

View file

@ -3,6 +3,7 @@ import os
import asyncpg import asyncpg
import smtplib import smtplib
import re import re
import argparse
from datetime import datetime from datetime import datetime
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@ -12,12 +13,77 @@ try:
except ImportError: except ImportError:
from playwright_stealth import stealth as apply_stealth from playwright_stealth import stealth as apply_stealth
from google import genai
from dotenv import load_dotenv from dotenv import load_dotenv
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 å 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 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): def clean_text(text):
return re.sub(r'[^a-zA-Z0-9æøåÆØÅ]', '', text).lower() 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): def send_report(changes, warnings, successes):
if not changes and not warnings and not successes: return if not changes and not warnings and not successes: return
subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}" subject = f"TeeOff Banestatus Rapport - {datetime.now().strftime('%d.%m.%Y')}"
body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n" body = "BANESTATUS RAPPORT\n" + "="*30 + "\n\n"
if changes: body += "✅ OPPDATERINGER:\n" + "\n".join(changes) + "\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: except Exception as e:
print(f"❌ E-post feil: {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')}...") print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
conn = await asyncpg.connect(DB_URL) 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 = [], [], [] changes, warnings, successes = [], [], []
async with async_playwright() as p: async with async_playwright() as p:
@ -84,8 +173,7 @@ async def run_daily_scraping():
except: pass except: pass
try: try:
print(f"🔍 Besøker {f['name']}...") print(f"🔍 Besøker {f['name']} (Metode: {f.get('scrape_method') or 'css_selector'})...")
# Endret fra networkidle til domcontentloaded for å unngå Arendal-timeout
await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded") await page.goto(f['scrape_status_url'], timeout=60000, wait_until="domcontentloaded")
await asyncio.sleep(3) # Gir Javascript 3 sekunder på å bygge siden 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() full_text = await element.inner_text()
elif method == 'click_then_css': elif method == 'click_then_css':
# Vi forventer formatet: "knappe_selector||tekst_selector"
parts = f['scrape_status_selector'].split('||') parts = f['scrape_status_selector'].split('||')
if len(parts) != 2: if len(parts) != 2:
warnings.append(f"{f['name']}: Ugyldig selector for click_then_css (mangler ||)") warnings.append(f"{f['name']}: Ugyldig selector for click_then_css (mangler ||)")
continue continue
btn_selector, text_selector = parts btn_selector, text_selector = parts
# 1. Finn og klikk på knappen
btn = page.locator(btn_selector).first btn = page.locator(btn_selector).first
if await btn.count() == 0: if await btn.count() == 0:
warnings.append(f"{f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'") warnings.append(f"{f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'")
continue continue
await btn.click() await btn.click()
# 2. Vent 2 sekunder så animasjonen (sidepanelet) rekker å bli ferdig
await asyncio.sleep(2) await asyncio.sleep(2)
# 3. Les av teksten
element = page.locator(text_selector).first element = page.locator(text_selector).first
if await element.count() == 0: if await element.count() == 0:
warnings.append(f"{f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk") 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() 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: else:
warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'") warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'")
continue 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']) 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']) 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: 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": 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 continue
old_status = c['status'] or "ukjent" old_status = c['status'] or "ukjent"
@ -159,11 +277,11 @@ async def run_daily_scraping():
print(f" - {c['name']}: Ingen endring ({new_status.upper()})") print(f" - {c['name']}: Ingen endring ({new_status.upper()})")
except Exception as e: 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] err_msg = str(e).split('\n')[0]
warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}") warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}")
finally: finally:
await page.close() await page.close()
await browser.close() await browser.close()
await conn.close() await conn.close()
@ -171,4 +289,17 @@ async def run_daily_scraping():
print("🏁 Ferdig.") print("🏁 Ferdig.")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(run_daily_scraping()) # --- 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))

116
backend/test_gemini.py Normal file
View file

@ -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 å 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 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())

View file

@ -19,8 +19,7 @@ services:
- "8001:8000" - "8001:8000"
volumes: volumes:
- ./backend:/app - ./backend:/app
# Denne linjen sørger for at bilder lastet ned av import_wp.py # Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe:
# lagres direkte i frontendens public-mappe på serveren din:
- ./frontend/public/media:/app/public/media - ./frontend/public/media:/app/public/media
depends_on: depends_on:
- db - db
@ -29,10 +28,11 @@ services:
frontend: frontend:
build: ./frontend build: ./frontend
container_name: teeoff_frontend container_name: teeoff_frontend
# NY LINJE: Tvinger produksjonsmodus for å stoppe WebSocket-feil og relasting
command: sh -c "npm run build && npm start"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: # VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
- ./frontend:/app
depends_on: depends_on:
- api - api
restart: unless-stopped restart: unless-stopped

View file

@ -9,6 +9,5 @@ RUN npm install
# Kopier resten av koden # Kopier resten av koden
COPY . . COPY . .
# Vi starter serveren i "dev"-modus (utviklingsmodus). # Vi starter IKKE serveren i "dev"-modus (utviklingsmodus).
# Dette gjør at vi kan se endringer live mens vi koder! CMD ["sh", "-c", "npm run build && npm start"]
CMD ["npm", "run", "dev"]

View file

@ -1,20 +1,26 @@
"use client"; "use client";
/** /**
* TEE OFF ADMIN DASHBOARD v1.1 * TEE OFF ADMIN DASHBOARD v1.4 - LIVE PROGRESSION
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx * 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 { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
export default function AdminDashboard() { export default function AdminDashboard() {
const [facilities, setFacilities] = useState([]); const [facilities, setFacilities] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
// 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`) fetch(`${API_URL}/facilities`)
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@ -22,13 +28,69 @@ export default function AdminDashboard() {
setLoading(false); setLoading(false);
}) })
.catch(() => 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<HTMLInputElement>) => {
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 <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>; if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans"> <div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
{/* SIDEBAR (22%) */}
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col"> <aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1> <h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow"> <nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
@ -41,7 +103,6 @@ export default function AdminDashboard() {
</div> </div>
</aside> </aside>
{/* HOVEDINNHOLD (78%) */}
<main className="lg:w-[78%] p-6 md:p-12"> <main className="lg:w-[78%] p-6 md:p-12">
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white"> <div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
<header className="flex justify-between items-center mb-12"> <header className="flex justify-between items-center mb-12">
@ -49,33 +110,87 @@ export default function AdminDashboard() {
<h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2> <h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status {facilities.length} anlegg</p> <p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status {facilities.length} anlegg</p>
</div> </div>
<button className="bg-[#8bc34a] text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-105 transition-all">Kjør alle skrapere</button>
{/* NYTT: Knappen endrer utseende når skraping pågår */}
<button
onClick={handleRunScrapers}
disabled={selectedFacilities.length === 0 || isScraping}
className={`text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl transition-all
${isScraping
? 'bg-yellow-500 animate-pulse cursor-wait'
: 'bg-[#8bc34a] hover:scale-105 disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed'
}`}
>
{isScraping ? '🤖 Skraper... Henter live data' : `Kjør valgte skrapere (${selectedFacilities.length})`}
</button>
</header> </header>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50"> <tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-6">Anlegg</th> <th className="pb-6 pl-6 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.length === facilities.length && facilities.length > 0}
onChange={handleSelectAll}
/>
</th>
<th className="pb-6 pr-10">Anlegg</th>
<th className="pb-6">Konfigurasjon</th> <th className="pb-6">Konfigurasjon</th>
<th className="pb-6">Metode</th>
<th className="pb-6">Siste Sjekk</th> <th className="pb-6">Siste Sjekk</th>
<th className="pb-6 text-right">Status</th> <th className="pb-6">Banestatus</th>
<th className="pb-6 text-right">Handling</th>
</tr> </tr>
</thead> </thead>
<tbody className="text-sm font-bold text-[#11280f]"> <tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => ( {facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors"> <tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-8"> <td className="py-8 pl-6 w-10">
<input
type="checkbox"
className="w-4 h-4 cursor-pointer accent-[#8bc34a]"
checked={selectedFacilities.includes(f.id)}
onChange={(e) => handleSelectOne(f.id, e.target.checked)}
/>
</td>
<td className="py-8 pl-10">
<div className="font-black text-lg">{f.name}</div> <div className="font-black text-lg">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div> <div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td> </td>
<td className="py-8"> <td className="py-8">
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div> <div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div> <div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
</td> </td>
<td className="py-8 pr-4">
<ScrapeMethodSelect facility={f} />
</td>
<td className="py-8 text-gray-400 font-mono text-xs"> <td className="py-8 text-gray-400 font-mono text-xs">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'} {f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td> </td>
<td className="py-8">
<div className="flex flex-col gap-2">
{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 (
<div key={idx} className="flex items-center gap-2">
<span className="text-[10px] uppercase tracking-widest text-gray-400 truncate max-w-[100px]" title={cs.name}>
{cs.name}
</span>
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase tracking-widest ${badgeColor}`}>
{cs.status || 'UKJENT'}
</span>
</div>
)
})}
</div>
</td>
<td className="py-8 text-right"> <td className="py-8 text-right">
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button> <button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
</td> </td>

View file

@ -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 (
<select
value={method}
onChange={(e) => handleMethodChange(e.target.value)}
disabled={isLoading}
className={`border rounded p-1 text-sm transition-colors duration-300 ${statusColor} ${isLoading ? 'opacity-50' : ''}`}
>
<option value="css_selector">Standard (CSS)</option>
<option value="llm_parse"> Gemini AI (LLM)</option>
<option value="iframe_golfbox">Golfbox iframe</option>
<option value="click_then_css">Auto-klikk + CSS</option>
<option value="">Ingen (Avslått)</option>
</select>
);
}

View file

@ -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). * REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler. * 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 { NextResponse, type NextRequest } from 'next/server';
import type { NextRequest } from 'next/request';
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;