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 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."""
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ python-dotenv
|
|||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
pyotp
|
||||
google-generativeai
|
||||
google-genai
|
||||
|
|
@ -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,10 +134,32 @@ 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 = [], [], []
|
||||
|
||||
|
|
@ -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())
|
||||
# --- 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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedFacilities, setSelectedFacilities] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// NYTT: Holder styr på om en skraping pågår akkurat nå
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
|
||||
// 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<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>;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
|
|
@ -41,7 +103,6 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
{/* HOVEDINNHOLD (78%) */}
|
||||
<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">
|
||||
<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>
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {facilities.length} anlegg</p>
|
||||
</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>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<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">Metode</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>
|
||||
</thead>
|
||||
<tbody className="text-sm font-bold text-[#11280f]">
|
||||
{facilities.map((f: any) => (
|
||||
<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="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
|
||||
</td>
|
||||
<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>
|
||||
</td>
|
||||
<td className="py-8 pr-4">
|
||||
<ScrapeMethodSelect facility={f} />
|
||||
</td>
|
||||
<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'}
|
||||
</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">
|
||||
<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>
|
||||
|
|
|
|||
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).
|
||||
* 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue