Dagens økt
This commit is contained in:
parent
1ca1868e11
commit
78e7d2b12e
10 changed files with 564 additions and 47 deletions
Binary file not shown.
105
backend/main.py
105
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 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 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
|
@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."""
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,4 @@ python-dotenv
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
passlib[bcrypt]
|
passlib[bcrypt]
|
||||||
pyotp
|
pyotp
|
||||||
google-generativeai
|
google-genai
|
||||||
|
|
@ -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 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):
|
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
116
backend/test_gemini.py
Normal 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 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())
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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 på {facilities.length} anlegg</p>
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {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>
|
||||||
|
|
|
||||||
71
frontend/src/components/ScrapeMethodSelect.tsx
Normal file
71
frontend/src/components/ScrapeMethodSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue