Jobber med scraping
This commit is contained in:
parent
726785dfcc
commit
f25331c5f0
4 changed files with 76 additions and 52 deletions
Binary file not shown.
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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 på å 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()
|
||||
|
|
|
|||
|
|
@ -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 på 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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue