diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8509ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +.env +.env.* +.claude/ +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0563c50 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,254 @@ +# Role in Ecosystem + +**ScraperControl** is the data pipeline for the Church project — handling scraping, enrichment, ChromaDB semantic search, and data transfer to Neon production. It runs on the Synology NAS (Docker), not Vercel. + +- **Schema sync**: Handled by `npm run sync` from the `Church/` root directory. No need to manually copy schema files. +- **Coordinated deployment**: Use `npm run deploy` from `Church/` root for full pipeline deployment. +- **Schema source of truth**: BethelGuide — never run `prisma migrate` in ScraperControl. + +--- + +# Claude Instructions for ScraperControl + +## Project Overview + +**ScraperControl** is the scraping, enrichment, and data management backend for the NearestMass church finder. It provides: + +1. **Admin Dashboard** (Next.js): Job management UI at port 3001 +2. **Web Scrapers**: Playwright-based scrapers for extracting mass schedules from church websites +3. **Enrichment Pipelines**: Google Places, FreeSearch, reverse geocode enrichment +4. **ChromaDB Integration**: Semantic search for deduplication, content classification, and change detection +5. **Scheduler**: Database-driven job queue for automated scraping + +### Shared Database Architecture + +ScraperControl and BethelGuide share the **same NAS PostgreSQL database** (192.168.0.145:5434). BethelGuide is the **schema source of truth**. After any schema change in BethelGuide: + +1. Copy `BethelGuide/prisma/schema.prisma` → `ScraperControl/prisma/schema.prisma` +2. Run `npx prisma generate` in ScraperControl (NOT `migrate`) +3. Rebuild Docker containers if needed + +--- + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Admin UI | Next.js 16, React 19, Tailwind CSS v4 | +| Database | Shared NAS PostgreSQL (192.168.0.145:5434) | +| ORM | Prisma 7 (`@prisma/adapter-pg` + `pg` Pool) | +| Web Scraping | Playwright (headless Chromium) | +| Vector DB | ChromaDB (192.168.0.145:8000) | +| Embeddings | Ollama on MacBook (192.168.0.75:11434) with nomic-embed-text | +| Scheduling | node-cron + database-driven job queue | +| Containerization | Docker, Docker Compose | + +--- + +## Project Structure + +``` +src/ +├── app/ # Next.js Admin Dashboard (port 3001) +│ ├── page.tsx # Main dashboard (Jobs, Scrapes, Search tabs) +│ └── api/admin/ # Admin API routes +│ ├── jobs/ # Job management (GET/POST/PATCH) +│ ├── scrape-log/ # Recently scraped churches log +│ └── freesearch-log/ # FreeSearch results log +│ +├── chromadb/ # ChromaDB integration +│ ├── client.ts # ChromaDB client singleton +│ ├── embeddings.ts # OpenAI-compatible embedding helper (Ollama) +│ ├── collections.ts # Collection definitions (5 collections) +│ └── queries.ts # Query helpers per use case +│ +├── lib/ # Core business logic +│ ├── db.ts # Prisma client singleton +│ ├── admin-auth.ts # Timing-safe API key auth +│ ├── geo.ts # Haversine distance (minimal) +│ ├── scraper-service.ts # Scraper orchestration +│ ├── overpass-client.ts # OpenStreetMap Overpass API +│ ├── church-matcher.ts # Church matching/dedup +│ └── masstimes-scraper.ts # MassTimes.org integration +│ +└── scrapers/ # Web scraping system + ├── base-scraper.ts # Base class + ├── index.ts # Exports + ├── registry.ts # Strategy registry + ├── url-discovery.ts # Mass schedule URL finder + ├── strategies/ # Language-specific scrapers + │ ├── generic.ts # Fallback (10+ languages) + │ ├── english.ts + │ ├── french.ts + │ ├── german.ts + │ ├── italian.ts + │ └── spanish.ts + └── i18n/ # Internationalization + ├── day-names.ts # Day name patterns per language + └── day-ranges.ts # Day range parsing ("Monday-Friday") + +scripts/ # CLI scripts +├── scrape-churches.ts # Scrape churches by language +├── scrape-masstimes.ts # Scrape from MassTimes.org +├── import-osm-churches.ts # Import from OpenStreetMap +├── import-osm-region.ts # Import specific OSM region +├── enrich-with-google-places.ts # Google Places enrichment +├── enrich-with-freesearch.ts # FreeSearch website enrichment +├── enrich-with-reverse-geocode.ts # Reverse geocode enrichment +├── scheduler.ts # Background job scheduler +├── dedup-mass-schedules.ts # Mass schedule deduplication +├── dedup-churches.ts # Church dedup via ChromaDB +├── transfer-enriched-to-neon.ts # NAS → Neon production sync +├── populate-chromadb.ts # Bulk-populate ChromaDB collections +├── populate-city-normalized.ts +├── save-schedules-to-db.ts +├── test-scraper.ts # Test scraper on a URL +├── test-url-discovery.ts # Test URL discovery +├── test-edge-cases.ts # International edge case tests +└── debug/ # Debug/investigation scripts (~44 files) +``` + +--- + +## Common Commands + +```bash +# === DEVELOPMENT === +npm run dev # Start admin dashboard (localhost:3001) +npm run build # Build Next.js app + +# === SCRAPING === +npm run scrape:churches # Scrape churches (pass --language, --all flags) +npm run scrape:masstimes # Scrape from MassTimes.org +npm run test:scraper # Test scraper on a URL +npm run test:discover # Test URL discovery + +# === ENRICHMENT === +npm run enrich:places # Google Places enrichment +npm run enrich:freesearch # FreeSearch website enrichment + +# === DATA MANAGEMENT === +npm run dedup:masses # Deduplicate mass schedules +npm run import:osm # Import churches from OpenStreetMap +npm run transfer:neon # Transfer enriched data to Neon production +npm run scheduler # Start background job scheduler + +# === CHROMADB === +npx tsx scripts/populate-chromadb.ts --all # Populate all collections +npx tsx scripts/populate-chromadb.ts --collection church_identity # Single collection +npx tsx scripts/dedup-churches.ts --threshold 0.15 # Find duplicate churches + +# === DOCKER (on NAS) === +docker compose build scraper # Build scraper image +docker compose --profile tools run --rm scraper # Run one-off scraper +docker compose up -d scheduler freesearch-enrichment # Start background services +``` + +--- + +## ChromaDB Integration + +### Collections + +| Collection | Purpose | Documents | +|---|---|---| +| `church_identity` | Deduplication | `{name} {address} {city} {country}` | +| `search_results` | FreeSearch matching | `{title} {snippet} {url}` | +| `page_classification` | Content classification | Page text (first 2000 chars) | +| `schedule_sections` | Schedule detection | Text blocks with mass times | +| `page_snapshots` | Change detection | Full page text | + +### Infrastructure + +- **ChromaDB server**: `http://192.168.0.145:8000` (on NAS) +- **Embedding API**: `http://192.168.0.75:11434/v1` (Ollama on MacBook M1) +- **Embedding model**: `nomic-embed-text` (~270MB, fast on M1) + +### Prerequisite + +Ollama must be running on the MacBook with LAN access enabled: +```bash +OLLAMA_HOST=0.0.0.0 ollama serve +ollama pull nomic-embed-text +``` + +--- + +## Docker Services + +| Service | Profile | Purpose | +|---|---|---| +| app | (default) | Admin dashboard on port 3001 | +| scraper | tools | Generic scraper (on-demand) | +| scraper-english | scraper-english | English language scraper | +| scraper-french | scraper-french | French language scraper | +| scraper-german | scraper-german | German language scraper | +| scraper-italian | scraper-italian | Italian language scraper | +| scraper-spanish | scraper-spanish | Spanish language scraper | +| scraper-generic | scraper-generic | Generic fallback scraper | +| scheduler | (default) | Background job scheduler | +| freesearch-enrichment | (default) | FreeSearch enrichment daemon | + +--- + +## Environment Variables + +```env +DATABASE_URL="postgresql://postgres:postgres@192.168.0.145:5434/nearestmass" +ADMIN_API_KEY=your-secret-key +CHROMADB_URL=http://192.168.0.145:8000 +EMBEDDING_API_URL=http://192.168.0.75:11434/v1 +EMBEDDING_MODEL=nomic-embed-text +GOOGLE_PLACES_API_KEY=your-google-key +FREESEARCH_URL=http://192.168.0.145:3111 +``` + +--- + +## NAS Deployment + +ScraperControl is deployed on the Synology NAS at `/volume1/docker/scraper-control/`. + +### Container Layout + +| Container | Purpose | Port | +|-----------|---------|------| +| scraper-control-app-1 | Admin dashboard | 3001 | +| scraper-control-scheduler-1 | Job scheduler | - | +| scraper-control-freesearch-enrichment-1 | FreeSearch daemon | - | + +The `db` container (`nearestmass-db-1`) is managed by BethelGuide's compose file at `/volume1/docker/nearestmass/`. ScraperControl joins the same `nearestmass_default` external Docker network — no `depends_on` allowed since `db` is in a different compose file. + +### Deploying Updates + +```bash +# From local machine: +bash scripts/deploy-to-nas.sh + +# Or manually: +rsync -avz --exclude 'node_modules' --exclude '.next' --exclude '.git' --exclude '.env.local' --exclude '*.log' \ + /Users/albert/Documents/Projects/Church/ScraperControl/ albert@192.168.0.145:/volume1/docker/scraper-control/ + +ssh albert@192.168.0.145 'cd /volume1/docker/scraper-control && /usr/local/bin/docker compose build scraper && /usr/local/bin/docker compose up -d scheduler freesearch-enrichment' +``` + +### Rebuilding Admin Dashboard + +```bash +ssh albert@192.168.0.145 'cd /volume1/docker/scraper-control && /usr/local/bin/docker compose build app && /usr/local/bin/docker compose up -d app' +``` + +### Important Notes + +- **DO NOT** add `depends_on: db` to any service — `db` is in BethelGuide's compose file +- The `.env` on NAS uses host IP (`192.168.0.145:5434`) for scripts run outside Docker +- The `docker-compose.yml` environment overrides use `db:5432` (Docker DNS via shared network) +- Docker binary on NAS is at `/usr/local/bin/docker` + +### NAS Docker Health + +The Synology NAS (4 CPU, 17GB RAM) runs 23 containers across 7 projects. Church project containers (5) all have memory limits and log rotation. See `memory/nas-docker-health.md` for full inventory. + +**Scheduler hardening**: Uses `detached: true` + process group kill to prevent orphaned Chromium processes, `init: true` for zombie reaping, 24h job timeout, 8GB memory limit. + +**Maintenance**: Docker is on /volume1 (15TB free). Run `docker builder prune -f` occasionally to keep build cache tidy. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..246b5e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY prisma ./prisma/ +RUN npm ci && npx prisma generate + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3001 +ENV HOSTNAME="0.0.0.0" + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3001 +CMD ["node", "server.js"] diff --git a/Dockerfile.scraper b/Dockerfile.scraper new file mode 100644 index 0000000..159a355 --- /dev/null +++ b/Dockerfile.scraper @@ -0,0 +1,21 @@ +FROM node:20-bookworm-slim + +# Install Playwright system dependencies + Chromium +RUN apt-get update && \ + npx playwright install --with-deps chromium && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY prisma ./prisma/ +RUN npm ci +RUN npx prisma generate + +COPY src ./src/ +COPY scripts ./scripts/ +COPY tsconfig.json ./ + +# Default: run the masstimes scraper +CMD ["npx", "tsx", "scripts/scrape-masstimes.ts"] diff --git a/docs/plans/2026-02-25-parallel-scrapers-design.md b/docs/plans/2026-02-25-parallel-scrapers-design.md new file mode 100644 index 0000000..b56675e --- /dev/null +++ b/docs/plans/2026-02-25-parallel-scrapers-design.md @@ -0,0 +1,43 @@ +# Parallel Scrapers with Country Mapping Fix + +## Problem + +The scheduler runs scrapers sequentially — one language at a time. With 19,996 unscraped churches queued across 10 language scrapers, a full cycle takes days. The English scraper alone runs 30+ hours. Additionally, 1,414 churches in unmapped countries (BE, CH, IN, etc.) fall through to the generic scraper instead of being handled by appropriate language scrapers. + +## Changes + +### 1. Country Mapping Additions (scraper-service.ts) + +Add to `COUNTRY_SCRAPER_MAP`: +- **English**: IN, SG, MY, KE, JM, TT, GH, NG, ZA, TZ, UG +- **French**: BE, LU +- **German**: CH, SI +- **Italian**: HR, RO + +### 2. Parallel Pipeline Groups (scheduler.ts) + +Replace sequential `PIPELINE_PHASES` array with grouped phases: + +| Group | Phases | Concurrency | +|-------|--------|-------------| +| 1 | osm-import, gcatholic-import | Sequential (shared data) | +| 2 | english, french, german | Parallel (3) | +| 3 | polish, spanish, italian | Parallel (3) | +| 4 | portuguese, czech, dutch | Parallel (3) | +| 5 | hungarian, generic | Parallel (2) | + +Scheduler starts all jobs in a group simultaneously, waits for all to finish, then advances to the next group. + +### 3. Generic Scraper Deprioritized + +- Moved to last group +- Pre-check query: skip if no unscraped churches in generic queue (avoids wasteful re-scrapes) + +### 4. Resource Changes + +- Scheduler container memory limit: 4GB → 10GB (3 concurrent Playwright/Chromium processes) +- No new Docker containers or compose changes needed — existing child process spawning approach is kept + +## Approach + +Approach B: parallel child processes inside the scheduler container. No Docker-in-Docker. The scheduler already spawns `npx tsx` processes — we just allow multiple to run concurrently instead of waiting for each to finish before starting the next. diff --git a/docs/plans/2026-02-25-parallel-scrapers.md b/docs/plans/2026-02-25-parallel-scrapers.md new file mode 100644 index 0000000..5bd7341 --- /dev/null +++ b/docs/plans/2026-02-25-parallel-scrapers.md @@ -0,0 +1,423 @@ +# Parallel Scrapers Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Run language scrapers in parallel groups of 3, add missing country mappings, and deprioritize the generic scraper. + +**Architecture:** Replace sequential pipeline phases with grouped phases. Groups run their jobs concurrently (max 3), then wait for all to complete before advancing. Import phases stay sequential. The scheduler tracks a `groupJobsRemaining` counter per group instead of advancing on every job completion. + +**Tech Stack:** TypeScript, node child_process spawn, Prisma, Docker Compose + +--- + +### Task 1: Add Missing Country Mappings + +**Files:** +- Modify: `src/lib/scraper-service.ts:29-45` + +**Step 1: Update COUNTRY_SCRAPER_MAP** + +Add these entries to the existing `COUNTRY_SCRAPER_MAP` object at `src/lib/scraper-service.ts:29`: + +```typescript +const COUNTRY_SCRAPER_MAP: Record = { + US: 'english', CA: 'english', GB: 'english', + AU: 'english', NZ: 'english', IE: 'english', PH: 'english', + IN: 'english', SG: 'english', MY: 'english', KE: 'english', + JM: 'english', TT: 'english', GH: 'english', NG: 'english', + ZA: 'english', TZ: 'english', UG: 'english', + FR: 'french', BE: 'french', LU: 'french', + ES: 'spanish', MX: 'spanish', AR: 'spanish', CO: 'spanish', + CL: 'spanish', PE: 'spanish', EC: 'spanish', VE: 'spanish', + CR: 'spanish', PA: 'spanish', GT: 'spanish', CU: 'spanish', + HN: 'spanish', SV: 'spanish', NI: 'spanish', BO: 'spanish', + PY: 'spanish', UY: 'spanish', DO: 'spanish', + IT: 'italian', SM: 'italian', VA: 'italian', + HR: 'italian', RO: 'italian', + DE: 'german', AT: 'german', LI: 'german', + CH: 'german', SI: 'german', + PL: 'polish', + PT: 'portuguese', BR: 'portuguese', + NL: 'dutch', + CZ: 'czech', SK: 'czech', + HU: 'hungarian', +}; +``` + +Also update `buildLanguageFilter` at `src/lib/scraper-service.ts:346-463` to include the new countries in each language filter's country list: + +- `english` filter (line 356): add `'IN', 'SG', 'MY', 'KE', 'JM', 'TT', 'GH', 'NG', 'ZA', 'TZ', 'UG'` +- `french` filter (line 366): add `'BE', 'LU'` → `{ in: ['FR', 'BE', 'LU'] }` +- `spanish` filter: already has all needed countries +- `italian` filter (line 387): add `'HR', 'RO'` → `{ in: ['IT', 'SM', 'VA', 'HR', 'RO'] }` +- `german` filter (line 397): add `'CH', 'SI'` → `{ in: ['DE', 'AT', 'LI', 'CH', 'SI'] }` + +**Step 2: Verify build** + +Run: `npm run build` +Expected: Build succeeds with no errors + +**Step 3: Commit** + +```bash +git add src/lib/scraper-service.ts +git commit -m "feat: add missing country mappings to language scrapers + +Add BE/LU→french, CH/SI→german, HR/RO→italian, IN/SG/MY/KE/JM/TT/GH/NG/ZA/TZ/UG→english. +~1,400 previously unmapped churches now routed to proper language scrapers." +``` + +--- + +### Task 2: Rewrite Scheduler for Parallel Groups + +**Files:** +- Modify: `scripts/scheduler.ts` + +**Step 1: Replace pipeline data structure** + +Replace the `PipelinePhase` interface, `PIPELINE_PHASES` array (lines 27-49), and `CycleState` interface (lines 53-69) with: + +```typescript +interface PipelinePhase { + name: string; + type: string; + language?: string; + config: Record; +} + +interface PipelineGroup { + name: string; + phases: PipelinePhase[]; + mode: 'sequential' | 'parallel'; +} + +const PIPELINE_GROUPS: PipelineGroup[] = [ + { + name: 'imports', + mode: 'sequential', + phases: [ + { name: 'osm-import-p1', type: 'osm-import', config: { priority: 1 } }, + { name: 'gcatholic-import', type: 'gcatholic-import', config: { delay: 2000 } }, + ], + }, + { + name: 'scrapers-batch-1', + mode: 'parallel', + phases: [ + { name: 'scraper-english', type: 'scraper', language: 'english', config: { allMode: true, maxFailures: 10, language: 'english' } }, + { name: 'scraper-french', type: 'scraper', language: 'french', config: { allMode: true, maxFailures: 10, language: 'french' } }, + { name: 'scraper-german', type: 'scraper', language: 'german', config: { allMode: true, maxFailures: 10, language: 'german' } }, + ], + }, + { + name: 'scrapers-batch-2', + mode: 'parallel', + phases: [ + { name: 'scraper-polish', type: 'scraper', language: 'polish', config: { allMode: true, maxFailures: 10, language: 'polish' } }, + { name: 'scraper-spanish', type: 'scraper', language: 'spanish', config: { allMode: true, maxFailures: 10, language: 'spanish' } }, + { name: 'scraper-italian', type: 'scraper', language: 'italian', config: { allMode: true, maxFailures: 10, language: 'italian' } }, + ], + }, + { + name: 'scrapers-batch-3', + mode: 'parallel', + phases: [ + { name: 'scraper-portuguese', type: 'scraper', language: 'portuguese', config: { allMode: true, maxFailures: 10, language: 'portuguese' } }, + { name: 'scraper-czech', type: 'scraper', language: 'czech', config: { allMode: true, maxFailures: 10, language: 'czech' } }, + { name: 'scraper-dutch', type: 'scraper', language: 'dutch', config: { allMode: true, maxFailures: 10, language: 'dutch' } }, + ], + }, + { + name: 'scrapers-batch-4', + mode: 'parallel', + phases: [ + { name: 'scraper-hungarian', type: 'scraper', language: 'hungarian', config: { allMode: true, maxFailures: 10, language: 'hungarian' } }, + { name: 'scraper-generic', type: 'scraper', language: 'generic', config: { allMode: true, maxFailures: 10, language: 'generic' } }, + ], + }, +]; +``` + +**Step 2: Replace CycleState** + +```typescript +interface CycleState { + currentGroupIndex: number; + currentSequentialPhaseIndex: number; // for sequential groups, tracks which phase within the group + cycleNumber: number; + cycleStartedAt: Date | null; + lastCycleCompletedAt: Date | null; + waitingForCooldown: boolean; + activeGroupJobs: number; // how many jobs still running in the current group +} + +const cycleState: CycleState = { + currentGroupIndex: 0, + currentSequentialPhaseIndex: 0, + cycleNumber: 0, + cycleStartedAt: null, + lastCycleCompletedAt: null, + waitingForCooldown: false, + activeGroupJobs: 0, +}; +``` + +**Step 3: Rewrite pollAndAdvancePipeline** + +Replace the entire `pollAndAdvancePipeline` function (lines 306-385) and `advancePipelinePhase` function (lines 387-390) with: + +```typescript +async function pollAndAdvancePipeline(): Promise { + try { + // 1. Check for manual pending jobs from admin API (priority over pipeline) + if (runningJobs.size === 0) { + const manualJob = await prisma.backgroundJob.findFirst({ + where: { + status: 'pending', + NOT: { config: { path: ['pipelineManaged'], equals: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + if (manualJob) { + log(`Found manual job: ${manualJob.type}${manualJob.language ? `:${manualJob.language}` : ''} (${manualJob.id})`); + await startJobProcess( + manualJob.id, + manualJob.type, + manualJob.language, + manualJob.config as Record | null + ); + return; + } + } + + // 2. If jobs are still running for the current group, wait + if (cycleState.activeGroupJobs > 0) { + return; + } + + // 3. If in cooldown, check if expired + if (cycleState.waitingForCooldown) { + if (cycleState.lastCycleCompletedAt) { + const elapsed = Date.now() - cycleState.lastCycleCompletedAt.getTime(); + if (elapsed < CYCLE_COOLDOWN_MS) { + const remaining = Math.round((CYCLE_COOLDOWN_MS - elapsed) / 60_000); + if (remaining % 30 === 0 || remaining <= 5) { + log(`Cooldown: ${remaining} minutes remaining before next cycle`); + } + return; + } + } + cycleState.waitingForCooldown = false; + cycleState.currentGroupIndex = 0; + cycleState.currentSequentialPhaseIndex = 0; + log('Cooldown expired, starting new cycle'); + } + + // 4. If past the last group, complete the cycle + if (cycleState.currentGroupIndex >= PIPELINE_GROUPS.length) { + cycleState.cycleNumber++; + cycleState.lastCycleCompletedAt = new Date(); + cycleState.waitingForCooldown = true; + const cooldownHours = CYCLE_COOLDOWN_MS / (60 * 60 * 1000); + log(`=== Cycle ${cycleState.cycleNumber} complete! Entering ${cooldownHours}h cooldown ===`); + return; + } + + // 5. Start the current group + const group = PIPELINE_GROUPS[cycleState.currentGroupIndex]; + + if (cycleState.currentGroupIndex === 0 && cycleState.currentSequentialPhaseIndex === 0 && !cycleState.cycleStartedAt) { + cycleState.cycleStartedAt = new Date(); + log(`=== Starting cycle ${cycleState.cycleNumber + 1} ===`); + } + + if (group.mode === 'parallel') { + // Launch all phases in the group concurrently + log(`Pipeline group ${cycleState.currentGroupIndex + 1}/${PIPELINE_GROUPS.length}: ${group.name} (parallel, ${group.phases.length} jobs)`); + cycleState.activeGroupJobs = group.phases.length; + + for (const phase of group.phases) { + const jobId = await createPendingJob( + phase.type, + phase.language, + { ...phase.config, pipelineManaged: true } + ); + await startJobProcess(jobId, phase.type, phase.language || null, phase.config); + } + } else { + // Sequential: run one phase at a time within the group + const phaseIndex = cycleState.currentSequentialPhaseIndex; + if (phaseIndex >= group.phases.length) { + // All phases in this sequential group are done + cycleState.currentGroupIndex++; + cycleState.currentSequentialPhaseIndex = 0; + return; // Will pick up next group on next poll + } + + const phase = group.phases[phaseIndex]; + log(`Pipeline group ${cycleState.currentGroupIndex + 1}/${PIPELINE_GROUPS.length}: ${group.name} (sequential ${phaseIndex + 1}/${group.phases.length}: ${phase.name})`); + cycleState.activeGroupJobs = 1; + + const jobId = await createPendingJob( + phase.type, + phase.language, + { ...phase.config, pipelineManaged: true } + ); + await startJobProcess(jobId, phase.type, phase.language || null, phase.config); + } + } catch (err) { + logError(`Error in pipeline: ${err}`); + } +} + +function onJobCompleted(): void { + cycleState.activeGroupJobs--; + + if (cycleState.activeGroupJobs <= 0) { + cycleState.activeGroupJobs = 0; + const group = PIPELINE_GROUPS[cycleState.currentGroupIndex]; + + if (group?.mode === 'sequential') { + cycleState.currentSequentialPhaseIndex++; + // Check if there are more phases in this sequential group + if (cycleState.currentSequentialPhaseIndex < group.phases.length) { + return; // Don't advance group yet + } + } + + // Advance to next group + cycleState.currentGroupIndex++; + cycleState.currentSequentialPhaseIndex = 0; + log(`Group "${group?.name}" complete, advancing to group ${cycleState.currentGroupIndex + 1}`); + } +} +``` + +**Step 4: Update startJobProcess callbacks** + +In the `child.on('close')` callback (line 442) and `child.on('error')` callback (line 472), replace `advancePipelinePhase()` with `onJobCompleted()`. + +**Step 5: Update crash recovery** + +In `recoverFromCrash` (lines 259-268), replace the `PIPELINE_PHASES.findIndex` logic with a search through `PIPELINE_GROUPS`: + +```typescript + if (lastRunningPipelineJob) { + for (let gi = 0; gi < PIPELINE_GROUPS.length; gi++) { + const group = PIPELINE_GROUPS[gi]; + const phaseIdx = group.phases.findIndex( + p => p.type === lastRunningPipelineJob.type && + (p.language || null) === (lastRunningPipelineJob.language || null) + ); + if (phaseIdx >= 0) { + cycleState.currentGroupIndex = gi; + cycleState.currentSequentialPhaseIndex = group.mode === 'sequential' ? phaseIdx : 0; + log(`Resuming pipeline from group ${gi + 1}: ${group.name}`); + break; + } + } + } +``` + +**Step 6: Update heartbeat log in main()** + +Replace the heartbeat cron (lines 551-562) and the startup log (lines 574-580) to reference groups instead of phases: + +```typescript + cron.schedule('0 * * * *', () => { + const currentGroup = cycleState.currentGroupIndex < PIPELINE_GROUPS.length + ? PIPELINE_GROUPS[cycleState.currentGroupIndex].name + : 'none'; + const jobs = runningJobs.size > 0 + ? `Running: ${[...runningJobs.keys()].join(', ')}` + : 'No jobs running'; + const state = cycleState.waitingForCooldown + ? 'cooldown' + : `group ${cycleState.currentGroupIndex + 1}/${PIPELINE_GROUPS.length} (${currentGroup})`; + log(`Heartbeat: Cycle ${cycleState.cycleNumber + 1}, ${state}. ${jobs}`); + }, { timezone: 'UTC' }); +``` + +For the startup log: + +```typescript + log('=== Scheduler running (parallel grouped pipeline) ==='); + log(`Pipeline groups (${PIPELINE_GROUPS.length}):`); + for (let i = 0; i < PIPELINE_GROUPS.length; i++) { + const g = PIPELINE_GROUPS[i]; + const phaseNames = g.phases.map(p => p.name).join(', '); + log(` ${i + 1}. ${g.name} [${g.mode}]: ${phaseNames}`); + } +``` + +**Step 7: Remove dead Google Places env log** + +Delete lines 167-169 (the `GOOGLE_PLACES_API_KEY` log in `validateEnvironment`). + +**Step 8: Verify build** + +Run: `npm run build` +Expected: Build succeeds + +**Step 9: Commit** + +```bash +git add scripts/scheduler.ts +git commit -m "feat: parallel grouped pipeline scheduler + +Replace sequential pipeline with grouped phases. Import phases run +sequentially, scraper phases run in parallel groups of 3. This reduces +cycle time from days to hours. Generic scraper moved to last group." +``` + +--- + +### Task 3: Increase Scheduler Memory Limit + +**Files:** +- Modify: `docker-compose.yml:217-220` + +**Step 1: Increase memory limit** + +Change the scheduler service's `deploy.resources.limits.memory` from `4G` to `10G`: + +```yaml + deploy: + resources: + limits: + memory: 10G +``` + +**Step 2: Commit** + +```bash +git add docker-compose.yml +git commit -m "chore: increase scheduler memory to 10G for parallel scrapers" +``` + +--- + +### Task 4: Deploy and Verify + +**Step 1: Deploy to NAS** + +```bash +rsync -avz --exclude 'node_modules' --exclude '.next' --exclude '.git' --exclude '.env.local' --exclude '*.log' \ + /Users/albert/Documents/Projects/Church/ScraperControl/ albert@192.168.0.145:/volume1/docker/scraper-control/ +``` + +**Step 2: Rebuild and restart scheduler** + +```bash +ssh albert@192.168.0.145 'cd /volume1/docker/scraper-control && /usr/local/bin/docker compose build scheduler && /usr/local/bin/docker compose up -d scheduler' +``` + +**Step 3: Verify logs show parallel groups** + +```bash +ssh albert@192.168.0.145 '/usr/local/bin/docker logs --tail 30 scraper-control-scheduler-1' +``` + +Expected: Logs show "parallel grouped pipeline", group listings with `[parallel]` and `[sequential]` tags, and eventually multiple concurrent `Running:` entries in heartbeat. diff --git a/docs/plans/2026-02-26-horariosmisas-spain-design.md b/docs/plans/2026-02-26-horariosmisas-spain-design.md new file mode 100644 index 0000000..74e082c --- /dev/null +++ b/docs/plans/2026-02-26-horariosmisas-spain-design.md @@ -0,0 +1,72 @@ +# Spain Church Importer (horariosmisas.com) — Design + +## Overview + +Import ~10,000 Spanish churches with mass schedules from horariosmisas.com. Static WordPress site with fully permissive robots.txt and sitemaps. No Playwright needed — simple HTTP + HTML parsing. + +## Data Source + +- **Site:** https://horariosmisas.com +- **Coverage:** 18,000+ churches claimed, ~10,000 in sitemaps across 52 Spanish provinces +- **Data:** Church name, address, phone, website, mass schedules (summer/winter seasonal variants) +- **No coordinates** — addresses only. Forward geocoding via Nominatim as a separate pass. +- **robots.txt:** Fully permissive (`User-agent: * / Disallow:`) +- **Sitemaps:** 20 post sitemaps + 7 category sitemaps + +## Architecture + +### Two-Pass Approach + +**Pass 1: Import** — Fetch all churches from sitemaps, parse HTML, match against existing Spanish OSM churches, upsert with mass schedules. Unmatched churches created with address but no coordinates. + +**Pass 2: Geocode** — Forward-geocode unmatched churches via Nominatim public API (`address → lat/lng`). 1 req/sec rate limit. + +### Schema Change + +Add `horariosMisasId String? @unique` to Church model (same pattern as `philmassId`, `massSchedulesPhId`). Update church matcher and all existing importers. + +### URL Structure + +- Sitemap index: `/sitemap_index.xml` → 20 post sitemaps +- Church pages: `/{province}/{city}/{church-slug}/` +- Non-church posts (filtered out): `/misas-diarias/`, `/santos-del-dia/`, `/oraciones/`, etc. + +### HTML Parsing + +- **Name:** `

Church Name (City)

` — strip `(City)` suffix +- **Address:** `

📌 Street, PostalCode City (Province)

` +- **Phone:** `Teléfono: ...` +- **Website:** `Página Web: ...` +- **Schedule:** `` with `DÍA`/`HORARIO` columns + - Two seasonal tables: `☀️ Horario de verano` and `⛄ Misas en invierno` + - Import seasonally appropriate one (Oct-May = winter, Jun-Sep = summer) + - Day names: Lunes, Martes, Miércoles, Jueves, Viernes, Sábado, Domingos y Festivos + - Day ranges: "Lunes a Viernes" (Monday-Friday) + - Time format: `HH:MMh` (24-hour), multiple per cell via `
` + - Annotations stripped: `(familias)`, etc. + +### Matching Strategy + +1. `horariosMisasId` exact match (for re-imports) +2. Name + proximity against existing Spanish churches (from OSM) +3. Unmatched: create new church with address, country=ES, no coordinates + +### CLI + +``` +npx tsx scripts/import-horariosmisas.ts --all +npx tsx scripts/import-horariosmisas.ts --all --dry-run +npx tsx scripts/import-horariosmisas.ts --province madrid +npx tsx scripts/import-horariosmisas.ts --all --geocode +npx tsx scripts/import-horariosmisas.ts --geocode-only +npx tsx scripts/import-horariosmisas.ts --all --resume-from 5000 +``` + +### Rate Limiting + +- Import: 1.5s between requests (~10,000 × 1.5s = ~4.2 hours) +- Geocode: 1s between requests (Nominatim public API limit) + +### Scheduler Integration + +Add to PIPELINE_GROUPS imports group (sequential, after philmass-import). diff --git a/docs/plans/2026-02-26-horariosmisas-spain.md b/docs/plans/2026-02-26-horariosmisas-spain.md new file mode 100644 index 0000000..42cbdb9 --- /dev/null +++ b/docs/plans/2026-02-26-horariosmisas-spain.md @@ -0,0 +1,322 @@ +# Spain Church Importer (horariosmisas.com) — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Import ~10,000 Spanish churches with mass schedules from horariosmisas.com, with optional Nominatim forward geocoding for unmatched churches. + +**Architecture:** Sitemap-driven importer. Fetch 20 post sitemaps for church URLs, parse static WordPress HTML for names/addresses/schedule tables, match against existing Spanish OSM churches, upsert with mass schedules. Separate geocoding pass via Nominatim public API. + +**Tech Stack:** TypeScript, Prisma, HTML parsing (regex — no Playwright), Nominatim geocoding API. + +--- + +## Task 1: Add `horariosMisasId` to Prisma Schema + +**Files:** +- Modify: `prisma/schema.prisma` + +**Step 1: Add field and index** + +After the `philmassId` line (around line 38), add: + +```prisma +horariosMisasId String? @unique @map("horarios_misas_id") // horariosmisas.com URL slug +``` + +And add an index in the `@@index` block (around line 78): + +```prisma +@@index([horariosMisasId]) +``` + +**Step 2: Push schema to NAS database** + +```bash +npx prisma db push --accept-data-loss +``` + +Expected: `Your database is now in sync with your Prisma schema.` + +**Step 3: Regenerate Prisma client** + +```bash +npx prisma generate +``` + +**Step 4: Push schema to Neon production** + +```bash +npx prisma db push --url "$(grep DATABASE_URL .env.production | sed 's/DATABASE_URL="//' | sed 's/"$//')" --accept-data-loss +``` + +**Step 5: Commit** + +```bash +git add prisma/schema.prisma +git commit -m "feat: add horariosMisasId to Church model for Spain import" +``` + +--- + +## Task 2: Extend Church Matcher and Existing Importers + +**Files:** +- Modify: `src/lib/church-matcher.ts` +- Modify: `scripts/import-osm-churches.ts` +- Modify: `scripts/import-gcatholic.ts` +- Modify: `scripts/import-baidu-churches.ts` +- Modify: `scripts/import-osm-region.ts` +- Modify: `scripts/import-orarimesse.ts` +- Modify: `scripts/import-mass-schedules-ph.ts` +- Modify: `scripts/import-philmass.ts` + +### Step 1: Update church-matcher.ts + +In `ExistingChurch` interface (line ~11-26), add after `philmassId`: + +```typescript +horariosMisasId: string | null; +``` + +In `ChurchCandidate` type (line ~113-122), add after `philmassId`: + +```typescript +horariosMisasId?: string; +``` + +In `findDuplicateChurch()`, add a new pass after the fifth pass (philmassId match, line ~169-175). Before the proximity+name pass: + +```typescript +// Sixth pass: exact horariosMisasId match +if (candidate.horariosMisasId) { + const horariosMisasMatch = existingChurches.find( + (church) => church.horariosMisasId === candidate.horariosMisasId + ); + if (horariosMisasMatch) return horariosMisasMatch; +} +``` + +Update the comment on the proximity pass to say "Seventh pass". + +### Step 2: Update all existing importers + +In every importer that queries churches with a `select` clause containing `philmassId: true`, add: + +```typescript +horariosMisasId: true, +``` + +In every importer that creates/pushes churches with `philmassId: null`, add: + +```typescript +horariosMisasId: null, +``` + +**Files to update:** `import-osm-churches.ts`, `import-gcatholic.ts`, `import-baidu-churches.ts`, `import-osm-region.ts`, `import-orarimesse.ts`, `import-mass-schedules-ph.ts`, `import-philmass.ts` + +### Step 3: Verify build + +```bash +npx tsc --noEmit +``` + +Expected: No errors. + +### Step 4: Commit + +```bash +git add src/lib/church-matcher.ts scripts/import-*.ts +git commit -m "feat: add horariosMisasId to church matcher and all importers" +``` + +--- + +## Task 3: Create `import-horariosmisas.ts` + +**Files:** +- Create: `scripts/import-horariosmisas.ts` + +### Architecture + +This importer follows the exact same structure as `scripts/import-mass-schedules-ph.ts`. Key differences: + +- **Sitemap:** Fetches 20 post sitemaps from sitemap index (not a single sitemap) +- **URL filtering:** Church URLs have 3 path segments (`/{province}/{city}/{slug}/`). Non-church URLs (blog posts, daily readings) are filtered out. +- **Schedule parsing:** Two seasonal tables (summer/winter). Import seasonally appropriate one based on current month. +- **Day names:** Spanish (`Lunes`, `Martes`, etc.) with range support (`Lunes a Viernes`) +- **Times:** 24-hour `HH:MMh` format (e.g., `08:00h`, `20:30h`) +- **No coordinates:** Churches created with `latitude: 0, longitude: 0` — geocoded separately +- **Geocoding:** Optional `--geocode` flag uses Nominatim public API (1 req/sec) + +### Constants + +```typescript +const SITE_BASE = 'https://horariosmisas.com'; +const SITEMAP_INDEX_URL = `${SITE_BASE}/sitemap_index.xml`; +const USER_AGENT = 'NearestMass-Importer/1.0 (parish data aggregator; contact: privacy@nearestmass.com)'; +const REQUEST_DELAY_MS = 1500; +const NOMINATIM_DELAY_MS = 1100; +const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'; +``` + +### Spanish Day Mapping + +```typescript +const DAY_MAP: Record = { + 'domingos y festivos': [0], + 'domingos': [0], + 'domingo': [0], + 'lunes': [1], + 'martes': [2], + 'miércoles': [3], + 'miercoles': [3], + 'jueves': [4], + 'viernes': [5], + 'sábado': [6], + 'sabado': [6], + 'sábados': [6], + 'sabados': [6], +}; +``` + +### Sitemap Fetching + +1. Fetch sitemap index → extract `post-sitemap*.xml` URLs +2. Fetch each post sitemap → extract URLs with exactly 3 path segments +3. Filter out non-church URLs (patterns: `/misas-diarias/`, `/santos-del-dia/`, `/oraciones/`, `/noticias/`, `/blog/`, `/contacto/`, `/aviso-legal/`, `/politica-de-privacidad/`, `/politica-de-cookies/`) +4. Deduplicate by slug + +### HTML Parsing + +**Church name:** `

Church Name (City)

` → strip `(City)` suffix + +**Address:** `📌 Calle Goya, 26 28001 Madrid (Madrid)` → extract street, postal code (5-digit `\b\d{5}\b`), city (text after postal code), strip `(Province)` suffix + +**Phone:** `Teléfono: number` + +**Website:** `Página Web: ...` + +**Schedule tables:** Find `
` elements with DÍA/HORARIO headers. Split by seasonal headings (☀️ verano / ⛄ invierno). Pick seasonally appropriate section (Oct-May = winter, Jun-Sep = summer). Parse `
` cells: first cell = day name(s), second cell = times. Times in `HH:MMh` format extracted via regex `(\d{1,2}):(\d{2})\s*h?`. + +### Day Range Resolution + +Support ranges like `Lunes a Viernes` → [1,2,3,4,5] and compound entries like `Lunes, Miércoles y Viernes` → [1,3,5]. + +### Geocoding (--geocode / --geocode-only) + +Query Nominatim with: `{address}, Spain` → fallback to `{postalCode} {city}, Spain` → fallback to `{city}, Spain`. Use `countrycodes=es` parameter. Max 1 req/sec. + +### Matching Strategy + +1. `horariosMisasId` exact match (primary — for re-imports) +2. Name + proximity against existing Spanish OSM churches (secondary) +3. Unmatched: create new church with `latitude: 0, longitude: 0`, country=ES + +### CLI + +``` +--all Import all churches from sitemaps +--province Import only churches from this province +--dry-run No database writes +--geocode After import, geocode unmatched churches +--geocode-only Only geocode (skip import) +--resume-from Skip first N churches +--job-id Background job tracking +``` + +### Mass Schedule Language + +Set `language: 'Spanish'` on all created mass schedules. + +### Step 1: Create the file + +Use `scripts/import-mass-schedules-ph.ts` as the structural template. Implement all functions described above. + +### Step 2: Verify build + +```bash +npx tsc --noEmit +``` + +### Step 3: Dry-run test + +```bash +npx tsx scripts/import-horariosmisas.ts --province navarra --dry-run +``` + +### Step 4: Commit + +```bash +git add scripts/import-horariosmisas.ts +git commit -m "feat: add horariosmisas.com Spain church importer" +``` + +--- + +## Task 4: Add to Scheduler Pipeline and npm Scripts + +**Files:** +- Modify: `scripts/scheduler.ts` +- Modify: `package.json` + +### Step 1: Add to PIPELINE_GROUPS + +In `scripts/scheduler.ts`, in the `imports` group (line ~40-51), add after the `philmass-import` entry: + +```typescript +{ name: 'horariosmisas-import', type: 'horariosmisas-import', config: {} }, +``` + +### Step 2: Add getJobCommand case + +In the `getJobCommand` function (around line ~182), before the `default:` case, add: + +```typescript +case 'horariosmisas-import': { + const args = ['tsx', 'scripts/import-horariosmisas.ts', '--all', '--geocode']; + if (config?.province) args.push('--province', String(config.province)); + if (config?.resumeFrom) args.push('--resume-from', String(config.resumeFrom)); + return { command: 'npx', args }; +} +``` + +### Step 3: Add npm scripts + +In `package.json`, add after the `"import:philmass"` line: + +```json +"import:horariosmisas": "tsx scripts/import-horariosmisas.ts", +``` + +### Step 4: Verify build + +```bash +npx tsc --noEmit +``` + +### Step 5: Commit + +```bash +git add scripts/scheduler.ts package.json +git commit -m "feat: add horariosmisas import to scheduler pipeline" +``` + +--- + +## Verification + +1. **Dry run on single province**: `npx tsx scripts/import-horariosmisas.ts --province navarra --dry-run` + - Verify: church names parsed correctly, schedules extracted, matches found +2. **Dry run on Madrid**: `npx tsx scripts/import-horariosmisas.ts --province madrid --dry-run` + - Verify: larger province, summer/winter schedule selection, address parsing +3. **Single province real import**: `npx tsx scripts/import-horariosmisas.ts --province navarra` + - Verify: churches created/updated, mass schedules in database +4. **Geocode test**: `npx tsx scripts/import-horariosmisas.ts --geocode-only --dry-run` + - Verify: finds churches needing geocoding, Nominatim returns coordinates +5. **Full import**: `npx tsx scripts/import-horariosmisas.ts --all --geocode` + +## Runtime Estimate + +- Sitemap fetch: 20 sitemaps x 1.5s = ~30s +- Import: ~10,000 churches x 1.5s = ~4.2 hours +- Geocode: depends on unmatched count x 1.1s diff --git a/docs/plans/2026-03-01-weekdaymasses-importer-design.md b/docs/plans/2026-03-01-weekdaymasses-importer-design.md new file mode 100644 index 0000000..078af05 --- /dev/null +++ b/docs/plans/2026-03-01-weekdaymasses-importer-design.md @@ -0,0 +1,103 @@ +# weekdaymasses.org.uk Global Importer + +## Context + +weekdaymasses.org.uk is a UK-based Catholic directory covering ~3,500-4,000 churches globally with mass schedules, coordinates, addresses, and phone numbers. Covers GB, Ireland, and 49+ international countries (India, Sri Lanka, South Korea, Japan, and more). All data served on single HTML pages per area — no pagination or API needed. + +## Data Source + +Three area pages cover the entire site: + +| Page | URL | Est. Churches | +|------|-----|---------------| +| GB | `/en/area/gb/churches` | ~3,000+ | +| Ireland | `/en/area/ireland/churches` | ~300+ | +| Outside GB | `/en/area/outside-gb/churches` | ~152+ | + +Individual country/region pages (e.g. `/en/area/india/churches`) are subsets of these three. + +### Data per church + +- **Name**: h3 heading, format "Church Name (Location)" +- **Address**: plain text after mass times, with postal/zip code +- **Coordinates**: in map link query params `lat=XX.XXXX&lon=YY.YYYY&church_id=NNNNN` +- **Mass times**: format `Day: HH.MMam/pm(Language), HH.MMam/pm(Language)` +- **Phone**: `Tel: +XX XXXX XXXXXX` +- **Website**: occasional links +- **church_id**: unique numeric identifier in map links + +### Mass time format + +``` +Sunday: 6.30am(Tamil), 8.30am(Tamil), 5.30pm(English) +Mon Tue Wed Thu Fri: 6.30am(Tamil) +Saturday: 6.30am(Tamil), 5.30pm(English) +``` + +Day labels: `Sunday`, `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Saturday`, or combinations like `Mon Tue Wed Thu Fri`. Also `Holy Day` entries. + +Time format: `H.MMam/pm` — needs conversion to 24h `HH:MM`. + +Language in parentheses maps to our `language` field on mass_schedules. + +### Country detection + +The address is the last line of each church entry. Country can be detected by: +- GB: UK postal code pattern (e.g. `SW1A 1AA`) +- Ireland: Irish Eircode (e.g. `D01 F5P2`) or "Ireland" in address +- India: 6-digit postal code (e.g. `600088`) +- Others: country name at end of address, or fallback to the area page being scraped + +## Design + +### Schema + +Add to Church model in both BethelGuide and ScraperControl: + +```prisma +weekdayMassesId String? @unique @map("weekday_masses_id") +@@index([weekdayMassesId]) +``` + +### Script: `scripts/import-weekdaymasses.ts` + +Single script that: + +1. Fetches area pages (default: all 3; filterable with `--area gb|ireland|outside-gb|india|...`) +2. Parses HTML into structured church entries +3. Converts mass times from `H.MMam/pm` to `HH:MM` 24h format +4. Detects country from address patterns +5. Matches against existing churches by `weekdayMassesId` (exact) then proximity+name +6. Upserts churches and replaces mass schedules + +### HTML parsing strategy + +Each church is a block between consecutive h3 headings. Within each block: +- h3 content = church name +- Lines with day labels + times = mass schedule +- Map link = coordinates + church_id +- Last text block before next h3 = address +- `Tel:` prefix = phone + +### CLI flags + +- `--all` — import all 3 area pages +- `--area ` — import specific area (gb, ireland, outside-gb, india, sri-lanka, etc.) +- `--dry-run` — no database writes +- `--resume-from ` — skip first N churches +- `--job-id ` — background job tracking + +### Church matcher integration + +Add `weekdayMassesId` to `ExistingChurch`, `ChurchCandidate`, and a new match pass in `findDuplicateChurch()`. + +### Scheduler integration + +Add `weekdaymasses-import` to the sequential imports group in the pipeline, with `getJobCommand()` case and npm script. + +## Scope + +- ~3,500-4,000 churches with mass schedules +- Most GB/Ireland churches already in DB from OSM (will match and add schedules) +- India/Sri Lanka/international churches partially in DB from OSM/gcatholic +- Value: mass schedule data for thousands of churches that currently have none diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..fa1e26a --- /dev/null +++ b/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + poweredByHeader: false, + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..96c64ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8677 @@ +{ + "name": "scraper-control", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scraper-control", + "version": "1.0.0", + "hasInstallScript": true, + "dependencies": { + "@prisma/adapter-pg": "^7.3.0", + "@prisma/client": "^7.3.0", + "axios": "^1.13.3", + "chromadb": "^1.9.2", + "coordtransform": "^2.1.2", + "next": "^16.0.0", + "node-cron": "^3.0.3", + "open-location-code": "^1.0.3", + "openai": "^4.77.0", + "pg": "^8.17.2", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", + "@types/open-location-code": "^1.0.1", + "@types/pg": "^8.11.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "eslint": "^9.0.0", + "eslint-config-next": "^16.0.0", + "playwright": "^1.58.0", + "prisma": "^7.3.0", + "tailwindcss": "^4.0.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.0.tgz", + "integrity": "sha512-LWwTHaio0bMxvzahmpwpWqsZM0vTfMqwF8zo06YvILL/o47voaSfKzCVxZw/o9awf4fRgS5Vgthobikj9Dusaw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.4.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.0.tgz", + "integrity": "sha512-Sc+ncr7+ph1hMf1LQfn6UyEXDEamCd5pXMsx8Q3SBH0NGX+zjqs3eaABt9hXwbcK9l7f8UyK8ldxOWA2LyPynQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.4.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.0.tgz", + "integrity": "sha512-jTmWAOBGBSCT8n7SMbpjCpHjELgcDW9GNP/CeK6CeqjUFlEL6dn8Cl81t/NBDjJdXDm85XDJmc+PEQqqQee3xw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.0.tgz", + "integrity": "sha512-EnNrZMwZ9+O6UlG+YO9SP3VhVw4zwMahDRzQm3r0DQn9KeU5NwzmaDAY+BzACrgmaU71Id1/0FtWIDdl7xQp9g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.0.tgz", + "integrity": "sha512-fZicwzgFHvvPMrRLCUinrsBTdadJsi/1oirzShjmFvNLwtu2DYlkxwRVy5zEGhp85mrEGnLeS/PdNRCdE027+Q==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.0.tgz", + "integrity": "sha512-jEyE5LkqZ27Ba/DIOfCGOQl6nKMLxuwJNRceCfh7/LRs46UkIKn3bmkI97MEH2t7zkYV3PGBrUr+6sMJaHvc0A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.0.tgz", + "integrity": "sha512-H+dgpbbY3VN/j5hOSVP1LXsv/rU0w/4C2zh5PZUwo/Q3NqZjOvBlVvkhtziioRmeEZ3SBAqPCsf1sQ74sI3O/w==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.0", + "@prisma/engines-version": "7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57", + "@prisma/fetch-engine": "7.4.0", + "@prisma/get-platform": "7.4.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57.tgz", + "integrity": "sha512-5o3/bubIYdUeg38cyNf+VDq+LVtxvvi2393Fd1Uru52LPfkGJnmVbCaX1wBOAncgKR3BCloMJFD+Koog9LtYqQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.0.tgz", + "integrity": "sha512-fOUIoGzAPgtjHVs4DsVSnEDPBEauAmFeZr4Ej3tMwxywam7hHdRtCzgKagQBKcYIJuya8gzYrTqUoukzXtWJaA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.0.tgz", + "integrity": "sha512-IXPOYskT89UTVsntuSnMTiKRWCuTg5JMWflgEDV1OSKFpuhwP5vqbfF01/iwo9y6rCjR0sDIO+jdV5kq38/hgA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.0", + "@prisma/engines-version": "7.4.0-20.ab56fe763f921d033a6c195e7ddeb3e255bdbb57", + "@prisma/get-platform": "7.4.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.0.tgz", + "integrity": "sha512-fOUIoGzAPgtjHVs4DsVSnEDPBEauAmFeZr4Ej3tMwxywam7hHdRtCzgKagQBKcYIJuya8gzYrTqUoukzXtWJaA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/open-location-code": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/open-location-code/-/open-location-code-1.0.1.tgz", + "integrity": "sha512-txJKhrRpT2fw8RgZQUeT6qycBVIzRQ2zD7ecdLvc5R2QaeqSLcYIh1jxLSC+LjcpUT26Qyfw2UTttmhgk3sWmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chromadb": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/chromadb/-/chromadb-1.10.5.tgz", + "integrity": "sha512-+IeTjjf44pKUY3vp1BacwO2tFAPcWCd64zxPZZm98dVj/kbSBeaHKB2D6eX7iRLHS1PTVASuqoR6mAJ+nrsTBg==", + "license": "Apache-2.0", + "dependencies": { + "cliui": "^8.0.1", + "isomorphic-fetch": "^3.0.0" + }, + "engines": { + "node": ">=14.17.0" + }, + "peerDependencies": { + "@google/generative-ai": "^0.1.1", + "cohere-ai": "^5.0.0 || ^6.0.0 || ^7.0.0", + "ollama": "^0.5.0", + "openai": "^3.0.0 || ^4.0.0", + "voyageai": "^0.0.3-1" + }, + "peerDependenciesMeta": { + "@google/generative-ai": { + "optional": true + }, + "cohere-ai": { + "optional": true + }, + "ollama": { + "optional": true + }, + "openai": { + "optional": true + }, + "voyageai": { + "optional": true + } + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/coordtransform": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/coordtransform/-/coordtransform-2.1.2.tgz", + "integrity": "sha512-0xLJApBlrUP+clyLJWIaqg4GXE5JTbAJb5d/CDMqebIksAMMze8eAyO6YfHEIxWJ+c42mXoMHBzWTeUrG7RFhw==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.1.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/open-location-code": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz", + "integrity": "sha512-DBm14BSn40Ee241n80zIFXIT6+y8Tb0I+jTdosLJ8Sidvr2qONvymwqymVbHV2nS+1gkDZ5eTNpnOIVV0Kn2fw==", + "license": "Apache-2.0" + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prisma": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.0.tgz", + "integrity": "sha512-n2xU9vSaH4uxZF/l2aKoGYtKtC7BL936jM9Q94Syk1zOD39t/5hjDUxMgaPkVRDX5wWEMsIqvzQxoebNIesOKw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.4.0", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.4.0", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/scripts/debug/analyze-enrichment-priority.ts b/scripts/debug/analyze-enrichment-priority.ts new file mode 100644 index 0000000..7f138eb --- /dev/null +++ b/scripts/debug/analyze-enrichment-priority.ts @@ -0,0 +1,165 @@ +import { config } from 'dotenv'; +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; + +// Load .env.local first, then .env +config({ path: '.env.local' }); +config({ path: '.env' }); + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set'); +} + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +interface CountryStats { + country: string; + totalChurches: number; + withWebsite: number; + withoutWebsite: number; + websitePercent: number; + needEnrichment: number; + priority: number; +} + +async function analyzeEnrichmentPriority() { + try { + console.log('Analyzing enrichment priority by country...\n'); + + // Get all OSM churches grouped by country + const churches = await prisma.church.findMany({ + where: { + source: 'osm', + }, + select: { + country: true, + hasWebsite: true, + website: true, + }, + }); + + // Group by country and calculate stats + const byCountry = churches.reduce((acc, church) => { + const country = church.country || 'Unknown'; + if (!acc[country]) { + acc[country] = { + country, + totalChurches: 0, + withWebsite: 0, + withoutWebsite: 0, + websitePercent: 0, + needEnrichment: 0, + priority: 0, + }; + } + + acc[country].totalChurches++; + if (church.hasWebsite || church.website) { + acc[country].withWebsite++; + } else { + acc[country].withoutWebsite++; + acc[country].needEnrichment++; + } + + return acc; + }, {} as Record); + + // Calculate percentages and priority score + const stats = Object.values(byCountry).map((stat) => { + stat.websitePercent = (stat.withWebsite / stat.totalChurches) * 100; + + // Priority formula: + // - Weight heavily on churches needing enrichment (80%) + // - Weight on low website coverage (20%) + // This favors large countries with low coverage + const needWeight = stat.needEnrichment / 1000; // Normalize to thousands + const coverageGap = 100 - stat.websitePercent; // How much coverage is missing + stat.priority = needWeight * 0.8 + (coverageGap / 100) * needWeight * 0.2; + + return stat; + }); + + // Sort by priority (highest first) + stats.sort((a, b) => b.priority - a.priority); + + // Display results + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log('ENRICHMENT PRIORITY RANKING'); + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log(''); + console.log('Priority formula: (churches_needing_enrichment * 0.8) + (coverage_gap * 0.2)'); + console.log('This favors countries with many churches and low website coverage.'); + console.log(''); + console.log('Rank | Country | Total | Need Enrichment | Coverage | Priority Score'); + console.log('─────┼─────────┼───────┼────────────────┼──────────┼────────────────'); + + stats.forEach((stat, index) => { + const rank = String(index + 1).padStart(4); + const country = stat.country.padEnd(7); + const total = String(stat.totalChurches).padStart(5); + const need = String(stat.needEnrichment).padStart(15); + const coverage = `${stat.websitePercent.toFixed(1)}%`.padStart(8); + const priority = stat.priority.toFixed(2).padStart(14); + + console.log(`${rank} | ${country} | ${total} | ${need} | ${coverage} | ${priority}`); + }); + + console.log(''); + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log(''); + + // Show top 10 with details + console.log('TOP 10 COUNTRIES TO PRIORITIZE:'); + console.log(''); + + stats.slice(0, 10).forEach((stat, index) => { + console.log(`${index + 1}. ${stat.country}`); + console.log(` Total churches: ${stat.totalChurches.toLocaleString()}`); + console.log(` Need enrichment: ${stat.needEnrichment.toLocaleString()} (${(100 - stat.websitePercent).toFixed(1)}% missing)`); + console.log(` Current coverage: ${stat.websitePercent.toFixed(1)}%`); + console.log(` Priority score: ${stat.priority.toFixed(2)}`); + console.log(''); + }); + + // Calculate enrichment timeline + const totalNeedEnrichment = stats.reduce((sum, s) => sum + s.needEnrichment, 0); + const daysAtFullSpeed = Math.ceil(totalNeedEnrichment / 390); + const monthsAtFullSpeed = (daysAtFullSpeed / 30).toFixed(1); + + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log('ENRICHMENT TIMELINE'); + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log(`Total churches needing enrichment: ${totalNeedEnrichment.toLocaleString()}`); + console.log(`At 390 churches/day (free tier): ${daysAtFullSpeed} days (~${monthsAtFullSpeed} months)`); + console.log(''); + + // Output country priority order for the script + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log('COUNTRY PRIORITY ORDER (for enrichment script)'); + console.log('═══════════════════════════════════════════════════════════════════════════'); + console.log(''); + console.log('const COUNTRY_PRIORITY = ['); + stats + .filter((s) => s.needEnrichment > 0) + .forEach((stat, index) => { + const comma = index < stats.filter((s) => s.needEnrichment > 0).length - 1 ? ',' : ''; + console.log(` '${stat.country}'${comma} // ${stat.needEnrichment.toLocaleString()} churches`); + }); + console.log('];'); + console.log(''); + + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +analyzeEnrichmentPriority(); diff --git a/scripts/debug/check-2-real-bugs.ts b/scripts/debug/check-2-real-bugs.ts new file mode 100644 index 0000000..0a0cda7 --- /dev/null +++ b/scripts/debug/check-2-real-bugs.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env tsx +/** + * Check the 2 potentially real bugs + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function checkRealBugs() { + const scraper = new GenericScraper(); + await scraper.init(); + + console.log('=== 1. Iglesia de San Fernando (trying Spanish page) ===\n'); + + scraper.setCountry('ES'); + const spanishUrl = 'https://www.parroquiasanfernandomaspalomas.net/'; // Remove /de/ + const result1 = await scraper.scrape(spanishUrl); + + console.log(`URL: ${spanishUrl}`); + console.log(`Success: ${result1.success}`); + console.log(`Schedules: ${result1.schedules.length}`); + console.log(`Error: ${result1.error || 'none'}\n`); + + if (result1.schedules.length > 0) { + console.log('Sample schedules:'); + result1.schedules.slice(0, 5).forEach(s => { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + console.log(` ${days[s.dayOfWeek]} ${s.time} - ${s.language} ${s.massType}`); + }); + } + + console.log('\n=== 2. Kościół (Poland) ===\n'); + + scraper.setCountry('PL'); + const result2 = await scraper.scrape('http://parafialubojna.pl'); + + console.log(`Success: ${result2.success}`); + console.log(`Schedules: ${result2.schedules.length}`); + console.log(`Error: ${result2.error || 'none'}\n`); + + if (result2.schedules.length > 0) { + console.log('Sample schedules:'); + result2.schedules.slice(0, 5).forEach(s => { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + console.log(` ${days[s.dayOfWeek]} ${s.time} - ${s.language} ${s.massType}`); + }); + } else if (result2.rawHtml) { + const text = result2.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Look for Polish schedule keywords + const scheduleIndex = text.indexOf('msze') || text.indexOf('msza') || text.indexOf('nabożeńst'); + if (scheduleIndex !== -1) { + const snippet = text.substring(scheduleIndex, scheduleIndex + 300); + console.log('Found schedule section:'); + console.log(snippet); + } + } + + await scraper.close(); +} + +checkRealBugs().catch(console.error); diff --git a/scripts/debug/check-enrichment-detail.ts b/scripts/debug/check-enrichment-detail.ts new file mode 100644 index 0000000..a5db2ea --- /dev/null +++ b/scripts/debug/check-enrichment-detail.ts @@ -0,0 +1,79 @@ +import { Pool } from 'pg'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// Load .env.local first (takes precedence), then .env +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }); +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function checkEnrichmentDetail() { + try { + console.log('Connecting to database...\n'); + + // Check churches awaiting enrichment + const pendingResult = await pool.query(` + SELECT + country, + COUNT(*) as pending_count + FROM churches + WHERE google_place_id IS NULL + GROUP BY country + ORDER BY pending_count DESC + LIMIT 20; + `); + + console.log('=== Churches Awaiting Enrichment (Top 20 Countries) ==='); + let totalPending = 0; + pendingResult.rows.forEach((row) => { + console.log(`${row.country}: ${row.pending_count} churches`); + totalPending += parseInt(row.pending_count); + }); + console.log(`\nTotal pending shown: ${totalPending}`); + + // Check total stats + const statsResult = await pool.query(` + SELECT + COUNT(*) as total_churches, + COUNT(CASE WHEN google_place_id IS NOT NULL THEN 1 END) as enriched, + COUNT(CASE WHEN google_place_id IS NULL THEN 1 END) as pending + FROM churches; + `); + + console.log('\n=== Overall Stats ==='); + console.log(`Total churches: ${statsResult.rows[0].total_churches}`); + console.log(`Enriched: ${statsResult.rows[0].enriched} (${((statsResult.rows[0].enriched / statsResult.rows[0].total_churches) * 100).toFixed(2)}%)`); + console.log(`Pending: ${statsResult.rows[0].pending} (${((statsResult.rows[0].pending / statsResult.rows[0].total_churches) * 100).toFixed(2)}%)`); + + // Check enrichment rate + const rateResult = await pool.query(` + SELECT + DATE(updated_at) as date, + COUNT(*) as enriched_count + FROM churches + WHERE google_place_id IS NOT NULL + AND updated_at > NOW() - INTERVAL '7 days' + GROUP BY DATE(updated_at) + ORDER BY date DESC; + `); + + console.log('\n=== Enrichment Activity (Last 7 Days) ==='); + if (rateResult.rows.length === 0) { + console.log('No enrichment activity in the last 7 days'); + } else { + rateResult.rows.forEach((row) => { + console.log(`${row.date}: ${row.enriched_count} churches`); + }); + } + + } catch (error) { + console.error('Error checking enrichment detail:', error); + } finally { + await pool.end(); + } +} + +checkEnrichmentDetail(); diff --git a/scripts/debug/check-enrichment-status.ts b/scripts/debug/check-enrichment-status.ts new file mode 100644 index 0000000..920c2c8 --- /dev/null +++ b/scripts/debug/check-enrichment-status.ts @@ -0,0 +1,146 @@ +import { config } from 'dotenv'; +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; + +// Load .env.local first, then .env +config({ path: '.env.local' }); +config({ path: '.env' }); + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set'); +} + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function checkEnrichmentStatus() { + try { + console.log('Checking enrichment status...\n'); + + // Overall stats + const totalOSM = await prisma.church.count({ + where: { source: 'osm' }, + }); + + const enriched = await prisma.church.count({ + where: { + source: 'osm', + googlePlaceId: { not: null }, + }, + }); + + const withWebsite = await prisma.church.count({ + where: { + source: 'osm', + hasWebsite: true, + }, + }); + + const needEnrichment = await prisma.church.count({ + where: { + source: 'osm', + hasWebsite: false, + website: null, + }, + }); + + // Recently enriched (last 24 hours) + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const recentlyEnriched = await prisma.church.count({ + where: { + source: 'osm', + googlePlaceId: { not: null }, + updatedAt: { gte: yesterday }, + }, + }); + + // Get top 10 priority countries status + const PRIORITY_COUNTRIES = ['FR', 'DE', 'ES', 'PL', 'BR', 'PT', 'PH', 'CZ', 'MX', 'HU']; + + console.log('═══════════════════════════════════════════════════════════════'); + console.log('OVERALL ENRICHMENT STATUS'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(`Total OSM churches: ${totalOSM.toLocaleString()}`); + console.log(`Churches with Google Place ID: ${enriched.toLocaleString()} (${((enriched / totalOSM) * 100).toFixed(2)}%)`); + console.log(`Churches with websites: ${withWebsite.toLocaleString()} (${((withWebsite / totalOSM) * 100).toFixed(2)}%)`); + console.log(`Need enrichment: ${needEnrichment.toLocaleString()} (${((needEnrichment / totalOSM) * 100).toFixed(2)}%)`); + console.log(''); + console.log(`Recently enriched (24h): ${recentlyEnriched.toLocaleString()}`); + console.log(''); + + // Priority countries breakdown + console.log('═══════════════════════════════════════════════════════════════'); + console.log('TOP 10 PRIORITY COUNTRIES STATUS'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(''); + + for (const country of PRIORITY_COUNTRIES) { + const total = await prisma.church.count({ + where: { source: 'osm', country }, + }); + + const countryEnriched = await prisma.church.count({ + where: { + source: 'osm', + country, + googlePlaceId: { not: null }, + }, + }); + + const countryWithWebsite = await prisma.church.count({ + where: { + source: 'osm', + country, + OR: [ + { hasWebsite: true }, + { googlePlaceId: { not: null } }, + ], + }, + }); + + const countryNeedEnrichment = await prisma.church.count({ + where: { + source: 'osm', + country, + hasWebsite: false, + website: null, + }, + }); + + const websitePercent = (countryWithWebsite / total) * 100; + const enrichedPercent = (countryEnriched / total) * 100; + + console.log(`${country.padEnd(4)} | Total: ${String(total).padStart(6)} | Enriched: ${String(countryEnriched).padStart(5)} (${enrichedPercent.toFixed(1)}%) | With Website: ${String(countryWithWebsite).padStart(5)} (${websitePercent.toFixed(1)}%) | Need: ${String(countryNeedEnrichment).padStart(6)}`); + } + + console.log(''); + + // Estimate timeline + const daysRemaining = Math.ceil(needEnrichment / 390); + const monthsRemaining = (daysRemaining / 30).toFixed(1); + + console.log('═══════════════════════════════════════════════════════════════'); + console.log('TIMELINE ESTIMATE'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(`At 390 churches/day:`); + console.log(` Days remaining: ${daysRemaining} days`); + console.log(` Months remaining: ~${monthsRemaining} months`); + console.log(` Estimated completion: ${new Date(Date.now() + daysRemaining * 24 * 60 * 60 * 1000).toLocaleDateString()}`); + console.log(''); + + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +checkEnrichmentStatus(); diff --git a/scripts/debug/check-enrichment.ts b/scripts/debug/check-enrichment.ts new file mode 100644 index 0000000..d1354df --- /dev/null +++ b/scripts/debug/check-enrichment.ts @@ -0,0 +1,78 @@ +import { Pool } from 'pg'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// Load .env.local first (takes precedence), then .env +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }); +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function checkEnrichment() { + try { + console.log('Connecting to database...'); + + // Check total enriched churches + const totalResult = await pool.query(` + SELECT + COUNT(*) as total_enriched, + COUNT(CASE WHEN updated_at > NOW() - INTERVAL '24 hours' THEN 1 END) as enriched_last_24h, + MAX(updated_at) as last_enrichment + FROM churches + WHERE google_place_id IS NOT NULL; + `); + + console.log('\n=== Google Enrichment Summary ==='); + console.log(`Total churches with Google Place ID: ${totalResult.rows[0].total_enriched}`); + console.log(`Enriched in last 24 hours: ${totalResult.rows[0].enriched_last_24h}`); + console.log(`Last enrichment: ${totalResult.rows[0].last_enrichment}`); + + // Check by country + const countryResult = await pool.query(` + SELECT + country, + COUNT(*) as enriched_count, + COUNT(CASE WHEN updated_at > NOW() - INTERVAL '24 hours' THEN 1 END) as enriched_last_24h + FROM churches + WHERE google_place_id IS NOT NULL + GROUP BY country + ORDER BY enriched_last_24h DESC + LIMIT 10; + `); + + console.log('\n=== Top Countries Enriched (Last 24h) ==='); + countryResult.rows.forEach((row) => { + console.log(`${row.country}: ${row.enriched_last_24h} new / ${row.enriched_count} total`); + }); + + // Check recent enrichments with details + const recentResult = await pool.query(` + SELECT + name, + city, + country, + google_place_id, + updated_at + FROM churches + WHERE google_place_id IS NOT NULL + AND updated_at > NOW() - INTERVAL '24 hours' + ORDER BY updated_at DESC + LIMIT 20; + `); + + console.log('\n=== Recent Enrichments (Last 24h, sample) ==='); + recentResult.rows.forEach((row) => { + const timestamp = row.updated_at ? new Date(row.updated_at).toISOString() : 'unknown'; + console.log(`${row.name}, ${row.city}, ${row.country} - ${timestamp}`); + }); + + } catch (error) { + console.error('Error checking enrichment:', error); + } finally { + await pool.end(); + } +} + +checkEnrichment(); diff --git a/scripts/debug/check-german-office-hours.ts b/scripts/debug/check-german-office-hours.ts new file mode 100644 index 0000000..76df827 --- /dev/null +++ b/scripts/debug/check-german-office-hours.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env tsx +/** + * Check the full section text for German church to understand office hours pattern + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function checkGerman() { + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('DE'); + + const result = await scraper.scrape('https://www.alterpeter.de/'); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find Monday section + const montagIndex = text.indexOf('montag'); + if (montagIndex !== -1) { + const montagContext = text.substring(montagIndex, montagIndex + 200); + console.log('=== Monday (Montag) context ==='); + console.log(montagContext); + console.log(''); + } + + // Find Sunday section + const sonntagIndex = text.indexOf('sonntag'); + if (sonntagIndex !== -1) { + const sonntagContext = text.substring(sonntagIndex, sonntagIndex + 300); + console.log('=== Sunday (Sonntag) context ==='); + console.log(sonntagContext); + console.log(''); + } + } + + await scraper.close(); +} + +checkGerman().catch(console.error); diff --git a/scripts/debug/check-neon-poland.ts b/scripts/debug/check-neon-poland.ts new file mode 100644 index 0000000..7c43f0e --- /dev/null +++ b/scripts/debug/check-neon-poland.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env tsx +import { config } from 'dotenv'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +// Load environment variables +config({ path: '.env.local' }); +config({ path: '.env' }); + +async function main() { + const connectionString = process.env.DATABASE_URL || ''; + console.log('DATABASE_URL:', connectionString.replace(/:[^:@]+@/, ':****@')); + + const pool = new Pool({ connectionString }); + const adapter = new PrismaPg(pool); + const prisma = new PrismaClient({ adapter }); + + console.log('PrismaClient created:', !!prisma); + console.log('prisma.churches:', !!prisma.churches); + + await prisma.$connect(); + + const count = await prisma.churches.count({ where: { country: 'PL' } }); + console.log(`Poland churches in Neon: ${count}`); + + const withSchedules = await prisma.churches.count({ + where: { + country: 'PL', + massSchedules: { some: {} } + } + }); + console.log(`With mass schedules: ${withSchedules}`); + + // Sample a few churches + const sample = await prisma.churches.findMany({ + where: { country: 'PL' }, + include: { massSchedules: true }, + take: 3 + }); + + console.log('\nSample churches:'); + for (const church of sample) { + console.log(` - ${church.name} (${church.city}): ${church.massSchedules.length} schedules`); + } + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch(console.error); diff --git a/scripts/debug/check-niedziela-occurrences.ts b/scripts/debug/check-niedziela-occurrences.ts new file mode 100644 index 0000000..50e7371 --- /dev/null +++ b/scripts/debug/check-niedziela-occurrences.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env tsx +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function check() { + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('PL'); + + const result = await scraper.scrape('http://parafialubojna.pl'); + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + const niedziela_matches = []; + let idx = 0; + while ((idx = text.indexOf('niedziela', idx)) !== -1) { + niedziela_matches.push({ + position: idx, + context: text.substring(Math.max(0, idx-30), idx+70) + }); + idx++; + } + + console.log(`niedziela occurrences: ${niedziela_matches.length}\n`); + niedziela_matches.forEach((m, i) => { + console.log(`Occurrence ${i+1} at position ${m.position}:`); + console.log(` "${m.context}"`); + console.log(''); + }); + } + await scraper.close(); +} + +check(); diff --git a/scripts/debug/check-osm-counts.ts b/scripts/debug/check-osm-counts.ts new file mode 100644 index 0000000..ee95165 --- /dev/null +++ b/scripts/debug/check-osm-counts.ts @@ -0,0 +1,34 @@ +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }); +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +import { Pool } from 'pg'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +async function main() { + const totalRes = await pool.query(`SELECT COUNT(*) as total FROM churches WHERE source = 'osm'`); + console.log('Total OSM churches:', totalRes.rows[0].total); + + const countryRes = await pool.query(`SELECT country, COUNT(*) as count FROM churches WHERE source = 'osm' AND country IS NOT NULL GROUP BY country ORDER BY count DESC LIMIT 40`); + console.log('\nTop 40 countries by OSM church count:'); + for (const row of countryRes.rows) { + console.log(` ${row.country}: ${row.count}`); + } + + // Check key countries that were under-imported + const keyCountries = ['AT','HR','UA','RO','LV','BY','RS','BA','MK','AL','EE','GE','AM','RU','IN','JP','CA','US','MX','AR','CO','ID','CN']; + const keyRes = await pool.query(`SELECT country, COUNT(*) as count FROM churches WHERE source = 'osm' AND country = ANY($1) GROUP BY country ORDER BY count DESC`, [keyCountries]); + console.log('\nKey countries to check (were under-imported):'); + const found = new Map(keyRes.rows.map((r: any) => [r.country, r.count])); + for (const c of keyCountries) { + console.log(` ${c}: ${found.get(c) || 0}`); + } + + // Total countries + const countriesRes = await pool.query(`SELECT COUNT(DISTINCT country) as total FROM churches WHERE source = 'osm'`); + console.log(`\nTotal countries with OSM data: ${countriesRes.rows[0].total}`); + + await pool.end(); +} +main(); diff --git a/scripts/debug/check-production-db.ts b/scripts/debug/check-production-db.ts new file mode 100755 index 0000000..6cd9aaf --- /dev/null +++ b/scripts/debug/check-production-db.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env tsx + +/** + * Check production database (Neon) for data + * Run with: npx tsx scripts/check-production-db.ts + */ + +import { Pool } from 'pg'; +import { config } from 'dotenv'; + +// Load environment variables (.env.local overrides .env) +config({ path: '.env.local' }); +config({ path: '.env' }); + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + console.error('❌ DATABASE_URL not found in environment'); + process.exit(1); +} + +console.log('🔍 Checking production database...'); +console.log('📍 Connection:', connectionString.includes('neon.tech') ? 'Neon (Production)' : 'localhost'); + +const pool = new Pool({ connectionString }); + +async function checkDatabase() { + try { + // Test connection + console.log('\n1️⃣ Testing database connection...'); + await pool.query('SELECT NOW()'); + console.log('✅ Database connection successful'); + + // Check tables exist + console.log('\n2️⃣ Checking tables...'); + const tablesResult = await pool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name + `); + console.log(`✅ Found ${tablesResult.rows.length} tables:`, tablesResult.rows.map(r => r.table_name).join(', ')); + + // Check churches + console.log('\n3️⃣ Checking churches...'); + const churchCount = await pool.query('SELECT COUNT(*) FROM "churches"'); + console.log(`📊 Churches: ${churchCount.rows[0].count}`); + + if (parseInt(churchCount.rows[0].count) > 0) { + const sampleChurch = await pool.query('SELECT id, name, city, state, latitude, longitude FROM "churches" LIMIT 1'); + console.log('📍 Sample church:', sampleChurch.rows[0]); + } else { + console.log('⚠️ No churches found in database!'); + } + + // Check mass schedules + console.log('\n4️⃣ Checking mass schedules...'); + const massCount = await pool.query('SELECT COUNT(*) FROM "mass_schedules"'); + console.log(`📊 Mass schedules: ${massCount.rows[0].count}`); + + // Check liturgical days + console.log('\n5️⃣ Checking liturgical days...'); + const liturgicalCount = await pool.query('SELECT COUNT(*) FROM "liturgical_days"'); + console.log(`📊 Liturgical days: ${liturgicalCount.rows[0].count}`); + + // Check today's liturgical data + const today = new Date().toISOString().split('T')[0]; + const todayData = await pool.query( + 'SELECT * FROM "liturgical_days" WHERE date = $1', + [today] + ); + if (todayData.rows.length > 0) { + console.log(`✅ Today's liturgical data exists:`, todayData.rows[0].season); + } else { + console.log(`⚠️ No liturgical data for today (${today})`); + } + + console.log('\n✨ Database check complete!\n'); + + } catch (error) { + console.error('❌ Error:', error); + process.exit(1); + } finally { + await pool.end(); + } +} + +checkDatabase(); diff --git a/scripts/debug/check-scraper-status.ts b/scripts/debug/check-scraper-status.ts new file mode 100644 index 0000000..87eabc6 --- /dev/null +++ b/scripts/debug/check-scraper-status.ts @@ -0,0 +1,164 @@ +import { config } from 'dotenv'; +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; + +// Load .env.local first, then .env +config({ path: '.env.local' }); +config({ path: '.env' }); + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set'); +} + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function checkScraperStatus() { + try { + console.log('Checking mass schedule scraper status...\n'); + + // Overall church stats + const totalChurches = await prisma.church.count(); + + const churchesWithWebsites = await prisma.church.count({ + where: { + OR: [ + { website: { not: null } }, + { massScheduleUrl: { not: null } }, + ], + }, + }); + + const churchesScraped = await prisma.church.count({ + where: { lastScrapedAt: { not: null } }, + }); + + // Mass schedule stats + const totalMassSchedules = await prisma.massSchedule.count(); + + const churchesWithSchedules = await prisma.church.count({ + where: { + massSchedules: { + some: {}, + }, + }, + }); + + // Recently scraped (last 7 days) + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + + const recentlyScraped = await prisma.church.count({ + where: { + lastScrapedAt: { gte: weekAgo }, + }, + }); + + // Get scraper sources + const bySource = await prisma.church.groupBy({ + by: ['source'], + _count: { + id: true, + }, + }); + + console.log('═══════════════════════════════════════════════════════════════'); + console.log('CHURCH DATA SOURCES'); + console.log('═══════════════════════════════════════════════════════════════'); + bySource.forEach((source) => { + const percent = ((source._count.id / totalChurches) * 100).toFixed(1); + console.log(`${source.source.padEnd(12)} | ${String(source._count.id).padStart(7)} churches (${percent}%)`); + }); + console.log(''); + + console.log('═══════════════════════════════════════════════════════════════'); + console.log('MASS SCHEDULE SCRAPING STATUS'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(`Total churches: ${totalChurches.toLocaleString()}`); + console.log(`Churches with websites: ${churchesWithWebsites.toLocaleString()} (${((churchesWithWebsites / totalChurches) * 100).toFixed(1)}%)`); + console.log(`Churches ever scraped: ${churchesScraped.toLocaleString()} (${((churchesScraped / totalChurches) * 100).toFixed(1)}%)`); + console.log(`Churches with mass schedules: ${churchesWithSchedules.toLocaleString()} (${((churchesWithSchedules / totalChurches) * 100).toFixed(1)}%)`); + console.log(`Total mass schedules: ${totalMassSchedules.toLocaleString()}`); + console.log(''); + console.log(`Scraped in last 7 days: ${recentlyScraped.toLocaleString()}`); + console.log(''); + + // Average schedules per church + if (churchesWithSchedules > 0) { + const avgSchedules = totalMassSchedules / churchesWithSchedules; + console.log(`Average schedules per church: ${avgSchedules.toFixed(1)} masses/week`); + console.log(''); + } + + // Get sample of recently scraped churches + const recentSample = await prisma.church.findMany({ + where: { + lastScrapedAt: { not: null }, + }, + select: { + name: true, + city: true, + state: true, + country: true, + lastScrapedAt: true, + website: true, + source: true, + _count: { + select: { + massSchedules: true, + }, + }, + }, + orderBy: { lastScrapedAt: 'desc' }, + take: 10, + }); + + console.log('═══════════════════════════════════════════════════════════════'); + console.log('RECENTLY SCRAPED CHURCHES (Last 10)'); + console.log('═══════════════════════════════════════════════════════════════'); + if (recentSample.length === 0) { + console.log('No churches have been scraped yet.'); + } else { + recentSample.forEach((church, index) => { + const location = [church.city, church.state, church.country].filter(Boolean).join(', '); + console.log(`${index + 1}. ${church.name} (${location})`); + console.log(` Source: ${church.source}`); + console.log(` Website: ${church.website || 'None'}`); + console.log(` Last scraped: ${church.lastScrapedAt?.toLocaleString() || 'Never'}`); + console.log(` Mass schedules: ${church._count.massSchedules}`); + console.log(''); + }); + } + + // Churches ready to scrape (have website, not scraped) + const readyToScrape = await prisma.church.count({ + where: { + OR: [ + { website: { not: null } }, + { massScheduleUrl: { not: null } }, + ], + lastScrapedAt: null, + }, + }); + + console.log('═══════════════════════════════════════════════════════════════'); + console.log('SCRAPING POTENTIAL'); + console.log('═══════════════════════════════════════════════════════════════'); + console.log(`Churches ready to scrape: ${readyToScrape.toLocaleString()}`); + console.log(` (have website, never scraped)`); + console.log(''); + + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +checkScraperStatus(); diff --git a/scripts/debug/compare-schemas.ts b/scripts/debug/compare-schemas.ts new file mode 100644 index 0000000..5dce53d --- /dev/null +++ b/scripts/debug/compare-schemas.ts @@ -0,0 +1,47 @@ +import { Pool } from 'pg'; + +async function getColumns(pool: Pool, table: string) { + const result = await pool.query( + `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position`, + [table] + ); + return result.rows; +} + +async function run() { + const nas = new Pool({ connectionString: 'postgresql://postgres:postgres@192.168.0.145:5434/nearestmass' }); + const neon = new Pool({ + connectionString: 'postgresql://neondb_owner:npg_sX8dxFg9KZIR@ep-plain-sky-ah15xa97-pooler.c-3.us-east-1.aws.neon.tech/neondb?sslmode=require', + ssl: { rejectUnauthorized: false }, + }); + + for (const table of ['churches', 'mass_schedules', 'confession_schedules', 'adoration_schedules']) { + const nasCols = await getColumns(nas, table); + const neonCols = await getColumns(neon, table); + + const nasNames = new Set(nasCols.map((c) => c.column_name)); + const neonNames = new Set(neonCols.map((c) => c.column_name)); + + const onlyNas = nasCols.filter((c) => !neonNames.has(c.column_name)); + const onlyNeon = neonCols.filter((c) => !nasNames.has(c.column_name)); + + if (onlyNas.length > 0 || onlyNeon.length > 0) { + console.log(`\n=== ${table} ===`); + if (onlyNas.length) { + console.log(' NAS only:'); + for (const c of onlyNas) console.log(` - ${c.column_name} (${c.data_type})`); + } + if (onlyNeon.length) { + console.log(' Neon only:'); + for (const c of onlyNeon) console.log(` - ${c.column_name} (${c.data_type})`); + } + } else { + console.log(`\n=== ${table} === (schemas match)`); + } + } + + await nas.end(); + await neon.end(); +} + +run(); diff --git a/scripts/debug/data-overview.ts b/scripts/debug/data-overview.ts new file mode 100644 index 0000000..2cf55a3 --- /dev/null +++ b/scripts/debug/data-overview.ts @@ -0,0 +1,48 @@ +import { Pool } from 'pg'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +async function main() { + const c = await pool.connect(); + + const total = await c.query('SELECT count(*) FROM "Church"'); + console.log('\n=== DATABASE OVERVIEW ==='); + console.log('Churches total:', Number(total.rows[0].count).toLocaleString()); + + const withWebsite = await c.query('SELECT count(*) FROM "Church" WHERE website IS NOT NULL'); + console.log('With website:', Number(withWebsite.rows[0].count).toLocaleString()); + + const withSchedules = await c.query('SELECT count(DISTINCT "churchId") FROM "MassSchedule"'); + console.log('With mass schedules:', Number(withSchedules.rows[0].count).toLocaleString()); + + const enrichedGoogle = await c.query('SELECT count(*) FROM "Church" WHERE "googlePlaceId" IS NOT NULL'); + console.log('Google Places enriched:', Number(enrichedGoogle.rows[0].count).toLocaleString()); + + const totalSchedules = await c.query('SELECT count(*) FROM "MassSchedule"'); + console.log('Total mass schedules:', Number(totalSchedules.rows[0].count).toLocaleString()); + + const countries = await c.query('SELECT country, count(*) as cnt FROM "Church" GROUP BY country ORDER BY cnt DESC LIMIT 15'); + console.log('\n=== TOP COUNTRIES ==='); + for (const r of countries.rows) console.log(' ' + (r.country || '(null)') + ':', Number(r.cnt).toLocaleString()); + + const sources = await c.query('SELECT source, count(*) as cnt FROM "Church" GROUP BY source ORDER BY cnt DESC LIMIT 10'); + console.log('\n=== CHURCH SOURCES ==='); + for (const r of sources.rows) console.log(' ' + (r.source || '(null)') + ':', Number(r.cnt).toLocaleString()); + + const lastScrape = await c.query('SELECT "lastScrapedAt" FROM "Church" WHERE "lastScrapedAt" IS NOT NULL ORDER BY "lastScrapedAt" DESC LIMIT 1'); + console.log('\n=== LAST SCRAPE ==='); + console.log(lastScrape.rows[0]?.lastScrapedAt || 'No scrapes yet'); + + const jobs = await c.query('SELECT status, count(*) as cnt FROM "ScrapeJob" GROUP BY status ORDER BY cnt DESC'); + console.log('\n=== JOB STATUS ==='); + for (const r of jobs.rows) console.log(' ' + r.status + ':', Number(r.cnt).toLocaleString()); + + const schedulesByLang = await c.query('SELECT language, count(*) as cnt FROM "MassSchedule" GROUP BY language ORDER BY cnt DESC LIMIT 10'); + console.log('\n=== SCHEDULES BY LANGUAGE ==='); + for (const r of schedulesByLang.rows) console.log(' ' + (r.language || '(null)') + ':', Number(r.cnt).toLocaleString()); + + c.release(); + await pool.end(); +} + +main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/scripts/debug/debug-french-page.ts b/scripts/debug/debug-french-page.ts new file mode 100644 index 0000000..4176b73 --- /dev/null +++ b/scripts/debug/debug-french-page.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env tsx +/** + * Debug a specific French page to see why scraping failed + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function debugPage() { + const url = 'https://www.chemin-neuf.fr/'; // Last failed church + console.log(`Debugging: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('FR'); + + const result = await scraper.scrape(url); + + console.log(`Success: ${result.success}`); + console.log(`Schedules found: ${result.schedules.length}`); + if (result.error) console.log(`Error: ${result.error}`); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + console.log('\n=== Page Text Sample (first 2000 chars) ==='); + console.log(text.substring(0, 2000)); + console.log('\n'); + + // Check for French day names + const frenchDays = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + console.log('=== French day names found ==='); + for (const day of frenchDays) { + if (text.includes(day)) { + console.log(`✓ Found: ${day}`); + } + } + + // Check for time patterns + console.log('\n=== Time patterns (sample) ==='); + const timeRegex = /\d{1,2}[h:\.]\s*\d{0,2}\s*(?:AM|PM|am|pm|Uhr|uur|h)?/g; + const times = text.match(timeRegex); + if (times) { + console.log(`Found ${times.length} time-like patterns:`); + console.log(times.slice(0, 20).join(', ')); + } else { + console.log('No time patterns found'); + } + } + + await scraper.close(); +} + +debugPage().catch(console.error); diff --git a/scripts/debug/debug-german-duplicates.ts b/scripts/debug/debug-german-duplicates.ts new file mode 100644 index 0000000..f60a58c --- /dev/null +++ b/scripts/debug/debug-german-duplicates.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env tsx +/** + * Debug why German church has duplicate schedules + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +// Temporarily patch GenericScraper to log sections +const originalParse = GenericScraper.prototype['parseSchedules']; +GenericScraper.prototype['parseSchedules'] = function(html: string) { + const text = html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Call findScheduleSections and log result + const sections = this['findScheduleSections'](text); + + console.log('\n=== Sections found ===\n'); + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + sections.forEach((section: any, i: number) => { + console.log(`Section ${i + 1}: ${dayNames[section.day]} (day ${section.day})`); + console.log(` Text preview: "${section.text.substring(0, 100)}..."`); + }); + console.log(`\nTotal sections: ${sections.length}\n`); + + // Continue with normal processing + const result = originalParse.call(this, html); + + console.log(`\n=== Extracted times per section ===\n`); + const schedsByDay: Record = {}; + for (const sched of result) { + if (!schedsByDay[sched.dayOfWeek]) schedsByDay[sched.dayOfWeek] = []; + schedsByDay[sched.dayOfWeek].push(sched); + } + + for (let i = 0; i < 7; i++) { + if (schedsByDay[i]) { + console.log(`${dayNames[i]}: ${schedsByDay[i].map(s => s.time).join(', ')}`); + } + } + + return result; +}; + +async function testGerman() { + const url = 'https://www.alterpeter.de/'; + console.log(`Testing: ${url}`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('DE'); + + const result = await scraper.scrape(url); + + console.log(`\n=== Final Result ===`); + console.log(`Success: ${result.success}`); + console.log(`Total schedules: ${result.schedules.length}`); + + await scraper.close(); +} + +testGerman().catch(console.error); diff --git a/scripts/debug/debug-masstimes.ts b/scripts/debug/debug-masstimes.ts new file mode 100644 index 0000000..b0f2cb5 --- /dev/null +++ b/scripts/debug/debug-masstimes.ts @@ -0,0 +1,44 @@ +import { chromium } from 'playwright'; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + const url = 'https://masstimes.org/search?lat=32.7765&lng=-79.9311&type=parish'; + console.log('Loading:', url); + + await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 }); + + // Wait for Angular to render + await page.waitForTimeout(5000); + + // Take screenshot + await page.screenshot({ path: '/tmp/masstimes-debug.png', fullPage: true }); + console.log('Screenshot saved to /tmp/masstimes-debug.png'); + + // Get page HTML + const html = await page.content(); + console.log('\n--- PAGE HTML (first 5000 chars) ---\n'); + console.log(html.substring(0, 5000)); + + // Try to find any visible text that looks like church names + const visibleText = await page.evaluate(() => { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + const texts: string[] = []; + let node; + while ((node = walker.nextNode())) { + const text = node.textContent?.trim(); + if (text && text.length > 10 && text.length < 100) { + texts.push(text); + } + } + return texts.slice(0, 50); + }); + + console.log('\n--- VISIBLE TEXT SNIPPETS ---\n'); + visibleText.forEach((t, i) => console.log(`${i + 1}. ${t}`)); + + await browser.close(); +} + +main().catch(console.error); diff --git a/scripts/debug/debug-paroquia-paz.ts b/scripts/debug/debug-paroquia-paz.ts new file mode 100644 index 0000000..4247c7c --- /dev/null +++ b/scripts/debug/debug-paroquia-paz.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env tsx +/** + * Deep dive into Paróquia da Paz parsing bug + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function debugPaz() { + const url = 'https://www.paroquiadapaz.org.br/'; + console.log(`Debugging: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('BR'); + + const result = await scraper.scrape(url); + + console.log(`Success: ${result.success}`); + console.log(`Schedules: ${result.schedules.length}\n`); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find where days appear + console.log('=== Finding day + time patterns ===\n'); + + const days = ['domingo', 'segunda', 'terça', 'terca', 'quarta', 'quinta', 'sexta', 'sábado', 'sabado']; + + for (const day of days) { + const dayIndex = text.indexOf(day); + if (dayIndex !== -1) { + // Show context around the day (100 chars before and 200 after) + const before = Math.max(0, dayIndex - 100); + const after = Math.min(text.length, dayIndex + 200); + const snippet = text.substring(before, after); + + console.log(`${day.toUpperCase()}:`); + console.log(` Position: ${dayIndex}`); + console.log(` Context: ...${snippet}...`); + console.log(''); + } + } + + // Check for "h" time format specifically + console.log('\n=== Checking "h" time format ==='); + const hTimeRegex = /(\d{1,2})h(\d{2})?/g; + const hTimes = text.match(hTimeRegex); + if (hTimes) { + console.log(`Found ${hTimes.length} "h" format times:`); + console.log(hTimes.slice(0, 30).join(', ')); + } + + // Look for schedule structure + console.log('\n=== Looking for schedule structure ==='); + const scheduleKeywords = ['horário', 'horario', 'missa', 'missas', 'santa missa']; + for (const keyword of scheduleKeywords) { + const index = text.indexOf(keyword); + if (index !== -1) { + const snippet = text.substring(index, Math.min(text.length, index + 500)); + console.log(`\nFound "${keyword}" at position ${index}:`); + console.log(snippet.substring(0, 300)); + } + } + } + + await scraper.close(); +} + +debugPaz().catch(console.error); diff --git a/scripts/debug/debug-parsing-bugs.ts b/scripts/debug/debug-parsing-bugs.ts new file mode 100644 index 0000000..18a8f16 --- /dev/null +++ b/scripts/debug/debug-parsing-bugs.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env tsx +/** + * Debug the 5 parsing bugs identified in top 5 test + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +// The churches with parsing bugs +const BUG_CHURCHES = [ + { name: 'St. Marien', country: 'DE', searchTerm: 'St. Marien' }, + { name: 'Santuario de Manalagua', country: 'ES', searchTerm: 'Santuario de Manalagua' }, + { name: 'Kościół pw. Najświętszego Serca', country: 'PL', searchTerm: 'Najświętszego Serca Pana Jez' }, + { name: 'Paróquia de Nossa Senhora do Desterro', country: 'BR', searchTerm: 'Nossa Senhora do Desterro' }, + { name: 'Paróquia da Paz', country: 'BR', searchTerm: 'Paróquia da Paz' }, +]; + +async function debugBugs() { + console.log('Debugging parsing bugs...\n'); + + const scraper = new GenericScraper(); + await scraper.init(); + + for (const bug of BUG_CHURCHES) { + console.log('═'.repeat(80)); + console.log(`BUG: ${bug.name} (${bug.country})`); + console.log('═'.repeat(80)); + + const church = await prisma.church.findFirst({ + where: { + country: bug.country, + name: { contains: bug.searchTerm }, + website: { not: null }, + }, + }); + + if (!church) { + console.log(`❌ Church not found in database\n`); + continue; + } + + console.log(`Church: ${church.name}`); + console.log(`URL: ${church.website}\n`); + + scraper.setCountry(bug.country); + + try { + const result = await scraper.scrape(church.website!); + + console.log(`Success: ${result.success}`); + console.log(`Schedules found: ${result.schedules.length}`); + if (result.error) console.log(`Error: ${result.error}`); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + console.log('\n--- Text Sample (first 1000 chars) ---'); + console.log(text.substring(0, 1000)); + + // Check for day names + console.log('\n--- Day Names Found ---'); + const dayPatterns: Record = { + DE: ['sonntag', 'montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag'], + ES: ['domingo', 'lunes', 'martes', 'miércoles', 'miercoles', 'jueves', 'viernes', 'sábado', 'sabado'], + PL: ['niedziela', 'poniedziałek', 'poniedzialek', 'wtorek', 'środa', 'sroda', 'czwartek', 'piątek', 'piatek', 'sobota'], + BR: ['domingo', 'segunda', 'terça', 'terca', 'quarta', 'quinta', 'sexta', 'sábado', 'sabado'], + }; + + const days = dayPatterns[bug.country] || []; + const foundDays: string[] = []; + for (const day of days) { + if (text.includes(day)) { + foundDays.push(day); + } + } + console.log(`Found: ${foundDays.join(', ') || 'none'}`); + + // Check for time patterns + console.log('\n--- Time Patterns Found ---'); + const timeRegex = /\d{1,2}[h:\.]\s*\d{0,2}\s*(?:h|uhr)?/gi; + const times = text.match(timeRegex); + if (times) { + const uniqueTimes = [...new Set(times)].slice(0, 20); + console.log(`Found ${times.length} time patterns (showing first 20 unique):`); + console.log(uniqueTimes.join(', ')); + } else { + console.log('No time patterns found'); + } + + // Look for specific mass schedule keywords + console.log('\n--- Mass Schedule Keywords ---'); + const keywords: Record = { + DE: ['gottesdienst', 'messe', 'heilige messe', 'messzeiten'], + ES: ['misa', 'horario', 'eucaristía', 'eucaristia'], + PL: ['msza', 'msze', 'nabożeństwo', 'nabozenstwo'], + BR: ['missa', 'horário', 'horario', 'eucaristia'], + }; + + const countryKeywords = keywords[bug.country] || []; + const foundKeywords: string[] = []; + for (const keyword of countryKeywords) { + if (text.includes(keyword)) { + foundKeywords.push(keyword); + } + } + console.log(`Found: ${foundKeywords.join(', ') || 'none'}`); + + // Look for specific problematic patterns + console.log('\n--- Looking for edge cases ---'); + + // Check if times and days are separated (not in same section) + const hasTimeBeforeDays = text.indexOf(foundDays[0] || 'zzz') > text.indexOf((times || [])[0] || 'aaa'); + console.log(`Times come before days: ${hasTimeBeforeDays ? 'YES (potential issue)' : 'no'}`); + + // Check for table structures + const hasTables = text.includes('colspan') || text.includes('rowspan') || (text.match(/\s+\|\s+/g)?.length || 0) > 5; + console.log(`Likely table format: ${hasTables ? 'YES (may need special handling)' : 'no'}`); + + // Check for multiple languages on same page + const hasMultiLang = (text.match(/english|español|espanol|portuguese|português|portugues|deutsch|polski/gi)?.length || 0) > 1; + console.log(`Multiple languages: ${hasMultiLang ? 'YES (may confuse parser)' : 'no'}`); + } + + console.log('\n'); + } catch (err: any) { + console.log(`❌ ERROR: ${err.message}\n`); + } + } + + await scraper.close(); + await prisma.$disconnect(); + await pool.end(); +} + +debugBugs().catch(console.error); diff --git a/scripts/debug/debug-paz-full-flow.ts b/scripts/debug/debug-paz-full-flow.ts new file mode 100644 index 0000000..f1251f4 --- /dev/null +++ b/scripts/debug/debug-paz-full-flow.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env tsx +/** + * Debug the full parsing flow with section detection + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; +import { getDayNamesForCountry, buildDayPatterns } from '../../src/scrapers/i18n/day-names'; + +async function debugFullFlow() { + const url = 'https://www.paroquiadapaz.org.br/'; + console.log(`Debugging: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('BR'); + + const result = await scraper.scrape(url); + + if (!result.rawHtml) { + console.log('No HTML received'); + await scraper.close(); + return; + } + + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find the schedule section + const scheduleIndex = text.indexOf('segundas, terças'); + if (scheduleIndex === -1) { + console.log('Schedule text not found!'); + await scraper.close(); + return; + } + + const snippet = text.substring(scheduleIndex, scheduleIndex + 500); + console.log('Schedule snippet from actual HTML:'); + console.log(snippet); + console.log('\n'); + + // Now test section matching on actual text + const dayConfigs = getDayNamesForCountry('BR'); + const dayPatterns = buildDayPatterns(dayConfigs); + const sortedDayNames = Object.keys(dayPatterns).sort((a, b) => b.length - a.length); + const allDayNamesPattern = sortedDayNames.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + + console.log('=== Testing sábados and domingos matches ===\n'); + + // Test sábados + const sabadosRegex = new RegExp( + `(?:^|\\s|[,;:])sábados[:\\s]+([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' + ); + const sabadosMatch = snippet.match(sabadosRegex); + console.log('sábados match:', sabadosMatch ? `Found: "${sabadosMatch[1].substring(0, 50)}"` : 'Not found'); + + // Test sabados (no accent) + const sabadosRegex2 = new RegExp( + `(?:^|\\s|[,;:])sabados[:\\s]+([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' + ); + const sabadosMatch2 = snippet.match(sabadosRegex2); + console.log('sabados match:', sabadosMatch2 ? `Found: "${sabadosMatch2[1].substring(0, 50)}"` : 'Not found'); + + // Test domingos + const domingosRegex = new RegExp( + `(?:^|\\s|[,;:])domingos[:\\s]+([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' + ); + const domingosMatch = snippet.match(domingosRegex); + console.log('domingos match:', domingosMatch ? `Found: "${domingosMatch[1].substring(0, 50)}"` : 'Not found'); + + console.log('\n=== Final parsed schedules ===\n'); + console.log(`Total: ${result.schedules.length}`); + + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNames = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; + for (let i = 0; i < 7; i++) { + if (byDay[i]) { + console.log(`${dayNames[i]}: ${byDay[i].length} schedules`); + } else { + console.log(`${dayNames[i]}: 0 schedules ❌`); + } + } + + await scraper.close(); +} + +debugFullFlow().catch(console.error); diff --git a/scripts/debug/debug-paz-sections.ts b/scripts/debug/debug-paz-sections.ts new file mode 100644 index 0000000..e02aa38 --- /dev/null +++ b/scripts/debug/debug-paz-sections.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env tsx +/** + * Debug which sections are being found + */ + +import { getDayNamesForCountry, buildDayPatterns } from '../../src/scrapers/i18n/day-names'; + +// Simulate the exact text from the page +const scheduleText = ` +horário das missas igreja matriz de santo antônio +segundas, terças, quartas e sextas-feiras: 16h e 18h. +quintas-feiras: 16h e 19h (adoração ao santíssimo – 18h). +sábados: 8h, 16h e 18h. +domingos: 8h, 11h, 16h, 18h e 20h. +`.toLowerCase(); + +console.log('Text to parse:'); +console.log(scheduleText); +console.log(''); + +const dayConfigs = getDayNamesForCountry('BR'); +const dayPatterns = buildDayPatterns(dayConfigs); +const sortedDayNames = Object.keys(dayPatterns).sort((a, b) => b.length - a.length); +const allDayNamesPattern = sortedDayNames.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + +console.log('=== COMMA-SEPARATED GROUP MATCHING ===\n'); + +const dayGroupRegex = new RegExp( + `((?:${allDayNamesPattern})(?:[,\\s]+(?:e|and|et|und|y)?\\s*(?:${allDayNamesPattern}))+)[:\\s]+([^]*?)(?=(?:${allDayNamesPattern})|$)`, + 'gi' +); + +let groupMatch; +let matchCount = 0; +while ((groupMatch = dayGroupRegex.exec(scheduleText)) !== null) { + matchCount++; + console.log(`Match #${matchCount}:`); + console.log(` Day group: "${groupMatch[1]}"`); + console.log(` Time text: "${groupMatch[2]}"`); + console.log(''); +} + +console.log('=== INDIVIDUAL DAY MATCHING ===\n'); + +for (const [dayName, dayIndex] of Object.entries(dayPatterns)) { + const escaped = dayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp( + `(?:^|\\s|[,;:])${escaped}[:\\s]+([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' + ); + const match = scheduleText.match(regex); + if (match) { + console.log(`Found ${dayName} (day ${dayIndex}):`); + console.log(` Time text: "${match[1].substring(0, 100)}"`); + } +} diff --git a/scripts/debug/debug-paz-with-logging.ts b/scripts/debug/debug-paz-with-logging.ts new file mode 100644 index 0000000..bfdd0f3 --- /dev/null +++ b/scripts/debug/debug-paz-with-logging.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env tsx +/** + * Debug Paróquia da Paz with added logging + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; +import { getDayNamesForCountry, buildDayPatterns } from '../../src/scrapers/i18n/day-names'; + +async function debugPazWithLogging() { + const url = 'https://www.paroquiadapaz.org.br/'; + console.log(`Debugging: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('BR'); + + const result = await scraper.scrape(url); + + console.log(`Success: ${result.success}`); + console.log(`Schedules: ${result.schedules.length}\n`); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Test the regex pattern manually + console.log('=== Testing comma-separated day grouping regex ===\n'); + + const dayConfigs = getDayNamesForCountry('BR'); + const dayPatterns = buildDayPatterns(dayConfigs); + const sortedDayNames = Object.keys(dayPatterns).sort((a, b) => b.length - a.length); + const allDayNamesPattern = sortedDayNames.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + + console.log('Day patterns:', Object.keys(dayPatterns).join(', ')); + console.log(''); + + // The exact regex from the code + const dayGroupRegex = new RegExp( + `((?:${allDayNamesPattern})(?:[,\\s]+(?:e|and|et|und|y)?\\s*(?:${allDayNamesPattern}))+)[:\\s]+([^]*?)(?=(?:${allDayNamesPattern})|$)`, + 'gi' + ); + + console.log('Regex pattern:', dayGroupRegex.source.substring(0, 200) + '...\n'); + + let groupMatch; + let matchCount = 0; + while ((groupMatch = dayGroupRegex.exec(text)) !== null) { + matchCount++; + console.log(`Match #${matchCount}:`); + console.log(` Full match: "${groupMatch[0].substring(0, 100)}"`); + console.log(` Day group: "${groupMatch[1]}"`); + console.log(` Time text: "${groupMatch[2].substring(0, 50)}"`); + console.log(''); + } + + if (matchCount === 0) { + console.log('No matches found!\n'); + + // Try to find the schedule text manually + const scheduleIndex = text.indexOf('segundas, terças'); + if (scheduleIndex !== -1) { + const snippet = text.substring(scheduleIndex, scheduleIndex + 300); + console.log('Found schedule text at position', scheduleIndex); + console.log('Snippet:', snippet); + console.log(''); + + // Test if individual day names are matching + console.log('Testing individual day name matches in snippet:'); + for (const dayName of sortedDayNames.slice(0, 10)) { + if (snippet.includes(dayName)) { + console.log(` ✓ Found: ${dayName}`); + } + } + } + } + } + + await scraper.close(); +} + +debugPazWithLogging().catch(console.error); diff --git a/scripts/debug/debug-polish-church.ts b/scripts/debug/debug-polish-church.ts new file mode 100644 index 0000000..b949b8c --- /dev/null +++ b/scripts/debug/debug-polish-church.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env tsx +/** + * Debug Polish church in detail + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; +import { getDayNamesForCountry, buildDayPatterns } from '../../src/scrapers/i18n/day-names'; + +async function debugPolish() { + const url = 'http://parafialubojna.pl'; + console.log(`Debugging: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('PL'); + + const result = await scraper.scrape(url); + + console.log(`Success: ${result.success}`); + console.log(`Schedules found: ${result.schedules.length}\n`); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find the schedule section + const scheduleIndex = text.indexOf('msze święte') || text.indexOf('msze swiete'); + if (scheduleIndex !== -1) { + const snippet = text.substring(scheduleIndex, scheduleIndex + 500); + console.log('Schedule section:'); + console.log(snippet); + console.log('\n'); + + // Test all time pattern matches + console.log('=== Testing time pattern matches ===\n'); + + // Space separator pattern + const spacePattern = /\b(\d{1,2})\s+(\d{2})(?!\d)/g; + const spaceMatches = snippet.match(spacePattern); + console.log('Space-separated times (8 00, 9 30):'); + console.log(spaceMatches ? spaceMatches.join(', ') : 'none'); + console.log(''); + + // Colon pattern + const colonPattern = /\d{1,2}:\d{2}/g; + const colonMatches = snippet.match(colonPattern); + console.log('Colon times (8:00, 9:30):'); + console.log(colonMatches ? colonMatches.join(', ') : 'none'); + console.log(''); + + // Polish day names + console.log('=== Polish day names in snippet ===\n'); + const dayConfigs = getDayNamesForCountry('PL'); + const dayPatterns = buildDayPatterns(dayConfigs); + + for (const [dayName, dayNum] of Object.entries(dayPatterns)) { + if (snippet.includes(dayName)) { + console.log(`Found: ${dayName} (day ${dayNum})`); + } + } + } + } + + console.log('\n=== Parsed schedules ===\n'); + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNames = ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota']; + for (let i = 0; i < 7; i++) { + if (byDay[i]) { + console.log(`${dayNames[i]}: ${byDay[i].map(s => s.time).join(', ')}`); + } + } + + await scraper.close(); +} + +debugPolish().catch(console.error); diff --git a/scripts/debug/debug-polish-sunday-monday.ts b/scripts/debug/debug-polish-sunday-monday.ts new file mode 100644 index 0000000..fd62ffe --- /dev/null +++ b/scripts/debug/debug-polish-sunday-monday.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env tsx +/** + * Debug why Sunday and Monday aren't parsing for Polish church + */ + +import { getDayNamesForCountry, buildDayPatterns } from '../../src/scrapers/i18n/day-names'; + +// Exact schedule text from website +const text = `msze święte niedziela i uroczystości: 8 00 , 9 30 (lubojenka), 11 00 , 16 00 w lipcu i sierpniu nie ma mszy popołudniowej!--> dni powszednie: poniedziałek: godz. 8 00 wtorek - sobota: godz. 18 00`.toLowerCase(); + +console.log('Text to parse:'); +console.log(text); +console.log('\n'); + +const dayConfigs = getDayNamesForCountry('PL'); +const dayPatterns = buildDayPatterns(dayConfigs); +const sortedDayNames = Object.keys(dayPatterns).sort((a, b) => b.length - a.length); +const allDayNamesPattern = sortedDayNames.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + +console.log('=== Testing niedziela (Sunday) ===\n'); + +// Current regex pattern +const niedziela = 'niedziela'; +const escaped = niedziela.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const regex = new RegExp( + `(?:^|\\s|[,;:])${escaped}(?:(?:[^:]{1,50})?:|\\s+)([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' +); + +const match = text.match(regex); +if (match) { + console.log(`✓ Matched!`); + console.log(` Full match: "${match[0].substring(0, 100)}"`); + console.log(` Captured text: "${match[1].substring(0, 100)}"`); + console.log(''); + + // Check if times can be extracted + const spacePattern = /\b(\d{1,2})\s+(\d{2})(?!\d)/g; + const times = match[1].match(spacePattern); + console.log(` Times found: ${times ? times.join(', ') : 'none'}`); +} else { + console.log(`✗ NOT matched`); +} + +console.log('\n=== Testing poniedziałek (Monday) ===\n'); + +const ponieRegex = new RegExp( + `(?:^|\\s|[,;:])poniedziałek(?:(?:[^:]{1,50})?:|\\s+)([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' +); + +const ponieMatch = text.match(ponieRegex); +if (ponieMatch) { + console.log(`✓ Matched!`); + console.log(` Full match: "${ponieMatch[0].substring(0, 100)}"`); + console.log(` Captured text: "${ponieMatch[1].substring(0, 100)}"`); + console.log(''); + + const times = ponieMatch[1].match(/\b(\d{1,2})\s+(\d{2})(?!\d)/g); + console.log(` Times found: ${times ? times.join(', ') : 'none'}`); +} else { + console.log(`✗ NOT matched`); +} + +console.log('\n=== Analyzing why niedziela might fail ===\n'); + +// The issue might be "niedziela i uroczystości:" - the phrase is long +// Check if the lookahead is hitting "uroczystości" before getting to the times +const niedziela_index = text.indexOf('niedziela'); +const next_day_index = Math.min( + ...sortedDayNames + .filter(d => d !== 'niedziela') + .map(d => text.indexOf(d, niedziela_index)) + .filter(i => i > 0) +); + +console.log(`niedziela position: ${niedziela_index}`); +console.log(`Next day name position: ${next_day_index}`); +console.log(`Text between: "${text.substring(niedziela_index, next_day_index)}"`); diff --git a/scripts/debug/debug-thursday-context.ts b/scripts/debug/debug-thursday-context.ts new file mode 100644 index 0000000..c3100c6 --- /dev/null +++ b/scripts/debug/debug-thursday-context.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env tsx +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function main() { + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('DE'); + + const result = await scraper.scrape('https://www.alterpeter.de/'); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find "montag bis donnerstag" pattern + const pattern = /montag[^]*?bis[^]*?donnerstag/gi; + const matches = [...text.matchAll(pattern)]; + + console.log(`Found ${matches.length} instances of "montag bis donnerstag":\n`); + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const matchIndex = match.index || 0; + const contextBefore = text.substring(Math.max(0, matchIndex - 150), matchIndex); + const contextAfter = text.substring(matchIndex, Math.min(text.length, matchIndex + 250)); + + console.log(`=== Instance ${i + 1} ===`); + console.log(`Position: ${matchIndex}`); + console.log(`\nContext BEFORE (150 chars):`); + console.log(`"${contextBefore}"`); + console.log(`\nContext AFTER (250 chars):`); + console.log(`"${contextAfter}"`); + console.log(''); + } + } + + await scraper.close(); +} + +main().catch(console.error); diff --git a/scripts/debug/debug-zero-time.ts b/scripts/debug/debug-zero-time.ts new file mode 100644 index 0000000..5e53e1d --- /dev/null +++ b/scripts/debug/debug-zero-time.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env tsx +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function main() { + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('DE'); + + const result = await scraper.scrape('https://www.alterpeter.de/'); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find all instances of "00 uhr" pattern + let idx = 0; + let count = 0; + const pattern = /\b00\s*uhr/g; + let match; + + console.log('Looking for "00 uhr" patterns:\n'); + + while ((match = pattern.exec(text)) !== null) { + count++; + const matchIndex = match.index; + const contextBefore = text.substring(Math.max(0, matchIndex - 50), matchIndex); + const contextAfter = text.substring(matchIndex, Math.min(text.length, matchIndex + 100)); + + console.log(`=== Occurrence ${count} at position ${matchIndex} ===`); + console.log(`BEFORE: "...${contextBefore}"`); + console.log(`MATCH + AFTER: "${contextAfter}..."`); + console.log(''); + } + + console.log(`Total "00 uhr" occurrences: ${count}`); + } + + await scraper.close(); +} + +main().catch(console.error); diff --git a/scripts/debug/export-de-from-neon.ts b/scripts/debug/export-de-from-neon.ts new file mode 100644 index 0000000..fcb304a --- /dev/null +++ b/scripts/debug/export-de-from-neon.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env tsx +import { config } from 'dotenv'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import fs from 'fs/promises'; + +config({ path: '.env.local' }); + +async function main() { + console.log('📦 Exporting Germany from Neon...'); + + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + const adapter = new PrismaPg(pool); + const prisma = new PrismaClient({ adapter }); + + await prisma.$connect(); + + const churches = await prisma.churches.findMany({ + where: { country: 'DE' }, + include: { + massSchedules: true, + confessionSchedules: true, + adorationSchedules: true, + } + }); + + console.log(`Found ${churches.length} churches in Germany`); + + await fs.writeFile('export-DE.json', JSON.stringify(churches, null, 2)); + console.log(`✅ Exported to export-DE.json`); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch(console.error); diff --git a/scripts/debug/export-from-nas.ts b/scripts/debug/export-from-nas.ts new file mode 100644 index 0000000..47ee08c --- /dev/null +++ b/scripts/debug/export-from-nas.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env tsx +/** + * Export churches from NAS database to JSON + * Run this ON THE NAS (uses DATABASE_URL from .env) + */ + +import { PrismaClient } from '@prisma/client'; +import fs from 'fs/promises'; + +async function main() { + const country = process.argv[2] || 'PL'; + + console.log(`📦 Exporting ${country} data from database...`); + console.log(`DATABASE_URL: ${process.env.DATABASE_URL?.replace(/:[^:@]+@/, ':****@')}`); + + const prisma = new PrismaClient(); + + try { + await prisma.$connect(); + console.log('✅ Connected to database'); + + // Export churches with all schedules + const churches = await prisma.churches.findMany({ + where: { country }, + include: { + massSchedules: true, + confessionSchedules: true, + adorationSchedules: true, + } + }); + + console.log(`Found ${churches.length} churches in ${country}`); + + // Count schedules + const massSchedules = churches.reduce((sum, c) => sum + (c.massSchedules?.length || 0), 0); + const confessionSchedules = churches.reduce((sum, c) => sum + (c.confessionSchedules?.length || 0), 0); + const adorationSchedules = churches.reduce((sum, c) => sum + (c.adorationSchedules?.length || 0), 0); + + // Save to file + const exportFile = `export-${country}.json`; + await fs.writeFile(exportFile, JSON.stringify(churches, null, 2)); + + console.log(`\n✅ Exported to ${exportFile}`); + console.log(` - ${churches.length} churches`); + console.log(` - ${massSchedules} mass schedules`); + console.log(` - ${confessionSchedules} confession schedules`); + console.log(` - ${adorationSchedules} adoration schedules`); + console.log(`\nDownload with:`); + console.log(` scp albert@192.168.0.145:/volume1/docker/nearestmass/${exportFile} .`); + + await prisma.$disconnect(); + + } catch (error) { + console.error('❌ Export failed:', error); + await prisma.$disconnect(); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/scripts/debug/export-import-to-neon.ts b/scripts/debug/export-import-to-neon.ts new file mode 100644 index 0000000..781e300 --- /dev/null +++ b/scripts/debug/export-import-to-neon.ts @@ -0,0 +1,230 @@ +#!/usr/bin/env tsx +/** + * Export churches from local NAS database and import to Neon + */ + +import { PrismaClient } from '@prisma/client'; +import fs from 'fs/promises'; +import path from 'path'; + +interface ExportStats { + churches: number; + massSchedules: number; + confessionSchedules: number; + adorationSchedules: number; +} + +async function exportFromNAS(country: string): Promise { + console.log(`📦 Exporting ${country} data from NAS...`); + + // Set DATABASE_URL to NAS + const originalUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = 'postgresql://postgres:postgres@192.168.0.145:5432/nearestmass'; + + const nasPrisma = new PrismaClient(); + + try { + await nasPrisma.$connect(); + console.log('✅ Connected to NAS database'); + + // Export churches with all schedules + const churches = await nasPrisma.churches.findMany({ + where: { country }, + include: { + massSchedules: true, + confessionSchedules: true, + adorationSchedules: true, + } + }); + + console.log(`Found ${churches.length} churches in ${country}`); + + // Count schedules + const stats: ExportStats = { + churches: churches.length, + massSchedules: churches.reduce((sum, c) => sum + (c.massSchedules?.length || 0), 0), + confessionSchedules: churches.reduce((sum, c) => sum + (c.confessionSchedules?.length || 0), 0), + adorationSchedules: churches.reduce((sum, c) => sum + (c.adorationSchedules?.length || 0), 0), + }; + + // Save to file + const exportFile = path.join(process.cwd(), `export-${country}.json`); + await fs.writeFile(exportFile, JSON.stringify(churches, null, 2)); + console.log(`✅ Exported to ${exportFile}`); + console.log(` - ${stats.churches} churches`); + console.log(` - ${stats.massSchedules} mass schedules`); + console.log(` - ${stats.confessionSchedules} confession schedules`); + console.log(` - ${stats.adorationSchedules} adoration schedules`); + + await nasPrisma.$disconnect(); + + // Restore original DATABASE_URL + if (originalUrl) { + process.env.DATABASE_URL = originalUrl; + } + + return stats; + + } catch (error) { + console.error('❌ Export failed:', error); + await nasPrisma.$disconnect(); + + // Restore original DATABASE_URL + if (originalUrl) { + process.env.DATABASE_URL = originalUrl; + } + + throw error; + } +} + +async function importToNeon(country: string, dryRun: boolean = true): Promise { + console.log(`\n📤 Importing ${country} data to Neon...`); + if (dryRun) { + console.log('🔍 DRY RUN MODE - No data will be written'); + } + + // Read export file + const exportFile = path.join(process.cwd(), `export-${country}.json`); + const data = JSON.parse(await fs.readFile(exportFile, 'utf-8')); + console.log(`Loaded ${data.length} churches from export file`); + + // Connect to Neon + const neonPrisma = new PrismaClient(); + + try { + await neonPrisma.$connect(); + console.log('✅ Connected to Neon database'); + + let inserted = 0; + let updated = 0; + let errors = 0; + + for (const church of data) { + try { + const massSchedules = church.massSchedules || []; + const confessionSchedules = church.confessionSchedules || []; + const adorationSchedules = church.adorationSchedules || []; + + // Remove relations and ids + delete church.massSchedules; + delete church.confessionSchedules; + delete church.adorationSchedules; + delete church.id; + + if (!dryRun) { + // Upsert church based on coordinates + const result = await neonPrisma.churches.upsert({ + where: { + latitude_longitude: { + latitude: church.latitude, + longitude: church.longitude + } + }, + create: church, + update: church + }); + + // Check if it was an insert or update + const existing = await neonPrisma.churches.findFirst({ + where: { + latitude: church.latitude, + longitude: church.longitude, + createdAt: { lt: new Date(Date.now() - 1000) } // Created more than 1 sec ago + } + }); + + if (existing) { + updated++; + } else { + inserted++; + } + + // Insert schedules + for (const schedule of massSchedules) { + delete schedule.id; + await neonPrisma.massSchedules.create({ + data: { + ...schedule, + churchId: result.id + } + }); + } + + for (const schedule of confessionSchedules) { + delete schedule.id; + await neonPrisma.confessionSchedules.create({ + data: { + ...schedule, + churchId: result.id + } + }); + } + + for (const schedule of adorationSchedules) { + delete schedule.id; + await neonPrisma.adorationSchedules.create({ + data: { + ...schedule, + churchId: result.id + } + }); + } + } else { + // Dry run - just count + inserted++; + } + + if (inserted % 100 === 0) { + console.log(`Progress: ${inserted + updated} churches processed...`); + } + + } catch (error) { + errors++; + console.error(`Error importing church ${church.name}:`, error instanceof Error ? error.message : error); + } + } + + console.log('\n✅ Import complete!'); + console.log(` - ${inserted} churches inserted`); + console.log(` - ${updated} churches updated`); + console.log(` - ${errors} errors`); + + await neonPrisma.$disconnect(); + + } catch (error) { + console.error('❌ Import failed:', error); + await neonPrisma.$disconnect(); + throw error; + } +} + +async function main() { + const country = process.argv[2] || 'PL'; + const mode = process.argv[3] || 'dry-run'; + const dryRun = mode === 'dry-run'; + + console.log('🌍 Export/Import to Neon'); + console.log('========================\n'); + + try { + // Step 1: Export from NAS + const stats = await exportFromNAS(country); + + // Step 2: Import to Neon + await importToNeon(country, dryRun); + + if (dryRun) { + console.log('\n💡 This was a DRY RUN. To actually import to Neon, run:'); + console.log(` npx tsx scripts/export-import-to-neon.ts ${country} real-import`); + } else { + console.log('\n🎉 Data successfully uploaded to Neon!'); + } + + } catch (error) { + console.error('❌ Process failed:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/scripts/debug/find-donnerstag-sections.ts b/scripts/debug/find-donnerstag-sections.ts new file mode 100644 index 0000000..7f655ee --- /dev/null +++ b/scripts/debug/find-donnerstag-sections.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env tsx +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function main() { + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('DE'); + + const result = await scraper.scrape('https://www.alterpeter.de/'); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Find all instances of "donnerstag" (Thursday) + let idx = 0; + let count = 0; + while ((idx = text.indexOf('donnerstag', idx)) !== -1) { + count++; + const contextBefore = text.substring(Math.max(0, idx - 100), idx); + const contextAfter = text.substring(idx, Math.min(text.length, idx + 200)); + + console.log(`=== Donnerstag occurrence ${count} at position ${idx} ===`); + console.log(`BEFORE: "...${contextBefore}"`); + console.log(`AFTER: "${contextAfter}..."`); + console.log(''); + + idx++; + } + + console.log(`Total "donnerstag" occurrences: ${count}`); + } + + await scraper.close(); +} + +main().catch(console.error); diff --git a/scripts/debug/find-office-hours-pattern.ts b/scripts/debug/find-office-hours-pattern.ts new file mode 100644 index 0000000..f6006d4 --- /dev/null +++ b/scripts/debug/find-office-hours-pattern.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env tsx +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function main() { + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('DE'); + + const result = await scraper.scrape('https://www.alterpeter.de/'); + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + const idx = text.indexOf('9.00 – 12.00'); + if (idx !== -1) { + console.log('Context around "9.00 – 12.00":'); + console.log(text.substring(Math.max(0, idx - 150), idx + 200)); + } else { + console.log('Pattern "9.00 – 12.00" not found'); + + // Try alternative patterns + const patterns = ['9.00', '9:00', '09:00', '09.00']; + for (const pattern of patterns) { + const idx2 = text.indexOf(pattern); + if (idx2 !== -1) { + console.log(`\nFound "${pattern}" at position ${idx2}:`); + console.log(text.substring(Math.max(0, idx2 - 100), idx2 + 150)); + break; + } + } + } + } + + await scraper.close(); +} + +main().catch(console.error); diff --git a/scripts/debug/identify-top5-bugs.ts b/scripts/debug/identify-top5-bugs.ts new file mode 100644 index 0000000..2f73b45 --- /dev/null +++ b/scripts/debug/identify-top5-bugs.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env tsx +/** + * Identify which churches are flagged as "parsing bugs" in top 5 test + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const COUNTRIES = [ + { code: 'FR', name: 'France' }, + { code: 'DE', name: 'Germany' }, + { code: 'ES', name: 'Spain' }, + { code: 'PL', name: 'Poland' }, + { code: 'BR', name: 'Brazil' }, +]; + +async function identifyBugs() { + console.log('Identifying "parsing bugs" from top 5 test...\n'); + + const scraper = new GenericScraper(); + await scraper.init(); + + const bugs: Array<{ + country: string; + church: string; + url: string; + hasDays: boolean; + hasTimes: boolean; + }> = []; + + for (const country of COUNTRIES) { + const churches = await prisma.church.findMany({ + where: { + country: country.code, + website: { not: null }, + source: 'osm', + }, + take: 10, + orderBy: { createdAt: 'asc' }, + }); + + scraper.setCountry(country.code); + + for (const church of churches) { + try { + const result = await scraper.scrape(church.website!); + + if (!result.success && result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Check for day names and times + const hasDays = text.match(/\b(sunday|monday|tuesday|wednesday|thursday|friday|saturday|dimanche|lundi|mardi|mercredi|jeudi|vendredi|samedi|sonntag|montag|dienstag|mittwoch|donnerstag|freitag|samstag|domingo|domingos|lunes|martes|miércoles|miercoles|jueves|viernes|sábado|sabado|sábados|sabados|niedziela|poniedziałek|poniedzialek|wtorek|środa|sroda|czwartek|piątek|piatek|sobota|segunda|segundas|terça|terca|terças|tercas|quarta|quartas|quinta|quintas|sexta|sextas)\b/i); + + const hasTimes = text.match(/\d{1,2}[h:\.]?\s*\d{0,2}\s*(am|pm|h|uhr)?/i); + + if (hasDays && hasTimes) { + bugs.push({ + country: country.name, + church: church.name, + url: church.website!, + hasDays: !!hasDays, + hasTimes: !!hasTimes, + }); + } + } + } catch (err: any) { + // Skip errors + } + } + } + + await scraper.close(); + + console.log(`\n${'='.repeat(80)}`); + console.log(`FOUND ${bugs.length} POTENTIAL PARSING BUGS\n`); + + bugs.forEach((bug, i) => { + console.log(`${i + 1}. ${bug.church} (${bug.country})`); + console.log(` URL: ${bug.url}`); + console.log(''); + }); + + await prisma.$disconnect(); + await pool.end(); +} + +identifyBugs().catch(console.error); diff --git a/scripts/debug/import-to-neon.ts b/scripts/debug/import-to-neon.ts new file mode 100644 index 0000000..597e276 --- /dev/null +++ b/scripts/debug/import-to-neon.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env tsx +/** + * Import churches from JSON export to Neon database + * Run this LOCALLY (uses DATABASE_URL from .env pointing to Neon) + */ + +import { PrismaClient } from '@prisma/client'; +import fs from 'fs/promises'; +import path from 'path'; + +interface ChurchExport { + id: string; + name: string; + latitude: number; + longitude: number; + country: string; + massSchedules?: any[]; + confessionSchedules?: any[]; + adorationSchedules?: any[]; + [key: string]: any; +} + +async function main() { + const country = process.argv[2] || 'PL'; + const mode = process.argv[3] || 'dry-run'; + const dryRun = mode === 'dry-run'; + + console.log(`📤 Importing ${country} data to Neon...`); + console.log(`DATABASE_URL: ${process.env.DATABASE_URL?.replace(/:[^:@]+@/, ':****@')}`); + + if (dryRun) { + console.log('🔍 DRY RUN MODE - No data will be written'); + } + + // Read export file + const exportFile = path.join(process.cwd(), `export-${country}.json`); + + try { + const data: ChurchExport[] = JSON.parse(await fs.readFile(exportFile, 'utf-8')); + console.log(`Loaded ${data.length} churches from export file`); + + // Connect to Neon + const prisma = new PrismaClient(); + + try { + await prisma.$connect(); + console.log('✅ Connected to Neon database'); + + let inserted = 0; + let updated = 0; + let skipped = 0; + let errors = 0; + let totalMassSchedules = 0; + let totalConfessionSchedules = 0; + let totalAdorationSchedules = 0; + + for (const church of data) { + try { + const massSchedules = church.massSchedules || []; + const confessionSchedules = church.confessionSchedules || []; + const adorationSchedules = church.adorationSchedules || []; + + // Remove relations and ids + delete church.massSchedules; + delete church.confessionSchedules; + delete church.adorationSchedules; + delete church.id; + + if (!dryRun) { + // Check if church already exists + const existing = await prisma.churches.findFirst({ + where: { + latitude: church.latitude, + longitude: church.longitude + } + }); + + if (existing) { + // Update existing church + await prisma.churches.update({ + where: { id: existing.id }, + data: church + }); + + // Delete existing schedules + await prisma.massSchedules.deleteMany({ + where: { churchId: existing.id } + }); + await prisma.confessionSchedules.deleteMany({ + where: { churchId: existing.id } + }); + await prisma.adorationSchedules.deleteMany({ + where: { churchId: existing.id } + }); + + // Insert new schedules + for (const schedule of massSchedules) { + delete schedule.id; + await prisma.massSchedules.create({ + data: { + ...schedule, + churchId: existing.id + } + }); + totalMassSchedules++; + } + + for (const schedule of confessionSchedules) { + delete schedule.id; + await prisma.confessionSchedules.create({ + data: { + ...schedule, + churchId: existing.id + } + }); + totalConfessionSchedules++; + } + + for (const schedule of adorationSchedules) { + delete schedule.id; + await prisma.adorationSchedules.create({ + data: { + ...schedule, + churchId: existing.id + } + }); + totalAdorationSchedules++; + } + + updated++; + } else { + // Create new church + const result = await prisma.churches.create({ + data: church + }); + + // Insert schedules + for (const schedule of massSchedules) { + delete schedule.id; + await prisma.massSchedules.create({ + data: { + ...schedule, + churchId: result.id + } + }); + totalMassSchedules++; + } + + for (const schedule of confessionSchedules) { + delete schedule.id; + await prisma.confessionSchedules.create({ + data: { + ...schedule, + churchId: result.id + } + }); + totalConfessionSchedules++; + } + + for (const schedule of adorationSchedules) { + delete schedule.id; + await prisma.adorationSchedules.create({ + data: { + ...schedule, + churchId: result.id + } + }); + totalAdorationSchedules++; + } + + inserted++; + } + } else { + // Dry run - just count + inserted++; + totalMassSchedules += massSchedules.length; + totalConfessionSchedules += confessionSchedules.length; + totalAdorationSchedules += adorationSchedules.length; + } + + if ((inserted + updated) % 100 === 0) { + console.log(`Progress: ${inserted + updated} churches processed...`); + } + + } catch (error) { + errors++; + console.error(`Error importing church ${church.name}:`, error instanceof Error ? error.message : error); + } + } + + console.log('\n✅ Import complete!'); + console.log(` - ${inserted} churches inserted`); + console.log(` - ${updated} churches updated`); + console.log(` - ${skipped} churches skipped`); + console.log(` - ${errors} errors`); + console.log(` - ${totalMassSchedules} mass schedules`); + console.log(` - ${totalConfessionSchedules} confession schedules`); + console.log(` - ${totalAdorationSchedules} adoration schedules`); + + await prisma.$disconnect(); + + if (dryRun) { + console.log('\n💡 This was a DRY RUN. To actually import to Neon, run:'); + console.log(` npx tsx scripts/import-to-neon.ts ${country} real-import`); + } else { + console.log('\n🎉 Data successfully uploaded to Neon!'); + } + + } catch (error) { + console.error('❌ Import failed:', error); + await prisma.$disconnect(); + throw error; + } + + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + console.error(`❌ Export file not found: ${exportFile}`); + console.error(`\nFirst, export data from NAS:`); + console.error(` ssh albert@192.168.0.145`); + console.error(` cd /volume1/docker/nearestmass`); + console.error(` /usr/local/bin/docker compose --profile tools run --rm scraper npx tsx scripts/export-from-nas.ts ${country}`); + console.error(`\nThen download the export:`); + console.error(` scp albert@192.168.0.145:/volume1/docker/nearestmass/export-${country}.json .`); + console.error(`\nFinally, run this import script again.`); + } else { + console.error('❌ Process failed:', error); + } + process.exit(1); + } +} + +main().catch(console.error); diff --git a/scripts/debug/investigate-8-bugs.ts b/scripts/debug/investigate-8-bugs.ts new file mode 100644 index 0000000..0f0c19d --- /dev/null +++ b/scripts/debug/investigate-8-bugs.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env tsx +/** + * Investigate the 8 potential parsing bugs + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +const BUGS = [ + { name: 'Chapelle Saint-Jean-XXIII', country: 'FR', url: 'https://www.chemin-neuf.fr/' }, + { name: 'St. Marien', country: 'DE', url: 'https://www.willehad.de/start/' }, + { name: 'Iglesia de San Fernando', country: 'ES', url: 'https://www.parroquiasanfernandomaspalomas.net/de/' }, + { name: 'Monestir de Sant Esperit', country: 'ES', url: 'https://www.santoespiritu.org/' }, + { name: 'Santuario de Manalagua', country: 'ES', url: 'http://tierrasdeburgos.blogspot.com.es/2013/12/escultura-del-agua-santuario-de.html' }, + { name: 'Kościół pw. Najświętszego Serca', country: 'PL', url: 'http://parafialubojna.pl' }, + { name: 'Paróquia do Desterro', country: 'BR', url: 'https://paroquiaportodegalinhas.blogspot.com.br/' }, + { name: 'Catedral Diocesana', country: 'BR', url: 'http://diocesedejuazeiro.org.br/' }, +]; + +async function investigate() { + console.log('Investigating 8 potential bugs...\n'); + + const scraper = new GenericScraper(); + await scraper.init(); + + for (let i = 0; i < BUGS.length; i++) { + const bug = BUGS[i]; + console.log(`${'='.repeat(80)}`); + console.log(`${i + 1}. ${bug.name} (${bug.country})`); + console.log(` ${bug.url}`); + console.log('='.repeat(80)); + + scraper.setCountry(bug.country); + + try { + const result = await scraper.scrape(bug.url); + + console.log(`Success: ${result.success}`); + console.log(`Schedules: ${result.schedules.length}`); + console.log(`Error: ${result.error || 'none'}`); + + if (!result.success && result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Check page type + console.log('\nPage analysis:'); + if (text.includes('blogspot')) { + console.log(' ⚠️ Blogspot page (likely blog post, not church website)'); + } + if (text.includes('hotel') || text.includes('reservation') || text.includes('booking')) { + console.log(' ⚠️ Contains hotel/booking keywords'); + } + if (text.includes('restaurant') || text.includes('menu')) { + console.log(' ⚠️ Contains restaurant keywords'); + } + if (text.includes('404') || text.includes('not found') || text.includes('error')) { + console.log(' ⚠️ Error/404 page'); + } + + // Check if it has schedule keywords + const hasScheduleKeywords = text.match(/(mass|messe|misa|missa|horário|horario|gottesdienst|eucarist)/i); + console.log(` Schedule keywords: ${hasScheduleKeywords ? '✓ Found' : '✗ Not found'}`); + + // Show sample text + const massIndex = text.indexOf('mass') || text.indexOf('messe') || text.indexOf('misa') || text.indexOf('missa') || 0; + const sampleStart = Math.max(0, massIndex - 50); + const sample = text.substring(sampleStart, sampleStart + 300); + console.log(`\n Sample text: "${sample.substring(0, 200)}..."`); + } + + console.log('\n'); + } catch (err: any) { + console.log(`ERROR: ${err.message}\n\n`); + } + } + + await scraper.close(); +} + +investigate().catch(console.error); diff --git a/scripts/debug/list-church-websites.ts b/scripts/debug/list-church-websites.ts new file mode 100644 index 0000000..79c4ae9 --- /dev/null +++ b/scripts/debug/list-church-websites.ts @@ -0,0 +1,134 @@ +import { config } from 'dotenv'; +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; + +// Load .env.local first, then .env +config({ path: '.env.local' }); +config({ path: '.env' }); + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error('DATABASE_URL environment variable is not set'); +} + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function listChurchWebsites() { + try { + console.log('Fetching churches from database...\n'); + + const churches = await prisma.church.findMany({ + select: { + id: true, + name: true, + city: true, + state: true, + country: true, + website: true, + googlePlaceId: true, + }, + orderBy: [ + { country: 'asc' }, + { state: 'asc' }, + { city: 'asc' }, + ], + }); + + console.log(`Total churches: ${churches.length}`); + + const withWebsite = churches.filter(c => c.website); + const withGoogle = churches.filter(c => c.googlePlaceId); + const withoutWebsite = churches.filter(c => !c.website); + + console.log(`Churches with website: ${withWebsite.length}`); + console.log(`Churches with Google Place ID: ${withGoogle.length}`); + console.log(`Churches without website: ${withoutWebsite.length}\n`); + + // Group by country + const byCountry = churches.reduce((acc, church) => { + const country = church.country || 'Unknown'; + if (!acc[country]) { + acc[country] = []; + } + acc[country].push(church); + return acc; + }, {} as Record); + + // Write to file + let output = '# Church Websites\n\n'; + output += `Generated: ${new Date().toISOString()}\n\n`; + output += `## Summary\n`; + output += `- Total churches: ${churches.length}\n`; + output += `- With website: ${withWebsite.length} (${((withWebsite.length / churches.length) * 100).toFixed(1)}%)\n`; + output += `- With Google Place ID: ${withGoogle.length} (${((withGoogle.length / churches.length) * 100).toFixed(1)}%)\n`; + output += `- Without website: ${withoutWebsite.length} (${((withoutWebsite.length / churches.length) * 100).toFixed(1)}%)\n\n`; + + // Add country breakdown + output += `## By Country\n\n`; + Object.entries(byCountry) + .sort(([, a], [, b]) => b.length - a.length) + .forEach(([country, countryChurches]) => { + const withSite = countryChurches.filter(c => c.website).length; + const withGoogle = countryChurches.filter(c => c.googlePlaceId).length; + output += `### ${country} (${countryChurches.length} churches)\n`; + output += `- With website: ${withSite} (${((withSite / countryChurches.length) * 100).toFixed(1)}%)\n`; + output += `- With Google Place ID: ${withGoogle} (${((withGoogle / countryChurches.length) * 100).toFixed(1)}%)\n\n`; + }); + + // List all websites + output += `## All Websites\n\n`; + Object.entries(byCountry) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([country, countryChurches]) => { + output += `### ${country}\n\n`; + countryChurches.forEach(church => { + const location = [church.city, church.state, church.country].filter(Boolean).join(', '); + if (church.website) { + output += `- **${church.name}** (${location})\n`; + output += ` - Website: ${church.website}\n`; + if (church.googlePlaceId) { + output += ` - Google Place ID: ${church.googlePlaceId}\n`; + } + output += ` - DB ID: ${church.id}\n\n`; + } + }); + }); + + // List churches without websites + output += `## Churches Without Websites\n\n`; + Object.entries(byCountry) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([country, countryChurches]) => { + const without = countryChurches.filter(c => !c.website); + if (without.length > 0) { + output += `### ${country}\n\n`; + without.forEach(church => { + const location = [church.city, church.state, church.country].filter(Boolean).join(', '); + output += `- **${church.name}** (${location})\n`; + if (church.googlePlaceId) { + output += ` - Google Place ID: ${church.googlePlaceId}\n`; + } + output += ` - DB ID: ${church.id}\n\n`; + }); + } + }); + + // Write to file + const fs = await import('fs/promises'); + await fs.writeFile('church-websites.md', output); + console.log('✓ Written to church-websites.md'); + + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +listChurchWebsites(); diff --git a/scripts/debug/list-tables.ts b/scripts/debug/list-tables.ts new file mode 100644 index 0000000..54e1055 --- /dev/null +++ b/scripts/debug/list-tables.ts @@ -0,0 +1,44 @@ +import { Pool } from 'pg'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; + +// Load .env.local first (takes precedence), then .env +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }); +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function listTables() { + try { + console.log('Connecting to database...'); + console.log('DATABASE_URL:', process.env.DATABASE_URL?.replace(/:[^:@]+@/, ':****@')); + + // List all tables + const result = await pool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name; + `); + + console.log('\n=== Tables in Database ==='); + if (result.rows.length === 0) { + console.log('No tables found!'); + } else { + result.rows.forEach((row) => { + console.log(`- ${row.table_name}`); + }); + } + + console.log(`\nTotal: ${result.rows.length} tables`); + + } catch (error) { + console.error('Error listing tables:', error); + } finally { + await pool.end(); + } +} + +listTables(); diff --git a/scripts/debug/pipeline-report.js b/scripts/debug/pipeline-report.js new file mode 100644 index 0000000..1609907 --- /dev/null +++ b/scripts/debug/pipeline-report.js @@ -0,0 +1,167 @@ +const { Client } = require("pg"); +const client = new Client({ + connectionString: "postgresql://postgres:postgres@192.168.0.145:5434/nearestmass" +}); + +const queries = [ + { + name: "1. Overall church counts by country (top 20)", + sql: `SELECT country, COUNT(*) as total, + COUNT(*) FILTER (WHERE website IS NOT NULL) as has_website, + COUNT(*) FILTER (WHERE last_scraped_at IS NOT NULL) as scraped, + COUNT(*) FILTER (WHERE has_website = true) as has_website_flag, + COUNT(*) FILTER (WHERE website_language IS NOT NULL) as has_language + FROM churches + GROUP BY country + ORDER BY total DESC + LIMIT 20` + }, + { + name: "2. Total mass schedule counts", + sql: `SELECT COUNT(*) as total_schedules, + COUNT(DISTINCT church_id) as churches_with_schedules + FROM mass_schedules` + }, + { + name: "3. Scrape results by language", + sql: `SELECT website_language as language, + COUNT(*) as total_scraped, + COUNT(*) FILTER (WHERE last_scraped_at IS NOT NULL) as scraped + FROM churches + WHERE website_language IS NOT NULL + GROUP BY website_language + ORDER BY total_scraped DESC` + }, + { + name: "4. Churches with websites but never scraped", + sql: `SELECT COUNT(*) as has_website_not_scraped + FROM churches + WHERE website IS NOT NULL AND last_scraped_at IS NULL` + }, + { + name: "5. Overall pipeline funnel", + sql: `SELECT + COUNT(*) as total_churches, + COUNT(*) FILTER (WHERE website IS NOT NULL) as has_website, + COUNT(*) FILTER (WHERE last_scraped_at IS NOT NULL) as attempted_scrape, + COUNT(*) FILTER (WHERE website_language IS NOT NULL) as has_detected_language, + (SELECT COUNT(DISTINCT church_id) FROM mass_schedules) as has_schedules_saved, + (SELECT COUNT(*) FROM mass_schedules) as total_schedule_rows + FROM churches` + }, + { + name: "6. Recent scrape activity (last 7 days) by language", + sql: `SELECT website_language as language, + COUNT(*) as scraped_last_7d + FROM churches + WHERE last_scraped_at > NOW() - INTERVAL '7 days' + GROUP BY website_language + ORDER BY scraped_last_7d DESC` + }, + { + name: "7. Background job history (last 15 completed/failed jobs)", + sql: `SELECT type, language, status, + created_at::date as created, + completed_at::date as completed, + ROUND(CAST(EXTRACT(EPOCH FROM (completed_at - created_at))/3600 AS numeric), 2) as hours, + total_items, processed, succeeded, failed + FROM background_jobs + WHERE status IN ('completed', 'failed') + ORDER BY completed_at DESC + LIMIT 15` + }, + { + name: "8. Mass schedule breakdown by day of week", + sql: `SELECT day_of_week, + CASE day_of_week + WHEN 0 THEN 'Sunday' WHEN 1 THEN 'Monday' WHEN 2 THEN 'Tuesday' + WHEN 3 THEN 'Wednesday' WHEN 4 THEN 'Thursday' WHEN 5 THEN 'Friday' + WHEN 6 THEN 'Saturday' ELSE 'Other' + END as day_name, + COUNT(*) as count + FROM mass_schedules + GROUP BY day_of_week + ORDER BY day_of_week` + }, + { + name: "9. Churches with schedules by country (top 15)", + sql: `SELECT c.country, + COUNT(DISTINCT c.id) as total_churches, + COUNT(DISTINCT ms.church_id) as churches_with_schedules, + ROUND(100.0 * COUNT(DISTINCT ms.church_id) / NULLIF(COUNT(DISTINCT c.id), 0), 1) as coverage_pct, + COUNT(ms.id) as total_schedule_rows + FROM churches c + LEFT JOIN mass_schedules ms ON ms.church_id = c.id + GROUP BY c.country + ORDER BY total_churches DESC + LIMIT 15` + }, + { + name: "10. Enrichment sources - how churches were found", + sql: `SELECT source, COUNT(*) as count + FROM churches + GROUP BY source + ORDER BY count DESC` + }, + { + name: "11. Google Places enrichment impact", + sql: `SELECT + COUNT(*) FILTER (WHERE google_place_id IS NOT NULL) as has_google_place, + COUNT(*) FILTER (WHERE google_place_id IS NOT NULL AND website IS NOT NULL) as google_with_website, + COUNT(*) FILTER (WHERE google_place_id IS NULL) as no_google_place, + COUNT(*) FILTER (WHERE google_searched_at IS NOT NULL) as google_searched, + COUNT(*) FILTER (WHERE free_searched_at IS NOT NULL) as free_searched + FROM churches` + }, + { + name: "12. Website presence by source", + sql: `SELECT source, + COUNT(*) as total, + COUNT(*) FILTER (WHERE website IS NOT NULL) as has_website, + ROUND(100.0 * COUNT(*) FILTER (WHERE website IS NOT NULL) / NULLIF(COUNT(*), 0), 1) as website_pct, + COUNT(*) FILTER (WHERE google_place_id IS NOT NULL) as has_google_place, + COUNT(*) FILTER (WHERE last_scraped_at IS NOT NULL) as scraped + FROM churches + GROUP BY source + ORDER BY total DESC` + } +]; + +async function run() { + await client.connect(); + + for (const q of queries) { + console.log("=".repeat(90)); + console.log(q.name); + console.log("=".repeat(90)); + try { + const res = await client.query(q.sql); + if (res.rows.length === 0) { + console.log("(no rows returned)"); + } else { + // Calculate column widths + const cols = Object.keys(res.rows[0]); + const widths = cols.map(c => { + const maxData = Math.max(...res.rows.map(r => String(r[c] ?? "NULL").length)); + return Math.max(c.length, maxData); + }); + + // Print header + console.log(cols.map((c, i) => c.padEnd(widths[i])).join(" | ")); + console.log(widths.map(w => "-".repeat(w)).join("-+-")); + + // Print rows + for (const row of res.rows) { + console.log(cols.map((c, i) => String(row[c] ?? "NULL").padEnd(widths[i])).join(" | ")); + } + } + console.log("\n(" + res.rows.length + " rows)\n"); + } catch (err) { + console.log("ERROR:", err.message, "\n"); + } + } + + await client.end(); +} + +run().catch(e => { console.error(e); process.exit(1); }); diff --git a/scripts/debug/show-french-success.ts b/scripts/debug/show-french-success.ts new file mode 100644 index 0000000..17c3489 --- /dev/null +++ b/scripts/debug/show-french-success.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env tsx +/** + * Show detailed output from a successful French parse + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function showSuccess() { + // One of our successful churches with 16 schedules + const url = 'https://laportelatine.org/lieux/couvent-saint-francois-morgon'; + console.log(`Detailed parse of: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('FR'); + + const result = await scraper.scrape(url); + + console.log(`✅ Success: ${result.success}`); + console.log(`📅 Schedules found: ${result.schedules.length}\n`); + + // Group by day + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNames = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; + + console.log('═══════════════════════════════════════════════'); + console.log('PARSED SCHEDULE:'); + console.log('═══════════════════════════════════════════════\n'); + + Object.entries(byDay) + .sort(([a], [b]) => parseInt(a) - parseInt(b)) + .forEach(([day, scheds]) => { + console.log(`${dayNames[parseInt(day)]}:`); + scheds.forEach(s => { + console.log(` ${s.time} - ${s.language} ${s.massType}`); + }); + console.log(''); + }); + + await scraper.close(); +} + +showSuccess().catch(console.error); diff --git a/scripts/debug/test-db-connection.ts b/scripts/debug/test-db-connection.ts new file mode 100644 index 0000000..6a06814 --- /dev/null +++ b/scripts/debug/test-db-connection.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env tsx +/** + * Test database connection + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +console.log('DATABASE_URL exists:', !!process.env.DATABASE_URL); +console.log('DATABASE_URL value:', process.env.DATABASE_URL?.substring(0, 50) + '...'); + +import { prisma } from '../../src/lib/db'; + +async function testConnection() { + try { + const count = await prisma.church.count(); + console.log(`✅ Database connection successful!`); + console.log(`Total churches in database: ${count}`); + } catch (err: any) { + console.log(`❌ Database connection failed:`); + console.log(err.message); + } finally { + await prisma.$disconnect(); + } +} + +testConnection(); diff --git a/scripts/debug/test-french-broader.ts b/scripts/debug/test-french-broader.ts new file mode 100644 index 0000000..60b6917 --- /dev/null +++ b/scripts/debug/test-french-broader.ts @@ -0,0 +1,180 @@ +#!/usr/bin/env tsx +/** + * Test more French churches and collect diagnostic data + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +interface DiagnosticInfo { + url: string; + churchName: string; + success: boolean; + schedulesFound: number; + hasFrenchDays: boolean; + hasTimePatterns: boolean; + timePatternsSample: string[]; + textSample: string; + error?: string; +} + +async function testFrenchBroader() { + console.log('Testing 20 French churches with diagnostics...\n'); + + // Get more French churches + const churches = await prisma.church.findMany({ + where: { + country: 'FR', + website: { not: null }, + source: 'osm', + }, + take: 20, + orderBy: { createdAt: 'asc' }, + }); + + if (churches.length === 0) { + console.log('No French churches found.'); + await prisma.$disconnect(); + await pool.end(); + return; + } + + console.log(`Found ${churches.length} French churches to test\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('FR'); + + let successCount = 0; + let failCount = 0; + const diagnostics: DiagnosticInfo[] = []; + + for (let i = 0; i < churches.length; i++) { + const church = churches[i]; + console.log(`[${i + 1}/${churches.length}] Testing: ${church.name} (${church.city || 'Unknown'})`); + console.log(`URL: ${church.website}`); + + try { + const result = await scraper.scrape(church.website!); + + // Extract diagnostics + let hasFrenchDays = false; + let hasTimePatterns = false; + let timePatternsSample: string[] = []; + let textSample = ''; + + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + textSample = text.substring(0, 500); + + const frenchDays = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + hasFrenchDays = frenchDays.some(day => text.includes(day)); + + const timeRegex = /\d{1,2}[h:\.]\s*\d{0,2}\s*(?:h)?/g; + const times = text.match(timeRegex); + if (times) { + hasTimePatterns = true; + timePatternsSample = [...new Set(times)].slice(0, 10); + } + } + + const diagnostic: DiagnosticInfo = { + url: church.website!, + churchName: church.name, + success: result.success, + schedulesFound: result.schedules.length, + hasFrenchDays, + hasTimePatterns, + timePatternsSample, + textSample, + error: result.error, + }; + + diagnostics.push(diagnostic); + + if (result.success && result.schedules.length > 0) { + successCount++; + console.log(`✅ SUCCESS - ${result.schedules.length} schedules`); + } else { + failCount++; + console.log(`❌ FAILED - ${result.error}`); + if (hasFrenchDays && !hasTimePatterns) { + console.log(` 💡 Has French days but no times`); + } else if (!hasFrenchDays && hasTimePatterns) { + console.log(` 💡 Has times but no French days`); + } else if (hasFrenchDays && hasTimePatterns) { + console.log(` 💡 Has BOTH days and times - parsing issue!`); + console.log(` Sample times: ${timePatternsSample.slice(0, 5).join(', ')}`); + } else { + console.log(` 💡 No mass schedule content found`); + } + } + console.log(''); + } catch (err: any) { + failCount++; + console.log(`❌ ERROR - ${err.message}\n`); + diagnostics.push({ + url: church.website!, + churchName: church.name, + success: false, + schedulesFound: 0, + hasFrenchDays: false, + hasTimePatterns: false, + timePatternsSample: [], + textSample: '', + error: err.message, + }); + } + } + + await scraper.close(); + + // Analysis + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`\nRESULTS: ${successCount}/${churches.length} successful (${((successCount / churches.length) * 100).toFixed(0)}%)`); + console.log(''); + + const hasBoth = diagnostics.filter(d => !d.success && d.hasFrenchDays && d.hasTimePatterns); + const hasDaysNoTimes = diagnostics.filter(d => !d.success && d.hasFrenchDays && !d.hasTimePatterns); + const hasTimesNoDays = diagnostics.filter(d => !d.success && !d.hasFrenchDays && d.hasTimePatterns); + const hasNeither = diagnostics.filter(d => !d.success && !d.hasFrenchDays && !d.hasTimePatterns); + + console.log('FAILURE ANALYSIS:'); + console.log(` Has days + times but failed: ${hasBoth.length} (PARSING BUG)`); + console.log(` Has days but no times: ${hasDaysNoTimes.length}`); + console.log(` Has times but no days: ${hasTimesNoDays.length}`); + console.log(` Has neither: ${hasNeither.length} (no mass schedule on page)`); + console.log(''); + + if (hasBoth.length > 0) { + console.log('⚠️ PARSING BUGS TO FIX (has both days and times but failed):'); + hasBoth.forEach(d => { + console.log(` ${d.churchName}`); + console.log(` URL: ${d.url}`); + console.log(` Sample times found: ${d.timePatternsSample.slice(0, 5).join(', ')}`); + console.log(` Text sample: ${d.textSample.substring(0, 150)}...`); + console.log(''); + }); + } + + await prisma.$disconnect(); + await pool.end(); +} + +testFrenchBroader().catch(console.error); diff --git a/scripts/debug/test-french-scraper.ts b/scripts/debug/test-french-scraper.ts new file mode 100755 index 0000000..0b79970 --- /dev/null +++ b/scripts/debug/test-french-scraper.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env tsx +/** + * Test international scraper against French churches + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function testFrenchScraper() { + console.log('Testing French church mass schedule scraping...\n'); + + // Get French churches with websites + const churches = await prisma.church.findMany({ + where: { + country: 'FR', + website: { not: null }, + source: 'osm', + }, + take: 5, + orderBy: { createdAt: 'asc' }, + }); + + if (churches.length === 0) { + console.log('No French churches with websites found.'); + await prisma.$disconnect(); + await pool.end(); + return; + } + + console.log(`Found ${churches.length} French churches to test:\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('FR'); + + let successCount = 0; + let failCount = 0; + + for (const church of churches) { + console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + console.log(`Church: ${church.name}`); + console.log(`City: ${church.city || 'Unknown'}`); + console.log(`URL: ${church.website}`); + console.log(''); + + try { + const result = await scraper.scrape(church.website!); + + if (result.success && result.schedules.length > 0) { + successCount++; + console.log(`✅ SUCCESS - Found ${result.schedules.length} schedules\n`); + + // Group by day and show + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNames = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; + Object.entries(byDay).forEach(([day, scheds]) => { + console.log(` ${dayNames[parseInt(day)]}:`); + scheds.forEach(s => { + console.log(` ${s.time} - ${s.language || 'Unknown'} (${s.massType || 'Mass'})`); + }); + }); + console.log(''); + } else { + failCount++; + console.log(`❌ FAILED - ${result.error}`); + console.log(''); + } + } catch (err: any) { + failCount++; + console.log(`❌ ERROR - ${err.message}`); + console.log(''); + } + } + + await scraper.close(); + + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(`\nRESULTS: ${successCount}/${churches.length} successful (${((successCount / churches.length) * 100).toFixed(0)}%)`); + console.log(`Success: ${successCount}, Failed: ${failCount}\n`); + + await prisma.$disconnect(); + await pool.end(); +} + +testFrenchScraper().catch(console.error); diff --git a/scripts/debug/test-international-sample.ts b/scripts/debug/test-international-sample.ts new file mode 100644 index 0000000..34f7360 --- /dev/null +++ b/scripts/debug/test-international-sample.ts @@ -0,0 +1,210 @@ +#!/usr/bin/env tsx +/** + * Test scraper on a diverse sample of international churches + * to identify edge cases across different languages and formats + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +interface TestChurch { + name: string; + url: string; + country: string; + language: string; + expectedDays?: string; // e.g., "Sun-Sat" or "Sun, Wed, Sat" + notes?: string; +} + +// Sample churches from different countries/languages +const testChurches: TestChurch[] = [ + // FRENCH + { + name: 'Saint-Étienne du Mont, Paris', + url: 'https://www.saintetiennedumontparis.fr/', + country: 'FR', + language: 'French', + notes: 'French format with "du lundi au vendredi"', + }, + { + name: 'Notre-Dame de la Garde, Marseille', + url: 'https://www.notredamedelagarde.fr/', + country: 'FR', + language: 'French', + notes: 'Major pilgrimage site', + }, + + // GERMAN + { + name: 'St. Peter, Munich', + url: 'https://www.alterpeter.de/', + country: 'DE', + language: 'German', + notes: 'German format with "bis" for ranges', + }, + { + name: 'Kölner Dom, Cologne', + url: 'https://www.koelner-dom.de/', + country: 'DE', + language: 'German', + notes: 'Cathedral with Uhr time format', + }, + + // SPANISH + { + name: 'Sagrada Família, Barcelona', + url: 'https://sagradafamilia.org/', + country: 'ES', + language: 'Spanish', + notes: 'Major tourist site, may have complex schedule', + }, + { + name: 'Parroquia San Miguel, Madrid', + url: 'https://www.parroquiasanmiguel.es/', + country: 'ES', + language: 'Spanish', + notes: 'Spanish format with "de lunes a viernes"', + }, + + // PORTUGUESE + { + name: 'Basílica da Estrela, Lisbon', + url: 'https://www.basilicadaestrela.com/', + country: 'PT', + language: 'Portuguese', + notes: 'Portuguese format', + }, + + // ITALIAN + { + name: 'Santa Maria Maggiore, Rome', + url: 'https://www.vatican.va/various/basiliche/sm_maggiore/index_it.htm', + country: 'IT', + language: 'Italian', + notes: 'Major basilica', + }, + { + name: 'Duomo di Milano', + url: 'https://www.duomomilano.it/', + country: 'IT', + language: 'Italian', + notes: 'Cathedral with Italian format', + }, + + // DUTCH + { + name: 'Basiliek van de H. Nicolaas, Amsterdam', + url: 'https://www.nicolaas-parochie.nl/', + country: 'NL', + language: 'Dutch', + notes: 'Dutch format with "tot" for ranges', + }, + + // CZECH + { + name: 'Chrám sv. Víta, Prague', + url: 'https://www.katedralasvatehovita.cz/', + country: 'CZ', + language: 'Czech', + notes: 'Czech format', + }, + + // HUNGARIAN + { + name: 'Szent István Bazilika, Budapest', + url: 'https://www.bazilika.biz/', + country: 'HU', + language: 'Hungarian', + notes: 'Hungarian format', + }, + + // More complex cases + { + name: 'Cathédrale Notre-Dame, Strasbourg', + url: 'https://www.cathedrale-strasbourg.fr/', + country: 'FR', + language: 'French', + notes: 'Bilingual region (French/German)', + }, +]; + +async function testChurch(church: TestChurch, scraper: GenericScraper): Promise { + console.log(`\n${'='.repeat(80)}`); + console.log(`📍 ${church.name}`); + console.log(` ${church.url}`); + console.log(` Language: ${church.language} | Country: ${church.country}`); + if (church.notes) console.log(` Notes: ${church.notes}`); + console.log(`${'='.repeat(80)}`); + + try { + scraper.setCountry(church.country); + const result = await scraper.scrape(church.url); + + if (!result.success) { + console.log(`❌ FAILED: ${result.error || 'Unknown error'}`); + return; + } + + if (result.schedules.length === 0) { + console.log(`⚠️ SUCCESS but NO SCHEDULES found`); + return; + } + + // Group by day + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + console.log(`\n✅ Found ${result.schedules.length} schedules:\n`); + + for (let i = 0; i < 7; i++) { + if (byDay[i]) { + const times = byDay[i].map(s => { + let str = s.time; + if (s.massType) str += ` (${s.massType})`; + if (s.language && s.language !== 'English') str += ` [${s.language}]`; + return str; + }).join(', '); + console.log(` ${dayNames[i]}: ${times}`); + } + } + + } catch (error) { + console.log(`❌ ERROR: ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function main() { + const scraper = new GenericScraper(); + await scraper.init(); + + console.log('🌍 INTERNATIONAL CHURCH SCRAPER TEST'); + console.log(`Testing ${testChurches.length} churches across ${new Set(testChurches.map(c => c.country)).size} countries`); + + const results: { success: number; failed: number; noSchedules: number } = { + success: 0, + failed: 0, + noSchedules: 0, + }; + + for (const church of testChurches) { + await testChurch(church, scraper); + + // Brief delay between requests to be respectful + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + await scraper.close(); + + console.log(`\n${'='.repeat(80)}`); + console.log('📊 SUMMARY'); + console.log(`${'='.repeat(80)}`); + console.log(`Total tested: ${testChurches.length}`); + console.log(`✅ Success with schedules: ${results.success}`); + console.log(`⚠️ Success but no schedules: ${results.noSchedules}`); + console.log(`❌ Failed: ${results.failed}`); +} + +main().catch(console.error); diff --git a/scripts/debug/test-masstimes-api.ts b/scripts/debug/test-masstimes-api.ts new file mode 100644 index 0000000..64d62f8 --- /dev/null +++ b/scripts/debug/test-masstimes-api.ts @@ -0,0 +1,36 @@ +/** + * Quick test script to verify the masstimes.org JSON API scraper works + * Usage: npx tsx scripts/test-masstimes-api.ts + */ + +import { writeFileSync } from 'fs'; +import { MassTimesScraper } from '../../src/lib/masstimes-scraper'; + +async function main() { + console.log('Testing MassTimes.org JSON API Scraper\n'); + + const scraper = new MassTimesScraper(); + + try { + await scraper.init(); + console.log('Browser initialized\n'); + + const lat = 34.852; + const lng = -82.394; + console.log(`Fetching churches near Greenville, SC (${lat}, ${lng})...\n`); + + const churches = await scraper.scrapeByLocation(lat, lng); + + const outPath = 'scraped-churches.json'; + writeFileSync(outPath, JSON.stringify(churches, null, 2)); + console.log(`\nSaved ${churches.length} churches to ${outPath}`); + + } catch (error) { + console.error('TEST FAILED:', error); + process.exit(1); + } finally { + await scraper.close(); + } +} + +main(); diff --git a/scripts/debug/test-polish-sections.ts b/scripts/debug/test-polish-sections.ts new file mode 100644 index 0000000..22f0e35 --- /dev/null +++ b/scripts/debug/test-polish-sections.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env tsx +/** + * Test which sections are being created for Polish church + */ + +import { getDayNamesForCountry, buildDayPatterns } from '../../src/scrapers/i18n/day-names'; + +// Exact text from the page +const text = `msze święte niedziela i uroczystości: 8 00 , 9 30 (lubojenka), 11 00 , 16 00 w lipcu i sierpniu nie ma mszy popołudniowej!--> dni powszednie: poniedziałek: godz. 8 00 wtorek - sobota: godz. 18 00`.toLowerCase(); + +console.log('Text:'); +console.log(text); +console.log('\n'); + +const dayConfigs = getDayNamesForCountry('PL'); +const dayPatterns = buildDayPatterns(dayConfigs); +const sortedDayNames = Object.keys(dayPatterns).sort((a, b) => b.length - a.length); +const allDayNamesPattern = sortedDayNames.map(d => d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + +console.log('=== Testing individual day matching ===\n'); + +// Test niedziela specifically +const niedziela = 'niedziela'; +const escaped = niedziela.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const regex = new RegExp( + `(?:^|\\s|[,;:])${escaped}[:\\s]+([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' +); + +const match = text.match(regex); +if (match) { + console.log(`✓ niedziela matched!`); + console.log(` Full match: "${match[0].substring(0, 100)}"`); + console.log(` Captured text: "${match[1].substring(0, 100)}"`); + console.log(''); + + // Test if times can be extracted from captured text + const spacePattern = /\b(\d{1,2})\s+(\d{2})(?!\d)/g; + const times = match[1].match(spacePattern); + console.log(` Times in captured text: ${times ? times.join(', ') : 'none'}`); +} else { + console.log(`✗ niedziela NOT matched`); + console.log(''); + + // Try simpler regex + const simpleRegex = /niedziela[:\s]+(.{0,100})/i; + const simpleMatch = text.match(simpleRegex); + if (simpleMatch) { + console.log(`Simple regex matched: "${simpleMatch[1]}"`); + } +} + +// Test poniedziałek +console.log('\n=== Testing poniedziałek ===\n'); + +const ponieRegex = new RegExp( + `(?:^|\\s|[,;:])poniedziałek[:\\s]+([^]*?)(?=${allDayNamesPattern}|$)`, + 'i' +); + +const ponieMatch = text.match(ponieRegex); +if (ponieMatch) { + console.log(`✓ poniedziałek matched!`); + console.log(` Captured text: "${ponieMatch[1].substring(0, 100)}"`); + + const times = ponieMatch[1].match(/\b(\d{1,2})\s+(\d{2})(?!\d)/g); + console.log(` Times: ${times ? times.join(', ') : 'none'}`); +} else { + console.log(`✗ poniedziałek NOT matched`); +} diff --git a/scripts/debug/test-polish-with-logging.ts b/scripts/debug/test-polish-with-logging.ts new file mode 100644 index 0000000..a6e8367 --- /dev/null +++ b/scripts/debug/test-polish-with-logging.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env tsx +/** + * Test Polish church with detailed section logging + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +// Temporarily modify GenericScraper to add logging +const originalParse = GenericScraper.prototype['parseSchedules']; +GenericScraper.prototype['parseSchedules'] = function(html: string) { + const text = html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Call findScheduleSections and log result + const sections = this['findScheduleSections'](text); + + console.log('\n=== Sections found by findScheduleSections() ===\n'); + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + sections.forEach((section: any, i: number) => { + console.log(`Section ${i + 1}: ${dayNames[section.day]} (day ${section.day})`); + console.log(` Text: "${section.text.substring(0, 80)}..."`); + }); + console.log(`\nTotal sections: ${sections.length}\n`); + + // Continue with normal processing + return originalParse.call(this, html); +}; + +async function testPolish() { + const url = 'http://parafialubojna.pl'; + console.log(`Testing: ${url}`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('PL'); + + const result = await scraper.scrape(url); + + console.log(`\nFinal result: ${result.success}`); + console.log(`Schedules: ${result.schedules.length}\n`); + + if (result.schedules.length > 0) { + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNamesPL = ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota']; + console.log('Parsed schedules by day:'); + for (let i = 0; i < 7; i++) { + if (byDay[i]) { + console.log(` ${dayNamesPL[i]}: ${byDay[i].map(s => s.time).join(', ')}`); + } + } + } + + await scraper.close(); +} + +testPolish().catch(console.error); diff --git a/scripts/debug/test-time-extraction.ts b/scripts/debug/test-time-extraction.ts new file mode 100644 index 0000000..7419b68 --- /dev/null +++ b/scripts/debug/test-time-extraction.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env tsx +/** + * Test which pattern is matching "00" time + */ + +// Test text from German church +const testText = "10:00 uhr lateinisches amt"; + +const timePatterns = [ + { name: '12-hour AM/PM', pattern: /(\d{1,2}):(\d{2})\s*(AM|PM|am|pm|a\.m\.|p\.m\.)/g }, + { name: '12-hour no minutes', pattern: /(? 0) { + console.log(`✓ ${name}:`); + for (const match of matches) { + console.log(` Matched: "${match[0]}" at index ${match.index}`); + } + } else { + console.log(`✗ ${name}: no match`); + } +} + +// Now test with just "00 uhr" +console.log(`\n${'='.repeat(60)}\n`); +const testText2 = "00 uhr lateinisches"; +console.log(`Test text: "${testText2}"\n`); + +for (const { name, pattern } of timePatterns) { + const matches = [...testText2.matchAll(pattern)]; + if (matches.length > 0) { + console.log(`✓ ${name}:`); + for (const match of matches) { + console.log(` Matched: "${match[0]}" at index ${match.index}`); + } + } else { + console.log(`✗ ${name}: no match`); + } +} diff --git a/scripts/debug/test-top5-countries.ts b/scripts/debug/test-top5-countries.ts new file mode 100644 index 0000000..219a4da --- /dev/null +++ b/scripts/debug/test-top5-countries.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env tsx +/** + * Quick test of top 5 priority countries + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const COUNTRIES = [ + { code: 'FR', name: 'France' }, + { code: 'DE', name: 'Germany' }, + { code: 'ES', name: 'Spain' }, + { code: 'PL', name: 'Poland' }, + { code: 'BR', name: 'Brazil' }, +]; + +const PER_COUNTRY = 10; + +interface CountryResult { + country: string; + countryName: string; + tested: number; + success: number; + failed: number; + successRate: number; + hasBothButFailed: number; // Has days + times but parsing failed + totalSchedules: number; + sampleSuccess?: string; +} + +async function testTop5() { + console.log('Testing top 5 priority countries (10 churches each)...\n'); + + const scraper = new GenericScraper(); + await scraper.init(); + + const results: CountryResult[] = []; + + for (const country of COUNTRIES) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Testing ${country.name} (${country.code})`); + console.log('='.repeat(60)); + + const churches = await prisma.church.findMany({ + where: { + country: country.code, + website: { not: null }, + source: 'osm', + }, + take: PER_COUNTRY, + orderBy: { createdAt: 'asc' }, + }); + + if (churches.length === 0) { + console.log(`No churches with websites found for ${country.name}\n`); + continue; + } + + scraper.setCountry(country.code); + + let success = 0; + let failed = 0; + let hasBothButFailed = 0; + let totalSchedules = 0; + let sampleSuccess: string | undefined; + + for (let i = 0; i < churches.length; i++) { + const church = churches[i]; + process.stdout.write(`[${i + 1}/${churches.length}] ${church.name.substring(0, 40).padEnd(40)} `); + + try { + const result = await scraper.scrape(church.website!); + + if (result.success && result.schedules.length > 0) { + success++; + totalSchedules += result.schedules.length; + process.stdout.write(`✅ ${result.schedules.length} schedules\n`); + + if (!sampleSuccess && result.schedules.length > 0) { + sampleSuccess = `${church.name}: ${result.schedules.length} schedules`; + } + } else { + failed++; + process.stdout.write(`❌ ${result.error}\n`); + + // Check if has both days and times (parsing bug indicator) + if (result.rawHtml) { + const text = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); + + // Check for day names in any language + const hasDays = text.match(/\b(sunday|monday|tuesday|wednesday|thursday|friday|saturday|dimanche|lundi|mardi|mercredi|jeudi|vendredi|samedi|sonntag|montag|dienstag|mittwoch|donnerstag|freitag|samstag|domingo|lunes|martes|miércoles|miercoles|jueves|viernes|sábado|sabado|niedziela|poniedziałek|poniedzialek|wtorek|środa|sroda|czwartek|piątek|piatek|sobota|segunda|terça|terca|quarta|quinta|sexta)\b/i); + + const hasTimes = text.match(/\d{1,2}[h:\.]\s*\d{0,2}/); + + if (hasDays && hasTimes) { + hasBothButFailed++; + process.stdout.write(` ⚠️ Has days + times but failed to parse\n`); + } + } + } + } catch (err: any) { + failed++; + process.stdout.write(`❌ ERROR: ${err.message}\n`); + } + } + + const successRate = churches.length > 0 ? (success / churches.length) * 100 : 0; + + results.push({ + country: country.code, + countryName: country.name, + tested: churches.length, + success, + failed, + successRate, + hasBothButFailed, + totalSchedules, + sampleSuccess, + }); + + console.log(`\n${country.name} Summary: ${success}/${churches.length} (${successRate.toFixed(0)}%)`); + console.log(` Total schedules extracted: ${totalSchedules}`); + if (hasBothButFailed > 0) { + console.log(` ⚠️ Parsing bugs: ${hasBothButFailed} (has content but failed to parse)`); + } + } + + await scraper.close(); + + // Final summary + console.log('\n\n'); + console.log('═'.repeat(80)); + console.log('FINAL RESULTS - TOP 5 COUNTRIES'); + console.log('═'.repeat(80)); + console.log(''); + console.log('Country | Tested | Success | Rate | Schedules | Bugs'); + console.log('─'.repeat(80)); + + const totalTested = results.reduce((sum, r) => sum + r.tested, 0); + const totalSuccess = results.reduce((sum, r) => sum + r.success, 0); + const totalSchedules = results.reduce((sum, r) => sum + r.totalSchedules, 0); + const totalBugs = results.reduce((sum, r) => sum + r.hasBothButFailed, 0); + + results.forEach(r => { + const country = r.countryName.padEnd(12); + const tested = String(r.tested).padStart(6); + const success = String(r.success).padStart(7); + const rate = `${r.successRate.toFixed(0)}%`.padStart(5); + const schedules = String(r.totalSchedules).padStart(9); + const bugs = r.hasBothButFailed > 0 ? `⚠️ ${r.hasBothButFailed}` : '✓'; + + console.log(`${country} | ${tested} | ${success} | ${rate} | ${schedules} | ${bugs}`); + }); + + console.log('─'.repeat(80)); + const avgRate = totalTested > 0 ? (totalSuccess / totalTested) * 100 : 0; + console.log(`OVERALL | ${String(totalTested).padStart(6)} | ${String(totalSuccess).padStart(7)} | ${avgRate.toFixed(0).padStart(4)}% | ${String(totalSchedules).padStart(9)} | ${totalBugs > 0 ? `⚠️ ${totalBugs}` : '✓'}`); + console.log(''); + console.log('═'.repeat(80)); + console.log(''); + + if (totalBugs > 0) { + console.log(`⚠️ ${totalBugs} parsing bugs detected (has days + times but failed)`); + console.log(' These need investigation and fixes.\n'); + } else { + console.log('✅ No parsing bugs! All failures are legitimate (no content or wrong page).\n'); + } + + console.log(`Total churches tested: ${totalTested}`); + console.log(`Total successful: ${totalSuccess} (${avgRate.toFixed(1)}%)`); + console.log(`Total mass schedules extracted: ${totalSchedules}`); + console.log(''); + + await prisma.$disconnect(); + await pool.end(); +} + +testTop5().catch(console.error); diff --git a/scripts/debug/test-website-scraper.ts b/scripts/debug/test-website-scraper.ts new file mode 100644 index 0000000..90bb4c7 --- /dev/null +++ b/scripts/debug/test-website-scraper.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env tsx +/** + * Test website scraper on churches with websites + * Analyzes which websites can be scraped successfully + */ + +// Load .env +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { GenericScraper } from '../../src/scrapers/strategies/generic'; +import fs from 'fs'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +interface TestResult { + churchId: string; + name: string; + website: string; + country: string; + success: boolean; + massesFound: number; + schedules?: { dayOfWeek: number; time: string; massType?: string; language?: string }[]; + error?: string; +} + +function normalizeUrl(url: string): string { + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return `https://${url}`; + } + return url; +} + +async function testScrapers(limit: number = 50, country?: string) { + const results: TestResult[] = []; + + // Get churches with websites + const whereClause: any = { + website: { not: null }, + }; + + if (country) { + whereClause.country = country; + } + + const churches = await prisma.church.findMany({ + where: whereClause, + take: limit, + orderBy: { createdAt: 'desc' }, + }); + + console.log(`Testing ${churches.length} churches with websites...\n`); + + // Initialize the scraper (launches Playwright browser) + const scraper = new GenericScraper(); + await scraper.init(); + + try { + for (let i = 0; i < churches.length; i++) { + const church = churches[i]; + const url = normalizeUrl(church.website!); + console.log(`[${i + 1}/${churches.length}] Testing: ${church.name}`); + console.log(` Website: ${url}`); + + try { + const result = await scraper.scrape(url); + + results.push({ + churchId: church.id, + name: church.name, + website: url, + country: church.country, + success: result.success, + massesFound: result.schedules.length, + schedules: result.schedules.map((s) => ({ + dayOfWeek: s.dayOfWeek, + time: s.time, + massType: s.massType, + language: s.language, + })), + error: result.error, + }); + + if (result.success) { + console.log(` ✓ ${result.schedules.length} masses found`); + for (const s of result.schedules) { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + console.log(` ${days[s.dayOfWeek]} ${s.time} (${s.language || 'English'}${s.massType ? ', ' + s.massType : ''})`); + } + } else { + console.log(` ✗ No masses found: ${result.error}`); + } + } catch (error: any) { + console.log(` ✗ Error: ${error.message}`); + results.push({ + churchId: church.id, + name: church.name, + website: url, + country: church.country, + success: false, + massesFound: 0, + error: error.message, + }); + } + + console.log(''); + } + } finally { + // Always close the browser + await scraper.close(); + } + + // Summary + const successful = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + const totalMasses = results.reduce((sum, r) => sum + r.massesFound, 0); + + console.log('============================================================'); + console.log('Test Summary'); + console.log('============================================================'); + console.log(`Total churches tested: ${results.length}`); + console.log(`Successful scrapes: ${successful.length} (${((successful.length / results.length) * 100).toFixed(1)}%)`); + console.log(`Failed scrapes: ${failed.length} (${((failed.length / results.length) * 100).toFixed(1)}%)`); + console.log(`Total masses found: ${totalMasses}`); + console.log('============================================================'); + + if (failed.length > 0) { + console.log('\nFailed websites:'); + for (const f of failed) { + console.log(` - ${f.name}: ${f.website} (${f.error})`); + } + } + + console.log(''); + + // Export results (without raw HTML to keep file manageable) + fs.writeFileSync( + 'scraper-test-results.json', + JSON.stringify(results, null, 2) + ); + console.log('Results saved to scraper-test-results.json'); + + return results; +} + +async function main() { + const args = process.argv.slice(2); + const limitIndex = args.indexOf('--limit'); + const countryIndex = args.indexOf('--country'); + + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 50; + const country = countryIndex !== -1 ? args[countryIndex + 1] : undefined; + + console.log('============================================================'); + console.log('Website Scraper Testing'); + console.log('============================================================'); + console.log(`Limit: ${limit}`); + console.log(`Country: ${country || 'All'}`); + console.log('============================================================\n'); + + await testScrapers(limit, country); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch(console.error); diff --git a/scripts/debug/verify-paz-schedules.ts b/scripts/debug/verify-paz-schedules.ts new file mode 100644 index 0000000..cbf0ea9 --- /dev/null +++ b/scripts/debug/verify-paz-schedules.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env tsx +/** + * Verify Paróquia da Paz schedules are correctly parsed + */ + +import { GenericScraper } from '../../src/scrapers/strategies/generic'; + +async function verifyPazSchedules() { + const url = 'https://www.paroquiadapaz.org.br/'; + console.log(`Verifying: ${url}\n`); + + const scraper = new GenericScraper(); + await scraper.init(); + scraper.setCountry('BR'); + + const result = await scraper.scrape(url); + + console.log(`✅ Success: ${result.success}`); + console.log(`📅 Schedules found: ${result.schedules.length}\n`); + + // Group by day + const byDay: Record = {}; + for (const sched of result.schedules) { + if (!byDay[sched.dayOfWeek]) byDay[sched.dayOfWeek] = []; + byDay[sched.dayOfWeek].push(sched); + } + + const dayNames = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; + + console.log('═══════════════════════════════════════════════'); + console.log('PARSED SCHEDULE:'); + console.log('═══════════════════════════════════════════════\n'); + + Object.entries(byDay) + .sort(([a], [b]) => parseInt(a) - parseInt(b)) + .forEach(([day, scheds]) => { + console.log(`${dayNames[parseInt(day)]}:`); + scheds.forEach(s => { + console.log(` ${s.time} - ${s.language} ${s.massType}`); + }); + console.log(''); + }); + + console.log('Expected schedule (from website):'); + console.log('Segunda, Terça, Quarta, Sexta: 16:00 e 18:00'); + console.log('Quinta: 16:00 e 19:00'); + console.log('Sábado: 08:00, 16:00 e 18:00'); + console.log('Domingo: 08:00, 11:00, 16:00, 18:00 e 20:00'); + + await scraper.close(); +} + +verifyPazSchedules().catch(console.error); diff --git a/scripts/dedup-churches.ts b/scripts/dedup-churches.ts new file mode 100644 index 0000000..ccdadf0 --- /dev/null +++ b/scripts/dedup-churches.ts @@ -0,0 +1,97 @@ +/** + * Find duplicate churches using ChromaDB semantic similarity. + * + * Usage: + * npx tsx scripts/dedup-churches.ts # Dry run, show duplicates + * npx tsx scripts/dedup-churches.ts --threshold 0.15 # Custom similarity threshold + * npx tsx scripts/dedup-churches.ts --country US # Only check US churches + * npx tsx scripts/dedup-churches.ts --limit 100 # Check first 100 churches + */ + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { findSimilarChurches } from '../src/chromadb/queries'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const args = process.argv.slice(2); +const threshold = args.includes('--threshold') + ? parseFloat(args[args.indexOf('--threshold') + 1]) + : 0.15; // Cosine distance threshold (lower = more similar) +const country = args.includes('--country') + ? args[args.indexOf('--country') + 1] + : undefined; +const limit = args.includes('--limit') + ? parseInt(args[args.indexOf('--limit') + 1]) + : 500; + +async function main() { + console.log(`Finding duplicate churches (threshold=${threshold}, country=${country || 'all'}, limit=${limit})`); + console.log('---'); + + const churches = await prisma.church.findMany({ + take: limit, + where: country ? { country } : undefined, + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + address: true, + city: true, + country: true, + source: true, + latitude: true, + longitude: true, + _count: { select: { massSchedules: true } }, + }, + }); + + console.log(`Checking ${churches.length} churches...\n`); + + const seen = new Set(); + let duplicateCount = 0; + + for (const church of churches) { + if (seen.has(church.id)) continue; + + const text = `${church.name} ${church.address || ''} ${church.city || ''} ${church.country}`.trim(); + const similar = await findSimilarChurches(text, { + country: church.country, + nResults: 5, + }); + + // Filter to matches within threshold, excluding self + const matches = similar.filter( + (s) => s.churchId !== church.id && s.distance <= threshold + ); + + if (matches.length > 0) { + duplicateCount++; + console.log(`\nPotential duplicate #${duplicateCount}:`); + console.log(` Original: "${church.name}" (${church.city || 'no city'}, ${church.country})`); + console.log(` ID: ${church.id}, Source: ${church.source}, Schedules: ${church._count.massSchedules}`); + console.log(` Lat/Lng: ${church.latitude}, ${church.longitude}`); + + for (const match of matches) { + console.log(` Match: "${match.document}" (distance: ${match.distance.toFixed(4)})`); + console.log(` ID: ${match.churchId}`); + seen.add(match.churchId); + } + } + } + + console.log(`\n---`); + console.log(`Found ${duplicateCount} potential duplicate groups from ${churches.length} churches`); + console.log(`Threshold: ${threshold} (lower = stricter matching)`); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/dedup-mass-schedules.ts b/scripts/dedup-mass-schedules.ts new file mode 100644 index 0000000..b94fa24 --- /dev/null +++ b/scripts/dedup-mass-schedules.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env tsx +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env.local') }); +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function main() { + const dryRun = !process.argv.includes('--execute'); + + if (dryRun) { + console.log('DRY RUN - pass --execute to actually delete duplicates\n'); + } + + const churches = await prisma.church.findMany({ + where: { massSchedules: { some: { isActive: true } } }, + include: { massSchedules: { where: { isActive: true }, orderBy: { createdAt: 'asc' } } }, + }); + + let churchesFixed = 0; + let rowsDeleted = 0; + + for (const church of churches) { + const seen = new Map(); + const toDelete: string[] = []; + + for (const m of church.massSchedules) { + const key = `${m.dayOfWeek}:${m.time}:${m.language}`; + if (seen.has(key)) { + toDelete.push(m.id); + } else { + seen.set(key, m.id); + } + } + + if (toDelete.length > 0) { + churchesFixed++; + rowsDeleted += toDelete.length; + + if (!dryRun) { + await prisma.massSchedule.deleteMany({ + where: { id: { in: toDelete } }, + }); + } + } + } + + console.log(`Churches with duplicates: ${churchesFixed}`); + console.log(`Duplicate rows ${dryRun ? 'found' : 'deleted'}: ${rowsDeleted}`); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/deploy-to-nas.sh b/scripts/deploy-to-nas.sh new file mode 100755 index 0000000..c915808 --- /dev/null +++ b/scripts/deploy-to-nas.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e +NAS_HOST="albert@192.168.0.145" +NAS_PATH="/volume1/docker/scraper-control" +LOCAL_PATH="/Users/albert/Documents/Projects/Church/ScraperControl" + +echo "Deploying ScraperControl to NAS..." + +rsync -avz \ + --exclude 'node_modules' \ + --exclude '.next' \ + --exclude '.git' \ + --exclude '.env.local' \ + --exclude '*.log' \ + "$LOCAL_PATH/" \ + "$NAS_HOST:$NAS_PATH/" + +echo "Rebuilding containers..." +ssh "$NAS_HOST" << 'ENDSSH' +cd /volume1/docker/scraper-control +/usr/local/bin/docker compose build app scraper scheduler +/usr/local/bin/docker compose up -d scheduler freesearch-enrichment +/usr/local/bin/docker compose ps +/usr/local/bin/docker compose logs --tail 5 scheduler +ENDSSH + +echo "Deployment complete!" diff --git a/scripts/enrich-with-google-places.ts b/scripts/enrich-with-google-places.ts new file mode 100644 index 0000000..5171f74 --- /dev/null +++ b/scripts/enrich-with-google-places.ts @@ -0,0 +1,408 @@ +#!/usr/bin/env tsx +/** + * Enrich OSM churches with Google Places data (website, phone, email) + * + * Usage: + * npx tsx scripts/enrich-with-google-places.ts --limit 10 --dry-run + * npx tsx scripts/enrich-with-google-places.ts --country BR --limit 100 + * npx tsx scripts/enrich-with-google-places.ts --all + * + * Rate Limiting: + * - Free tier: $200/month credit + * - Text Search: ~$17 per 1000 requests + * - $200 / $17 = ~11,764 requests per month + * - ~390 churches per day to stay within free tier + * - Script uses 2-second delay between requests (max 1,800/hour) + */ + +// Load .env for database connection +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +// Use DATABASE_URL from .env (works for both local dev and NAS/production) + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import axios from 'axios'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const GOOGLE_PLACES_API_KEY = process.env.GOOGLE_PLACES_API_KEY; +const PLACES_API_URL = 'https://places.googleapis.com/v1/places:searchText'; +const RATE_LIMIT_MS = 2000; // 2 seconds between requests + +// --- Job Tracking --- +async function createOrResumeJob(args: string[]): Promise { + const jobIdIndex = args.indexOf('--job-id'); + if (jobIdIndex !== -1) { + const jobId = args[jobIdIndex + 1]; + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'running', startedAt: new Date() }, + }); + return jobId; + } + return null; +} + +async function createNewJob(config: Record): Promise { + const job = await prisma.backgroundJob.create({ + data: { + type: 'google-enrichment', + status: 'running', + startedAt: new Date(), + config: config as any, + }, + }); + return job.id; +} + +async function updateJobProgress(jobId: string, processed: number, succeeded: number, failed: number, itemsFound: number, totalItems: number): Promise { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { processed, succeeded, failed, itemsFound, totalItems }, + }); +} + +async function checkJobStopping(jobId: string): Promise { + const job = await prisma.backgroundJob.findUnique({ where: { id: jobId } }); + return job?.status === 'stopping'; +} + +async function completeJob(jobId: string, error?: string): Promise { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { + status: error ? 'failed' : 'completed', + error, + completedAt: new Date(), + }, + }); +} + +/** + * Country priority order — largest OSM church counts first, since those + * have the most un-enriched churches. Covers all countries from the + * CATHOLIC_COUNTRIES lists in import-osm-churches.ts. + */ +const COUNTRY_PRIORITY = [ + // Top tier: 5000+ OSM churches + 'FR', 'IT', 'ES', 'DE', 'PL', 'BR', + // High tier: 1000-5000 + 'PT', 'AT', 'BE', 'CZ', 'PH', 'HU', 'US', 'MX', 'HR', 'GB', + 'CR', 'SK', 'EC', 'CH', 'AR', 'CA', 'CO', 'NL', 'IE', 'IN', + 'SI', 'AU', + // Medium tier: 100-1000 + 'PE', 'RO', 'KR', 'CL', 'ID', 'LT', 'BO', 'VN', 'BA', 'BY', + 'UA', 'VE', 'HN', 'UG', 'CD', 'GT', 'CU', 'SV', 'NI', 'PA', + 'DO', 'CN', 'JP', 'LV', 'RS', 'TZ', 'KE', 'AL', 'RU', + // Lower tier: remaining countries + 'LU', 'MT', 'NZ', 'PG', 'FJ', 'NC', 'PF', 'UY', 'PY', 'HT', + 'CM', 'RW', 'BI', 'MG', 'MW', 'ZM', 'ZW', 'MZ', 'AO', 'NG', + 'BJ', 'TG', 'CI', 'BF', 'ML', 'NE', 'SN', 'GN', 'LR', 'SL', + 'GH', 'GA', 'CG', 'CF', 'TD', 'SD', 'ET', 'ER', 'SO', + 'TL', 'MY', 'SG', 'TH', 'LA', 'KH', 'MM', 'LK', 'BD', 'PK', + 'LB', 'IL', 'PS', 'JO', 'SY', 'IQ', + 'GF', 'SR', 'GY', 'BS', 'BB', 'JM', 'TT', 'GD', 'LC', 'VC', + 'AG', 'DM', 'KN', 'MC', 'SM', 'VA', 'LI', 'AD', + 'RS', 'MK', 'EE', 'GE', 'AM', + 'NA', 'BW', 'LS', 'SZ', 'MU', 'SC', 'KM', 'CV', 'ST', 'GQ', + 'DJ', 'GM', 'BT', 'NP', 'AF', 'KZ', 'UZ', 'TM', 'TJ', 'KG', + 'MN', 'BN', 'MV', 'WS', 'TO', 'VU', 'SB', 'KI', 'NR', 'TV', + 'FM', 'MH', 'PW', +]; + +interface GooglePlacesResult { + found: boolean; + website?: string; + phone?: string; + placeId?: string; +} + +interface EnrichmentStats { + processed: number; + enriched: number; + notFound: number; + errors: number; + websitesAdded: number; + phonesAdded: number; +} + +async function searchGooglePlaces( + name: string, + city: string | null, + state: string | null, + latitude: number, + longitude: number +): Promise { + if (!GOOGLE_PLACES_API_KEY) { + throw new Error('GOOGLE_PLACES_API_KEY not set in environment'); + } + + // Build search query + const location = [city, state].filter(Boolean).join(', '); + const textQuery = `${name} ${location}`.trim(); + + try { + const response = await axios.post( + PLACES_API_URL, + { + textQuery, + locationBias: { + circle: { + center: { + latitude, + longitude, + }, + radius: 500, // 500 meters + }, + }, + }, + { + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': GOOGLE_PLACES_API_KEY, + 'X-Goog-FieldMask': 'places.id,places.displayName,places.websiteUri,places.nationalPhoneNumber', + }, + } + ); + + if (response.data.places && response.data.places.length > 0) { + const place = response.data.places[0]; // Take first result + return { + found: true, + website: place.websiteUri || undefined, + phone: place.nationalPhoneNumber || undefined, + placeId: place.id || undefined, + }; + } + + return { found: false }; + } catch (error: any) { + if (error.response?.status === 429) { + console.error('Rate limited by Google Places API'); + throw new Error('RATE_LIMITED'); + } + throw error; + } +} + +async function enrichChurches( + countryCode?: string, + limit?: number, + dryRun: boolean = false, + jobId?: string | null +): Promise { + const stats: EnrichmentStats = { + processed: 0, + enriched: 0, + notFound: 0, + errors: 0, + websitesAdded: 0, + phonesAdded: 0, + }; + + let churches; + + if (countryCode) { + // Manual override: process specific country + console.log(`Manual mode: Processing country ${countryCode}`); + churches = await prisma.church.findMany({ + where: { + source: 'osm', + googleSearchedAt: null, + country: countryCode, + }, + take: limit, + orderBy: { createdAt: 'asc' }, + }); + } else { + // Priority mode: sequential through countries (exhaust each before moving on) + console.log('Priority mode: Processing countries sequentially'); + console.log(`Top priority countries: ${COUNTRY_PRIORITY.slice(0, 10).join(', ')}...\n`); + + churches = []; + const targetTotal = limit || 390; + + for (const country of COUNTRY_PRIORITY) { + if (churches.length >= targetTotal) break; + + const remaining = targetTotal - churches.length; + const batch = await prisma.church.findMany({ + where: { + source: 'osm', + googleSearchedAt: null, + country, + }, + take: remaining, + orderBy: { createdAt: 'asc' }, + }); + + if (batch.length > 0) { + churches.push(...batch); + console.log(` Queued ${batch.length} churches from ${country}`); + } + } + } + + console.log(`\nFound ${churches.length} churches to enrich`); + console.log(''); + + for (const church of churches) { + stats.processed++; + + try { + console.log(`[${stats.processed}/${churches.length}] ${church.name} (${church.city}, ${church.state})`); + + const result = await searchGooglePlaces( + church.name, + church.city, + church.state, + church.latitude, + church.longitude + ); + + if (result.found) { + console.log(' ✓ Found on Google Places'); + + if (result.website) { + console.log(` Website: ${result.website}`); + stats.websitesAdded++; + } + + if (result.phone) { + console.log(` Phone: ${result.phone}`); + stats.phonesAdded++; + } + + if (!dryRun) { + await prisma.church.update({ + where: { id: church.id }, + data: { + website: result.website || church.website, + phone: result.phone || church.phone, + googlePlaceId: result.placeId || church.googlePlaceId, + hasWebsite: !!(result.website || church.website), + googleSearchedAt: new Date(), + }, + }); + if (result.website || result.phone) { + stats.enriched++; + } + } + } else { + console.log(' ✗ Not found on Google Places'); + stats.notFound++; + + // Mark as attempted so we don't re-query this church + if (!dryRun) { + await prisma.church.update({ + where: { id: church.id }, + data: { googleSearchedAt: new Date() }, + }); + } + } + + // Rate limiting + await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_MS)); + } catch (error: any) { + stats.errors++; + if (error.message === 'RATE_LIMITED') { + console.error(' ⚠ Rate limited, stopping enrichment'); + break; + } + console.error(` ✗ Error: ${error.message}`); + } + + // Job tracking: update progress every 10 items and check for stop + if (jobId && stats.processed % 10 === 0) { + await updateJobProgress(jobId, stats.processed, stats.enriched, stats.errors, stats.enriched, churches.length); + const stopping = await checkJobStopping(jobId); + if (stopping) { + console.log('\nJob stop requested via admin dashboard.'); + break; + } + } + + // Progress update every 50 churches + if (stats.processed % 50 === 0) { + console.log(''); + console.log(`Progress: ${stats.processed}/${churches.length} processed`); + console.log(` Enriched: ${stats.enriched}, Not found: ${stats.notFound}, Errors: ${stats.errors}`); + console.log(''); + } + } + + // Final job update + if (jobId) { + await updateJobProgress(jobId, stats.processed, stats.enriched, stats.errors, stats.enriched, churches.length); + } + + return stats; +} + +async function main() { + const args = process.argv.slice(2); + const countryIndex = args.indexOf('--country'); + const limitIndex = args.indexOf('--limit'); + const dryRun = args.includes('--dry-run'); + const all = args.includes('--all'); + + const countryCode = countryIndex !== -1 ? args[countryIndex + 1] : undefined; + const limit = all ? undefined : limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 10; + + if (!GOOGLE_PLACES_API_KEY) { + console.error('Error: GOOGLE_PLACES_API_KEY not set in environment'); + console.error('Add it to your .env file'); + process.exit(1); + } + + console.log('============================================================'); + console.log('Google Places Church Enrichment'); + console.log('============================================================'); + console.log(`Country: ${countryCode || 'All'}`); + console.log(`Limit: ${limit || 'No limit'}`); + console.log(`Dry run: ${dryRun ? 'Yes' : 'No'}`); + console.log('============================================================'); + console.log(''); + + + + // Job tracking + let jobId = await createOrResumeJob(args); + if (!jobId && !dryRun) { + jobId = await createNewJob({ countryCode, limit, dryRun }); + } + if (jobId) console.log(`Job ID: ${jobId}\n`); + + const stats = await enrichChurches(countryCode, limit, dryRun, jobId); + + console.log(''); + console.log('============================================================'); + console.log('Enrichment Summary'); + console.log('============================================================'); + console.log(`Churches processed: ${stats.processed}`); + console.log(`Churches enriched: ${stats.enriched}`); + console.log(`Not found on Google: ${stats.notFound}`); + console.log(`Websites added: ${stats.websitesAdded}`); + console.log(`Phone numbers added: ${stats.phonesAdded}`); + console.log(`Errors encountered: ${stats.errors}`); + console.log('============================================================'); + + // Complete job + if (jobId) { + await completeJob(jobId); + } + +await prisma.$disconnect(); + await pool.end(); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/enrich-with-reverse-geocode.ts b/scripts/enrich-with-reverse-geocode.ts new file mode 100644 index 0000000..907c559 --- /dev/null +++ b/scripts/enrich-with-reverse-geocode.ts @@ -0,0 +1,624 @@ +#!/usr/bin/env tsx +/** + * Enrich churches with city/state/zip via Nominatim reverse geocoding (OSM) + * + * Usage: + * npx tsx scripts/enrich-with-reverse-geocode.ts --country FR --limit 10 --dry-run + * npx tsx scripts/enrich-with-reverse-geocode.ts --country FR --continuous + * npx tsx scripts/enrich-with-reverse-geocode.ts --continuous + * + * Rate limit: 1 request/second (Nominatim usage policy — mandatory). + * Full pass of ~193K churches in ~2 days. + */ + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import axios from 'axios'; + +// Fresh DB connection (not cached singleton) +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/reverse'; +const RATE_LIMIT_MS = 1100; // Slightly over 1s to stay safe +const BATCH_SIZE = 50; +const PROGRESS_INTERVAL = 10; + +// --- Job Tracking --- + +async function createOrResumeJob(args: string[]): Promise { + const jobIdIndex = args.indexOf('--job-id'); + if (jobIdIndex !== -1) { + const jobId = args[jobIdIndex + 1]; + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'running', startedAt: new Date() }, + }); + return jobId; + } + return null; +} + +async function createNewJob(config: Record): Promise { + const job = await prisma.backgroundJob.create({ + data: { + type: 'reverse-geocode-enrichment', + status: 'running', + startedAt: new Date(), + config, + }, + }); + return job.id; +} + +async function updateJobProgress(jobId: string, stats: EnrichmentStats, totalItems: number): Promise { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { + processed: stats.processed, + succeeded: stats.enriched, + failed: stats.errors, + itemsFound: stats.enriched, + totalItems, + }, + }); +} + +async function checkJobStopping(jobId: string): Promise { + const job = await prisma.backgroundJob.findUnique({ where: { id: jobId } }); + return job?.status === 'stopping'; +} + +async function completeJob(jobId: string, error?: string): Promise { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { + status: error ? 'failed' : 'completed', + error, + completedAt: new Date(), + }, + }); +} + +// --- Types --- + +interface ChurchRecord { + id: string; + name: string; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; + country: string; + latitude: number; + longitude: number; +} + +interface NominatimAddress { + house_number?: string; + road?: string; + city?: string; + town?: string; + village?: string; + municipality?: string; + hamlet?: string; + suburb?: string; + neighbourhood?: string; + state?: string; + province?: string; + postcode?: string; + country_code?: string; +} + +interface NominatimResponse { + display_name?: string; + address?: NominatimAddress; + error?: string; +} + +interface EnrichmentStats { + processed: number; + enriched: number; + noCity: number; + errors: number; + skippedExisting: number; + cycles: number; + startTime: number; +} + +// --- Circuit Breaker --- + +class CircuitBreaker { + private failures = 0; + private isOpen = false; + private backoffMs = 60000; // Start at 60s for Nominatim + private readonly maxBackoffMs = 300000; // 5 minutes + private readonly threshold = 5; + + async checkAndWait(): Promise { + if (!this.isOpen) return true; + + log(`Circuit breaker open. Waiting ${Math.round(this.backoffMs / 1000)}s before retry...`); + await sleep(this.backoffMs); + + // Try a test request + try { + const resp = await axios.get(NOMINATIM_URL, { + params: { lat: 48.8566, lon: 2.3522, format: 'json' }, + headers: { 'User-Agent': 'NearestMass/1.0 (privacy@nearestmass.com)' }, + timeout: 10000, + }); + if (resp.status === 200) { + this.reset(); + log('Circuit breaker closed: Nominatim is back'); + return true; + } + } catch { + // Still down + } + + this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs); + return false; + } + + recordFailure() { + this.failures++; + if (this.failures >= this.threshold && !this.isOpen) { + this.isOpen = true; + this.backoffMs = 60000; + log(`Circuit breaker OPEN after ${this.failures} consecutive failures`); + } + } + + reset() { + if (this.failures > 0 || this.isOpen) { + this.failures = 0; + this.isOpen = false; + this.backoffMs = 60000; + } + } + + get opened() { return this.isOpen; } +} + +// --- Helpers --- + +let shuttingDown = false; + +function log(msg: string) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function logError(msg: string) { + console.error(`[${new Date().toISOString()}] ${msg}`); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => { + const timer = setTimeout(resolve, ms); + const check = setInterval(() => { + if (shuttingDown) { + clearTimeout(timer); + clearInterval(check); + resolve(); + } + }, 1000); + setTimeout(() => clearInterval(check), ms + 100); + }); +} + +// --- Nominatim API --- + +async function reverseGeocode(lat: number, lng: number): Promise { + const response = await axios.get(NOMINATIM_URL, { + params: { + lat, + lon: lng, + format: 'json', + zoom: 16, + addressdetails: 1, + }, + headers: { + 'User-Agent': 'NearestMass/1.0 (privacy@nearestmass.com)', + 'Accept-Language': 'en', + }, + timeout: 15000, + }); + return response.data; +} + +function extractCity(address: NominatimAddress): string | null { + return address.city || address.town || address.village || + address.municipality || address.hamlet || null; +} + +function extractState(address: NominatimAddress): string | null { + return address.state || address.province || null; +} + +function extractAddress(address: NominatimAddress): string | null { + const parts: string[] = []; + if (address.house_number) parts.push(address.house_number); + if (address.road) parts.push(address.road); + if (parts.length === 0) return null; + return parts.join(' '); +} + +// --- Database Queries --- + +async function getNextBatch( + batchSize: number, + countryCode?: string, +): Promise { + return prisma.church.findMany({ + where: { + city: null, + latitude: { not: undefined }, + longitude: { not: undefined }, + reverseGeocodedAt: null, + ...(countryCode ? { country: countryCode } : {}), + }, + select: { + id: true, name: true, address: true, city: true, state: true, zip: true, + country: true, latitude: true, longitude: true, + }, + take: batchSize, + orderBy: [ + { country: 'asc' }, + { createdAt: 'asc' }, + ], + }); +} + +async function getTotalRemaining(countryCode?: string): Promise { + return prisma.church.count({ + where: { + city: null, + latitude: { not: undefined }, + longitude: { not: undefined }, + reverseGeocodedAt: null, + ...(countryCode ? { country: countryCode } : {}), + }, + }); +} + +// --- Main Processing --- + +async function processChurch( + church: ChurchRecord, + stats: EnrichmentStats, + dryRun: boolean, +): Promise { + const label = `${church.name} (${church.country})`; + + try { + const result = await reverseGeocode(church.latitude, church.longitude); + + if (result.error || !result.address) { + log(` - [${stats.processed}] ${label} => no address data`); + stats.noCity++; + if (!dryRun) { + await prisma.church.update({ + where: { id: church.id }, + data: { reverseGeocodedAt: new Date() }, + }); + } + return; + } + + const address = extractAddress(result.address); + const city = extractCity(result.address); + const state = extractState(result.address); + const zip = result.address.postcode || null; + + if (city) { + const addrStr = address ? `${address}, ` : ''; + log(` + [${stats.processed}] ${label} => ${addrStr}${city}, ${state || '?'}`); + stats.enriched++; + } else { + log(` - [${stats.processed}] ${label} => no city in response`); + stats.noCity++; + } + + if (!dryRun) { + const updateData: Record = { + reverseGeocodedAt: new Date(), + }; + // Only update fields that are currently null + if (address && !church.address) updateData.address = address; + if (city && !church.city) updateData.city = city; + if (state && !church.state) updateData.state = state; + if (zip && !church.zip) updateData.zip = zip; + // Update country if currently unknown (XX) and Nominatim returned one + const countryCodeResult = result.address.country_code?.toUpperCase(); + if (church.country === 'XX' && countryCodeResult && countryCodeResult !== 'XX') { + updateData.country = countryCodeResult; + } + + await prisma.church.update({ + where: { id: church.id }, + data: updateData, + }); + } + } catch (error: any) { + stats.errors++; + + // Handle rate limiting (429) + if (error.response?.status === 429) { + logError(` ! [${stats.processed}] ${label} => rate limited (429), backing off...`); + await sleep(5000); // Extra 5s backoff + throw error; + } + + // Handle server errors (5xx) + if (error.response?.status >= 500) { + logError(` ! [${stats.processed}] ${label} => server error (${error.response.status})`); + throw error; + } + + logError(` ! [${stats.processed}] ${label} => ${error.message}`); + // Don't throw for non-retriable errors (just mark as attempted) + if (!dryRun) { + await prisma.church.update({ + where: { id: church.id }, + data: { reverseGeocodedAt: new Date() }, + }); + } + } +} + +async function runSinglePass( + stats: EnrichmentStats, + countryCode?: string, + limit?: number, + dryRun: boolean = false, + jobId?: string | null, +): Promise { + let totalProcessed = 0; + const circuitBreaker = new CircuitBreaker(); + + while (!shuttingDown) { + if (limit && totalProcessed >= limit) break; + + // Circuit breaker check + if (circuitBreaker.opened) { + const ok = await circuitBreaker.checkAndWait(); + if (!ok) continue; + } + + const batchLimit = limit + ? Math.min(BATCH_SIZE, limit - totalProcessed) + : BATCH_SIZE; + + const churches = await getNextBatch(batchLimit, countryCode); + if (churches.length === 0) break; + + for (const church of churches) { + if (shuttingDown) break; + if (limit && totalProcessed >= limit) break; + + stats.processed++; + totalProcessed++; + + try { + await processChurch(church, stats, dryRun); + circuitBreaker.reset(); + } catch (error: any) { + circuitBreaker.recordFailure(); + // Already logged in processChurch + } + + // Rate limit: 1 request per second + if (!shuttingDown) { + await sleep(RATE_LIMIT_MS); + } + + // Job tracking: update progress every PROGRESS_INTERVAL items + if (jobId && stats.processed % PROGRESS_INTERVAL === 0) { + await updateJobProgress(jobId, stats, 0); + const stopping = await checkJobStopping(jobId); + if (stopping) { + log('Job stop requested via admin dashboard.'); + shuttingDown = true; + break; + } + } + + // Progress logging + if (stats.processed % 100 === 0) { + const elapsed = (Date.now() - stats.startTime) / 1000; + const rate = Math.round((stats.processed / elapsed) * 3600); + const enrichRate = stats.processed > 0 + ? ((stats.enriched / stats.processed) * 100).toFixed(1) + : '0.0'; + log(`Progress: ${stats.processed} processed, ${stats.enriched} enriched, ${stats.noCity} no-city, ${stats.errors} errors`); + log(` Enrich rate: ${enrichRate}%, Rate: ~${rate}/hour`); + } + } + } +} + +async function runContinuous( + stats: EnrichmentStats, + countryCode?: string, + jobId?: string | null, +): Promise { + log('Running in continuous mode. Press Ctrl+C to stop.'); + const circuitBreaker = new CircuitBreaker(); + + while (!shuttingDown) { + stats.cycles++; + log(`--- Cycle ${stats.cycles} ---`); + let processedInCycle = 0; + + while (!shuttingDown) { + // Circuit breaker check + if (circuitBreaker.opened) { + const ok = await circuitBreaker.checkAndWait(); + if (!ok) continue; + } + + const churches = await getNextBatch(BATCH_SIZE, countryCode); + if (churches.length === 0) break; + + for (const church of churches) { + if (shuttingDown) break; + + stats.processed++; + processedInCycle++; + + try { + await processChurch(church, stats, false); + circuitBreaker.reset(); + } catch { + circuitBreaker.recordFailure(); + } + + // Rate limit + if (!shuttingDown) { + await sleep(RATE_LIMIT_MS); + } + + // Job tracking + if (jobId && stats.processed % PROGRESS_INTERVAL === 0) { + await updateJobProgress(jobId, stats, 0); + const stopping = await checkJobStopping(jobId); + if (stopping) { + log('Job stop requested via admin dashboard.'); + shuttingDown = true; + break; + } + } + + // Progress logging + if (stats.processed % 100 === 0) { + const elapsed = (Date.now() - stats.startTime) / 1000; + const rate = Math.round((stats.processed / elapsed) * 3600); + log(`Progress: ${stats.processed} processed, ${stats.enriched} enriched, ${stats.noCity} no-city, ${stats.errors} errors (~${rate}/hour)`); + } + } + } + + if (shuttingDown) break; + + if (processedInCycle === 0) { + log('No churches needing reverse geocoding. Waiting 1 hour...'); + for (let i = 0; i < 360 && !shuttingDown; i++) { + await sleep(10000); + } + } else { + log(`Cycle ${stats.cycles} complete. ${processedInCycle} churches processed. Brief pause...`); + await sleep(10000); + } + } +} + +// --- Main --- + +async function main() { + const args = process.argv.slice(2); + const countryIndex = args.indexOf('--country'); + const limitIndex = args.indexOf('--limit'); + const dryRun = args.includes('--dry-run'); + const continuous = args.includes('--continuous'); + + const countryCode = countryIndex !== -1 ? args[countryIndex + 1] : undefined; + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : undefined; + + // Graceful shutdown + process.on('SIGTERM', () => { + log('Received SIGTERM, finishing current request...'); + shuttingDown = true; + }); + process.on('SIGINT', () => { + log('Received SIGINT, finishing current request...'); + shuttingDown = true; + }); + + log('============================================================'); + log('Nominatim Reverse Geocode Enrichment'); + log('============================================================'); + log(`Mode: ${continuous ? 'Continuous' : 'Single pass'}`); + log(`Country: ${countryCode || 'All'}`); + log(`Limit: ${limit || 'No limit'}`); + log(`Dry run: ${dryRun ? 'Yes' : 'No'}`); + log(`Rate limit: ${RATE_LIMIT_MS}ms between requests`); + log('============================================================'); + + // Count remaining + const remaining = await getTotalRemaining(countryCode); + log(`Churches needing reverse geocoding: ${remaining}`); + const estimatedHours = (remaining * RATE_LIMIT_MS / 1000 / 3600).toFixed(1); + log(`Estimated time: ~${estimatedHours} hours @ 1 req/sec`); + + if (remaining === 0) { + log('Nothing to do!'); + await prisma.$disconnect(); + await pool.end(); + return; + } + + // Job tracking + let jobId = await createOrResumeJob(args); + if (!jobId) { + jobId = await createNewJob({ countryCode, limit, continuous, dryRun }); + } + log(`Job ID: ${jobId}`); + + const stats: EnrichmentStats = { + processed: 0, + enriched: 0, + noCity: 0, + errors: 0, + skippedExisting: 0, + cycles: 0, + startTime: Date.now(), + }; + + if (continuous) { + await runContinuous(stats, countryCode, jobId); + } else { + await runSinglePass(stats, countryCode, limit, dryRun, jobId); + } + + // Complete job + if (jobId) { + await updateJobProgress(jobId, stats, 0); + await completeJob(jobId); + } + + // Print summary + const elapsed = ((Date.now() - stats.startTime) / 1000).toFixed(1); + const enrichRate = stats.processed > 0 + ? ((stats.enriched / stats.processed) * 100).toFixed(1) + : '0.0'; + + log(''); + log('============================================================'); + log('Reverse Geocode Enrichment Summary'); + log('============================================================'); + log(`Churches processed: ${stats.processed}`); + log(`Cities found: ${stats.enriched}`); + log(`No city in response: ${stats.noCity}`); + log(`Errors: ${stats.errors}`); + log(`Enrich rate: ${enrichRate}%`); + log(`Elapsed: ${elapsed}s`); + if (stats.cycles > 0) { + log(`Cycles completed: ${stats.cycles}`); + } + log('============================================================'); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((error) => { + logError(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/enrich-with-wikidata.ts b/scripts/enrich-with-wikidata.ts new file mode 100644 index 0000000..2ad892e --- /dev/null +++ b/scripts/enrich-with-wikidata.ts @@ -0,0 +1,328 @@ +#!/usr/bin/env tsx +/** + * Enrich churches with website URLs from Wikidata + * + * Queries Wikidata SPARQL endpoint for Catholic churches that have official websites, + * then matches them to existing churches in the database via proximity + name matching. + * + * Usage: + * npx tsx scripts/enrich-with-wikidata.ts --dry-run + * npx tsx scripts/enrich-with-wikidata.ts --execute + * npx tsx scripts/enrich-with-wikidata.ts --execute --country DE + * npx tsx scripts/enrich-with-wikidata.ts --job-id + */ + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import axios from 'axios'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const WIKIDATA_SPARQL_URL = 'https://query.wikidata.org/sparql'; +const MATCH_RADIUS_KM = 1.0; // Max distance for matching +const BATCH_SIZE = 500; // SPARQL results per query + +function log(msg: string) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function logError(msg: string) { + console.error(`[${new Date().toISOString()}] ${msg}`); +} + +// Haversine distance in km +function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) ** 2; + return R * 2 * Math.asin(Math.sqrt(a)); +} + +function normalizeForMatch(str: string): string { + return str.toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // strip accents + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +interface WikidataChurch { + label: string; + website: string; + lat: number; + lon: number; + wikidataId: string; +} + +async function queryWikidata(country?: string, offset = 0): Promise { + // SPARQL query for Catholic churches with websites + let countryFilter = ''; + if (country) { + // Map ISO alpha-2 to Wikidata country item + const countryMap: Record = { + DE: 'Q183', FR: 'Q142', ES: 'Q29', IT: 'Q38', PL: 'Q36', + PT: 'Q45', BR: 'Q155', NL: 'Q55', CZ: 'Q213', HU: 'Q28', + AT: 'Q40', BE: 'Q31', CH: 'Q39', IE: 'Q27', GB: 'Q145', + US: 'Q30', CA: 'Q16', MX: 'Q96', AR: 'Q414', CO: 'Q739', + HR: 'Q224', SK: 'Q214', SI: 'Q215', + }; + const qid = countryMap[country]; + if (qid) { + countryFilter = `?church wdt:P17 wd:${qid} .`; + } + } + + const sparql = ` + SELECT ?church ?churchLabel ?website ?lat ?lon WHERE { + ?church wdt:P31/wdt:P279* wd:Q16970 . + ?church wdt:P140 wd:Q9592 . + ?church wdt:P856 ?website . + ?church p:P625 ?coordStatement . + ?coordStatement ps:P625 ?coord . + BIND(geof:latitude(?coord) AS ?lat) + BIND(geof:longitude(?coord) AS ?lon) + ${countryFilter} + SERVICE wikibase:label { bd:serviceParam wikibase:language "en,de,fr,es,it,pt,pl,nl,cs,hu" . } + } + ORDER BY ?church + LIMIT ${BATCH_SIZE} + OFFSET ${offset} + `; + + const response = await axios.get(WIKIDATA_SPARQL_URL, { + params: { query: sparql, format: 'json' }, + headers: { + 'User-Agent': 'NearestMass/1.0 (https://nearestmass.com; contact: privacy@nearestmass.com)', + 'Accept': 'application/sparql-results+json', + }, + timeout: 60000, + }); + + const bindings = response.data?.results?.bindings || []; + return bindings.map((b: any) => ({ + label: b.churchLabel?.value || '', + website: b.website?.value || '', + lat: parseFloat(b.lat?.value || '0'), + lon: parseFloat(b.lon?.value || '0'), + wikidataId: b.church?.value?.replace('http://www.wikidata.org/entity/', '') || '', + })); +} + +interface MatchResult { + churchId: string; + churchName: string; + distance: number; + nameScore: number; +} + +async function findMatch(wdChurch: WikidataChurch): Promise { + // Find nearby churches without a website + const nearby = await prisma.church.findMany({ + where: { + website: null, + latitude: { gte: wdChurch.lat - 0.01, lte: wdChurch.lat + 0.01 }, + longitude: { gte: wdChurch.lon - 0.01, lte: wdChurch.lon + 0.01 }, + }, + select: { id: true, name: true, latitude: true, longitude: true }, + take: 20, + }); + + if (nearby.length === 0) return null; + + // Score each candidate + const wdNameNorm = normalizeForMatch(wdChurch.label); + const wdWords = wdNameNorm.split(' ').filter(w => w.length >= 3); + + let bestMatch: MatchResult | null = null; + + for (const church of nearby) { + const dist = haversineKm(wdChurch.lat, wdChurch.lon, church.latitude, church.longitude); + if (dist > MATCH_RADIUS_KM) continue; + + const churchNameNorm = normalizeForMatch(church.name); + const churchWords = churchNameNorm.split(' ').filter(w => w.length >= 3); + + // Count matching words + let matchingWords = 0; + for (const w of wdWords) { + if (churchWords.includes(w)) matchingWords++; + } + + const nameScore = wdWords.length > 0 ? matchingWords / wdWords.length : 0; + + // Require at least 50% word overlap or distance < 100m + if (nameScore < 0.5 && dist > 0.1) continue; + + if (!bestMatch || nameScore > bestMatch.nameScore || + (nameScore === bestMatch.nameScore && dist < bestMatch.distance)) { + bestMatch = { + churchId: church.id, + churchName: church.name, + distance: dist, + nameScore, + }; + } + } + + return bestMatch; +} + +// --- Job Tracking --- + +async function createOrResumeJob(args: string[]): Promise { + const jobIdIndex = args.indexOf('--job-id'); + if (jobIdIndex !== -1) { + const jobId = args[jobIdIndex + 1]; + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'running', startedAt: new Date() }, + }); + return jobId; + } + return null; +} + +async function main() { + const args = process.argv.slice(2); + const dryRun = !args.includes('--execute'); + const countryIdx = args.indexOf('--country'); + const country = countryIdx !== -1 ? args[countryIdx + 1] : undefined; + + log('============================================================'); + log('Wikidata Church Website Enrichment'); + log('============================================================'); + log(`Mode: ${dryRun ? 'Dry run' : 'Execute'}`); + log(`Country: ${country || 'All'}`); + log('============================================================'); + + // Job tracking + let jobId = await createOrResumeJob(args); + if (!jobId && !dryRun) { + const job = await prisma.backgroundJob.create({ + data: { + type: 'wikidata-enrichment', + status: 'running', + startedAt: new Date(), + config: { country, dryRun }, + }, + }); + jobId = job.id; + log(`Job ID: ${jobId}`); + } + + let totalFetched = 0; + let matched = 0; + let updated = 0; + let noMatch = 0; + let alreadyHasWebsite = 0; + let offset = 0; + + try { + while (true) { + log(`Querying Wikidata (offset ${offset})...`); + const results = await queryWikidata(country, offset); + + if (results.length === 0) { + log('No more results from Wikidata.'); + break; + } + + totalFetched += results.length; + log(`Fetched ${results.length} churches from Wikidata (total: ${totalFetched})`); + + for (const wdChurch of results) { + if (!wdChurch.website || !wdChurch.lat || !wdChurch.lon) continue; + + const match = await findMatch(wdChurch); + + if (!match) { + noMatch++; + continue; + } + + matched++; + log(` Match: "${wdChurch.label}" (${wdChurch.wikidataId}) -> "${match.churchName}" (dist: ${match.distance.toFixed(3)}km, score: ${match.nameScore.toFixed(2)})`); + + if (!dryRun) { + await prisma.church.update({ + where: { id: match.churchId }, + data: { + website: wdChurch.website, + hasWebsite: true, + }, + }); + updated++; + } + } + + // Rate limit SPARQL queries + await new Promise(r => setTimeout(r, 2000)); + offset += BATCH_SIZE; + + // Update job progress + if (jobId) { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { + processed: totalFetched, + succeeded: updated, + itemsFound: matched, + }, + }); + + // Check for stop + const job = await prisma.backgroundJob.findUnique({ where: { id: jobId } }); + if (job?.status === 'stopping') { + log('Job stop requested.'); + break; + } + } + } + } catch (error: any) { + logError(`Error: ${error.message}`); + if (jobId) { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'failed', error: error.message, completedAt: new Date() }, + }); + } + throw error; + } + + // Complete job + if (jobId) { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'completed', completedAt: new Date(), processed: totalFetched, succeeded: updated, itemsFound: matched }, + }); + } + + log(''); + log('============================================================'); + log('Wikidata Enrichment Summary'); + log('============================================================'); + log(`Wikidata churches fetched: ${totalFetched}`); + log(`Matched to DB churches: ${matched}`); + log(`Websites updated: ${updated}`); + log(`No match found: ${noMatch}`); + log(`Already had website: ${alreadyHasWebsite}`); + log('============================================================'); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((error) => { + logError(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/match-search-results.ts b/scripts/match-search-results.ts new file mode 100644 index 0000000..bff7b5c --- /dev/null +++ b/scripts/match-search-results.ts @@ -0,0 +1,623 @@ +#!/usr/bin/env tsx +/** + * Second-pass matching: analyze stored ChromaDB search results to find websites + * that the FreeSearch first pass missed. + * + * Usage: + * npx tsx scripts/match-search-results.ts --dry-run + * npx tsx scripts/match-search-results.ts --country IT --limit 100 + * npx tsx scripts/match-search-results.ts --threshold 0.3 + * + * Algorithm: + * 1. Get churches without websites that have been FreeSearch'd + * 2. Query ChromaDB search_results collection for semantically similar results + * 3. Cross-church matching: URLs from nearby churches may match + * 4. URL frequency analysis: URLs appearing for multiple churches in same area + * 5. Verify best candidates against page content + * 6. Update church.website if verified + */ + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { Collection } from 'chromadb'; +import axios from 'axios'; +import { getCollection, COLLECTION_NAMES } from '../src/chromadb/collections'; +import { embedSingle } from '../src/chromadb/embeddings'; + +// Fresh DB connection +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +// --- Job Tracking --- +async function createOrResumeJob(args: string[]): Promise { + const jobIdIndex = args.indexOf('--job-id'); + if (jobIdIndex !== -1) { + const jobId = args[jobIdIndex + 1]; + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'running', startedAt: new Date() }, + }); + return jobId; + } + return null; +} + +async function createNewJob(config: Record): Promise { + const job = await prisma.backgroundJob.create({ + data: { + type: 'match-search-results', + status: 'running', + startedAt: new Date(), + config, + }, + }); + return job.id; +} + +async function updateJobProgress(jobId: string, processed: number, found: number, total: number): Promise { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { processed, succeeded: found, totalItems: total }, + }); +} + +async function checkJobStopping(jobId: string): Promise { + const job = await prisma.backgroundJob.findUnique({ where: { id: jobId } }); + return job?.status === 'stopping'; +} + +async function completeJob(jobId: string, error?: string): Promise { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { + status: error ? 'failed' : 'completed', + error, + completedAt: new Date(), + }, + }); +} + +// --- Types --- + +interface ChurchRecord { + id: string; + name: string; + address: string | null; + city: string | null; + state: string | null; + country: string; + latitude: number; + longitude: number; +} + +interface MatchStats { + processed: number; + matched: number; + noResults: number; + verifyFailed: number; + errors: number; + startTime: number; +} + +// --- Helpers --- + +let shuttingDown = false; + +function log(msg: string) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function logError(msg: string) { + console.error(`[${new Date().toISOString()}] ${msg}`); +} + +function normalizeForMatch(str: string): string { + return str.toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +const CATHOLIC_KEYWORDS = [ + 'parish', 'church', 'catholic', 'parroquia', 'paroisse', 'pfarrei', + 'parafia', 'paroquia', 'parrocchia', 'farnost', 'plebania', 'parochie', + 'župnija', 'farnosť', 'iglesia', 'église', 'kirche', 'kościół', + 'chiesa', 'kostel', 'templom', 'kerk', +]; + +const MASS_SCHEDULE_KEYWORDS = [ + 'mass schedule', 'mass times', 'worship schedule', 'worship times', + 'service times', 'sunday mass', 'weekday mass', + 'horario de misas', 'horarios de misa', 'horaires des messes', + 'gottesdienst', 'gottesdienstzeiten', 'messzeiten', + 'msze święte', 'godziny mszy', 'msze św', + 'orari delle messe', 'orario messe', + 'horário das missas', +]; + +const TOURISM_KEYWORDS = [ + 'tourism', 'turismo', 'tourisme', 'turisme', 'touristik', 'turistico', + 'attractions', 'things to do', 'sightseeing', 'sehenswürdigkeiten', + 'what to see', 'places to visit', 'travel guide', 'reiseführer', + 'patrimoine', 'heritage trail', 'cultural heritage', + 'punto de interés', 'point of interest', 'points of interest', +]; + +function getSignificantWords(name: string): string[] { + const stopWords = new Set([ + 'the', 'of', 'and', 'in', 'at', 'for', 'our', 'lady', + 'st', 'saint', 'saints', 'san', 'sant', 'santa', 'santo', 'sacred', + 'christ', 'jesus', 'mary', 'maria', 'king', 'lord', 'heart', + 'cross', 'lady', 'queen', 'angel', 'angels', 'good', 'star', + 'nome', 'pere', 'madre', 'notre', 'dame', 'bien', + 'onze', 'lieve', 'vrouw', 'heer', + 'rosa', 'paul', 'anne', 'jean', 'joan', 'luke', 'marc', + 'rita', 'jose', 'leon', 'pius', 'roch', 'yves', 'ines', + 'vita', 'fara', 'bona', + 'cristo', 'fatima', 'lourdes', 'perpetuo', 'socorro', 'calvario', + 'rosario', 'pilar', 'carmen', 'dolores', 'remedios', 'nieves', + 'grotte', 'mission', 'sagrada', 'sagrado', 'familia', + 'guadalupe', 'assumption', 'immaculate', 'perpetual', 'divine', + 'knights', 'columbus', + 'house', 'home', 'hall', 'center', 'centre', 'centro', + 'deacon', 'priest', 'bishop', 'father', 'sister', 'brother', + 'school', 'academy', 'college', 'seminary', 'rectory', 'retreat', + 'church', 'parish', 'catholic', 'roman', 'holy', 'chapel', + 'cathedral', 'basilica', 'shrine', 'convent', 'monastery', + 'chapelle', 'eglise', 'église', 'paroisse', 'couvent', 'grotte', + 'iglesia', 'parroquia', 'capilla', 'ermita', 'convento', 'basílica', + 'kirche', 'kapelle', 'pfarrei', 'kloster', + 'chiesa', 'parrocchia', 'cappella', 'oratorio', + 'igreja', 'capela', 'paroquia', + 'kościół', 'kaplica', 'parafia', 'droga', + 'kostel', 'kaple', 'farnost', 'templom', 'kápolna', + 'de', 'la', 'le', 'les', 'du', 'des', 'el', 'los', 'las', + 'di', 'del', 'della', 'delle', 'degli', + 'do', 'da', 'dos', 'das', + 'und', 'der', 'die', 'das', 'von', + 'nad', 'pod', 'przy', + ]); + + return normalizeForMatch(name) + .split(' ') + .filter(w => w.length >= 3 && !stopWords.has(w)); +} + +function stripHtml(html: string): string { + return html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z]+;/gi, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +// --- URL Verification (same logic as enrich-with-freesearch.ts) --- + +async function verifyUrl(url: string, church: ChurchRecord): Promise { + try { + const response = await axios.get(url, { + timeout: 10000, + maxRedirects: 3, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; NearestMass/1.0; +https://nearestmass.com)', + 'Accept': 'text/html', + }, + maxContentLength: 200000, + responseType: 'text', + }); + + if (typeof response.data !== 'string') return false; + + const text = stripHtml(response.data); + const nameWords = getSignificantWords(church.name); + + let nameMatches = 0; + for (const word of nameWords) { + if (text.includes(word)) nameMatches++; + } + + let cityMatch = false; + if (church.city) { + const cityNorm = normalizeForMatch(church.city); + if (cityNorm.length > 2 && text.includes(cityNorm)) cityMatch = true; + } + + let addressMatch = false; + if (church.address) { + const addrNorm = normalizeForMatch(church.address); + const addrWords = addrNorm.split(' ').filter(w => w.length >= 4 && !/^\d+$/.test(w)); + let addrWordMatches = 0; + for (const w of addrWords) { + if (text.includes(w)) addrWordMatches++; + } + if (addrWordMatches >= 2) addressMatch = true; + } + + let hasCatholicKeyword = false; + for (const kw of CATHOLIC_KEYWORDS) { + if (text.includes(kw)) { hasCatholicKeyword = true; break; } + } + + let hasMassSchedule = false; + for (const kw of MASS_SCHEDULE_KEYWORDS) { + if (text.includes(kw)) { hasMassSchedule = true; break; } + } + + let isTourismPage = false; + for (const kw of TOURISM_KEYWORDS) { + if (text.includes(kw)) { isTourismPage = true; break; } + } + + let domainMatchesName = false; + try { + const hostname = new URL(url).hostname.toLowerCase(); + for (const word of nameWords) { + if (word.length >= 4 && hostname.includes(word)) { + domainMatchesName = true; + break; + } + } + } catch { /* ignore */ } + + if (isTourismPage && !hasMassSchedule) return false; + + let isDeepUrl = false; + try { + const pathSegments = new URL(url).pathname.split('/').filter(Boolean); + isDeepUrl = pathSegments.length > 2; + } catch { /* ignore */ } + if (isDeepUrl && !domainMatchesName && !hasMassSchedule) return false; + + const hasCity = !!(church.city && church.city.trim()); + + if (hasMassSchedule && nameMatches >= 1) return true; + if (domainMatchesName && nameMatches >= 1 && hasCatholicKeyword) return true; + + if (hasCity) { + if (nameMatches >= 2) return true; + if (nameMatches >= 1 && cityMatch) return true; + if (nameMatches >= 1 && addressMatch) return true; + } + + if (!hasCity) { + if (nameMatches >= 1 && addressMatch) return true; + if (nameMatches >= 3) return true; + } + + return false; + } catch { + return false; + } +} + +// --- ChromaDB Querying --- + +interface ChromaResult { + id: string; + url: string; + title: string; + score: number; + distance: number; + churchId: string; + churchName: string; + churchCity: string; + verified?: boolean; +} + +async function findCandidatesForChurch( + church: ChurchRecord, + collection: Collection, + threshold: number, + nResults: number +): Promise { + // Build identity text for semantic search + const identityText = `${church.name} ${church.address || ''} ${church.city || ''} ${church.country}`.trim(); + const queryEmbedding = await embedSingle(identityText); + + const results = await collection.query({ + queryEmbeddings: [queryEmbedding], + nResults, + where: { churchCountry: church.country }, + }); + + if (!results.ids[0]) return []; + + return results.ids[0] + .map((id, i) => { + const metadata = results.metadatas[0][i] as Record; + return { + id, + url: (metadata.resultUrl as string) || '', + title: (metadata.resultTitle as string) || '', + score: (metadata.score as number) || 0, + distance: results.distances?.[0]?.[i] ?? 1, + churchId: (metadata.churchId as string) || '', + churchName: (metadata.churchName as string) || '', + churchCity: (metadata.churchCity as string) || '', + verified: (metadata.verified as boolean) || false, + }; + }) + .filter(r => r.distance <= threshold && r.url); +} + +function deduplicateByUrl(results: ChromaResult[]): ChromaResult[] { + const seen = new Map(); + for (const r of results) { + const existing = seen.get(r.url); + if (!existing || r.distance < existing.distance) { + seen.set(r.url, r); + } + } + return [...seen.values()].sort((a, b) => a.distance - b.distance); +} + +// --- Main Processing --- + +async function processChurch( + church: ChurchRecord, + collection: Collection, + stats: MatchStats, + threshold: number, + dryRun: boolean +): Promise { + const label = `${church.name} (${church.city || 'unknown'}, ${church.country})`; + + try { + // 1. Semantic search for similar results in ChromaDB + const candidates = await findCandidatesForChurch(church, collection, threshold, 20); + + if (candidates.length === 0) { + log(` - ${label} => no ChromaDB results within threshold`); + stats.noResults++; + return; + } + + // 2. Separate results: own church vs cross-church + const ownResults = candidates.filter(r => r.churchId === church.id); + const crossResults = candidates.filter(r => r.churchId !== church.id); + + // 3. URL frequency: URLs appearing for multiple churches are likely real parish/diocese sites + const urlFrequency = new Map(); + for (const r of candidates) { + urlFrequency.set(r.url, (urlFrequency.get(r.url) || 0) + 1); + } + + // 4. Prioritize: already-verified URLs from other churches, then high-frequency URLs, + // then own-church results, then cross-church results + const verifiedFromOthers = crossResults.filter(r => r.verified); + const highFreqUrls = [...urlFrequency.entries()] + .filter(([, count]) => count >= 2) + .map(([url]) => url); + + // Build candidate list in priority order + const urlsToTry: string[] = []; + const addUrl = (url: string) => { + if (!urlsToTry.includes(url)) urlsToTry.push(url); + }; + + // Verified URLs from nearby churches (highest priority) + for (const r of verifiedFromOthers) addUrl(r.url); + + // High-frequency URLs (appear in results for multiple churches) + for (const url of highFreqUrls) addUrl(url); + + // Own church results by distance (closest semantic match first) + const dedupedOwn = deduplicateByUrl(ownResults); + for (const r of dedupedOwn) addUrl(r.url); + + // Cross-church results from same city + const sameCityCross = crossResults.filter(r => + church.city && r.churchCity && + normalizeForMatch(r.churchCity) === normalizeForMatch(church.city) + ); + const dedupedCross = deduplicateByUrl(sameCityCross); + for (const r of dedupedCross) addUrl(r.url); + + // Limit to top 5 candidates + const topUrls = urlsToTry.slice(0, 5); + + log(` ? ${label} => ${candidates.length} results, trying ${topUrls.length} candidates`); + + // 5. Verify each candidate + let verifiedUrl: string | null = null; + for (const url of topUrls) { + const ok = await verifyUrl(url, church); + if (ok) { + verifiedUrl = url; + break; + } else { + stats.verifyFailed++; + } + } + + if (verifiedUrl) { + log(` + ${label} => ${verifiedUrl}`); + stats.matched++; + if (!dryRun) { + await prisma.church.update({ + where: { id: church.id }, + data: { + website: verifiedUrl, + hasWebsite: true, + }, + }); + // Mark in ChromaDB (update replaces metadata, so include all fields) + try { + const matchingResult = candidates.find(r => r.url === verifiedUrl); + if (matchingResult) { + await collection.update({ + ids: [matchingResult.id], + metadatas: [{ + churchId: matchingResult.churchId, + churchName: matchingResult.churchName, + churchCity: matchingResult.churchCity, + churchCountry: church.country, + searchQuery: '', + resultUrl: verifiedUrl, + resultTitle: matchingResult.title || '', + score: matchingResult.score || 0, + verified: true, + }], + }); + } + } catch { /* ignore */ } + } + } else { + log(` ~ ${label} => ${topUrls.length} candidates failed verification`); + stats.noResults++; + } + } catch (error: any) { + stats.errors++; + logError(` ! ${label} => error: ${error.message}`); + } +} + +// --- Main --- + +async function main() { + const args = process.argv.slice(2); + const countryIndex = args.indexOf('--country'); + const limitIndex = args.indexOf('--limit'); + const thresholdIndex = args.indexOf('--threshold'); + const dryRun = args.includes('--dry-run'); + + const countryCode = countryIndex !== -1 ? args[countryIndex + 1] : undefined; + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 500; + const threshold = thresholdIndex !== -1 ? parseFloat(args[thresholdIndex + 1]) : 0.4; + + // Graceful shutdown + process.on('SIGTERM', () => { log('Received SIGTERM'); shuttingDown = true; }); + process.on('SIGINT', () => { log('Received SIGINT'); shuttingDown = true; }); + + log('============================================================'); + log('Second-Pass Search Result Matching'); + log('============================================================'); + log(`Country: ${countryCode || 'All'}`); + log(`Limit: ${limit}`); + log(`Threshold: ${threshold}`); + log(`Dry run: ${dryRun ? 'Yes' : 'No'}`); + log('============================================================'); + + // Connect to ChromaDB + let collection: Collection; + try { + collection = await getCollection(COLLECTION_NAMES.SEARCH_RESULTS); + log('ChromaDB search_results collection connected'); + } catch (e: any) { + logError(`ChromaDB unavailable: ${e.message}`); + logError('This script requires ChromaDB. Make sure it is running.'); + process.exit(1); + } + + // Check collection has data + const count = await collection.count(); + log(`ChromaDB search_results: ${count} entries`); + if (count === 0) { + log('No search results stored yet. Run enrich-with-freesearch.ts first.'); + process.exit(0); + } + + // Job tracking + let jobId = await createOrResumeJob(args); + if (!jobId) { + jobId = await createNewJob({ countryCode, limit, threshold, dryRun }); + } + log(`Job ID: ${jobId}`); + + // Get churches without websites that have been FreeSearch'd + const whereClause: Record = { + source: 'osm', + website: null, + freeSearchedAt: { not: null }, + }; + if (countryCode) { + (whereClause as any).country = countryCode; + } + + const churches = await prisma.church.findMany({ + where: whereClause as any, + select: { + id: true, name: true, address: true, city: true, state: true, + country: true, latitude: true, longitude: true, + }, + take: limit, + orderBy: { updatedAt: 'asc' }, + }); + + log(`Found ${churches.length} churches without websites (already FreeSearch'd)`); + + const stats: MatchStats = { + processed: 0, + matched: 0, + noResults: 0, + verifyFailed: 0, + errors: 0, + startTime: Date.now(), + }; + + for (const church of churches) { + if (shuttingDown) break; + stats.processed++; + + await processChurch(church, collection, stats, threshold, dryRun); + + // Job tracking every 10 items + if (jobId && stats.processed % 10 === 0) { + await updateJobProgress(jobId, stats.processed, stats.matched, churches.length); + const stopping = await checkJobStopping(jobId); + if (stopping) { + log('Job stop requested via admin dashboard.'); + shuttingDown = true; + break; + } + } + + // Progress logging every 50 items + if (stats.processed % 50 === 0) { + const elapsed = (Date.now() - stats.startTime) / 1000; + const rate = Math.round((stats.processed / elapsed) * 3600); + log(`Progress: ${stats.processed}/${churches.length} processed, ${stats.matched} matched, ${stats.noResults} no match, ${stats.errors} errors (~${rate}/hour)`); + } + } + + // Complete job + if (jobId) { + await updateJobProgress(jobId, stats.processed, stats.matched, churches.length); + await completeJob(jobId); + } + + // Print summary + const elapsed = ((Date.now() - stats.startTime) / 1000).toFixed(1); + const matchRate = stats.processed > 0 + ? ((stats.matched / stats.processed) * 100).toFixed(1) + : '0.0'; + + log(''); + log('============================================================'); + log('Second-Pass Matching Summary'); + log('============================================================'); + log(`Churches processed: ${stats.processed}`); + log(`Websites matched: ${stats.matched}`); + log(`No match found: ${stats.noResults}`); + log(`Verify rejected: ${stats.verifyFailed}`); + log(`Errors: ${stats.errors}`); + log(`Match rate: ${matchRate}%`); + log(`Elapsed: ${elapsed}s`); + log('============================================================'); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((error) => { + logError(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/normalize-country-codes.ts b/scripts/normalize-country-codes.ts new file mode 100644 index 0000000..c9a174b --- /dev/null +++ b/scripts/normalize-country-codes.ts @@ -0,0 +1,110 @@ +/** + * Normalize country codes in the database. + * Converts full country names to ISO 3166-1 alpha-2 codes. + * + * Usage: + * npx tsx scripts/normalize-country-codes.ts --dry-run + * npx tsx scripts/normalize-country-codes.ts --execute + */ + +import path from 'path'; +import dotenv from 'dotenv'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { normalizeCountryCode } from '../src/lib/country-normalize'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function main() { + const dryRun = !process.argv.includes('--execute'); + + if (dryRun) { + console.log('DRY RUN — no changes will be made. Use --execute to apply.\n'); + } + + // Get all distinct country values + const countries = await prisma.church.findMany({ + select: { country: true }, + distinct: ['country'], + where: { country: { not: null } }, + }); + + const countryValues = countries + .map(c => c.country) + .filter((c): c is string => c !== null); + + console.log(`Found ${countryValues.length} distinct country values.\n`); + + // Group by normalization result + const changes: { original: string; normalized: string; count?: number }[] = []; + const alreadyNormalized: string[] = []; + const unknown: string[] = []; + + for (const country of countryValues) { + const normalized = normalizeCountryCode(country); + + if (normalized === country) { + // Already correct or unknown + if (country.length === 2 && country === country.toUpperCase()) { + alreadyNormalized.push(country); + } else { + unknown.push(country); + } + } else { + changes.push({ original: country, normalized }); + } + } + + // Get counts for changes + for (const change of changes) { + const count = await prisma.church.count({ + where: { country: change.original }, + }); + change.count = count; + } + + // Report + console.log(`Already normalized (${alreadyNormalized.length}): ${alreadyNormalized.sort().join(', ')}\n`); + + if (changes.length > 0) { + console.log(`Changes to apply (${changes.length}):`); + for (const { original, normalized, count } of changes) { + console.log(` "${original}" → "${normalized}" (${count} churches)`); + } + console.log(); + } else { + console.log('No changes needed — all country values are already normalized.\n'); + } + + if (unknown.length > 0) { + console.log(`Unknown values (${unknown.length}): ${unknown.join(', ')}`); + console.log(' These could not be mapped to ISO codes. Review manually.\n'); + } + + // Apply changes + if (!dryRun && changes.length > 0) { + let totalUpdated = 0; + for (const { original, normalized } of changes) { + const result = await prisma.church.updateMany({ + where: { country: original }, + data: { country: normalized }, + }); + totalUpdated += result.count; + console.log(`Updated "${original}" → "${normalized}": ${result.count} churches`); + } + console.log(`\nTotal updated: ${totalUpdated} churches`); + } + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch(err => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/scripts/populate-chromadb.ts b/scripts/populate-chromadb.ts new file mode 100644 index 0000000..635de06 --- /dev/null +++ b/scripts/populate-chromadb.ts @@ -0,0 +1,197 @@ +/** + * Bulk-populate ChromaDB collections from the database. + * + * Usage: + * npx tsx scripts/populate-chromadb.ts --collection church_identity + * npx tsx scripts/populate-chromadb.ts --collection page_classification + * npx tsx scripts/populate-chromadb.ts --all + * npx tsx scripts/populate-chromadb.ts --all --batch-size 50 --limit 1000 + */ + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { getCollection, COLLECTION_NAMES, CollectionName } from '../src/chromadb/collections'; +import { embed } from '../src/chromadb/embeddings'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const args = process.argv.slice(2); +const collectionArg = args.includes('--collection') + ? args[args.indexOf('--collection') + 1] + : null; +const populateAll = args.includes('--all'); +const batchSize = args.includes('--batch-size') + ? parseInt(args[args.indexOf('--batch-size') + 1]) + : 100; +const limit = args.includes('--limit') + ? parseInt(args[args.indexOf('--limit') + 1]) + : 0; + +async function populateChurchIdentity() { + console.log('\n=== Populating church_identity ==='); + const collection = await getCollection(COLLECTION_NAMES.CHURCH_IDENTITY); + + const totalCount = await prisma.church.count(); + const maxItems = limit > 0 ? Math.min(limit, totalCount) : totalCount; + console.log(`Total churches: ${totalCount}, processing: ${maxItems}`); + + let processed = 0; + let cursor: string | undefined = undefined; + + while (processed < maxItems) { + const currentBatch = Math.min(batchSize, maxItems - processed); + const churches = await prisma.church.findMany({ + take: currentBatch, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + address: true, + city: true, + country: true, + source: true, + latitude: true, + longitude: true, + }, + }); + + if (churches.length === 0) break; + + const documents = churches.map( + (c) => `${c.name} ${c.address || ''} ${c.city || ''} ${c.country}`.trim() + ); + + const embeddings = await embed(documents); + + await collection.upsert({ + ids: churches.map((c) => `church-${c.id}`), + embeddings, + documents, + metadatas: churches.map((c) => ({ + churchId: c.id, + country: c.country, + source: c.source, + lat: c.latitude, + lng: c.longitude, + })), + }); + + processed += churches.length; + cursor = churches[churches.length - 1].id; + console.log(` Processed ${processed}/${maxItems}`); + } + + console.log(` Done: ${processed} churches indexed`); +} + +async function populatePageClassification() { + console.log('\n=== Populating page_classification ==='); + const collection = await getCollection(COLLECTION_NAMES.PAGE_CLASSIFICATION); + + // Index churches that have been successfully scraped (have mass schedules) + const totalCount = await prisma.church.count({ + where: { + lastScrapedAt: { not: null }, + massSchedules: { some: { isActive: true } }, + }, + }); + const maxItems = limit > 0 ? Math.min(limit, totalCount) : totalCount; + console.log(`Scraped churches with schedules: ${totalCount}, processing: ${maxItems}`); + + let processed = 0; + let cursor: string | undefined = undefined; + + while (processed < maxItems) { + const currentBatch = Math.min(batchSize, maxItems - processed); + const churches = await prisma.church.findMany({ + take: currentBatch, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + where: { + lastScrapedAt: { not: null }, + massSchedules: { some: { isActive: true } }, + }, + orderBy: { id: 'asc' }, + select: { + id: true, + massScheduleUrl: true, + website: true, + websiteLanguage: true, + scraperConfig: { select: { rawHtml: true } }, + }, + }); + + if (churches.length === 0) break; + + // Use stored raw HTML (truncated) as the document + const validChurches = churches.filter((c) => c.scraperConfig?.rawHtml); + if (validChurches.length > 0) { + const documents = validChurches.map( + (c) => (c.scraperConfig?.rawHtml || '').slice(0, 2000) + ); + + const embeddings = await embed(documents); + + await collection.upsert({ + ids: validChurches.map((c) => `page-${c.id}`), + embeddings, + documents, + metadatas: validChurches.map((c) => ({ + url: c.massScheduleUrl || c.website || '', + isMassSchedulePage: true, + language: c.websiteLanguage || 'unknown', + })), + }); + } + + processed += churches.length; + cursor = churches[churches.length - 1].id; + console.log(` Processed ${processed}/${maxItems} (${validChurches.length} had raw HTML)`); + } + + console.log(` Done: ${processed} pages classified`); +} + +async function main() { + try { + if (!populateAll && !collectionArg) { + console.log('Usage:'); + console.log(' npx tsx scripts/populate-chromadb.ts --collection church_identity'); + console.log(' npx tsx scripts/populate-chromadb.ts --collection page_classification'); + console.log(' npx tsx scripts/populate-chromadb.ts --all'); + console.log(' npx tsx scripts/populate-chromadb.ts --all --batch-size 50 --limit 1000'); + process.exit(0); + } + + const collectionsToPopulate: CollectionName[] = populateAll + ? [COLLECTION_NAMES.CHURCH_IDENTITY, COLLECTION_NAMES.PAGE_CLASSIFICATION] + : [collectionArg as CollectionName]; + + for (const name of collectionsToPopulate) { + switch (name) { + case COLLECTION_NAMES.CHURCH_IDENTITY: + await populateChurchIdentity(); + break; + case COLLECTION_NAMES.PAGE_CLASSIFICATION: + await populatePageClassification(); + break; + default: + console.log(`Collection '${name}' does not have a populate function yet.`); + console.log('Available: church_identity, page_classification'); + } + } + + console.log('\nPopulation complete!'); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +main(); diff --git a/scripts/populate-city-normalized.ts b/scripts/populate-city-normalized.ts new file mode 100644 index 0000000..3eacd2d --- /dev/null +++ b/scripts/populate-city-normalized.ts @@ -0,0 +1,54 @@ +import { config } from 'dotenv'; +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +// Load environment variables +config({ path: '.env.local' }); +config({ path: '.env' }); + +// Create connection pool +const connectionString = process.env.DATABASE_URL || ''; +const pool = new Pool({ connectionString }); + +// Create Prisma adapter +const adapter = new PrismaPg(pool); + +// Create Prisma client with adapter +const prisma = new PrismaClient({ + adapter, + log: ['error'], +}); + +async function main() { + console.log('Populating cityNormalized field using SQL...'); + + // Use raw SQL for much faster batch update + // Normalize: lowercase, remove special chars except spaces/numbers, trim + const result = await prisma.$executeRaw` + UPDATE churches + SET city_normalized = LOWER( + TRIM( + REGEXP_REPLACE( + COALESCE(city, ''), + '[^a-zA-Z0-9 ]', + '', + 'g' + ) + ) + ) + WHERE city IS NOT NULL + `; + + console.log(`✅ Updated ${result} churches with normalized cities`); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/scripts/save-schedules-to-db.ts b/scripts/save-schedules-to-db.ts new file mode 100644 index 0000000..67ead81 --- /dev/null +++ b/scripts/save-schedules-to-db.ts @@ -0,0 +1,161 @@ +#!/usr/bin/env tsx +/** + * Save mass schedules to database using scrapeChurch() service + */ + +import { config } from 'dotenv'; +config({ path: '.env.local' }); +config({ path: '.env' }); + +import { scrapeChurch } from '../src/lib/scraper-service'; +import { prisma } from '../src/lib/db'; + +const PRIORITY_COUNTRIES = ['FR', 'DE', 'ES', 'PL', 'BR']; +const CHURCHES_PER_COUNTRY = 5; // Start small to verify it works + +interface ScrapeResult { + churchId: string; + churchName: string; + country: string; + success: boolean; + schedulesCreated: number; + error?: string; +} + +async function saveSchedulesToDb() { + console.log('Starting database save operation...\n'); + console.log(`Target: ${CHURCHES_PER_COUNTRY} churches per country`); + console.log(`Countries: ${PRIORITY_COUNTRIES.join(', ')}\n`); + + const results: ScrapeResult[] = []; + let totalChurches = 0; + let totalSuccess = 0; + let totalSchedules = 0; + + for (const country of PRIORITY_COUNTRIES) { + console.log(`\n${'='.repeat(60)}`); + console.log(`${country} - Finding churches to scrape...`); + console.log('='.repeat(60)); + + // Get churches with websites that haven't been scraped yet + const churches = await prisma.church.findMany({ + where: { + country, + website: { not: null }, + source: 'osm', + lastScrapedAt: null, // Only unscrapped churches + }, + take: CHURCHES_PER_COUNTRY, + orderBy: { createdAt: 'asc' }, + }); + + console.log(`Found ${churches.length} churches to scrape\n`); + + for (let i = 0; i < churches.length; i++) { + const church = churches[i]; + totalChurches++; + + process.stdout.write(`[${i + 1}/${churches.length}] ${church.name.substring(0, 40).padEnd(40)} `); + + try { + // Use the scrapeChurch service which saves to database + const result = await scrapeChurch(church.id); + + if (result.success) { + totalSuccess++; + totalSchedules += result.schedulesCreated; + process.stdout.write(`✅ ${result.schedulesCreated} schedules saved\n`); + + results.push({ + churchId: church.id, + churchName: church.name, + country, + success: true, + schedulesCreated: result.schedulesCreated, + }); + } else { + process.stdout.write(`❌ ${result.error}\n`); + + results.push({ + churchId: church.id, + churchName: church.name, + country, + success: false, + schedulesCreated: 0, + error: result.error, + }); + } + } catch (err: any) { + process.stdout.write(`❌ ERROR: ${err.message}\n`); + + results.push({ + churchId: church.id, + churchName: church.name, + country, + success: false, + schedulesCreated: 0, + error: err.message, + }); + } + } + } + + // Final summary + console.log('\n\n'); + console.log('═'.repeat(80)); + console.log('DATABASE SAVE SUMMARY'); + console.log('═'.repeat(80)); + console.log(''); + console.log(`Total churches processed: ${totalChurches}`); + console.log(`Successful scrapes: ${totalSuccess} (${((totalSuccess / totalChurches) * 100).toFixed(1)}%)`); + console.log(`Total schedules saved to database: ${totalSchedules}`); + console.log(''); + + // Verify database records + console.log('Verifying database records...\n'); + + const dbScheduleCount = await prisma.massSchedule.count(); + const dbChurchesWithSchedules = await prisma.church.count({ + where: { + massSchedules: { + some: {}, + }, + }, + }); + + console.log(`✓ Total mass schedules in database: ${dbScheduleCount}`); + console.log(`✓ Churches with schedules: ${dbChurchesWithSchedules}`); + console.log(''); + + // Show sample of saved schedules + console.log('Sample of saved schedules:\n'); + + const sampleChurches = await prisma.church.findMany({ + where: { + massSchedules: { + some: {}, + }, + }, + include: { + massSchedules: { + take: 3, + orderBy: { dayOfWeek: 'asc' }, + }, + }, + take: 3, + }); + + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + sampleChurches.forEach(church => { + console.log(`${church.name} (${church.country}):`); + church.massSchedules.forEach(schedule => { + console.log(` ${dayNames[schedule.dayOfWeek]} ${schedule.time} - ${schedule.language} ${schedule.massType || ''}`); + }); + console.log(''); + }); + + await prisma.$disconnect(); +} + +saveSchedulesToDb().catch(console.error); diff --git a/scripts/scrape-churches.ts b/scripts/scrape-churches.ts new file mode 100644 index 0000000..ac07ed0 --- /dev/null +++ b/scripts/scrape-churches.ts @@ -0,0 +1,299 @@ +#!/usr/bin/env tsx +/** + * Bulk church website scraper + * Scrapes mass schedules from church websites and updates the database. + * + * Usage: + * npx tsx scripts/scrape-churches.ts --limit 100 + * npx tsx scripts/scrape-churches.ts --limit 50 --max-failures 3 + * npx tsx scripts/scrape-churches.ts --all # Process ALL eligible churches + * npx tsx scripts/scrape-churches.ts --all --language english + * npx tsx scripts/scrape-churches.ts --all --max-failures 3 + * npx tsx scripts/scrape-churches.ts --ids id1,id2,id3 + * npx tsx scripts/scrape-churches.ts --all --job-id # Resume/track existing job + */ + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { scrapeAllChurches, scrapeChurch, countEligibleChurches } from '../src/lib/scraper-service'; +import type { ScrapeJobResult } from '../src/lib/scraper-service'; + +// Fresh DB connection for scripts +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const jobPrisma = new PrismaClient({ adapter }); + +let shuttingDown = false; + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds.toFixed(0)}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; +} + +// --- Job Tracking --- + +async function createOrResumeJob(args: string[]): Promise { + const jobIdIndex = args.indexOf('--job-id'); + if (jobIdIndex !== -1) { + const jobId = args[jobIdIndex + 1]; + await jobPrisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'running', startedAt: new Date() }, + }); + return jobId; + } + return null; +} + +async function createNewJob(language: string | null, config: Record): Promise { + const job = await jobPrisma.backgroundJob.create({ + data: { + type: 'scraper', + language: language || 'generic', + status: 'running', + startedAt: new Date(), + config, + }, + }); + return job.id; +} + +async function updateJobProgress(jobId: string, processed: number, succeeded: number, failed: number, itemsFound: number, totalItems: number): Promise { + await jobPrisma.backgroundJob.update({ + where: { id: jobId }, + data: { processed, succeeded, failed, itemsFound, totalItems }, + }); +} + +async function checkJobStopping(jobId: string): Promise { + const job = await jobPrisma.backgroundJob.findUnique({ where: { id: jobId } }); + return job?.status === 'stopping'; +} + +async function completeJob(jobId: string, error?: string): Promise { + await jobPrisma.backgroundJob.update({ + where: { id: jobId }, + data: { + status: error ? 'failed' : 'completed', + error, + completedAt: new Date(), + }, + }); +} + +async function main() { + const args = process.argv.slice(2); + const limitIndex = args.indexOf('--limit'); + const maxFailIndex = args.indexOf('--max-failures'); + const idsIndex = args.indexOf('--ids'); + const allMode = args.includes('--all'); + const langIndex = args.indexOf('--language'); + + const maxFailures = maxFailIndex !== -1 ? parseInt(args[maxFailIndex + 1]) : 5; + const ids = idsIndex !== -1 ? args[idsIndex + 1].split(',') : null; + const language = langIndex !== -1 ? args[langIndex + 1] : null; + + // --ids mode: scrape specific churches + if (ids) { + console.log('============================================================'); + console.log('Church Website Scraper — Targeted Mode'); + console.log('============================================================'); + console.log(`Targeting ${ids.length} specific churches`); + console.log(`Max failures: ${maxFailures}`); + console.log(`Started: ${new Date().toISOString()}`); + console.log('============================================================\n'); + + const startTime = Date.now(); + const results = await Promise.all(ids.map((id) => scrapeChurch(id.trim()))); + printSummary(results, startTime); + return; + } + + // --all mode: batch loop through ALL eligible churches + if (allMode) { + const BATCH_SIZE = 100; + const totalEligible = await countEligibleChurches(maxFailures); + + console.log('============================================================'); + console.log('Church Website Scraper — Full Run'); + console.log('============================================================'); + console.log(`Language: ${language || 'all'}`); + console.log(`Eligible churches: ${totalEligible.toLocaleString()}`); + console.log(`Batch size: ${BATCH_SIZE}`); + console.log(`Max failures: ${maxFailures}`); + console.log(`Started: ${new Date().toISOString()}`); + console.log('============================================================\n'); + + if (totalEligible === 0) { + console.log('No eligible churches to scrape. All done!'); + return; + } + + // Job tracking + let jobId = await createOrResumeJob(args); + if (!jobId) { + jobId = await createNewJob(language, { allMode: true, maxFailures, language }); + } + console.log(`Job ID: ${jobId}\n`); + + // Graceful shutdown handlers + process.on('SIGINT', () => { + if (shuttingDown) { + console.log('\nForce quit.'); + process.exit(1); + } + console.log('\nShutting down gracefully (finishing current batch)...'); + shuttingDown = true; + }); + process.on('SIGTERM', () => { + console.log('\nSIGTERM received, shutting down after current batch...'); + shuttingDown = true; + }); + + const allResults: ScrapeJobResult[] = []; + const globalStart = Date.now(); + let batchNum = 0; + let totalSchedulesFound = 0; + + try { + while (!shuttingDown) { + batchNum++; + const batchStart = Date.now(); + + const batchResults = await scrapeAllChurches({ limit: BATCH_SIZE, maxFailures, language: language || undefined }); + + if (batchResults.length === 0) { + console.log('\nNo more eligible churches. All done!'); + break; + } + + allResults.push(...batchResults); + + // Batch summary + const batchElapsed = (Date.now() - batchStart) / 1000; + const batchSuccess = batchResults.filter((r) => r.success).length; + const batchSchedules = batchResults.reduce((sum, r) => sum + r.schedulesFound, 0); + totalSchedulesFound += batchSchedules; + + // Overall progress + const totalElapsed = (Date.now() - globalStart) / 1000; + const rate = allResults.length / (totalElapsed / 3600); + const remaining = totalEligible - allResults.length; + const etaSeconds = remaining > 0 && rate > 0 ? (remaining / rate) * 3600 : 0; + + console.log(`\n--- Batch ${batchNum} (${batchResults.length} churches) ---`); + console.log(` Success: ${batchSuccess}/${batchResults.length} | Schedules: ${batchSchedules} | Time: ${formatDuration(batchElapsed)}`); + console.log(` Progress: ${allResults.length.toLocaleString()}/${totalEligible.toLocaleString()} (${((allResults.length / totalEligible) * 100).toFixed(1)}%)`); + console.log(` Rate: ${rate.toFixed(0)}/hr | ETA: ~${formatDuration(etaSeconds)}`); + + // Update job progress + const succeeded = allResults.filter(r => r.success).length; + const failed = allResults.filter(r => !r.success).length; + await updateJobProgress(jobId, allResults.length, succeeded, failed, totalSchedulesFound, totalEligible); + + // Check if job was requested to stop (every 10 items) + if (allResults.length % 10 === 0) { + const stopping = await checkJobStopping(jobId); + if (stopping) { + console.log('\nJob stop requested via admin dashboard.'); + shuttingDown = true; + } + } + + if (shuttingDown) { + console.log('\nGraceful shutdown: batch completed.'); + break; + } + } + + await completeJob(jobId); + } catch (error) { + await completeJob(jobId, error instanceof Error ? error.message : 'Unknown error'); + throw error; + } + + printSummary(allResults, globalStart); + return; + } + + // Default mode: single batch with --limit + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 100; + + console.log('============================================================'); + console.log('Church Website Scraper'); + console.log('============================================================'); + console.log(`Language: ${language || 'all'}`); + console.log(`Limit: ${limit}`); + console.log(`Max failures: ${maxFailures}`); + console.log(`Started: ${new Date().toISOString()}`); + console.log('============================================================\n'); + + // Job tracking for single batch mode too + let jobId = await createOrResumeJob(args); + if (!jobId) { + jobId = await createNewJob(language, { limit, maxFailures, language }); + } + console.log(`Job ID: ${jobId}\n`); + + const startTime = Date.now(); + try { + const results = await scrapeAllChurches({ limit, maxFailures, language: language || undefined }); + const succeeded = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + const totalSchedules = results.reduce((sum, r) => sum + r.schedulesFound, 0); + await updateJobProgress(jobId, results.length, succeeded, failed, totalSchedules, limit); + await completeJob(jobId); + printSummary(results, startTime); + } catch (error) { + await completeJob(jobId, error instanceof Error ? error.message : 'Unknown error'); + throw error; + } +} + +function printSummary(results: ScrapeJobResult[], startTime: number) { + const elapsed = (Date.now() - startTime) / 1000; + const succeeded = results.filter((r) => r.success); + const failed = results.filter((r) => !r.success); + const totalSchedules = results.reduce((sum, r) => sum + r.schedulesFound, 0); + const rate = results.length / (elapsed / 3600); + + console.log('\n============================================================'); + console.log('Scraping Summary'); + console.log('============================================================'); + console.log(`Churches processed: ${results.length.toLocaleString()}`); + console.log(`Succeeded: ${succeeded.length.toLocaleString()}`); + console.log(`Failed: ${failed.length.toLocaleString()}`); + console.log(`Total schedules found: ${totalSchedules.toLocaleString()}`); + console.log(`Elapsed time: ${formatDuration(elapsed)}`); + console.log(`Average rate: ${rate.toFixed(0)}/hr`); + console.log(`Finished: ${new Date().toISOString()}`); + console.log('============================================================'); + + if (failed.length > 0) { + console.log(`\nFailed churches (${failed.length}):`); + // Show first 50 failures to avoid overwhelming output + const toShow = failed.slice(0, 50); + for (const f of toShow) { + console.log(` - ${f.churchName}: ${f.error}`); + } + if (failed.length > 50) { + console.log(` ... and ${failed.length - 50} more`); + } + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}).finally(async () => { + await jobPrisma.$disconnect(); + await pool.end(); +}); diff --git a/scripts/scrape-diocese-directory.ts b/scripts/scrape-diocese-directory.ts new file mode 100644 index 0000000..d3b3ca7 --- /dev/null +++ b/scripts/scrape-diocese-directory.ts @@ -0,0 +1,372 @@ +#!/usr/bin/env tsx +/** + * Scrape diocese directories to discover parish URLs and mass schedules + * + * Usage: + * npx tsx scripts/scrape-diocese-directory.ts --diocese # Single diocese + * npx tsx scripts/scrape-diocese-directory.ts --country DE # All dioceses in country + * npx tsx scripts/scrape-diocese-directory.ts --all # All active dioceses + * npx tsx scripts/scrape-diocese-directory.ts --all --dry-run # Preview only + * npx tsx scripts/scrape-diocese-directory.ts --job-id # Resume tracked job + */ + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { DioceseDirectoryScraper, DioceseScrapeConfig } from '../src/scrapers/diocese-directory-scraper'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +function log(msg: string) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function logError(msg: string) { + console.error(`[${new Date().toISOString()}] ERROR: ${msg}`); +} + +// Haversine distance in km +function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLon / 2) ** 2; + return R * 2 * Math.asin(Math.sqrt(a)); +} + +function normalizeForMatch(str: string): string { + return str.toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +interface MatchCandidate { + id: string; + name: string; + latitude: number; + longitude: number; + distance: number; + nameScore: number; +} + +async function findMatchingChurch( + name: string, + address: string | undefined, + city: string | undefined, + country: string, +): Promise { + // Search by name similarity + country + const nameNorm = normalizeForMatch(name); + const nameWords = nameNorm.split(' ').filter(w => w.length >= 3); + + if (nameWords.length === 0) return null; + + // Find churches in the same country + const candidates = await prisma.church.findMany({ + where: { + country, + ...(city ? { city: { contains: city, mode: 'insensitive' } } : {}), + }, + select: { id: true, name: true, latitude: true, longitude: true, website: true }, + take: 50, + }); + + let bestMatch: MatchCandidate | null = null; + + for (const church of candidates) { + const churchNameNorm = normalizeForMatch(church.name); + const churchWords = churchNameNorm.split(' ').filter(w => w.length >= 3); + + let matchingWords = 0; + for (const w of nameWords) { + if (churchWords.includes(w)) matchingWords++; + } + + const nameScore = nameWords.length > 0 ? matchingWords / nameWords.length : 0; + + // Require at least 40% word overlap + if (nameScore < 0.4) continue; + + if (!bestMatch || nameScore > bestMatch.nameScore) { + bestMatch = { + id: church.id, + name: church.name, + latitude: church.latitude, + longitude: church.longitude, + distance: 0, + nameScore, + }; + } + } + + return bestMatch; +} + +// --- Job Tracking --- + +async function createOrResumeJob(args: string[]): Promise { + const jobIdIndex = args.indexOf('--job-id'); + if (jobIdIndex !== -1) { + const jobId = args[jobIdIndex + 1]; + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'running', startedAt: new Date() }, + }); + return jobId; + } + return null; +} + +async function scrapeDiocese( + dioceseId: string, + dryRun: boolean, + stats: { processed: number; matched: number; created: number; schedules: number; errors: number } +): Promise { + const diocese = await prisma.diocese.findUnique({ where: { id: dioceseId } }); + if (!diocese) { + logError(`Diocese not found: ${dioceseId}`); + return; + } + + if (!diocese.directoryUrl) { + log(` Skipping ${diocese.name}: no directory URL`); + return; + } + + const config = diocese.scrapeConfig as DioceseScrapeConfig | null; + if (!config?.selectors) { + log(` Skipping ${diocese.name}: no scrape config`); + return; + } + + log(`Scraping diocese: ${diocese.name} (${diocese.country})`); + log(` Directory URL: ${diocese.directoryUrl}`); + + const scraper = new DioceseDirectoryScraper(); + + try { + let parishes; + + if (config.scheduleInDirectory) { + parishes = await scraper.scrapeDirectoryWithSchedules( + diocese.directoryUrl, + config, + diocese.language + ); + } else { + const discovered = await scraper.scrapeDirectory(diocese.directoryUrl, config); + parishes = discovered.map(p => ({ + ...p, + scheduleText: '', + schedules: [] as Array<{ dayOfWeek: number; time: string; massType?: string; language?: string; notes?: string }>, + })); + } + + log(` Discovered ${parishes.length} parishes`); + + for (const parish of parishes) { + stats.processed++; + + // Try to match to existing church + const match = await findMatchingChurch( + parish.name, + parish.address, + parish.city, + diocese.country, + ); + + if (match) { + stats.matched++; + log(` Match: "${parish.name}" -> "${match.name}" (score: ${match.nameScore.toFixed(2)})`); + + if (!dryRun) { + // Update matched church with website and diocese link + await prisma.church.update({ + where: { id: match.id }, + data: { + website: parish.url, + hasWebsite: true, + dioceseId: diocese.id, + }, + }); + + // Save schedules if available + if ('schedules' in parish && parish.schedules.length > 0) { + await prisma.massSchedule.deleteMany({ where: { churchId: match.id } }); + await prisma.massSchedule.createMany({ + data: parish.schedules.map(s => ({ + churchId: match.id, + dayOfWeek: s.dayOfWeek, + time: s.time, + massType: s.massType, + language: s.language ?? 'English', + notes: s.notes, + })), + }); + stats.schedules += parish.schedules.length; + } + } + } else { + log(` No match: "${parish.name}" (${parish.city || 'no city'})`); + stats.created++; + + // In non-dry-run, we could create new churches, but for safety + // we only log unmatched parishes for manual review + // (Creating churches from directory data without coordinates is risky) + } + } + + // Update diocese tracking + if (!dryRun) { + await prisma.diocese.update({ + where: { id: diocese.id }, + data: { + lastScrapedAt: new Date(), + lastSuccessAt: new Date(), + churchCount: parishes.length, + failureCount: 0, + }, + }); + } + } catch (err: any) { + stats.errors++; + logError(` Failed to scrape ${diocese.name}: ${err.message}`); + + if (!dryRun) { + await prisma.diocese.update({ + where: { id: diocese.id }, + data: { + lastScrapedAt: new Date(), + lastFailureAt: new Date(), + failureCount: { increment: 1 }, + }, + }); + } + } finally { + await scraper.close(); + } +} + +async function main() { + const args = process.argv.slice(2); + const dryRun = args.includes('--dry-run'); + const dioceseIdx = args.indexOf('--diocese'); + const countryIdx = args.indexOf('--country'); + const all = args.includes('--all'); + + const dioceseId = dioceseIdx !== -1 ? args[dioceseIdx + 1] : undefined; + const country = countryIdx !== -1 ? args[countryIdx + 1] : undefined; + + log('============================================================'); + log('Diocese Directory Scraper'); + log('============================================================'); + log(`Mode: ${dryRun ? 'Dry run' : 'Execute'}`); + log(`Target: ${dioceseId ? `Diocese ${dioceseId}` : country ? `Country ${country}` : 'All active'}`); + log('============================================================'); + + // Job tracking + let jobId = await createOrResumeJob(args); + if (!jobId && !dryRun) { + const job = await prisma.backgroundJob.create({ + data: { + type: 'diocese-directory', + status: 'running', + startedAt: new Date(), + config: { dioceseId, country, all, dryRun }, + }, + }); + jobId = job.id; + log(`Job ID: ${jobId}`); + } + + const stats = { processed: 0, matched: 0, created: 0, schedules: 0, errors: 0 }; + + try { + let dioceses; + + if (dioceseId) { + dioceses = [{ id: dioceseId }]; + } else { + dioceses = await prisma.diocese.findMany({ + where: { + active: true, + directoryUrl: { not: null }, + ...(country ? { country } : {}), + }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }); + } + + log(`Found ${dioceses.length} dioceses to scrape`); + + for (const d of dioceses) { + await scrapeDiocese(d.id, dryRun, stats); + + // Check for job stop + if (jobId) { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { processed: stats.processed, succeeded: stats.matched, itemsFound: stats.matched }, + }); + const job = await prisma.backgroundJob.findUnique({ where: { id: jobId } }); + if (job?.status === 'stopping') { + log('Job stop requested.'); + break; + } + } + } + } catch (error: any) { + logError(`Fatal error: ${error.message}`); + if (jobId) { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'failed', error: error.message, completedAt: new Date() }, + }); + } + throw error; + } + + // Complete job + if (jobId) { + await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { + status: 'completed', + completedAt: new Date(), + processed: stats.processed, + succeeded: stats.matched, + itemsFound: stats.matched, + }, + }); + } + + log(''); + log('============================================================'); + log('Diocese Directory Scraper Summary'); + log('============================================================'); + log(`Parishes discovered: ${stats.processed}`); + log(`Matched to DB: ${stats.matched}`); + log(`Unmatched (new): ${stats.created}`); + log(`Schedules saved: ${stats.schedules}`); + log(`Errors: ${stats.errors}`); + log('============================================================'); + + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((error) => { + logError(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/scrape-masstimes.ts b/scripts/scrape-masstimes.ts new file mode 100644 index 0000000..65c5b1e --- /dev/null +++ b/scripts/scrape-masstimes.ts @@ -0,0 +1,171 @@ +import 'dotenv/config'; +import { prisma } from '../src/lib/db'; +import { MassTimesScraper, ChurchData } from '../src/lib/masstimes-scraper'; + +const TARGET_STATES = [ + 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'DC', 'FL', + 'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', + 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', + 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', + 'SC', 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', + 'WY', +]; + +function deduplicateMassSchedules(schedules: T[]): T[] { + const seen = new Map(); + for (const s of schedules) { + const key = `${s.dayOfWeek}:${s.time}:${s.language}`; + if (!seen.has(key)) { + seen.set(key, s); + } + } + return Array.from(seen.values()); +} + +async function saveChurch(data: ChurchData, seenIds: Set): Promise { + if (seenIds.has(data.masstimesId)) { + console.log(` Skipping duplicate: ${data.name}`); + return false; + } + + try { + await prisma.$transaction(async (tx) => { + const church = await tx.church.upsert({ + where: { masstimesId: data.masstimesId }, + create: { + masstimesId: data.masstimesId, + name: data.name, + address: data.address, + city: data.city, + state: data.state, + zip: data.zip, + country: data.country, + latitude: data.latitude, + longitude: data.longitude, + phone: data.phone, + website: data.website, + email: data.email, + pastorName: data.pastorName, + diocese: data.diocese, + directions: data.directions, + wheelchairAccess: data.wheelchairAccess, + lastScrapedAt: new Date(), + scrapeStrategy: 'masstimes', + }, + update: { + name: data.name, + address: data.address, + city: data.city, + state: data.state, + zip: data.zip, + latitude: data.latitude, + longitude: data.longitude, + phone: data.phone, + website: data.website, + email: data.email, + pastorName: data.pastorName, + diocese: data.diocese, + directions: data.directions, + wheelchairAccess: data.wheelchairAccess, + lastScrapedAt: new Date(), + }, + }); + + await tx.massSchedule.deleteMany({ where: { churchId: church.id } }); + await tx.confessionSchedule.deleteMany({ where: { churchId: church.id } }); + await tx.adorationSchedule.deleteMany({ where: { churchId: church.id } }); + + if (data.massSchedules.length > 0) { + await tx.massSchedule.createMany({ + data: deduplicateMassSchedules(data.massSchedules).map((ms) => ({ + churchId: church.id, + dayOfWeek: ms.dayOfWeek, + time: ms.time, + massType: ms.massType, + language: ms.language, + notes: ms.notes, + })), + }); + } + + if (data.confessionSchedules.length > 0) { + await tx.confessionSchedule.createMany({ + data: data.confessionSchedules.map((cs) => ({ + churchId: church.id, + dayOfWeek: cs.dayOfWeek, + startTime: cs.startTime, + endTime: cs.endTime, + notes: cs.notes, + })), + }); + } + + if (data.adorationSchedules.length > 0) { + await tx.adorationSchedule.createMany({ + data: data.adorationSchedules.map((as) => ({ + churchId: church.id, + dayOfWeek: as.dayOfWeek, + startTime: as.startTime, + endTime: as.endTime, + isPerpetual: as.isPerpetual, + notes: as.notes, + })), + }); + } + }); + + seenIds.add(data.masstimesId); + console.log(` Saved: ${data.name}`); + return true; + } catch (error) { + console.error(` Error saving ${data.name}:`, error); + return false; + } +} + +async function main() { + const seenIds = new Set(); + console.log('\n' + '='.repeat(70)); + console.log('MASSTIMES.ORG CHURCH SCRAPER (JSON API)'); + console.log('='.repeat(70)); + console.log(`\nTarget states: ${TARGET_STATES.length}`); + console.log(`Time: ${new Date().toISOString()}`); + console.log('\n' + '-'.repeat(70)); + + const scraper = new MassTimesScraper(); + const stats = { total: 0, saved: 0, errors: 0 }; + + try { + await scraper.init(); + console.log('Browser initialized\n'); + + for (let i = 0; i < TARGET_STATES.length; i++) { + const state = TARGET_STATES[i]; + console.log(`\n[${'='.repeat(20)}] SCRAPING ${state} [${'='.repeat(20)}]\n`); + console.log(`State ${i + 1}/${TARGET_STATES.length}: ${state}`); + const churches = await scraper.scrapeState(state); + stats.total += churches.length; + console.log(`\n Saving ${churches.length} churches from ${state} to database...`); + for (const church of churches) { + const saved = await saveChurch(church, seenIds); + if (saved) stats.saved++; + else stats.errors++; + } + console.log(`\n Resting 5 minutes before next state...\n`); + await new Promise(resolve => setTimeout(resolve, 300000)); + } + } finally { + await scraper.close(); + await prisma.$disconnect(); + } + + console.log('\n' + '='.repeat(70)); + console.log('SUMMARY'); + console.log('='.repeat(70)); + console.log(`Total scraped: ${stats.total}`); + console.log(`Saved: ${stats.saved}`); + console.log(`Errors: ${stats.errors}`); + console.log('='.repeat(70) + '\n'); +} + +main().catch(console.error); diff --git a/scripts/setup-diocese.ts b/scripts/setup-diocese.ts new file mode 100755 index 0000000..1393086 --- /dev/null +++ b/scripts/setup-diocese.ts @@ -0,0 +1,328 @@ +#!/usr/bin/env tsx +/** + * Interactive helper to configure a new diocese for scraping + * + * Usage: + * npx tsx scripts/setup-diocese.ts --url https://bistum-mainz.de/pfarreien --country DE --language de + * npx tsx scripts/setup-diocese.ts --url https://diocese-paris.fr/paroisses --country FR --language fr + * npx tsx scripts/setup-diocese.ts --list # List all configured dioceses + * npx tsx scripts/setup-diocese.ts --test # Test scraping a diocese + */ + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +import { Pool } from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import { DioceseDirectoryScraper, DioceseScrapeConfig } from '../src/scrapers/diocese-directory-scraper'; +import readline from 'readline'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +function log(msg: string) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function logError(msg: string) { + console.error(`[${new Date().toISOString()}] ERROR: ${msg}`); +} + +function ask(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(question, answer => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function listDioceses() { + const dioceses = await prisma.diocese.findMany({ + orderBy: [{ country: 'asc' }, { name: 'asc' }], + }); + + if (dioceses.length === 0) { + log('No dioceses configured yet.'); + return; + } + + console.log('\nConfigured Dioceses:'); + console.log('─'.repeat(100)); + console.log( + 'ID'.padEnd(38) + + 'Name'.padEnd(30) + + 'Country'.padEnd(10) + + 'Active'.padEnd(8) + + 'Churches'.padEnd(10) + + 'Last Scraped' + ); + console.log('─'.repeat(100)); + + for (const d of dioceses) { + console.log( + d.id.padEnd(38) + + d.name.substring(0, 28).padEnd(30) + + d.country.padEnd(10) + + (d.active ? 'Yes' : 'No').padEnd(8) + + String(d.churchCount).padEnd(10) + + (d.lastScrapedAt ? d.lastScrapedAt.toISOString().split('T')[0] : 'Never') + ); + } + + console.log('─'.repeat(100)); + console.log(`Total: ${dioceses.length} dioceses`); +} + +async function testDiocese(dioceseId: string) { + const diocese = await prisma.diocese.findUnique({ where: { id: dioceseId } }); + if (!diocese) { + logError(`Diocese not found: ${dioceseId}`); + return; + } + + if (!diocese.directoryUrl) { + logError(`Diocese ${diocese.name} has no directory URL`); + return; + } + + const config = diocese.scrapeConfig as DioceseScrapeConfig | null; + if (!config?.selectors) { + logError(`Diocese ${diocese.name} has no scrape config`); + return; + } + + log(`Testing diocese: ${diocese.name}`); + log(`Directory URL: ${diocese.directoryUrl}`); + log(''); + + const scraper = new DioceseDirectoryScraper(); + try { + const parishes = await scraper.scrapeDirectory(diocese.directoryUrl, config); + + log(`\nDiscovered ${parishes.length} parishes:\n`); + for (const p of parishes.slice(0, 10)) { + console.log(` ${p.name}`); + console.log(` URL: ${p.url}`); + if (p.address) console.log(` Address: ${p.address}`); + if (p.city) console.log(` City: ${p.city}`); + console.log(''); + } + + if (parishes.length > 10) { + console.log(` ... and ${parishes.length - 10} more`); + } + } finally { + await scraper.close(); + } +} + +async function setupDiocese(url: string, country: string, language: string) { + log(`Setting up diocese from: ${url}`); + log(`Country: ${country}, Language: ${language}`); + + // Ask for diocese name + const name = await ask('\nDiocese name (e.g. "Bistum Mainz"): '); + if (!name) { + logError('Name is required'); + return; + } + + // Check if already exists + const existing = await prisma.diocese.findFirst({ + where: { name, country }, + }); + if (existing) { + logError(`Diocese "${name}" already exists in ${country} (ID: ${existing.id})`); + return; + } + + // Probe the page structure + log('\nProbing page structure...'); + const scraper = new DioceseDirectoryScraper(); + await scraper.init(); + + try { + const page = (scraper as any).page; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + + // Analyze page - count links and common patterns + const analysis = await page.evaluate(() => { + const links = Array.from(document.querySelectorAll('a')); + const linkPatterns: Record = {}; + + for (const link of links) { + const href = link.href; + if (!href) continue; + // Extract pattern from URL path + try { + const path = new URL(href).pathname; + const segments = path.split('/').filter(Boolean); + if (segments.length >= 1) { + const pattern = '/' + segments.slice(0, -1).join('/') + '/*'; + linkPatterns[pattern] = (linkPatterns[pattern] || 0) + 1; + } + } catch { /* ignore */ } + } + + // Find most common list-like elements + const listSelectors = [ + 'ul li', 'ol li', 'div.parish', 'div.item', 'article', + 'tr', '.card', '.entry', '.listing', '.result', + ]; + + const selectorCounts: Record = {}; + for (const sel of listSelectors) { + selectorCounts[sel] = document.querySelectorAll(sel).length; + } + + return { + title: document.title, + totalLinks: links.length, + linkPatterns: Object.entries(linkPatterns) + .sort(([, a], [, b]) => b - a) + .slice(0, 10), + selectorCounts, + bodyTextLength: document.body?.textContent?.length || 0, + }; + }); + + console.log(`\nPage: ${analysis.title}`); + console.log(`Total links: ${analysis.totalLinks}`); + console.log(`\nMost common link patterns:`); + for (const [pattern, count] of analysis.linkPatterns) { + console.log(` ${pattern}: ${count} links`); + } + console.log(`\nElement counts:`); + for (const [sel, count] of Object.entries(analysis.selectorCounts)) { + if (count > 0) console.log(` ${sel}: ${count}`); + } + + // Ask for selectors + console.log('\nNow configure CSS selectors for this diocese.\n'); + + const parishList = await ask('Parish list container selector (e.g. "ul.parishes li", ".parish-item"): '); + const parishLink = await ask('Parish link selector within container (e.g. "a", "a.parish-link"): '); + const parishName = await ask('Parish name selector (leave empty to use link text): ') || undefined; + const parishAddress = await ask('Address selector (leave empty if none): ') || undefined; + const parishCity = await ask('City selector (leave empty if none): ') || undefined; + const pagination = await ask('Pagination "next" selector (leave empty if none): ') || undefined; + const urlPatternStr = await ask('URL pattern regex (leave empty for all): ') || undefined; + const waitForSelector = await ask('Wait for selector (leave empty if not needed): ') || undefined; + + const scrapeConfig: DioceseScrapeConfig = { + selectors: { + parishList, + parishLink, + parishName, + parishAddress, + parishCity, + pagination, + }, + urlPattern: urlPatternStr, + waitForSelector, + maxPages: 50, + scheduleInDirectory: false, + }; + + // Test the config + console.log('\nTesting selectors...'); + const testResults = await page.$$eval( + parishList, + (elements: Element[], linkSel: string) => { + return elements.slice(0, 5).map(el => { + const link = el.querySelector(linkSel); + return { + name: link?.textContent?.trim() || el.textContent?.trim()?.substring(0, 80) || '(empty)', + url: link?.getAttribute('href') || '(no link)', + }; + }); + }, + parishLink + ); + + console.log(`\nTest extraction (first 5):`); + for (const r of testResults) { + console.log(` ${r.name}`); + console.log(` -> ${r.url}`); + } + + const confirm = await ask('\nSave this configuration? (yes/no): '); + if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') { + log('Cancelled.'); + return; + } + + // Save to database + const diocese = await prisma.diocese.create({ + data: { + name, + country, + language, + website: new URL(url).origin, + directoryUrl: url, + scrapeConfig: scrapeConfig as any, + active: true, + }, + }); + + log(`\nDiocese saved! ID: ${diocese.id}`); + log(`Run: npx tsx scripts/scrape-diocese-directory.ts --diocese ${diocese.id} --dry-run`); + } finally { + await scraper.close(); + } +} + +async function main() { + const args = process.argv.slice(2); + + if (args.includes('--list')) { + await listDioceses(); + await prisma.$disconnect(); + await pool.end(); + return; + } + + const testIdx = args.indexOf('--test'); + if (testIdx !== -1) { + await testDiocese(args[testIdx + 1]); + await prisma.$disconnect(); + await pool.end(); + return; + } + + const urlIdx = args.indexOf('--url'); + const countryIdx = args.indexOf('--country'); + const langIdx = args.indexOf('--language'); + + if (urlIdx === -1 || countryIdx === -1) { + console.log('Usage:'); + console.log(' npx tsx scripts/setup-diocese.ts --url --country --language '); + console.log(' npx tsx scripts/setup-diocese.ts --list'); + console.log(' npx tsx scripts/setup-diocese.ts --test '); + console.log(''); + console.log('Examples:'); + console.log(' npx tsx scripts/setup-diocese.ts --url https://bistum-mainz.de/pfarreien --country DE --language de'); + console.log(' npx tsx scripts/setup-diocese.ts --url https://diocese-paris.fr/paroisses --country FR --language fr'); + await prisma.$disconnect(); + await pool.end(); + return; + } + + const url = args[urlIdx + 1]; + const country = args[countryIdx + 1]; + const language = langIdx !== -1 ? args[langIdx + 1] : country.toLowerCase(); + + await setupDiocese(url, country, language); + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((error) => { + logError(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/scripts/test-edge-cases.ts b/scripts/test-edge-cases.ts new file mode 100644 index 0000000..2b05a90 --- /dev/null +++ b/scripts/test-edge-cases.ts @@ -0,0 +1,397 @@ +#!/usr/bin/env tsx +/** + * Comprehensive edge case test suite for the international mass scraper + * + * This test suite validates all edge cases discovered and fixed during development: + * 1. Day range expansion (Monday-Friday, wtorek-sobota, etc.) + * 2. Office hours filtering (öffnungszeiten, horario, kancelaria, etc.) + * 3. Short abbreviation word boundaries (pn, cz, n in Polish) + * 4. Invalid time filtering (00:00-04:59) + * 5. Deduplication (same schedule appearing multiple times) + * 6. Context-based scoring (mass schedule vs office hours) + * 7. "Closed" notice filtering (nieczynna, fermé, cerrado, etc.) + */ + +import { GenericScraper } from '../src/scrapers/strategies/generic'; + +interface EdgeCaseTest { + name: string; + url: string; + country: string; + language: string; + edgeCases: string[]; + expectations: { + minSchedules?: number; + maxSchedules?: number; + shouldHaveDays?: number[]; // 0=Sun, 1=Mon, etc. + shouldNotHaveTimes?: string[]; // Invalid times that should be filtered + shouldHaveTimes?: string[]; // Valid times that should be found + }; + knownIssues?: string[]; +} + +const edgeCaseTests: EdgeCaseTest[] = [ + // POLISH - Day ranges, office hours, short abbreviations + { + name: 'Parafia Lubojna (PL)', + url: 'http://parafialubojna.pl', + country: 'PL', + language: 'Polish', + edgeCases: [ + 'Day range: "wtorek - sobota" (Tuesday-Saturday)', + 'Office hours: "kancelaria czynna" with times', + 'Short abbreviations: "pn", "cz", "n" in words like "sierpniu", "uroczystości"', + '"Closed" notice: "nieczynna: niedziela, poniedziałek"', + 'Space-separated times: "8 00", "9 30", "18 00"', + ], + expectations: { + minSchedules: 10, + maxSchedules: 10, + shouldHaveDays: [0, 1, 2, 3, 4, 5, 6], // All 7 days + shouldHaveTimes: ['08:00', '09:30', '11:00', '16:00', '18:00'], + shouldNotHaveTimes: ['18:30', '19:00', '09:00'], // Office hours times + }, + }, + + // GERMAN - Office hours, Uhr format, duplicates + { + name: 'St. Peter, Munich (DE)', + url: 'https://www.alterpeter.de/', + country: 'DE', + language: 'German', + edgeCases: [ + 'Office hours: "öffnungszeiten im pfarrbüro: montag bis donnerstag 9.00 – 12.00"', + 'Day range: "montag bis donnerstag" (Monday to Thursday)', + 'Uhr time format: "10:00 uhr", "17.15 Uhr"', + 'Invalid time: "00 uhr" from fragmented "10:00 uhr"', + 'Duplicates: Same schedule in current week + general schedule', + 'Multi-church parish: Different churches with different times', + ], + expectations: { + minSchedules: 10, + maxSchedules: 20, + shouldHaveDays: [0, 6], // At minimum Sunday and Saturday + shouldNotHaveTimes: ['09:00', '12:00', '14:00', '16:00', '00:00'], // Office hours + invalid + }, + }, + + // ITALIAN - Period separator + { + name: 'Duomo di Milano (IT)', + url: 'https://www.duomomilano.it/', + country: 'IT', + language: 'Italian', + edgeCases: [ + 'Period separator: "18.30", "9.00"', + 'Day ranges: "da lunedì a venerdì"', + 'Office hours: "orari" or "ufficio"', + ], + expectations: { + minSchedules: 10, + maxSchedules: 25, + shouldHaveDays: [0, 1, 2, 3, 4, 5, 6], // All days likely + }, + }, + + // SPANISH - Day ranges with "a" + { + name: 'Sagrada Família, Barcelona (ES)', + url: 'https://sagradafamilia.org/', + country: 'ES', + language: 'Spanish', + edgeCases: [ + 'Day ranges: "de lunes a viernes"', + 'Office hours: "horario de oficina"', + ], + expectations: { + minSchedules: 5, + maxSchedules: 15, + }, + knownIssues: [ + 'Tourist site, may have non-standard schedule format', + 'Some days showing only 1-2 masses', + ], + }, + + // CZECH - Minimal schedules + { + name: 'Chrám sv. Víta, Prague (CZ)', + url: 'https://www.katedralasvatehovita.cz/', + country: 'CZ', + language: 'Czech', + edgeCases: [ + 'Czech day names and time formats', + 'Limited schedule (cathedral, not parish)', + ], + expectations: { + minSchedules: 1, + maxSchedules: 10, + }, + }, + + // HUNGARIAN - Suffix-based day ranges + { + name: 'Szent István Bazilika, Budapest (HU)', + url: 'https://www.bazilika.biz/', + country: 'HU', + language: 'Hungarian', + edgeCases: [ + 'Hungarian day names', + 'Day range suffixes: "-tól", "-től"', + 'Limited weekday schedule', + ], + expectations: { + minSchedules: 3, + maxSchedules: 10, + shouldHaveDays: [1, 2, 3, 4, 5], // Weekdays + }, + }, +]; + +interface TestResult { + name: string; + passed: boolean; + scheduleCount: number; + issues: string[]; + edgeCasesValidated: string[]; +} + +async function runEdgeCaseTest(test: EdgeCaseTest, scraper: GenericScraper): Promise { + const result: TestResult = { + name: test.name, + passed: true, + scheduleCount: 0, + issues: [], + edgeCasesValidated: [], + }; + + try { + scraper.setCountry(test.country); + const scrapeResult = await scraper.scrape(test.url); + + if (!scrapeResult.success) { + result.passed = false; + result.issues.push(`Scrape failed: ${scrapeResult.error}`); + return result; + } + + result.scheduleCount = scrapeResult.schedules.length; + + // Validate schedule count + if (test.expectations.minSchedules && result.scheduleCount < test.expectations.minSchedules) { + result.passed = false; + result.issues.push( + `Too few schedules: ${result.scheduleCount} < ${test.expectations.minSchedules}` + ); + } + + if (test.expectations.maxSchedules && result.scheduleCount > test.expectations.maxSchedules) { + result.passed = false; + result.issues.push( + `Too many schedules: ${result.scheduleCount} > ${test.expectations.maxSchedules}` + ); + } + + // Validate days covered + if (test.expectations.shouldHaveDays) { + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const foundDays = new Set(scrapeResult.schedules.map(s => s.dayOfWeek)); + for (const day of test.expectations.shouldHaveDays) { + if (!foundDays.has(day)) { + result.passed = false; + result.issues.push(`Missing expected day: ${dayNames[day]}`); + } else { + result.edgeCasesValidated.push(`✓ Found ${dayNames[day]}`); + } + } + } + + // Validate invalid times are NOT present + if (test.expectations.shouldNotHaveTimes) { + const foundTimes = new Set(scrapeResult.schedules.map(s => s.time)); + for (const time of test.expectations.shouldNotHaveTimes) { + if (foundTimes.has(time)) { + result.passed = false; + result.issues.push(`Found invalid time that should be filtered: ${time}`); + } else { + result.edgeCasesValidated.push(`✓ Filtered out ${time}`); + } + } + } + + // Validate expected times ARE present + if (test.expectations.shouldHaveTimes) { + const foundTimes = new Set(scrapeResult.schedules.map(s => s.time)); + for (const time of test.expectations.shouldHaveTimes) { + if (!foundTimes.has(time)) { + result.passed = false; + result.issues.push(`Missing expected time: ${time}`); + } else { + result.edgeCasesValidated.push(`✓ Found ${time}`); + } + } + } + + // Check for duplicates (should be none after deduplication) + const uniqueKeys = new Set(); + const duplicates: string[] = []; + for (const schedule of scrapeResult.schedules) { + const key = `${schedule.dayOfWeek}-${schedule.time}`; + if (uniqueKeys.has(key)) { + duplicates.push(key); + } else { + uniqueKeys.add(key); + } + } + + if (duplicates.length > 0) { + result.passed = false; + result.issues.push(`Found ${duplicates.length} duplicate schedules: ${duplicates.join(', ')}`); + } else { + result.edgeCasesValidated.push('✓ No duplicates'); + } + + // Check for invalid early morning times (00:00-04:59) + const invalidTimes = scrapeResult.schedules.filter(s => { + const [hours] = s.time.split(':').map(Number); + return hours >= 0 && hours <= 4; + }); + + if (invalidTimes.length > 0) { + result.passed = false; + result.issues.push( + `Found ${invalidTimes.length} invalid early morning times: ${invalidTimes.map(t => t.time).join(', ')}` + ); + } else { + result.edgeCasesValidated.push('✓ No invalid times (00:00-04:59)'); + } + + } catch (error) { + result.passed = false; + result.issues.push(`Exception: ${error instanceof Error ? error.message : String(error)}`); + } + + return result; +} + +async function main() { + console.log('🧪 EDGE CASE TEST SUITE FOR INTERNATIONAL MASS SCRAPER'); + console.log('='.repeat(80)); + console.log(''); + + const scraper = new GenericScraper(); + await scraper.init(); + + const results: TestResult[] = []; + let passCount = 0; + let failCount = 0; + + for (const test of edgeCaseTests) { + console.log(`\n📍 Testing: ${test.name} (${test.language})`); + console.log(` URL: ${test.url}`); + console.log(` Edge cases to validate:`); + for (const edgeCase of test.edgeCases) { + console.log(` • ${edgeCase}`); + } + + const result = await runEdgeCaseTest(test, scraper); + results.push(result); + + if (result.passed) { + passCount++; + console.log(`\n ✅ PASSED (${result.scheduleCount} schedules)`); + } else { + failCount++; + console.log(`\n ❌ FAILED (${result.scheduleCount} schedules)`); + } + + if (result.edgeCasesValidated.length > 0) { + console.log(`\n Edge cases validated:`); + for (const validation of result.edgeCasesValidated) { + console.log(` ${validation}`); + } + } + + if (result.issues.length > 0) { + console.log(`\n ⚠️ Issues:`); + for (const issue of result.issues) { + console.log(` • ${issue}`); + } + } + + if (test.knownIssues && test.knownIssues.length > 0) { + console.log(`\n ℹ️ Known issues:`); + for (const issue of test.knownIssues) { + console.log(` • ${issue}`); + } + } + + // Brief delay between tests + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + await scraper.close(); + + // Summary + console.log('\n\n' + '='.repeat(80)); + console.log('📊 TEST SUMMARY'); + console.log('='.repeat(80)); + console.log(`Total tests: ${results.length}`); + console.log(`✅ Passed: ${passCount}`); + console.log(`❌ Failed: ${failCount}`); + console.log(`Success rate: ${((passCount / results.length) * 100).toFixed(1)}%`); + + // Detailed results table + console.log('\n' + '-'.repeat(80)); + console.log('Test | Status | Schedules | Issues'); + console.log('-'.repeat(80)); + for (const result of results) { + const status = result.passed ? '✅ PASS' : '❌ FAIL'; + const name = result.name.padEnd(33); + const schedules = result.scheduleCount.toString().padStart(9); + const issues = result.issues.length.toString(); + console.log(`${name} | ${status} | ${schedules} | ${issues}`); + } + console.log('-'.repeat(80)); + + // Edge case coverage summary + console.log('\n📋 EDGE CASE COVERAGE:'); + console.log(''); + console.log('1. Day Range Expansion:'); + console.log(' ✓ Polish: "wtorek - sobota"'); + console.log(' ✓ German: "montag bis donnerstag"'); + console.log(' ✓ Italian: "da lunedì a venerdì"'); + console.log(' ✓ Spanish: "de lunes a viernes"'); + console.log(''); + console.log('2. Office Hours Filtering:'); + console.log(' ✓ German: "öffnungszeiten im pfarrbüro"'); + console.log(' ✓ Polish: "kancelaria czynna"'); + console.log(' ✓ Spanish: "horario de oficina"'); + console.log(' ✓ Italian: "orari" / "ufficio"'); + console.log(''); + console.log('3. Short Abbreviation Word Boundaries:'); + console.log(' ✓ Polish: "pn", "cz", "n" (prevented false matches)'); + console.log(''); + console.log('4. Invalid Time Filtering:'); + console.log(' ✓ Filtered: 00:00-04:59 (unrealistic mass times)'); + console.log(' ✓ German "00 uhr" fragments filtered'); + console.log(''); + console.log('5. Deduplication:'); + console.log(' ✓ Same day+time appearing multiple times on page'); + console.log(''); + console.log('6. "Closed" Notice Filtering:'); + console.log(' ✓ Polish: "nieczynna: niedziela, poniedziałek"'); + console.log(' ✓ Multi-language: fermé, cerrado, geschlossen, chiuso'); + console.log(''); + console.log('7. Time Format Support:'); + console.log(' ✓ AM/PM: "8:30 AM", "8 PM"'); + console.log(' ✓ 24-hour: "18:00", "8:30"'); + console.log(' ✓ French/Portuguese: "18h30", "8h"'); + console.log(' ✓ German: "17 Uhr", "17:00 Uhr"'); + console.log(' ✓ Italian: "18.30"'); + console.log(' ✓ Polish: "8 00", "18 00"'); + + process.exit(failCount > 0 ? 1 : 0); +} + +main().catch(console.error); diff --git a/scripts/test-scraper.ts b/scripts/test-scraper.ts new file mode 100644 index 0000000..7df2550 --- /dev/null +++ b/scripts/test-scraper.ts @@ -0,0 +1,152 @@ +import { GenericScraper } from '../src/scrapers/strategies/generic'; +import { getScraper } from '../src/scrapers/registry'; +import type { BaseScraper, ScrapeResult } from '../src/scrapers/base-scraper'; + +const TEST_URL = process.argv[2] || 'https://www.saintpatrickscathedral.org/masses'; + +// Parse --country flag from CLI args +const countryFlagIndex = process.argv.indexOf('--country'); +const COUNTRY_CODE = countryFlagIndex !== -1 ? process.argv[countryFlagIndex + 1] : null; + +// Parse --lang flag from CLI args (e.g., --lang english) +const langFlagIndex = process.argv.indexOf('--lang'); +const LANG = langFlagIndex !== -1 ? process.argv[langFlagIndex + 1] : null; + +const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +async function main() { + console.log('\n' + '='.repeat(70)); + console.log('NEARESTMASS SCRAPER TEST'); + console.log('='.repeat(70)); + console.log(`\nURL: ${TEST_URL}`); + console.log(`Country: ${COUNTRY_CODE || '(auto-detect from )'}`); + console.log(`Scraper: ${LANG || 'generic'}`); + console.log(`Time: ${new Date().toISOString()}`); + console.log('\n' + '-'.repeat(70)); + + let scraper: BaseScraper; + + if (LANG) { + scraper = getScraper(LANG); + console.log(`\n Using ${LANG} scraper`); + } else { + scraper = new GenericScraper(); + } + + try { + console.log('\n[1/4] Initializing browser...'); + await scraper.init(); + console.log(' ✓ Browser ready'); + + if (COUNTRY_CODE && scraper instanceof GenericScraper) { + scraper.setCountry(COUNTRY_CODE); + console.log(` Country set to: ${COUNTRY_CODE}`); + } + + console.log('\n[2/4] Fetching page...'); + const startTime = Date.now(); + const result: ScrapeResult = await scraper.scrape(TEST_URL); + const elapsed = Date.now() - startTime; + console.log(` ✓ Page loaded in ${elapsed}ms`); + + console.log('\n[3/4] Parsing results...'); + console.log(` Status: ${result.success ? '✓ SUCCESS' : '✗ FAILED'}`); + console.log(` Schedules found: ${result.schedules.length}`); + + if (result.detectedLanguage) { + console.log(` Detected language: ${result.detectedLanguage}`); + } + + if (result.churchData) { + console.log('\n Church Data:'); + if (result.churchData.phone) console.log(` Phone: ${result.churchData.phone}`); + if (result.churchData.email) console.log(` Email: ${result.churchData.email}`); + if (result.churchData.pastorName) console.log(` Pastor: ${result.churchData.pastorName}`); + if (result.churchData.diocese) console.log(` Diocese: ${result.churchData.diocese}`); + } + + if (result.error) { + console.log(` Error: ${result.error}`); + } + + if (result.schedules.length > 0) { + console.log('\n' + '-'.repeat(70)); + console.log('PARSED MASS SCHEDULES'); + console.log('-'.repeat(70)); + + const byDay: Record = {}; + for (const schedule of result.schedules) { + if (!byDay[schedule.dayOfWeek]) { + byDay[schedule.dayOfWeek] = []; + } + byDay[schedule.dayOfWeek].push(schedule); + } + + for (let day = 0; day < 7; day++) { + const schedules = byDay[day]; + if (schedules && schedules.length > 0) { + console.log(`\n${DAY_NAMES[day]}:`); + for (const s of schedules) { + const parts = [ + ` ${s.time}`, + s.language && s.language !== 'English' ? `(${s.language})` : '', + s.massType ? `[${s.massType}]` : '', + s.notes ? `- ${s.notes}` : '', + ].filter(Boolean); + console.log(parts.join(' ')); + } + } + } + } + + if (result.rawHtml) { + console.log('\n' + '-'.repeat(70)); + console.log('RAW TEXT PREVIEW (first 1000 chars, stripped of HTML)'); + console.log('-'.repeat(70)); + + const textOnly = result.rawHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/[\u2013\u2014]/g, '-') + .replace(/\s+/g, ' ') + .trim() + .substring(0, 1000); + + console.log('\n' + textOnly); + + if (result.rawHtml.length > 1000) { + console.log('\n... (truncated)'); + } + } + + console.log('\n' + '='.repeat(70)); + console.log('SUMMARY'); + console.log('='.repeat(70)); + console.log(`URL: ${TEST_URL}`); + console.log(`Scraper: ${LANG || 'generic'}`); + console.log(`Country: ${COUNTRY_CODE || '(auto-detected)'}`); + console.log(`Language: ${result.detectedLanguage || '(unknown)'}`); + console.log(`Success: ${result.success ? 'Yes' : 'No'}`); + console.log(`Schedules: ${result.schedules.length}`); + console.log(`HTML Size: ${result.rawHtml ? Math.round(result.rawHtml.length / 1024) + ' KB' : 'N/A'}`); + + if (result.schedules.length > 0) { + const days = [...new Set(result.schedules.map(s => s.dayOfWeek))]; + const languages = [...new Set(result.schedules.map(s => s.language || 'English'))]; + console.log(`Days: ${days.map(d => DAY_NAMES[d]).join(', ')}`); + console.log(`Languages: ${languages.join(', ')}`); + } + + console.log('='.repeat(70) + '\n'); + + } catch (error) { + console.error('\n[ERROR]', error); + } finally { + console.log('[4/4] Closing browser...'); + await scraper.close(); + console.log(' ✓ Done\n'); + } +} + +main().catch(console.error); diff --git a/scripts/test-url-discovery.ts b/scripts/test-url-discovery.ts new file mode 100644 index 0000000..bd39573 --- /dev/null +++ b/scripts/test-url-discovery.ts @@ -0,0 +1,135 @@ +import { discoverMassScheduleUrl } from '../src/scrapers/url-discovery'; + +const TEST_SITES = [ + 'https://www.saintpatrickscathedral.org', + 'https://www.holynamecathedral.org', + 'https://www.olacathedral.org', +]; + +const CONFIDENCE_ICONS: Record = { + high: '🟢', + medium: '🟡', + low: '🔴', +}; + +const METHOD_DESCRIPTIONS: Record = { + pattern: 'Found via URL pattern matching', + link: 'Found via link crawling', + homepage: 'Fell back to homepage', +}; + +async function testSingleUrl(url: string) { + console.log('\n' + '='.repeat(70)); + console.log('NEARESTMASS URL DISCOVERY TEST'); + console.log('='.repeat(70)); + console.log(`\nURL: ${url}`); + console.log(`Time: ${new Date().toISOString()}`); + console.log('\n' + '-'.repeat(70)); + + console.log('\n[1/2] Discovering mass schedule URL...'); + const startTime = Date.now(); + const result = await discoverMassScheduleUrl(url); + const elapsed = Date.now() - startTime; + console.log(` ✓ Discovery completed in ${elapsed}ms`); + + console.log('\n[2/2] Results:'); + console.log(` Discovered URL: ${result.url}`); + console.log(` Method: ${result.method} (${METHOD_DESCRIPTIONS[result.method]})`); + console.log(` Confidence: ${CONFIDENCE_ICONS[result.confidence]} ${result.confidence}`); + + console.log('\n' + '='.repeat(70)); + console.log('SUMMARY'); + console.log('='.repeat(70)); + console.log(`Input: ${url}`); + console.log(`Output: ${result.url}`); + console.log(`Method: ${result.method}`); + console.log(`Confidence: ${result.confidence}`); + console.log(`Time: ${elapsed}ms`); + console.log('='.repeat(70) + '\n'); +} + +async function testMultipleSites() { + console.log('\n' + '='.repeat(70)); + console.log('NEARESTMASS URL DISCOVERY TEST (BATCH)'); + console.log('='.repeat(70)); + console.log(`\nTesting ${TEST_SITES.length} sites...`); + console.log(`Time: ${new Date().toISOString()}`); + + const results: Array<{ + site: string; + url: string; + method: string; + confidence: string; + elapsed: number; + }> = []; + + for (let i = 0; i < TEST_SITES.length; i++) { + const site = TEST_SITES[i]; + console.log('\n' + '-'.repeat(70)); + console.log(`[${i + 1}/${TEST_SITES.length}] Testing: ${site}`); + console.log('-'.repeat(70)); + + const startTime = Date.now(); + const result = await discoverMassScheduleUrl(site); + const elapsed = Date.now() - startTime; + + console.log(`\n Discovered URL: ${result.url}`); + console.log(` Method: ${result.method} (${METHOD_DESCRIPTIONS[result.method]})`); + console.log(` Confidence: ${CONFIDENCE_ICONS[result.confidence]} ${result.confidence}`); + console.log(` Time: ${elapsed}ms`); + + results.push({ + site, + url: result.url, + method: result.method, + confidence: result.confidence, + elapsed, + }); + + // Rate limiting between sites + if (i < TEST_SITES.length - 1) { + console.log('\n Waiting 2s before next site...'); + await new Promise((r) => setTimeout(r, 2000)); + } + } + + // Summary table + console.log('\n' + '='.repeat(70)); + console.log('SUMMARY'); + console.log('='.repeat(70)); + + const highCount = results.filter((r) => r.confidence === 'high').length; + const mediumCount = results.filter((r) => r.confidence === 'medium').length; + const lowCount = results.filter((r) => r.confidence === 'low').length; + const totalTime = results.reduce((sum, r) => sum + r.elapsed, 0); + + console.log(`\nSites tested: ${results.length}`); + console.log(`High conf: ${highCount} 🟢`); + console.log(`Medium conf: ${mediumCount} 🟡`); + console.log(`Low conf: ${lowCount} 🔴`); + console.log(`Total time: ${totalTime}ms`); + + console.log('\n' + '-'.repeat(70)); + console.log('RESULTS BY SITE'); + console.log('-'.repeat(70)); + + for (const r of results) { + console.log(`\n${r.site}`); + console.log(` → ${r.url}`); + console.log(` ${CONFIDENCE_ICONS[r.confidence]} ${r.confidence} via ${r.method}`); + } + + console.log('\n' + '='.repeat(70) + '\n'); +} + +async function main() { + const testUrl = process.argv[2]; + + if (testUrl) { + await testSingleUrl(testUrl); + } else { + await testMultipleSites(); + } +} + +main().catch(console.error); diff --git a/scripts/transfer-enriched-to-neon.ts b/scripts/transfer-enriched-to-neon.ts new file mode 100644 index 0000000..efe65af --- /dev/null +++ b/scripts/transfer-enriched-to-neon.ts @@ -0,0 +1,319 @@ +#!/usr/bin/env tsx +/** + * Transfer enriched church data from Synology NAS to Neon production + * + * This script transfers ONLY churches that have been enriched or scraped + * (have websites, phone numbers, or mass schedules) to reduce data transfer. + * + * Usage: + * npx tsx scripts/transfer-enriched-to-neon.ts # Dry run + * npx tsx scripts/transfer-enriched-to-neon.ts --execute # Actually transfer + */ + +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; +import dotenv from 'dotenv'; +import path from 'path'; + +interface TransferStats { + churchesProcessed: number; + churchesInserted: number; + churchesUpdated: number; + massSchedules: number; + confessionSchedules: number; + adorationSchedules: number; + errors: number; +} + +async function main() { + // Parse CLI arguments + const args = process.argv.slice(2); + const executeIndex = args.indexOf('--execute'); + const sinceIndex = args.indexOf('--since'); + const forceAllIndex = args.indexOf('--force-all'); + + const dryRun = executeIndex === -1; + const sinceTimestamp = sinceIndex !== -1 && args[sinceIndex + 1] + ? new Date(args[sinceIndex + 1]) + : null; + const forceAll = forceAllIndex !== -1; + + console.log('════════════════════════════════════════════════════════════'); + console.log(' Transfer Enriched Data: Synology NAS → Neon Production'); + console.log('════════════════════════════════════════════════════════════\n'); + + if (dryRun) { + console.log('🔍 DRY RUN MODE - No data will be written to Neon\n'); + } else { + console.log('⚠️ PRODUCTION MODE - Data will be written to Neon'); + console.log('Press Ctrl+C within 5 seconds to cancel...\n'); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + + if (forceAll) { + console.log('🔄 FORCE ALL MODE - Transferring all enriched churches\n'); + } else if (sinceTimestamp) { + console.log(`📅 INCREMENTAL MODE - Only churches modified since ${sinceTimestamp.toISOString()}\n`); + } else { + console.log('📅 AUTO INCREMENTAL MODE - Detecting last transfer timestamp...\n'); + } + + // Step 1: Connect to NAS database + console.log('[1/3] Connecting to Synology NAS database...'); + dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + + const nasPool = new Pool({ connectionString: process.env.DATABASE_URL }); + const nasAdapter = new PrismaPg(nasPool); + const nasPrisma = new PrismaClient({ adapter: nasAdapter }); + + try { + await nasPrisma.$connect(); + const nasUrl = process.env.DATABASE_URL?.split('@')[1]?.split('/')[0] || 'unknown'; + console.log(`✅ Connected to NAS: ${nasUrl}\n`); + + // Detect last transfer timestamp if not specified + let transferSince: Date | null = sinceTimestamp; + + if (!forceAll && !sinceTimestamp) { + // Auto-detect: find the most recent lastTransferredAt across all churches + const lastTransfer = await nasPrisma.church.findFirst({ + where: { lastTransferredAt: { not: null } }, + orderBy: { lastTransferredAt: 'desc' }, + select: { lastTransferredAt: true } + }); + + if (lastTransfer?.lastTransferredAt) { + transferSince = lastTransfer.lastTransferredAt; + console.log(`✅ Last transfer detected: ${transferSince.toISOString()}`); + console.log(` Will transfer churches modified after this time\n`); + } else { + console.log('ℹ️ No previous transfer detected - will transfer all enriched churches\n'); + } + } + + // Step 2: Export enriched churches from NAS + console.log('[2/3] Exporting enriched churches from NAS...'); + console.log('Criteria: Has website OR phone OR google_place_id OR mass schedules\n'); + + // Build WHERE clause + const whereClause: any = { + OR: [ + { website: { not: null } }, + { phone: { not: null } }, + { googlePlaceId: { not: null } }, + { massSchedules: { some: {} } }, + ] + }; + + // Add incremental filter if applicable + if (!forceAll && transferSince) { + whereClause.AND = { updatedAt: { gt: transferSince } }; + console.log(`🔄 Incremental filter: updatedAt > ${transferSince.toISOString()}\n`); + } + + const BATCH_SIZE = 200; + const totalCount = await nasPrisma.church.count({ where: whereClause }); + + console.log(`Found ${totalCount} enriched churches (will process in batches of ${BATCH_SIZE})\n`); + + if (totalCount === 0) { + console.log('⚠️ No enriched churches to transfer'); + await nasPrisma.$disconnect(); + return; + } + + // Step 3: Import to Neon + console.log('[3/3] Importing to Neon production database...'); + + // Load Neon credentials + dotenv.config({ path: path.resolve(process.cwd(), '.env.production'), override: true }); + + const neonPool = new Pool({ connectionString: process.env.DATABASE_URL }); + const neonAdapter = new PrismaPg(neonPool); + const neonPrisma = new PrismaClient({ adapter: neonAdapter }); + + try { + await neonPrisma.$connect(); + const neonUrl = process.env.DATABASE_URL?.split('@')[1]?.split('/')[0] || 'unknown'; + console.log(`✅ Connected to Neon: ${neonUrl}\n`); + + const stats: TransferStats = { + churchesProcessed: 0, + churchesInserted: 0, + churchesUpdated: 0, + massSchedules: 0, + confessionSchedules: 0, + adorationSchedules: 0, + errors: 0, + }; + + for (let skip = 0; skip < totalCount; skip += BATCH_SIZE) { + const churches = await nasPrisma.church.findMany({ + where: whereClause, + include: { + massSchedules: true, + confessionSchedules: true, + adorationSchedules: true, + }, + skip, + take: BATCH_SIZE, + orderBy: { id: 'asc' }, + }); + + console.log(`\nBatch ${Math.floor(skip / BATCH_SIZE) + 1}: processing ${churches.length} churches (${skip + 1}–${skip + churches.length} of ${totalCount})`); + + for (const church of churches) { + try { + stats.churchesProcessed++; + + const massSchedules = church.massSchedules || []; + const confessionSchedules = church.confessionSchedules || []; + const adorationSchedules = church.adorationSchedules || []; + + // Extract church data without relations (preserve lastTransferredAt) + const { massSchedules: _, confessionSchedules: __, adorationSchedules: ___, id, createdAt, updatedAt, lastTransferredAt, ...churchData } = church; + + if (!dryRun) { + // Check if church exists in Neon + const existing = await neonPrisma.church.findFirst({ + where: { + latitude: church.latitude, + longitude: church.longitude, + } + }); + + let resultId: string; + + if (existing) { + // Update existing church (only overwrite if NAS has better data) + await neonPrisma.church.update({ + where: { id: existing.id }, + data: { + website: churchData.website || existing.website, + phone: churchData.phone || existing.phone, + googlePlaceId: churchData.googlePlaceId || existing.googlePlaceId, + // Always update name, address if provided + name: churchData.name, + address: churchData.address || existing.address, + city: churchData.city || existing.city, + state: churchData.state || existing.state, + zip: churchData.zip || existing.zip, + massScheduleUrl: churchData.massScheduleUrl || existing.massScheduleUrl, + lastTransferredAt: new Date(), // Mark as transferred + } + }); + resultId = existing.id; + stats.churchesUpdated++; + + // Delete old schedules + await neonPrisma.massSchedule.deleteMany({ where: { churchId: existing.id } }); + await neonPrisma.confessionSchedule.deleteMany({ where: { churchId: existing.id } }); + await neonPrisma.adorationSchedule.deleteMany({ where: { churchId: existing.id } }); + + } else { + // Create new church + const newChurch = await neonPrisma.church.create({ + data: { + ...churchData, + lastTransferredAt: new Date(), // Mark as transferred + } + }); + resultId = newChurch.id; + stats.churchesInserted++; + } + + // Insert schedules + for (const schedule of massSchedules) { + const { id, createdAt, updatedAt, ...scheduleData } = schedule; + await neonPrisma.massSchedule.create({ + data: { ...scheduleData, churchId: resultId } + }); + stats.massSchedules++; + } + + for (const schedule of confessionSchedules) { + const { id, createdAt, updatedAt, ...scheduleData } = schedule; + await neonPrisma.confessionSchedule.create({ + data: { ...scheduleData, churchId: resultId } + }); + stats.confessionSchedules++; + } + + for (const schedule of adorationSchedules) { + const { id, createdAt, updatedAt, ...scheduleData } = schedule; + await neonPrisma.adorationSchedule.create({ + data: { ...scheduleData, churchId: resultId } + }); + stats.adorationSchedules++; + } + + // Update NAS record with transfer timestamp (after successful transfer to Neon) + await nasPrisma.church.update({ + where: { id: church.id }, + data: { lastTransferredAt: new Date() } + }); + } else { + // Dry run - just count + stats.massSchedules += massSchedules.length; + stats.confessionSchedules += confessionSchedules.length; + stats.adorationSchedules += adorationSchedules.length; + } + + if (stats.churchesProcessed % 100 === 0) { + console.log(`Progress: ${stats.churchesProcessed}/${totalCount} churches...`); + } + + } catch (error) { + stats.errors++; + console.error(`Error transferring ${church.name}:`, error instanceof Error ? error.message : error); + } + } + } // end batch loop + + console.log('\n════════════════════════════════════════════════════════════'); + console.log('Transfer Summary'); + console.log('════════════════════════════════════════════════════════════'); + if (!forceAll && transferSince) { + console.log(`Transfer mode: Incremental (since ${transferSince.toISOString()})`); + } else { + console.log(`Transfer mode: Full (all enriched churches)`); + } + console.log(`Churches processed: ${stats.churchesProcessed}`); + console.log(`Churches inserted: ${stats.churchesInserted}`); + console.log(`Churches updated: ${stats.churchesUpdated}`); + console.log(`Mass schedules: ${stats.massSchedules}`); + console.log(`Confession schedules: ${stats.confessionSchedules}`); + console.log(`Adoration schedules: ${stats.adorationSchedules}`); + console.log(`Errors: ${stats.errors}`); + console.log('════════════════════════════════════════════════════════════\n'); + + await neonPrisma.$disconnect(); + await nasPrisma.$disconnect(); + + if (dryRun) { + console.log('💡 This was a DRY RUN. To actually transfer to Neon, run:'); + console.log(' Incremental sync (default):'); + console.log(' npx tsx scripts/transfer-enriched-to-neon.ts --execute\n'); + console.log(' Transfer all enriched churches:'); + console.log(' npx tsx scripts/transfer-enriched-to-neon.ts --execute --force-all\n'); + console.log(' Transfer since specific date:'); + console.log(' npx tsx scripts/transfer-enriched-to-neon.ts --execute --since 2026-02-01T00:00:00Z\n'); + } else { + console.log('🎉 Data successfully transferred to Neon production!\n'); + } + + } catch (error) { + console.error('❌ Neon import failed:', error); + await neonPrisma.$disconnect(); + throw error; + } + + } catch (error) { + console.error('❌ Transfer failed:', error); + await nasPrisma.$disconnect(); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/src/app/api/admin/freesearch-log/route.ts b/src/app/api/admin/freesearch-log/route.ts new file mode 100644 index 0000000..54c2f6a --- /dev/null +++ b/src/app/api/admin/freesearch-log/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { validateAdminApiKey, unauthorizedResponse } from '@/lib/admin-auth'; +import { Prisma } from '@prisma/client'; + +const COUNTRY_KEYWORDS: Record = { + FR: 'paroisse', + DE: 'pfarrei', + ES: 'parroquia', + MX: 'parroquia', + PL: 'parafia', + BR: 'paroquia', + PT: 'paroquia', + IT: 'parrocchia', + CZ: 'farnost', + HU: 'plebania', + AR: 'parroquia', + CO: 'parroquia', + EC: 'parroquia', + PE: 'parroquia', + CL: 'parroquia', + VE: 'parroquia', + CR: 'parroquia', + SV: 'parroquia', + GT: 'parroquia', + CU: 'parroquia', + PA: 'parroquia', + BO: 'parroquia', + HN: 'parroquia', + BE: 'paroisse', + LU: 'paroisse', + CH: 'pfarrei', + NL: 'parochie', + SK: 'farnosť', + SI: 'župnija', +}; + +const STATES_COUNTRIES = new Set(['US', 'CA', 'AU', 'BR']); + +function buildSearchQuery(church: { + name: string; + city: string | null; + state: string | null; + country: string; +}): string { + const parts = [`"${church.name}"`]; + if (church.city) parts.push(church.city); + if (church.state && STATES_COUNTRIES.has(church.country)) parts.push(church.state); + const keyword = COUNTRY_KEYWORDS[church.country]; + if (keyword) parts.push(keyword); + parts.push('official website'); + return parts.join(' '); +} + +// GET /api/admin/freesearch-log — List FreeSearch results +export async function GET(request: NextRequest) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + const { searchParams } = new URL(request.url); + const filter = searchParams.get('filter') || 'all'; + const country = searchParams.get('country'); + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); + const offset = parseInt(searchParams.get('offset') || '0'); + + const where: Prisma.ChurchWhereInput = { + freeSearchedAt: { not: null }, + }; + + if (filter === 'found') { + where.hasWebsite = true; + where.website = { not: null }; + } else if (filter === 'not-found') { + where.OR = [{ hasWebsite: false }, { website: null }]; + } + + if (country) { + where.country = country; + } + + const [results, total] = await Promise.all([ + prisma.church.findMany({ + where, + select: { + id: true, + name: true, + city: true, + state: true, + country: true, + freeSearchedAt: true, + hasWebsite: true, + website: true, + }, + orderBy: { freeSearchedAt: 'desc' }, + take: limit, + skip: offset, + }), + prisma.church.count({ where }), + ]); + + return NextResponse.json({ + results: results.map((c) => ({ + id: c.id, + name: c.name, + city: c.city, + country: c.country, + searchQuery: buildSearchQuery(c), + freeSearchedAt: c.freeSearchedAt, + found: c.hasWebsite && c.website !== null, + website: c.website, + })), + total, + limit, + offset, + }); + } catch (error) { + console.error('Error fetching freesearch log:', error); + return NextResponse.json( + { error: 'Failed to fetch freesearch log' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/jobs/[jobId]/route.ts b/src/app/api/admin/jobs/[jobId]/route.ts new file mode 100644 index 0000000..a69b339 --- /dev/null +++ b/src/app/api/admin/jobs/[jobId]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { validateAdminApiKey, unauthorizedResponse } from '@/lib/admin-auth'; + +// GET /api/admin/jobs/[jobId] — Get detailed job status +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ jobId: string }> } +) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + const { jobId } = await params; + const job = await prisma.backgroundJob.findUnique({ + where: { id: jobId }, + }); + + if (!job) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + + return NextResponse.json({ job }); + } catch (error) { + console.error('Error fetching job:', error); + return NextResponse.json( + { error: 'Failed to fetch job' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/jobs/route.ts b/src/app/api/admin/jobs/route.ts new file mode 100644 index 0000000..4546289 --- /dev/null +++ b/src/app/api/admin/jobs/route.ts @@ -0,0 +1,155 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { validateAdminApiKey, unauthorizedResponse } from '@/lib/admin-auth'; + +// GET /api/admin/jobs — List all background jobs + church stats +export async function GET(request: NextRequest) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + // Get all jobs (most recent first) + const jobs = await prisma.backgroundJob.findMany({ + orderBy: { createdAt: 'desc' }, + take: 50, + }); + + // Church database stats + const [ + totalChurches, + withWebsites, + scraped, + withSchedules, + googlePlacesEnriched, + freeSearchSearched, + freeSearchFound, + ] = await Promise.all([ + prisma.church.count(), + prisma.church.count({ where: { hasWebsite: true } }), + prisma.church.count({ where: { lastScrapedAt: { not: null } } }), + prisma.church.count({ + where: { massSchedules: { some: {} } }, + }), + prisma.church.count({ where: { googlePlaceId: { not: null } } }), + prisma.church.count({ where: { freeSearchedAt: { not: null } } }), + prisma.church.count({ + where: { + freeSearchedAt: { not: null }, + hasWebsite: true, + }, + }), + ]); + + // Language breakdown + const languageGroups = await prisma.church.groupBy({ + by: ['websiteLanguage'], + _count: { id: true }, + where: { websiteLanguage: { not: null } }, + orderBy: { _count: { id: 'desc' } }, + }); + + const byLanguage: Record = {}; + for (const g of languageGroups) { + if (g.websiteLanguage) { + byLanguage[g.websiteLanguage] = g._count.id; + } + } + + return NextResponse.json({ + jobs, + stats: { + totalChurches, + withWebsites, + scraped, + withSchedules, + byLanguage, + enrichment: { + googlePlacesEnriched, + freeSearchSearched, + freeSearchFound, + }, + }, + }); + } catch (error) { + console.error('Error fetching jobs:', error); + return NextResponse.json( + { error: 'Failed to fetch jobs' }, + { status: 500 } + ); + } +} + +// POST /api/admin/jobs — Create a new pending job +export async function POST(request: NextRequest) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + const body = await request.json(); + const { type, language, config } = body; + + if (!type || !['scraper', 'freesearch-enrichment', 'reverse-geocode-enrichment'].includes(type)) { + return NextResponse.json( + { error: 'Invalid job type. Must be: scraper, freesearch-enrichment, or reverse-geocode-enrichment' }, + { status: 400 } + ); + } + + const job = await prisma.backgroundJob.create({ + data: { + type, + language: language || null, + status: 'pending', + config: config || null, + }, + }); + + return NextResponse.json({ job }, { status: 201 }); + } catch (error) { + console.error('Error creating job:', error); + return NextResponse.json( + { error: 'Failed to create job' }, + { status: 500 } + ); + } +} + +// PATCH /api/admin/jobs — Stop a running job +export async function PATCH(request: NextRequest) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + const body = await request.json(); + const { jobId, action } = body; + + if (!jobId || action !== 'stop') { + return NextResponse.json( + { error: 'Must provide jobId and action: "stop"' }, + { status: 400 } + ); + } + + const job = await prisma.backgroundJob.findUnique({ where: { id: jobId } }); + if (!job) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + + if (job.status !== 'running') { + return NextResponse.json( + { error: `Cannot stop job with status: ${job.status}` }, + { status: 400 } + ); + } + + const updated = await prisma.backgroundJob.update({ + where: { id: jobId }, + data: { status: 'stopping' }, + }); + + return NextResponse.json({ job: updated }); + } catch (error) { + console.error('Error stopping job:', error); + return NextResponse.json( + { error: 'Failed to stop job' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/scrape-log/route.ts b/src/app/api/admin/scrape-log/route.ts new file mode 100644 index 0000000..1ffb282 --- /dev/null +++ b/src/app/api/admin/scrape-log/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { validateAdminApiKey, unauthorizedResponse } from '@/lib/admin-auth'; +import { Prisma } from '@prisma/client'; + +// GET /api/admin/scrape-log — List recently scraped churches +export async function GET(request: NextRequest) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + const { searchParams } = new URL(request.url); + const filter = searchParams.get('filter') || 'all'; + const language = searchParams.get('language'); + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); + const offset = parseInt(searchParams.get('offset') || '0'); + + const where: Prisma.ChurchWhereInput = { + lastScrapedAt: { not: null }, + }; + + if (filter === 'success') { + where.massSchedules = { some: {} }; + } else if (filter === 'failed') { + where.massSchedules = { none: {} }; + } + + if (language) { + where.websiteLanguage = language; + } + + const [results, total] = await Promise.all([ + prisma.church.findMany({ + where, + select: { + id: true, + name: true, + website: true, + massScheduleUrl: true, + country: true, + city: true, + websiteLanguage: true, + lastScrapedAt: true, + scraperConfig: { + select: { + strategyName: true, + failureCount: true, + }, + }, + _count: { + select: { massSchedules: true }, + }, + }, + orderBy: { lastScrapedAt: 'desc' }, + take: limit, + skip: offset, + }), + prisma.church.count({ where }), + ]); + + return NextResponse.json({ + results: results.map((c) => ({ + id: c.id, + name: c.name, + website: c.website, + massScheduleUrl: c.massScheduleUrl, + country: c.country, + city: c.city, + websiteLanguage: c.websiteLanguage, + lastScrapedAt: c.lastScrapedAt, + strategy: c.scraperConfig?.strategyName || 'generic', + failureCount: c.scraperConfig?.failureCount || 0, + scheduleCount: c._count.massSchedules, + success: c._count.massSchedules > 0, + })), + total, + limit, + offset, + }); + } catch (error) { + console.error('Error fetching scrape log:', error); + return NextResponse.json( + { error: 'Failed to fetch scrape log' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/scraper-health/route.ts b/src/app/api/admin/scraper-health/route.ts new file mode 100644 index 0000000..6f0abb9 --- /dev/null +++ b/src/app/api/admin/scraper-health/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { validateAdminApiKey, unauthorizedResponse } from '@/lib/admin-auth'; +import { buildLanguageFilter } from '@/lib/scraper-service'; + +const LANGUAGES = [ + 'english', 'french', 'spanish', 'italian', 'german', + 'polish', 'portuguese', 'dutch', 'czech', 'hungarian', 'generic', +]; + +function formatDuration(ms: number): string { + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +// GET /api/admin/scraper-health — Quick health check for scraper pipeline +export async function GET(request: NextRequest) { + if (!validateAdminApiKey(request)) return unauthorizedResponse(); + + try { + const now = Date.now(); + const thirtyDaysAgo = new Date(now - 30 * 24 * 60 * 60 * 1000); + + // --- Throughput: count churches scraped in last 1h, 6h, 24h --- + const throughputPromise = Promise.all([ + prisma.church.count({ where: { lastScrapedAt: { gte: new Date(now - 1 * 3_600_000) } } }), + prisma.church.count({ where: { lastScrapedAt: { gte: new Date(now - 6 * 3_600_000) } } }), + prisma.church.count({ where: { lastScrapedAt: { gte: new Date(now - 24 * 3_600_000) } } }), + ]); + + // --- Running jobs --- + const runningJobsPromise = prisma.backgroundJob.findMany({ + where: { status: 'running', type: 'scraper' }, + select: { id: true, type: true, language: true, startedAt: true, processed: true }, + }); + + // --- Per-language queue counts --- + const baseWhere = { + claimed: false, + website: { not: null }, + OR: [ + { lastScrapedAt: null }, + { lastScrapedAt: { lt: thirtyDaysAgo } }, + ], + AND: [ + { + OR: [ + { scraperConfig: null }, + { scraperConfig: { failureCount: { lt: 5 } } }, + ], + }, + ], + }; + + const queuePromises = LANGUAGES.map(async (lang) => { + const filter = buildLanguageFilter(lang); + const count = await prisma.church.count({ + where: { + ...baseWhere, + AND: [...(baseWhere.AND as object[]), ...(filter ? [filter] : [])], + }, + }); + return [lang, count] as const; + }); + + // Run all queries concurrently + const [[last1h, last6h, last24h], runningJobs, queueResults] = await Promise.all([ + throughputPromise, + runningJobsPromise, + Promise.all(queuePromises), + ]); + + const queue: Record = {}; + for (const [lang, count] of queueResults) { + if (count > 0) queue[lang] = count; + } + + // Format running jobs + const jobs = runningJobs.map((job) => ({ + id: job.id, + type: job.type, + language: job.language, + startedAt: job.startedAt, + runningFor: job.startedAt ? formatDuration(now - job.startedAt.getTime()) : null, + processed: job.processed, + })); + + // Health check: unhealthy if any scraper running >6h with zero throughput in last hour + const hasStuckJob = runningJobs.some( + (job) => job.startedAt && (now - job.startedAt.getTime()) > 6 * 3_600_000 + ); + const healthy = !(hasStuckJob && last6h === 0); + const warning = !healthy + ? 'Scraper job running >6h with zero throughput in last 6 hours' + : null; + + return NextResponse.json({ + throughput: { last1h, last6h, last24h }, + runningJobs: jobs, + queue, + healthy, + warning, + }); + } catch (error) { + console.error('Error in scraper health:', error); + return NextResponse.json( + { error: 'Failed to get scraper health' }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..0483d19 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,57 @@ +@import "tailwindcss"; + +:root { + --sacred-gold: #D4AF37; + --soft-burgundy: #8B3A62; + --deep-purple: #4A2545; + --cream: #FAF8F3; + --warm-white: #FFFBF5; + + --background: var(--warm-white); + --foreground: var(--deep-purple); + --color-primary: var(--deep-purple); + --color-primary-light: var(--soft-burgundy); + --color-accent: var(--sacred-gold); + --color-success: #16a34a; + --color-warning: #ca8a04; + --color-error: #dc2626; + --color-card: #ffffff; + --color-card-border: rgba(212, 175, 55, 0.2); + --color-text-secondary: var(--soft-burgundy); + --color-text-muted: #8B6A7A; + --color-input-bg: #ffffff; + --color-input-border: rgba(74, 37, 69, 0.2); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --background: #1a0f19; + --foreground: var(--cream); + --color-primary: var(--sacred-gold); + --color-primary-light: #c9a030; + --color-card: #261a25; + --color-card-border: rgba(212, 175, 55, 0.15); + --color-text-secondary: #d4c0cb; + --color-text-muted: #a08a96; + --color-input-bg: #2d1f2c; + --color-input-border: rgba(212, 175, 55, 0.2); + } +} + +:root[data-theme="dark"] { + --background: #1a0f19; + --foreground: var(--cream); + --color-primary: var(--sacred-gold); + --color-primary-light: #c9a030; + --color-card: #261a25; + --color-card-border: rgba(212, 175, 55, 0.15); + --color-text-secondary: #d4c0cb; + --color-text-muted: #a08a96; + --color-input-bg: #2d1f2c; + --color-input-border: rgba(212, 175, 55, 0.2); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..c74f6be --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'ScraperControl', + robots: 'noindex, nofollow', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +