Jobber med scraping

This commit is contained in:
Erol 2026-03-05 15:11:04 +01:00
parent 726785dfcc
commit f25331c5f0
4 changed files with 76 additions and 52 deletions

View file

@ -112,15 +112,12 @@ def run_scrape_worker(facility_ids: List[int]):
"""
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"
# NYTT: Bruker "python -u" for LIVE logging, og fjerner "> /dev/null 2>&1"
command = f"python -u scrape_status.py --ids {ids_arg}"
subprocess.run(command, shell=True, check=True)
print(f"✅ BAKGRUNNSSKRAPING FULLFØRT FOR IDER: {facility_ids}")

View file

@ -23,22 +23,18 @@ DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_pass
# ==========================================
# 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, ai_instruction=None):
"""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}".'
# NYTT: Hvisker inn i øret til AI-en hvis vi har en instruks fra admin
ekstra_tekst = f"\nVIKTIG EKSTRA-INSTRUKS FRA ADMIN:\n{ai_instruction}\n" if ai_instruction else ""
ekstra_tekst = f"\n!!! VIKTIG EKSTRA-INSTRUKS FRA ADMIN (DENNE OVERSTYRER ALLE ANDRE REGLER) !!!:\n{ai_instruction}\n" if ai_instruction else ""
# 2. Selve promptet
prompt = f"""
Du er en ekspert å lese norske golfklubbers nettsider for å finne banestatus.
{bane_instruks}
@ -57,6 +53,15 @@ async def ask_llm_status(text, course_name, is_single_course, ai_instruction=Non
{text[:15000]}
"""
print("\n" + "="*60)
print(f"🤖 SENDER PROMPT TIL GEMINI FOR: '{course_name}'")
print(f"👉 STANDARD-INSTRUKS: {bane_instruks}")
if ai_instruction:
print(f"👉 ADMIN-HVISKER: {ai_instruction}")
clean_text_sample = " ".join(text.split())[:250]
print(f"👉 TEKST FRA NETTSIDEN (utdrag): '{clean_text_sample}...'")
print("="*60 + "\n")
try:
response = await client.aio.models.generate_content(
model='gemini-2.5-flash',
@ -64,15 +69,18 @@ async def ask_llm_status(text, course_name, is_single_course, ai_instruction=Non
)
svar = response.text.strip().lower()
# 3. Sikkerhetsfilteret som matcher ordene i promptet
print(f" 🧠 GEMINI RÅ-SVAR: '{svar}'")
# --- NYTT: SORTERT SIKKERHETSFILTER ---
# De lengste/mest spesifikke må stå først!
gyldige_svar = [
"aapen",
"stengt",
"aapen_med_vintergreener",
"aapner_snart",
"stenger_snart",
"under_utvikling",
"nedlagt",
"stengt",
"aapen",
"ukjent"
]
@ -146,7 +154,6 @@ async def run_daily_scraping(facility_ids=None):
print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...")
conn = await asyncpg.connect(DB_URL)
# --- 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(
@ -163,7 +170,6 @@ async def run_daily_scraping(facility_ids=None):
print("⚠️ Fant ingen anlegg å skrape.")
await conn.close()
return
# ----------------------------------------------------------------
changes, warnings, successes = [], [], []
@ -174,7 +180,6 @@ async def run_daily_scraping(facility_ids=None):
for f in facilities:
method = f.get('scrape_method') or 'css_selector'
# THE KILL SWITCH - Hopper over manuelle baner
if method == 'manual':
successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)")
print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)")
@ -187,7 +192,7 @@ async def run_daily_scraping(facility_ids=None):
try:
print(f"🔍 Besøker {f['name']} (Metode: {method})...")
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)
full_text = ""
@ -228,30 +233,25 @@ async def run_daily_scraping(facility_ids=None):
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()
print(" 🖱️ Leter etter knapper å klikke på for å avdekke skjult tekst...")
knapper = await page.get_by_text(re.compile(r"banestatus|dagens status|se status|baneinfo", 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...")
print(" 🎯 Klikket på en status-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:
@ -262,15 +262,16 @@ async def run_daily_scraping(facility_ids=None):
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:
# 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, f.get('ai_instruction'))
print(" ⏳ Tar 5 sekunders pause for å spare Gemini-kvoten...")
await asyncio.sleep(5)
else:
new_status = interpret_status(full_text, c['scrape_keyword'])
@ -282,10 +283,10 @@ async def run_daily_scraping(facility_ids=None):
if new_status != old_status and new_status != "ukjent":
await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id'])
changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()}{new_status.upper()}")
print(f"✅ Oppdatert status for {f['name']} - {c['name']}")
print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}")
else:
successes.append(f"{f['name']} ({c['name']}): {new_status.upper()}")
print(f" - {c['name']}: Ingen endring ({new_status.upper()})")
print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {new_status.upper()}")
except Exception as e:
err_msg = str(e).split('\n')[0]
@ -300,7 +301,6 @@ async def run_daily_scraping(facility_ids=None):
print("🏁 Ferdig.")
if __name__ == "__main__":
# --- 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()

View file

@ -1,14 +1,16 @@
"use client";
/**
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.4 (STABLE)
* TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.6 (STABLE)
* ---------------------------------------------------------------------------
* REGEL 1: Status-badge SKAL vises øverst til venstre. Bruk STATUS_MAP for tekst.
* REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER.
* Bruk STATUS_MAP for tekst.
* REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'
* fordi API-et ofte returnerer disse som strenger.
* fordi API-et ofte returnerer disse som strenger.
* REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst.
* REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2).
* REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler.
* REGEL 6: LOSBY-LOGIKK: Sjekk alle baner i arrayen. Hvis én er åpen, vis 'aapen'.
* REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille.
* Datoen har en svak bakgrunnsfarge for å sikre lesbarhet lys bakgrunn.
* ---------------------------------------------------------------------------
*/
@ -75,7 +77,7 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
return (
<div className="max-w-[1400px] mx-auto px-6 py-12 relative z-40">
<div className="text-center mb-6">
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100">
<button onClick={() => setSortMethod(sortMethod === 'dist' ? 'alpha' : 'dist')} className="bg-white px-6 py-3 rounded-full shadow-md text-[10px] font-black text-[#8bc34a] uppercase tracking-widest border border-gray-100 transition-colors">
{sortMethod === 'dist' ? "📍 Nærmeste baner først" : "🔠 Alfabetisk visning"} {processed.length} baner
</button>
</div>
@ -84,27 +86,52 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{processed.map((f: any) => {
// --- STATUS LOGIKK ---
const sArr = Array.isArray(f.statuses) ? f.statuses : [];
const activeStatus = sArr.find((s:any) => s.status === 'aapen') || sArr[0] || { status: 'ukjent' };
const rawStatus = (activeStatus.status || "ukjent").toLowerCase();
let statusColor = "bg-gray-400";
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
// Sikrer at vi har en gyldig array for banestatuser
const sArr = Array.isArray(f.statuses) && f.statuses.length > 0 ? f.statuses : [{ status: 'ukjent', name: 'Hovedbane' }];
// Formater datoen pent: "05. mars 2026"
const lastUpdated = f.status_updated_at
? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' })
: 'Ukjent';
return (
<Link href={`/golfbaner/${f.slug}`} key={f.id} className="bg-white rounded-[2.5rem] overflow-hidden shadow-sm hover:shadow-2xl transition-all duration-500 border border-gray-100 flex flex-col group relative">
<div className="h-64 relative overflow-hidden bg-gray-100">
<img src={f.image_url || "/Toppbilde-standard.jpg"} className="w-full h-full object-cover transition duration-1000 group-hover:scale-105" alt={f.name} />
{/* Status Badge */}
<div className={`absolute top-5 left-5 ${statusColor} text-white px-4 py-1.5 rounded-xl text-[10px] font-black uppercase shadow-xl z-20`}>
{STATUS_MAP[rawStatus] || rawStatus}
{/* Status Badges for ALLE baner på anlegget */}
<div className="absolute top-5 left-5 flex flex-col gap-2 z-20">
{sArr.map((course: any, idx: number) => {
const rawStatus = (course.status || "ukjent").toLowerCase();
let statusColor = "bg-gray-400";
if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]";
else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]";
else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500";
else if (rawStatus === 'stengt') statusColor = "bg-red-600";
else if (rawStatus === 'nedlagt') statusColor = "bg-black";
else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500";
return (
<div key={idx} className="flex items-center gap-3">
<div className={`${statusColor} text-white px-3 py-1.5 rounded-xl text-[9px] font-black uppercase shadow-lg backdrop-blur-sm bg-opacity-90 flex items-center gap-2 max-w-[200px]`}>
{sArr.length > 1 && (
<span className="opacity-80 border-r border-white/30 pr-2 truncate max-w-[90px]" title={course.name}>
{course.name}
</span>
)}
<span>{STATUS_MAP[rawStatus] || rawStatus}</span>
</div>
{/* Dato-pille ved siden av den øverste status-pillen */}
{idx === 0 && (
<div className="bg-white/30 backdrop-blur-sm text-[#11280f]/90 px-3 py-1.5 rounded-xl text-[11px] font-bold shadow-lg">
{lastUpdated}
</div>
)}
</div>
);
})}
</div>
{/* Avstandspille (Mørk oliven #2d3319) */}
@ -140,7 +167,7 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie
<div className="w-9 h-9 bg-[#ff5722] text-white rounded-full flex items-center justify-center font-black text-sm shadow-lg border-2 border-white translate-y-1">G</div>
)}
</div>
</div>
</div>
</div>
</Link>
);