Spaces:
Sleeping
Sleeping
Deploy latest verified Turnitin worker
Browse files- .env.example +39 -0
- .gitignore +5 -0
- Dockerfile +42 -0
- README.md +7 -5
- migrate-last-completed-step.js +57 -0
- package-lock.json +1845 -0
- package.json +31 -0
- src/config.ts +76 -0
- src/cron/cleanup-reports.ts +99 -0
- src/cron/quota-check.ts +251 -0
- src/cron/stale-recovery.ts +302 -0
- src/crypto/password.ts +73 -0
- src/db/accounts.ts +700 -0
- src/db/client.ts +6 -0
- src/db/events.ts +65 -0
- src/db/jobs.ts +257 -0
- src/db/storage.ts +222 -0
- src/db/tickets.ts +177 -0
- src/engine/legacy.ts +1917 -0
- src/engine/selectors.ts +502 -0
- src/engine/steps/class-management.ts +111 -0
- src/engine/steps/download.ts +190 -0
- src/engine/steps/filters.ts +780 -0
- src/engine/steps/login.ts +194 -0
- src/engine/steps/navigate.ts +90 -0
- src/engine/steps/quota-detect.ts +542 -0
- src/engine/steps/resubmit.ts +371 -0
- src/engine/steps/similarity.ts +267 -0
- src/engine/steps/submission-details.ts +147 -0
- src/engine/steps/submission-state.ts +263 -0
- src/engine/steps/upload.ts +931 -0
- src/engine/steps/viewer-similarity.ts +143 -0
- src/engine/steps/viewer.ts +139 -0
- src/engine/turnitin.ts +1036 -0
- src/index.ts +137 -0
- src/server/app.ts +67 -0
- src/server/middleware/admin-secret.ts +33 -0
- src/server/middleware/auth.ts +50 -0
- src/server/routes/accounts.ts +140 -0
- src/server/routes/health.ts +38 -0
- src/server/routes/internal.ts +145 -0
- src/server/routes/reports.ts +160 -0
- src/server/routes/submit.ts +275 -0
- src/utils/logger.ts +57 -0
- src/utils/retry.ts +62 -0
- src/worker/browser-pool.ts +107 -0
- src/worker/manager.ts +1313 -0
- tsconfig.json +20 -0
.env.example
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Supabase
|
| 2 |
+
SUPABASE_URL=
|
| 3 |
+
SUPABASE_SERVICE_ROLE_KEY=
|
| 4 |
+
|
| 5 |
+
# Worker
|
| 6 |
+
WORKER_ID=hf-worker-1
|
| 7 |
+
MAX_WORKERS=2
|
| 8 |
+
POLL_INTERVAL_MS=3000
|
| 9 |
+
ENABLE_WORKER=true
|
| 10 |
+
ENABLE_CRON=true
|
| 11 |
+
|
| 12 |
+
# Turnitin
|
| 13 |
+
TURNITIN_SHARED_PASSWORD=
|
| 14 |
+
TURNITIN_CREDENTIAL_ENCRYPTION_KEY=
|
| 15 |
+
TARGET_URL=https://www.turnitin.com/login_page.asp?lang=en_us
|
| 16 |
+
CLASS_TITLE=Summer Reading 2026
|
| 17 |
+
ASSIGNMENT_TITLE=HTLLP notes
|
| 18 |
+
|
| 19 |
+
# Playwright
|
| 20 |
+
PLAYWRIGHT_HEADLESS=true
|
| 21 |
+
|
| 22 |
+
# Storage buckets
|
| 23 |
+
INPUT_BUCKET=turnitin-inputs
|
| 24 |
+
REPORT_BUCKET=turnitin-reports
|
| 25 |
+
DIAGNOSTICS_BUCKET=turnitin-diagnostics
|
| 26 |
+
SESSION_BUCKET=turnitin-sessions
|
| 27 |
+
|
| 28 |
+
# Cron intervals (minutes)
|
| 29 |
+
QUOTA_CHECK_INTERVAL=30
|
| 30 |
+
CLEANUP_INTERVAL=15
|
| 31 |
+
STALE_RECOVERY_INTERVAL=5
|
| 32 |
+
|
| 33 |
+
# Report
|
| 34 |
+
REPORT_RETENTION_HOURS=24
|
| 35 |
+
|
| 36 |
+
# Server
|
| 37 |
+
PORT=7860
|
| 38 |
+
INTERNAL_SECRET=
|
| 39 |
+
ALLOWED_ORIGINS=https://hl-internal-finance-amber.vercel.app,http://localhost:3000
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
.env
|
| 4 |
+
*.log
|
| 5 |
+
tmp/
|
Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-slim
|
| 2 |
+
|
| 3 |
+
# Install Chromium system dependencies
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
chromium \
|
| 6 |
+
fonts-liberation \
|
| 7 |
+
libgbm1 \
|
| 8 |
+
libnss3 \
|
| 9 |
+
libatk1.0-0 \
|
| 10 |
+
libatk-bridge2.0-0 \
|
| 11 |
+
libcups2 \
|
| 12 |
+
libxdamage1 \
|
| 13 |
+
libxrandr2 \
|
| 14 |
+
libpango-1.0-0 \
|
| 15 |
+
libcairo2 \
|
| 16 |
+
libasound2 \
|
| 17 |
+
--no-install-recommends \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Copy package configurations
|
| 23 |
+
COPY package*.json tsconfig.json ./
|
| 24 |
+
RUN npm ci
|
| 25 |
+
|
| 26 |
+
# Copy source and build
|
| 27 |
+
COPY src/ ./src/
|
| 28 |
+
RUN npm run build
|
| 29 |
+
|
| 30 |
+
# Prune dev dependencies to reduce image size
|
| 31 |
+
RUN npm prune --production
|
| 32 |
+
|
| 33 |
+
# Install Playwright Chromium browser
|
| 34 |
+
RUN npx playwright install chromium
|
| 35 |
+
|
| 36 |
+
ENV NODE_ENV=production
|
| 37 |
+
ENV PLAYWRIGHT_HEADLESS=true
|
| 38 |
+
ENV PORT=7860
|
| 39 |
+
|
| 40 |
+
EXPOSE 7860
|
| 41 |
+
|
| 42 |
+
CMD ["node", "dist/index.js"]
|
README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🦀
|
| 4 |
-
colorFrom: red
|
| 5 |
-
colorTo: green
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: RelV Turnitin Worker
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
pinned: false
|
| 6 |
---
|
| 7 |
|
| 8 |
+
# RelV Turnitin Worker
|
| 9 |
+
|
| 10 |
+
Docker Space for the Turnitin automation worker.
|
| 11 |
+
|
| 12 |
+
Build revision: 2026-07-17T03:18:00Z
|
migrate-last-completed-step.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* Migration: add last_completed_step column to turnitin_jobs
|
| 4 |
+
* Run: node migrate-last-completed-step.js
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
const { createClient } = require('@supabase/supabase-js');
|
| 8 |
+
|
| 9 |
+
const SUPABASE_URL = 'https://zbvlvxpnmsccdmoihnpo.supabase.co';
|
| 10 |
+
const SUPABASE_SERVICE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpidmx2eHBubXNjY2Rtb2lobnBvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc4MjkxMjAwNSwiZXhwIjoyMDk4NDg4MDA1fQ.i6SfiVKB0YX4pi88OhQZyX8uCMREQcaOwdsB9CmGI1Q';
|
| 11 |
+
|
| 12 |
+
async function main() {
|
| 13 |
+
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
| 14 |
+
|
| 15 |
+
console.log('Running migration: add last_completed_step to turnitin_jobs...');
|
| 16 |
+
|
| 17 |
+
// Supabase JS client doesn't support raw DDL. Use the pg extension workaround
|
| 18 |
+
// by checking if the column already exists first.
|
| 19 |
+
const { data: columns, error: checkError } = await supabase
|
| 20 |
+
.from('information_schema.columns')
|
| 21 |
+
.select('column_name')
|
| 22 |
+
.eq('table_schema', 'public')
|
| 23 |
+
.eq('table_name', 'turnitin_jobs')
|
| 24 |
+
.eq('column_name', 'last_completed_step');
|
| 25 |
+
|
| 26 |
+
if (checkError) {
|
| 27 |
+
console.log('Could not check column existence via information_schema (RLS?):', checkError.message);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
if (columns && columns.length > 0) {
|
| 31 |
+
console.log('✅ Column last_completed_step already exists — no migration needed.');
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Try applying via the run_sql RPC if it exists
|
| 36 |
+
const { error } = await supabase.rpc('run_sql', {
|
| 37 |
+
sql: 'ALTER TABLE public.turnitin_jobs ADD COLUMN IF NOT EXISTS last_completed_step text;'
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
if (error) {
|
| 41 |
+
console.log('❌ run_sql RPC not available:', error.message);
|
| 42 |
+
console.log('');
|
| 43 |
+
console.log('Please run this SQL manually in the Supabase Dashboard SQL editor:');
|
| 44 |
+
console.log('');
|
| 45 |
+
console.log(' ALTER TABLE public.turnitin_jobs ADD COLUMN IF NOT EXISTS last_completed_step text;');
|
| 46 |
+
console.log('');
|
| 47 |
+
console.log('URL: https://supabase.com/dashboard/project/zbvlvxpnmsccdmoihnpo/sql/new');
|
| 48 |
+
process.exit(1);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
console.log('✅ Migration applied successfully.');
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
main().catch((e) => {
|
| 55 |
+
console.error('Fatal error:', e);
|
| 56 |
+
process.exit(1);
|
| 57 |
+
});
|
package-lock.json
ADDED
|
@@ -0,0 +1,1845 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "turnitin-worker",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "turnitin-worker",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"@supabase/supabase-js": "^2.49.0",
|
| 12 |
+
"dotenv": "^16.4.5",
|
| 13 |
+
"express": "^4.21.0",
|
| 14 |
+
"multer": "^1.4.5-lts.1",
|
| 15 |
+
"node-cron": "^3.0.3",
|
| 16 |
+
"playwright": "^1.49.0"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@types/express": "^5.0.0",
|
| 20 |
+
"@types/multer": "^1.4.12",
|
| 21 |
+
"@types/node": "^22.0.0",
|
| 22 |
+
"@types/node-cron": "^3.0.11",
|
| 23 |
+
"tsx": "^4.19.0",
|
| 24 |
+
"typescript": "^5.7.0"
|
| 25 |
+
},
|
| 26 |
+
"engines": {
|
| 27 |
+
"node": ">=20"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"node_modules/@esbuild/aix-ppc64": {
|
| 31 |
+
"version": "0.28.1",
|
| 32 |
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
| 33 |
+
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
| 34 |
+
"cpu": [
|
| 35 |
+
"ppc64"
|
| 36 |
+
],
|
| 37 |
+
"dev": true,
|
| 38 |
+
"license": "MIT",
|
| 39 |
+
"optional": true,
|
| 40 |
+
"os": [
|
| 41 |
+
"aix"
|
| 42 |
+
],
|
| 43 |
+
"engines": {
|
| 44 |
+
"node": ">=18"
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
"node_modules/@esbuild/android-arm": {
|
| 48 |
+
"version": "0.28.1",
|
| 49 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
| 50 |
+
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
| 51 |
+
"cpu": [
|
| 52 |
+
"arm"
|
| 53 |
+
],
|
| 54 |
+
"dev": true,
|
| 55 |
+
"license": "MIT",
|
| 56 |
+
"optional": true,
|
| 57 |
+
"os": [
|
| 58 |
+
"android"
|
| 59 |
+
],
|
| 60 |
+
"engines": {
|
| 61 |
+
"node": ">=18"
|
| 62 |
+
}
|
| 63 |
+
},
|
| 64 |
+
"node_modules/@esbuild/android-arm64": {
|
| 65 |
+
"version": "0.28.1",
|
| 66 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
| 67 |
+
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
| 68 |
+
"cpu": [
|
| 69 |
+
"arm64"
|
| 70 |
+
],
|
| 71 |
+
"dev": true,
|
| 72 |
+
"license": "MIT",
|
| 73 |
+
"optional": true,
|
| 74 |
+
"os": [
|
| 75 |
+
"android"
|
| 76 |
+
],
|
| 77 |
+
"engines": {
|
| 78 |
+
"node": ">=18"
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"node_modules/@esbuild/android-x64": {
|
| 82 |
+
"version": "0.28.1",
|
| 83 |
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
| 84 |
+
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
| 85 |
+
"cpu": [
|
| 86 |
+
"x64"
|
| 87 |
+
],
|
| 88 |
+
"dev": true,
|
| 89 |
+
"license": "MIT",
|
| 90 |
+
"optional": true,
|
| 91 |
+
"os": [
|
| 92 |
+
"android"
|
| 93 |
+
],
|
| 94 |
+
"engines": {
|
| 95 |
+
"node": ">=18"
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
"node_modules/@esbuild/darwin-arm64": {
|
| 99 |
+
"version": "0.28.1",
|
| 100 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
| 101 |
+
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
| 102 |
+
"cpu": [
|
| 103 |
+
"arm64"
|
| 104 |
+
],
|
| 105 |
+
"dev": true,
|
| 106 |
+
"license": "MIT",
|
| 107 |
+
"optional": true,
|
| 108 |
+
"os": [
|
| 109 |
+
"darwin"
|
| 110 |
+
],
|
| 111 |
+
"engines": {
|
| 112 |
+
"node": ">=18"
|
| 113 |
+
}
|
| 114 |
+
},
|
| 115 |
+
"node_modules/@esbuild/darwin-x64": {
|
| 116 |
+
"version": "0.28.1",
|
| 117 |
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
| 118 |
+
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
| 119 |
+
"cpu": [
|
| 120 |
+
"x64"
|
| 121 |
+
],
|
| 122 |
+
"dev": true,
|
| 123 |
+
"license": "MIT",
|
| 124 |
+
"optional": true,
|
| 125 |
+
"os": [
|
| 126 |
+
"darwin"
|
| 127 |
+
],
|
| 128 |
+
"engines": {
|
| 129 |
+
"node": ">=18"
|
| 130 |
+
}
|
| 131 |
+
},
|
| 132 |
+
"node_modules/@esbuild/freebsd-arm64": {
|
| 133 |
+
"version": "0.28.1",
|
| 134 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
| 135 |
+
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
| 136 |
+
"cpu": [
|
| 137 |
+
"arm64"
|
| 138 |
+
],
|
| 139 |
+
"dev": true,
|
| 140 |
+
"license": "MIT",
|
| 141 |
+
"optional": true,
|
| 142 |
+
"os": [
|
| 143 |
+
"freebsd"
|
| 144 |
+
],
|
| 145 |
+
"engines": {
|
| 146 |
+
"node": ">=18"
|
| 147 |
+
}
|
| 148 |
+
},
|
| 149 |
+
"node_modules/@esbuild/freebsd-x64": {
|
| 150 |
+
"version": "0.28.1",
|
| 151 |
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
| 152 |
+
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
| 153 |
+
"cpu": [
|
| 154 |
+
"x64"
|
| 155 |
+
],
|
| 156 |
+
"dev": true,
|
| 157 |
+
"license": "MIT",
|
| 158 |
+
"optional": true,
|
| 159 |
+
"os": [
|
| 160 |
+
"freebsd"
|
| 161 |
+
],
|
| 162 |
+
"engines": {
|
| 163 |
+
"node": ">=18"
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
"node_modules/@esbuild/linux-arm": {
|
| 167 |
+
"version": "0.28.1",
|
| 168 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
| 169 |
+
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
| 170 |
+
"cpu": [
|
| 171 |
+
"arm"
|
| 172 |
+
],
|
| 173 |
+
"dev": true,
|
| 174 |
+
"license": "MIT",
|
| 175 |
+
"optional": true,
|
| 176 |
+
"os": [
|
| 177 |
+
"linux"
|
| 178 |
+
],
|
| 179 |
+
"engines": {
|
| 180 |
+
"node": ">=18"
|
| 181 |
+
}
|
| 182 |
+
},
|
| 183 |
+
"node_modules/@esbuild/linux-arm64": {
|
| 184 |
+
"version": "0.28.1",
|
| 185 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
| 186 |
+
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
| 187 |
+
"cpu": [
|
| 188 |
+
"arm64"
|
| 189 |
+
],
|
| 190 |
+
"dev": true,
|
| 191 |
+
"license": "MIT",
|
| 192 |
+
"optional": true,
|
| 193 |
+
"os": [
|
| 194 |
+
"linux"
|
| 195 |
+
],
|
| 196 |
+
"engines": {
|
| 197 |
+
"node": ">=18"
|
| 198 |
+
}
|
| 199 |
+
},
|
| 200 |
+
"node_modules/@esbuild/linux-ia32": {
|
| 201 |
+
"version": "0.28.1",
|
| 202 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
| 203 |
+
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
| 204 |
+
"cpu": [
|
| 205 |
+
"ia32"
|
| 206 |
+
],
|
| 207 |
+
"dev": true,
|
| 208 |
+
"license": "MIT",
|
| 209 |
+
"optional": true,
|
| 210 |
+
"os": [
|
| 211 |
+
"linux"
|
| 212 |
+
],
|
| 213 |
+
"engines": {
|
| 214 |
+
"node": ">=18"
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
"node_modules/@esbuild/linux-loong64": {
|
| 218 |
+
"version": "0.28.1",
|
| 219 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
| 220 |
+
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
| 221 |
+
"cpu": [
|
| 222 |
+
"loong64"
|
| 223 |
+
],
|
| 224 |
+
"dev": true,
|
| 225 |
+
"license": "MIT",
|
| 226 |
+
"optional": true,
|
| 227 |
+
"os": [
|
| 228 |
+
"linux"
|
| 229 |
+
],
|
| 230 |
+
"engines": {
|
| 231 |
+
"node": ">=18"
|
| 232 |
+
}
|
| 233 |
+
},
|
| 234 |
+
"node_modules/@esbuild/linux-mips64el": {
|
| 235 |
+
"version": "0.28.1",
|
| 236 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
| 237 |
+
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
| 238 |
+
"cpu": [
|
| 239 |
+
"mips64el"
|
| 240 |
+
],
|
| 241 |
+
"dev": true,
|
| 242 |
+
"license": "MIT",
|
| 243 |
+
"optional": true,
|
| 244 |
+
"os": [
|
| 245 |
+
"linux"
|
| 246 |
+
],
|
| 247 |
+
"engines": {
|
| 248 |
+
"node": ">=18"
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
"node_modules/@esbuild/linux-ppc64": {
|
| 252 |
+
"version": "0.28.1",
|
| 253 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
| 254 |
+
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
| 255 |
+
"cpu": [
|
| 256 |
+
"ppc64"
|
| 257 |
+
],
|
| 258 |
+
"dev": true,
|
| 259 |
+
"license": "MIT",
|
| 260 |
+
"optional": true,
|
| 261 |
+
"os": [
|
| 262 |
+
"linux"
|
| 263 |
+
],
|
| 264 |
+
"engines": {
|
| 265 |
+
"node": ">=18"
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
"node_modules/@esbuild/linux-riscv64": {
|
| 269 |
+
"version": "0.28.1",
|
| 270 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
| 271 |
+
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
| 272 |
+
"cpu": [
|
| 273 |
+
"riscv64"
|
| 274 |
+
],
|
| 275 |
+
"dev": true,
|
| 276 |
+
"license": "MIT",
|
| 277 |
+
"optional": true,
|
| 278 |
+
"os": [
|
| 279 |
+
"linux"
|
| 280 |
+
],
|
| 281 |
+
"engines": {
|
| 282 |
+
"node": ">=18"
|
| 283 |
+
}
|
| 284 |
+
},
|
| 285 |
+
"node_modules/@esbuild/linux-s390x": {
|
| 286 |
+
"version": "0.28.1",
|
| 287 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
| 288 |
+
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
| 289 |
+
"cpu": [
|
| 290 |
+
"s390x"
|
| 291 |
+
],
|
| 292 |
+
"dev": true,
|
| 293 |
+
"license": "MIT",
|
| 294 |
+
"optional": true,
|
| 295 |
+
"os": [
|
| 296 |
+
"linux"
|
| 297 |
+
],
|
| 298 |
+
"engines": {
|
| 299 |
+
"node": ">=18"
|
| 300 |
+
}
|
| 301 |
+
},
|
| 302 |
+
"node_modules/@esbuild/linux-x64": {
|
| 303 |
+
"version": "0.28.1",
|
| 304 |
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
| 305 |
+
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
| 306 |
+
"cpu": [
|
| 307 |
+
"x64"
|
| 308 |
+
],
|
| 309 |
+
"dev": true,
|
| 310 |
+
"license": "MIT",
|
| 311 |
+
"optional": true,
|
| 312 |
+
"os": [
|
| 313 |
+
"linux"
|
| 314 |
+
],
|
| 315 |
+
"engines": {
|
| 316 |
+
"node": ">=18"
|
| 317 |
+
}
|
| 318 |
+
},
|
| 319 |
+
"node_modules/@esbuild/netbsd-arm64": {
|
| 320 |
+
"version": "0.28.1",
|
| 321 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
| 322 |
+
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
| 323 |
+
"cpu": [
|
| 324 |
+
"arm64"
|
| 325 |
+
],
|
| 326 |
+
"dev": true,
|
| 327 |
+
"license": "MIT",
|
| 328 |
+
"optional": true,
|
| 329 |
+
"os": [
|
| 330 |
+
"netbsd"
|
| 331 |
+
],
|
| 332 |
+
"engines": {
|
| 333 |
+
"node": ">=18"
|
| 334 |
+
}
|
| 335 |
+
},
|
| 336 |
+
"node_modules/@esbuild/netbsd-x64": {
|
| 337 |
+
"version": "0.28.1",
|
| 338 |
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
| 339 |
+
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
| 340 |
+
"cpu": [
|
| 341 |
+
"x64"
|
| 342 |
+
],
|
| 343 |
+
"dev": true,
|
| 344 |
+
"license": "MIT",
|
| 345 |
+
"optional": true,
|
| 346 |
+
"os": [
|
| 347 |
+
"netbsd"
|
| 348 |
+
],
|
| 349 |
+
"engines": {
|
| 350 |
+
"node": ">=18"
|
| 351 |
+
}
|
| 352 |
+
},
|
| 353 |
+
"node_modules/@esbuild/openbsd-arm64": {
|
| 354 |
+
"version": "0.28.1",
|
| 355 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
| 356 |
+
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
| 357 |
+
"cpu": [
|
| 358 |
+
"arm64"
|
| 359 |
+
],
|
| 360 |
+
"dev": true,
|
| 361 |
+
"license": "MIT",
|
| 362 |
+
"optional": true,
|
| 363 |
+
"os": [
|
| 364 |
+
"openbsd"
|
| 365 |
+
],
|
| 366 |
+
"engines": {
|
| 367 |
+
"node": ">=18"
|
| 368 |
+
}
|
| 369 |
+
},
|
| 370 |
+
"node_modules/@esbuild/openbsd-x64": {
|
| 371 |
+
"version": "0.28.1",
|
| 372 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
| 373 |
+
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
| 374 |
+
"cpu": [
|
| 375 |
+
"x64"
|
| 376 |
+
],
|
| 377 |
+
"dev": true,
|
| 378 |
+
"license": "MIT",
|
| 379 |
+
"optional": true,
|
| 380 |
+
"os": [
|
| 381 |
+
"openbsd"
|
| 382 |
+
],
|
| 383 |
+
"engines": {
|
| 384 |
+
"node": ">=18"
|
| 385 |
+
}
|
| 386 |
+
},
|
| 387 |
+
"node_modules/@esbuild/openharmony-arm64": {
|
| 388 |
+
"version": "0.28.1",
|
| 389 |
+
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
| 390 |
+
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
| 391 |
+
"cpu": [
|
| 392 |
+
"arm64"
|
| 393 |
+
],
|
| 394 |
+
"dev": true,
|
| 395 |
+
"license": "MIT",
|
| 396 |
+
"optional": true,
|
| 397 |
+
"os": [
|
| 398 |
+
"openharmony"
|
| 399 |
+
],
|
| 400 |
+
"engines": {
|
| 401 |
+
"node": ">=18"
|
| 402 |
+
}
|
| 403 |
+
},
|
| 404 |
+
"node_modules/@esbuild/sunos-x64": {
|
| 405 |
+
"version": "0.28.1",
|
| 406 |
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
| 407 |
+
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
| 408 |
+
"cpu": [
|
| 409 |
+
"x64"
|
| 410 |
+
],
|
| 411 |
+
"dev": true,
|
| 412 |
+
"license": "MIT",
|
| 413 |
+
"optional": true,
|
| 414 |
+
"os": [
|
| 415 |
+
"sunos"
|
| 416 |
+
],
|
| 417 |
+
"engines": {
|
| 418 |
+
"node": ">=18"
|
| 419 |
+
}
|
| 420 |
+
},
|
| 421 |
+
"node_modules/@esbuild/win32-arm64": {
|
| 422 |
+
"version": "0.28.1",
|
| 423 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
| 424 |
+
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
| 425 |
+
"cpu": [
|
| 426 |
+
"arm64"
|
| 427 |
+
],
|
| 428 |
+
"dev": true,
|
| 429 |
+
"license": "MIT",
|
| 430 |
+
"optional": true,
|
| 431 |
+
"os": [
|
| 432 |
+
"win32"
|
| 433 |
+
],
|
| 434 |
+
"engines": {
|
| 435 |
+
"node": ">=18"
|
| 436 |
+
}
|
| 437 |
+
},
|
| 438 |
+
"node_modules/@esbuild/win32-ia32": {
|
| 439 |
+
"version": "0.28.1",
|
| 440 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
| 441 |
+
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
| 442 |
+
"cpu": [
|
| 443 |
+
"ia32"
|
| 444 |
+
],
|
| 445 |
+
"dev": true,
|
| 446 |
+
"license": "MIT",
|
| 447 |
+
"optional": true,
|
| 448 |
+
"os": [
|
| 449 |
+
"win32"
|
| 450 |
+
],
|
| 451 |
+
"engines": {
|
| 452 |
+
"node": ">=18"
|
| 453 |
+
}
|
| 454 |
+
},
|
| 455 |
+
"node_modules/@esbuild/win32-x64": {
|
| 456 |
+
"version": "0.28.1",
|
| 457 |
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
| 458 |
+
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
| 459 |
+
"cpu": [
|
| 460 |
+
"x64"
|
| 461 |
+
],
|
| 462 |
+
"dev": true,
|
| 463 |
+
"license": "MIT",
|
| 464 |
+
"optional": true,
|
| 465 |
+
"os": [
|
| 466 |
+
"win32"
|
| 467 |
+
],
|
| 468 |
+
"engines": {
|
| 469 |
+
"node": ">=18"
|
| 470 |
+
}
|
| 471 |
+
},
|
| 472 |
+
"node_modules/@supabase/auth-js": {
|
| 473 |
+
"version": "2.110.0",
|
| 474 |
+
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.0.tgz",
|
| 475 |
+
"integrity": "sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg==",
|
| 476 |
+
"license": "MIT",
|
| 477 |
+
"dependencies": {
|
| 478 |
+
"tslib": "2.8.1"
|
| 479 |
+
},
|
| 480 |
+
"engines": {
|
| 481 |
+
"node": ">=22.0.0"
|
| 482 |
+
}
|
| 483 |
+
},
|
| 484 |
+
"node_modules/@supabase/functions-js": {
|
| 485 |
+
"version": "2.110.0",
|
| 486 |
+
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.0.tgz",
|
| 487 |
+
"integrity": "sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw==",
|
| 488 |
+
"license": "MIT",
|
| 489 |
+
"dependencies": {
|
| 490 |
+
"tslib": "2.8.1"
|
| 491 |
+
},
|
| 492 |
+
"engines": {
|
| 493 |
+
"node": ">=22.0.0"
|
| 494 |
+
}
|
| 495 |
+
},
|
| 496 |
+
"node_modules/@supabase/phoenix": {
|
| 497 |
+
"version": "0.4.4",
|
| 498 |
+
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
| 499 |
+
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
| 500 |
+
"license": "MIT"
|
| 501 |
+
},
|
| 502 |
+
"node_modules/@supabase/postgrest-js": {
|
| 503 |
+
"version": "2.110.0",
|
| 504 |
+
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.0.tgz",
|
| 505 |
+
"integrity": "sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ==",
|
| 506 |
+
"license": "MIT",
|
| 507 |
+
"dependencies": {
|
| 508 |
+
"tslib": "2.8.1"
|
| 509 |
+
},
|
| 510 |
+
"engines": {
|
| 511 |
+
"node": ">=22.0.0"
|
| 512 |
+
}
|
| 513 |
+
},
|
| 514 |
+
"node_modules/@supabase/realtime-js": {
|
| 515 |
+
"version": "2.110.0",
|
| 516 |
+
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.0.tgz",
|
| 517 |
+
"integrity": "sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw==",
|
| 518 |
+
"license": "MIT",
|
| 519 |
+
"dependencies": {
|
| 520 |
+
"@supabase/phoenix": "0.4.4",
|
| 521 |
+
"tslib": "2.8.1"
|
| 522 |
+
},
|
| 523 |
+
"engines": {
|
| 524 |
+
"node": ">=22.0.0"
|
| 525 |
+
}
|
| 526 |
+
},
|
| 527 |
+
"node_modules/@supabase/storage-js": {
|
| 528 |
+
"version": "2.110.0",
|
| 529 |
+
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.0.tgz",
|
| 530 |
+
"integrity": "sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ==",
|
| 531 |
+
"license": "MIT",
|
| 532 |
+
"dependencies": {
|
| 533 |
+
"iceberg-js": "^0.8.1",
|
| 534 |
+
"tslib": "2.8.1"
|
| 535 |
+
},
|
| 536 |
+
"engines": {
|
| 537 |
+
"node": ">=22.0.0"
|
| 538 |
+
}
|
| 539 |
+
},
|
| 540 |
+
"node_modules/@supabase/supabase-js": {
|
| 541 |
+
"version": "2.110.0",
|
| 542 |
+
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.0.tgz",
|
| 543 |
+
"integrity": "sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ==",
|
| 544 |
+
"license": "MIT",
|
| 545 |
+
"dependencies": {
|
| 546 |
+
"@supabase/auth-js": "2.110.0",
|
| 547 |
+
"@supabase/functions-js": "2.110.0",
|
| 548 |
+
"@supabase/postgrest-js": "2.110.0",
|
| 549 |
+
"@supabase/realtime-js": "2.110.0",
|
| 550 |
+
"@supabase/storage-js": "2.110.0"
|
| 551 |
+
},
|
| 552 |
+
"engines": {
|
| 553 |
+
"node": ">=22.0.0"
|
| 554 |
+
}
|
| 555 |
+
},
|
| 556 |
+
"node_modules/@types/body-parser": {
|
| 557 |
+
"version": "1.19.6",
|
| 558 |
+
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
| 559 |
+
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
| 560 |
+
"dev": true,
|
| 561 |
+
"license": "MIT",
|
| 562 |
+
"dependencies": {
|
| 563 |
+
"@types/connect": "*",
|
| 564 |
+
"@types/node": "*"
|
| 565 |
+
}
|
| 566 |
+
},
|
| 567 |
+
"node_modules/@types/connect": {
|
| 568 |
+
"version": "3.4.38",
|
| 569 |
+
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
| 570 |
+
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
| 571 |
+
"dev": true,
|
| 572 |
+
"license": "MIT",
|
| 573 |
+
"dependencies": {
|
| 574 |
+
"@types/node": "*"
|
| 575 |
+
}
|
| 576 |
+
},
|
| 577 |
+
"node_modules/@types/express": {
|
| 578 |
+
"version": "5.0.6",
|
| 579 |
+
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
| 580 |
+
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
| 581 |
+
"dev": true,
|
| 582 |
+
"license": "MIT",
|
| 583 |
+
"dependencies": {
|
| 584 |
+
"@types/body-parser": "*",
|
| 585 |
+
"@types/express-serve-static-core": "^5.0.0",
|
| 586 |
+
"@types/serve-static": "^2"
|
| 587 |
+
}
|
| 588 |
+
},
|
| 589 |
+
"node_modules/@types/express-serve-static-core": {
|
| 590 |
+
"version": "5.1.1",
|
| 591 |
+
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
| 592 |
+
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
| 593 |
+
"dev": true,
|
| 594 |
+
"license": "MIT",
|
| 595 |
+
"dependencies": {
|
| 596 |
+
"@types/node": "*",
|
| 597 |
+
"@types/qs": "*",
|
| 598 |
+
"@types/range-parser": "*",
|
| 599 |
+
"@types/send": "*"
|
| 600 |
+
}
|
| 601 |
+
},
|
| 602 |
+
"node_modules/@types/http-errors": {
|
| 603 |
+
"version": "2.0.5",
|
| 604 |
+
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
| 605 |
+
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
| 606 |
+
"dev": true,
|
| 607 |
+
"license": "MIT"
|
| 608 |
+
},
|
| 609 |
+
"node_modules/@types/multer": {
|
| 610 |
+
"version": "1.4.13",
|
| 611 |
+
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
|
| 612 |
+
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
|
| 613 |
+
"dev": true,
|
| 614 |
+
"license": "MIT",
|
| 615 |
+
"dependencies": {
|
| 616 |
+
"@types/express": "*"
|
| 617 |
+
}
|
| 618 |
+
},
|
| 619 |
+
"node_modules/@types/node": {
|
| 620 |
+
"version": "22.20.0",
|
| 621 |
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
| 622 |
+
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
| 623 |
+
"dev": true,
|
| 624 |
+
"license": "MIT",
|
| 625 |
+
"dependencies": {
|
| 626 |
+
"undici-types": "~6.21.0"
|
| 627 |
+
}
|
| 628 |
+
},
|
| 629 |
+
"node_modules/@types/node-cron": {
|
| 630 |
+
"version": "3.0.11",
|
| 631 |
+
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
| 632 |
+
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
| 633 |
+
"dev": true,
|
| 634 |
+
"license": "MIT"
|
| 635 |
+
},
|
| 636 |
+
"node_modules/@types/qs": {
|
| 637 |
+
"version": "6.15.1",
|
| 638 |
+
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
| 639 |
+
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
| 640 |
+
"dev": true,
|
| 641 |
+
"license": "MIT"
|
| 642 |
+
},
|
| 643 |
+
"node_modules/@types/range-parser": {
|
| 644 |
+
"version": "1.2.7",
|
| 645 |
+
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
| 646 |
+
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
| 647 |
+
"dev": true,
|
| 648 |
+
"license": "MIT"
|
| 649 |
+
},
|
| 650 |
+
"node_modules/@types/send": {
|
| 651 |
+
"version": "1.2.1",
|
| 652 |
+
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
| 653 |
+
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
| 654 |
+
"dev": true,
|
| 655 |
+
"license": "MIT",
|
| 656 |
+
"dependencies": {
|
| 657 |
+
"@types/node": "*"
|
| 658 |
+
}
|
| 659 |
+
},
|
| 660 |
+
"node_modules/@types/serve-static": {
|
| 661 |
+
"version": "2.2.0",
|
| 662 |
+
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
| 663 |
+
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
| 664 |
+
"dev": true,
|
| 665 |
+
"license": "MIT",
|
| 666 |
+
"dependencies": {
|
| 667 |
+
"@types/http-errors": "*",
|
| 668 |
+
"@types/node": "*"
|
| 669 |
+
}
|
| 670 |
+
},
|
| 671 |
+
"node_modules/accepts": {
|
| 672 |
+
"version": "1.3.8",
|
| 673 |
+
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
| 674 |
+
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
| 675 |
+
"license": "MIT",
|
| 676 |
+
"dependencies": {
|
| 677 |
+
"mime-types": "~2.1.34",
|
| 678 |
+
"negotiator": "0.6.3"
|
| 679 |
+
},
|
| 680 |
+
"engines": {
|
| 681 |
+
"node": ">= 0.6"
|
| 682 |
+
}
|
| 683 |
+
},
|
| 684 |
+
"node_modules/append-field": {
|
| 685 |
+
"version": "1.0.0",
|
| 686 |
+
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
| 687 |
+
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
| 688 |
+
"license": "MIT"
|
| 689 |
+
},
|
| 690 |
+
"node_modules/array-flatten": {
|
| 691 |
+
"version": "1.1.1",
|
| 692 |
+
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
| 693 |
+
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
| 694 |
+
"license": "MIT"
|
| 695 |
+
},
|
| 696 |
+
"node_modules/body-parser": {
|
| 697 |
+
"version": "1.20.5",
|
| 698 |
+
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
| 699 |
+
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
| 700 |
+
"license": "MIT",
|
| 701 |
+
"dependencies": {
|
| 702 |
+
"bytes": "~3.1.2",
|
| 703 |
+
"content-type": "~1.0.5",
|
| 704 |
+
"debug": "2.6.9",
|
| 705 |
+
"depd": "2.0.0",
|
| 706 |
+
"destroy": "~1.2.0",
|
| 707 |
+
"http-errors": "~2.0.1",
|
| 708 |
+
"iconv-lite": "~0.4.24",
|
| 709 |
+
"on-finished": "~2.4.1",
|
| 710 |
+
"qs": "~6.15.1",
|
| 711 |
+
"raw-body": "~2.5.3",
|
| 712 |
+
"type-is": "~1.6.18",
|
| 713 |
+
"unpipe": "~1.0.0"
|
| 714 |
+
},
|
| 715 |
+
"engines": {
|
| 716 |
+
"node": ">= 0.8",
|
| 717 |
+
"npm": "1.2.8000 || >= 1.4.16"
|
| 718 |
+
}
|
| 719 |
+
},
|
| 720 |
+
"node_modules/buffer-from": {
|
| 721 |
+
"version": "1.1.2",
|
| 722 |
+
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
| 723 |
+
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
| 724 |
+
"license": "MIT"
|
| 725 |
+
},
|
| 726 |
+
"node_modules/busboy": {
|
| 727 |
+
"version": "1.6.0",
|
| 728 |
+
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
| 729 |
+
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
| 730 |
+
"dependencies": {
|
| 731 |
+
"streamsearch": "^1.1.0"
|
| 732 |
+
},
|
| 733 |
+
"engines": {
|
| 734 |
+
"node": ">=10.16.0"
|
| 735 |
+
}
|
| 736 |
+
},
|
| 737 |
+
"node_modules/bytes": {
|
| 738 |
+
"version": "3.1.2",
|
| 739 |
+
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
| 740 |
+
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
| 741 |
+
"license": "MIT",
|
| 742 |
+
"engines": {
|
| 743 |
+
"node": ">= 0.8"
|
| 744 |
+
}
|
| 745 |
+
},
|
| 746 |
+
"node_modules/call-bind-apply-helpers": {
|
| 747 |
+
"version": "1.0.2",
|
| 748 |
+
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
| 749 |
+
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
| 750 |
+
"license": "MIT",
|
| 751 |
+
"dependencies": {
|
| 752 |
+
"es-errors": "^1.3.0",
|
| 753 |
+
"function-bind": "^1.1.2"
|
| 754 |
+
},
|
| 755 |
+
"engines": {
|
| 756 |
+
"node": ">= 0.4"
|
| 757 |
+
}
|
| 758 |
+
},
|
| 759 |
+
"node_modules/call-bound": {
|
| 760 |
+
"version": "1.0.4",
|
| 761 |
+
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
| 762 |
+
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
| 763 |
+
"license": "MIT",
|
| 764 |
+
"dependencies": {
|
| 765 |
+
"call-bind-apply-helpers": "^1.0.2",
|
| 766 |
+
"get-intrinsic": "^1.3.0"
|
| 767 |
+
},
|
| 768 |
+
"engines": {
|
| 769 |
+
"node": ">= 0.4"
|
| 770 |
+
},
|
| 771 |
+
"funding": {
|
| 772 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 773 |
+
}
|
| 774 |
+
},
|
| 775 |
+
"node_modules/concat-stream": {
|
| 776 |
+
"version": "1.6.2",
|
| 777 |
+
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
| 778 |
+
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
| 779 |
+
"engines": [
|
| 780 |
+
"node >= 0.8"
|
| 781 |
+
],
|
| 782 |
+
"license": "MIT",
|
| 783 |
+
"dependencies": {
|
| 784 |
+
"buffer-from": "^1.0.0",
|
| 785 |
+
"inherits": "^2.0.3",
|
| 786 |
+
"readable-stream": "^2.2.2",
|
| 787 |
+
"typedarray": "^0.0.6"
|
| 788 |
+
}
|
| 789 |
+
},
|
| 790 |
+
"node_modules/content-disposition": {
|
| 791 |
+
"version": "0.5.4",
|
| 792 |
+
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
| 793 |
+
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
| 794 |
+
"license": "MIT",
|
| 795 |
+
"dependencies": {
|
| 796 |
+
"safe-buffer": "5.2.1"
|
| 797 |
+
},
|
| 798 |
+
"engines": {
|
| 799 |
+
"node": ">= 0.6"
|
| 800 |
+
}
|
| 801 |
+
},
|
| 802 |
+
"node_modules/content-type": {
|
| 803 |
+
"version": "1.0.5",
|
| 804 |
+
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
| 805 |
+
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
| 806 |
+
"license": "MIT",
|
| 807 |
+
"engines": {
|
| 808 |
+
"node": ">= 0.6"
|
| 809 |
+
}
|
| 810 |
+
},
|
| 811 |
+
"node_modules/cookie": {
|
| 812 |
+
"version": "0.7.2",
|
| 813 |
+
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
| 814 |
+
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
| 815 |
+
"license": "MIT",
|
| 816 |
+
"engines": {
|
| 817 |
+
"node": ">= 0.6"
|
| 818 |
+
}
|
| 819 |
+
},
|
| 820 |
+
"node_modules/cookie-signature": {
|
| 821 |
+
"version": "1.0.7",
|
| 822 |
+
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
| 823 |
+
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
| 824 |
+
"license": "MIT"
|
| 825 |
+
},
|
| 826 |
+
"node_modules/core-util-is": {
|
| 827 |
+
"version": "1.0.3",
|
| 828 |
+
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
| 829 |
+
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
| 830 |
+
"license": "MIT"
|
| 831 |
+
},
|
| 832 |
+
"node_modules/debug": {
|
| 833 |
+
"version": "2.6.9",
|
| 834 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
| 835 |
+
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
| 836 |
+
"license": "MIT",
|
| 837 |
+
"dependencies": {
|
| 838 |
+
"ms": "2.0.0"
|
| 839 |
+
}
|
| 840 |
+
},
|
| 841 |
+
"node_modules/depd": {
|
| 842 |
+
"version": "2.0.0",
|
| 843 |
+
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
| 844 |
+
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
| 845 |
+
"license": "MIT",
|
| 846 |
+
"engines": {
|
| 847 |
+
"node": ">= 0.8"
|
| 848 |
+
}
|
| 849 |
+
},
|
| 850 |
+
"node_modules/destroy": {
|
| 851 |
+
"version": "1.2.0",
|
| 852 |
+
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
| 853 |
+
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
| 854 |
+
"license": "MIT",
|
| 855 |
+
"engines": {
|
| 856 |
+
"node": ">= 0.8",
|
| 857 |
+
"npm": "1.2.8000 || >= 1.4.16"
|
| 858 |
+
}
|
| 859 |
+
},
|
| 860 |
+
"node_modules/dotenv": {
|
| 861 |
+
"version": "16.6.1",
|
| 862 |
+
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
| 863 |
+
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
| 864 |
+
"license": "BSD-2-Clause",
|
| 865 |
+
"engines": {
|
| 866 |
+
"node": ">=12"
|
| 867 |
+
},
|
| 868 |
+
"funding": {
|
| 869 |
+
"url": "https://dotenvx.com"
|
| 870 |
+
}
|
| 871 |
+
},
|
| 872 |
+
"node_modules/dunder-proto": {
|
| 873 |
+
"version": "1.0.1",
|
| 874 |
+
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
| 875 |
+
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
| 876 |
+
"license": "MIT",
|
| 877 |
+
"dependencies": {
|
| 878 |
+
"call-bind-apply-helpers": "^1.0.1",
|
| 879 |
+
"es-errors": "^1.3.0",
|
| 880 |
+
"gopd": "^1.2.0"
|
| 881 |
+
},
|
| 882 |
+
"engines": {
|
| 883 |
+
"node": ">= 0.4"
|
| 884 |
+
}
|
| 885 |
+
},
|
| 886 |
+
"node_modules/ee-first": {
|
| 887 |
+
"version": "1.1.1",
|
| 888 |
+
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
| 889 |
+
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
| 890 |
+
"license": "MIT"
|
| 891 |
+
},
|
| 892 |
+
"node_modules/encodeurl": {
|
| 893 |
+
"version": "2.0.0",
|
| 894 |
+
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
| 895 |
+
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
| 896 |
+
"license": "MIT",
|
| 897 |
+
"engines": {
|
| 898 |
+
"node": ">= 0.8"
|
| 899 |
+
}
|
| 900 |
+
},
|
| 901 |
+
"node_modules/es-define-property": {
|
| 902 |
+
"version": "1.0.1",
|
| 903 |
+
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
| 904 |
+
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
| 905 |
+
"license": "MIT",
|
| 906 |
+
"engines": {
|
| 907 |
+
"node": ">= 0.4"
|
| 908 |
+
}
|
| 909 |
+
},
|
| 910 |
+
"node_modules/es-errors": {
|
| 911 |
+
"version": "1.3.0",
|
| 912 |
+
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
| 913 |
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
| 914 |
+
"license": "MIT",
|
| 915 |
+
"engines": {
|
| 916 |
+
"node": ">= 0.4"
|
| 917 |
+
}
|
| 918 |
+
},
|
| 919 |
+
"node_modules/es-object-atoms": {
|
| 920 |
+
"version": "1.1.2",
|
| 921 |
+
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
| 922 |
+
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
| 923 |
+
"license": "MIT",
|
| 924 |
+
"dependencies": {
|
| 925 |
+
"es-errors": "^1.3.0"
|
| 926 |
+
},
|
| 927 |
+
"engines": {
|
| 928 |
+
"node": ">= 0.4"
|
| 929 |
+
}
|
| 930 |
+
},
|
| 931 |
+
"node_modules/esbuild": {
|
| 932 |
+
"version": "0.28.1",
|
| 933 |
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
| 934 |
+
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
| 935 |
+
"dev": true,
|
| 936 |
+
"hasInstallScript": true,
|
| 937 |
+
"license": "MIT",
|
| 938 |
+
"bin": {
|
| 939 |
+
"esbuild": "bin/esbuild"
|
| 940 |
+
},
|
| 941 |
+
"engines": {
|
| 942 |
+
"node": ">=18"
|
| 943 |
+
},
|
| 944 |
+
"optionalDependencies": {
|
| 945 |
+
"@esbuild/aix-ppc64": "0.28.1",
|
| 946 |
+
"@esbuild/android-arm": "0.28.1",
|
| 947 |
+
"@esbuild/android-arm64": "0.28.1",
|
| 948 |
+
"@esbuild/android-x64": "0.28.1",
|
| 949 |
+
"@esbuild/darwin-arm64": "0.28.1",
|
| 950 |
+
"@esbuild/darwin-x64": "0.28.1",
|
| 951 |
+
"@esbuild/freebsd-arm64": "0.28.1",
|
| 952 |
+
"@esbuild/freebsd-x64": "0.28.1",
|
| 953 |
+
"@esbuild/linux-arm": "0.28.1",
|
| 954 |
+
"@esbuild/linux-arm64": "0.28.1",
|
| 955 |
+
"@esbuild/linux-ia32": "0.28.1",
|
| 956 |
+
"@esbuild/linux-loong64": "0.28.1",
|
| 957 |
+
"@esbuild/linux-mips64el": "0.28.1",
|
| 958 |
+
"@esbuild/linux-ppc64": "0.28.1",
|
| 959 |
+
"@esbuild/linux-riscv64": "0.28.1",
|
| 960 |
+
"@esbuild/linux-s390x": "0.28.1",
|
| 961 |
+
"@esbuild/linux-x64": "0.28.1",
|
| 962 |
+
"@esbuild/netbsd-arm64": "0.28.1",
|
| 963 |
+
"@esbuild/netbsd-x64": "0.28.1",
|
| 964 |
+
"@esbuild/openbsd-arm64": "0.28.1",
|
| 965 |
+
"@esbuild/openbsd-x64": "0.28.1",
|
| 966 |
+
"@esbuild/openharmony-arm64": "0.28.1",
|
| 967 |
+
"@esbuild/sunos-x64": "0.28.1",
|
| 968 |
+
"@esbuild/win32-arm64": "0.28.1",
|
| 969 |
+
"@esbuild/win32-ia32": "0.28.1",
|
| 970 |
+
"@esbuild/win32-x64": "0.28.1"
|
| 971 |
+
}
|
| 972 |
+
},
|
| 973 |
+
"node_modules/escape-html": {
|
| 974 |
+
"version": "1.0.3",
|
| 975 |
+
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
| 976 |
+
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
| 977 |
+
"license": "MIT"
|
| 978 |
+
},
|
| 979 |
+
"node_modules/etag": {
|
| 980 |
+
"version": "1.8.1",
|
| 981 |
+
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
| 982 |
+
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
| 983 |
+
"license": "MIT",
|
| 984 |
+
"engines": {
|
| 985 |
+
"node": ">= 0.6"
|
| 986 |
+
}
|
| 987 |
+
},
|
| 988 |
+
"node_modules/express": {
|
| 989 |
+
"version": "4.22.2",
|
| 990 |
+
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
| 991 |
+
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
| 992 |
+
"license": "MIT",
|
| 993 |
+
"dependencies": {
|
| 994 |
+
"accepts": "~1.3.8",
|
| 995 |
+
"array-flatten": "1.1.1",
|
| 996 |
+
"body-parser": "~1.20.5",
|
| 997 |
+
"content-disposition": "~0.5.4",
|
| 998 |
+
"content-type": "~1.0.4",
|
| 999 |
+
"cookie": "~0.7.1",
|
| 1000 |
+
"cookie-signature": "~1.0.6",
|
| 1001 |
+
"debug": "2.6.9",
|
| 1002 |
+
"depd": "2.0.0",
|
| 1003 |
+
"encodeurl": "~2.0.0",
|
| 1004 |
+
"escape-html": "~1.0.3",
|
| 1005 |
+
"etag": "~1.8.1",
|
| 1006 |
+
"finalhandler": "~1.3.1",
|
| 1007 |
+
"fresh": "~0.5.2",
|
| 1008 |
+
"http-errors": "~2.0.0",
|
| 1009 |
+
"merge-descriptors": "1.0.3",
|
| 1010 |
+
"methods": "~1.1.2",
|
| 1011 |
+
"on-finished": "~2.4.1",
|
| 1012 |
+
"parseurl": "~1.3.3",
|
| 1013 |
+
"path-to-regexp": "~0.1.12",
|
| 1014 |
+
"proxy-addr": "~2.0.7",
|
| 1015 |
+
"qs": "~6.15.1",
|
| 1016 |
+
"range-parser": "~1.2.1",
|
| 1017 |
+
"safe-buffer": "5.2.1",
|
| 1018 |
+
"send": "~0.19.0",
|
| 1019 |
+
"serve-static": "~1.16.2",
|
| 1020 |
+
"setprototypeof": "1.2.0",
|
| 1021 |
+
"statuses": "~2.0.1",
|
| 1022 |
+
"type-is": "~1.6.18",
|
| 1023 |
+
"utils-merge": "1.0.1",
|
| 1024 |
+
"vary": "~1.1.2"
|
| 1025 |
+
},
|
| 1026 |
+
"engines": {
|
| 1027 |
+
"node": ">= 0.10.0"
|
| 1028 |
+
},
|
| 1029 |
+
"funding": {
|
| 1030 |
+
"type": "opencollective",
|
| 1031 |
+
"url": "https://opencollective.com/express"
|
| 1032 |
+
}
|
| 1033 |
+
},
|
| 1034 |
+
"node_modules/finalhandler": {
|
| 1035 |
+
"version": "1.3.2",
|
| 1036 |
+
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
| 1037 |
+
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
| 1038 |
+
"license": "MIT",
|
| 1039 |
+
"dependencies": {
|
| 1040 |
+
"debug": "2.6.9",
|
| 1041 |
+
"encodeurl": "~2.0.0",
|
| 1042 |
+
"escape-html": "~1.0.3",
|
| 1043 |
+
"on-finished": "~2.4.1",
|
| 1044 |
+
"parseurl": "~1.3.3",
|
| 1045 |
+
"statuses": "~2.0.2",
|
| 1046 |
+
"unpipe": "~1.0.0"
|
| 1047 |
+
},
|
| 1048 |
+
"engines": {
|
| 1049 |
+
"node": ">= 0.8"
|
| 1050 |
+
}
|
| 1051 |
+
},
|
| 1052 |
+
"node_modules/forwarded": {
|
| 1053 |
+
"version": "0.2.0",
|
| 1054 |
+
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
| 1055 |
+
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
| 1056 |
+
"license": "MIT",
|
| 1057 |
+
"engines": {
|
| 1058 |
+
"node": ">= 0.6"
|
| 1059 |
+
}
|
| 1060 |
+
},
|
| 1061 |
+
"node_modules/fresh": {
|
| 1062 |
+
"version": "0.5.2",
|
| 1063 |
+
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
| 1064 |
+
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
| 1065 |
+
"license": "MIT",
|
| 1066 |
+
"engines": {
|
| 1067 |
+
"node": ">= 0.6"
|
| 1068 |
+
}
|
| 1069 |
+
},
|
| 1070 |
+
"node_modules/fsevents": {
|
| 1071 |
+
"version": "2.3.2",
|
| 1072 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
| 1073 |
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
| 1074 |
+
"hasInstallScript": true,
|
| 1075 |
+
"license": "MIT",
|
| 1076 |
+
"optional": true,
|
| 1077 |
+
"os": [
|
| 1078 |
+
"darwin"
|
| 1079 |
+
],
|
| 1080 |
+
"engines": {
|
| 1081 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1082 |
+
}
|
| 1083 |
+
},
|
| 1084 |
+
"node_modules/function-bind": {
|
| 1085 |
+
"version": "1.1.2",
|
| 1086 |
+
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
| 1087 |
+
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
| 1088 |
+
"license": "MIT",
|
| 1089 |
+
"funding": {
|
| 1090 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1091 |
+
}
|
| 1092 |
+
},
|
| 1093 |
+
"node_modules/get-intrinsic": {
|
| 1094 |
+
"version": "1.3.0",
|
| 1095 |
+
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
| 1096 |
+
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
| 1097 |
+
"license": "MIT",
|
| 1098 |
+
"dependencies": {
|
| 1099 |
+
"call-bind-apply-helpers": "^1.0.2",
|
| 1100 |
+
"es-define-property": "^1.0.1",
|
| 1101 |
+
"es-errors": "^1.3.0",
|
| 1102 |
+
"es-object-atoms": "^1.1.1",
|
| 1103 |
+
"function-bind": "^1.1.2",
|
| 1104 |
+
"get-proto": "^1.0.1",
|
| 1105 |
+
"gopd": "^1.2.0",
|
| 1106 |
+
"has-symbols": "^1.1.0",
|
| 1107 |
+
"hasown": "^2.0.2",
|
| 1108 |
+
"math-intrinsics": "^1.1.0"
|
| 1109 |
+
},
|
| 1110 |
+
"engines": {
|
| 1111 |
+
"node": ">= 0.4"
|
| 1112 |
+
},
|
| 1113 |
+
"funding": {
|
| 1114 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1115 |
+
}
|
| 1116 |
+
},
|
| 1117 |
+
"node_modules/get-proto": {
|
| 1118 |
+
"version": "1.0.1",
|
| 1119 |
+
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
| 1120 |
+
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
| 1121 |
+
"license": "MIT",
|
| 1122 |
+
"dependencies": {
|
| 1123 |
+
"dunder-proto": "^1.0.1",
|
| 1124 |
+
"es-object-atoms": "^1.0.0"
|
| 1125 |
+
},
|
| 1126 |
+
"engines": {
|
| 1127 |
+
"node": ">= 0.4"
|
| 1128 |
+
}
|
| 1129 |
+
},
|
| 1130 |
+
"node_modules/gopd": {
|
| 1131 |
+
"version": "1.2.0",
|
| 1132 |
+
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
| 1133 |
+
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
| 1134 |
+
"license": "MIT",
|
| 1135 |
+
"engines": {
|
| 1136 |
+
"node": ">= 0.4"
|
| 1137 |
+
},
|
| 1138 |
+
"funding": {
|
| 1139 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1140 |
+
}
|
| 1141 |
+
},
|
| 1142 |
+
"node_modules/has-symbols": {
|
| 1143 |
+
"version": "1.1.0",
|
| 1144 |
+
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
| 1145 |
+
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
| 1146 |
+
"license": "MIT",
|
| 1147 |
+
"engines": {
|
| 1148 |
+
"node": ">= 0.4"
|
| 1149 |
+
},
|
| 1150 |
+
"funding": {
|
| 1151 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1152 |
+
}
|
| 1153 |
+
},
|
| 1154 |
+
"node_modules/hasown": {
|
| 1155 |
+
"version": "2.0.4",
|
| 1156 |
+
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
| 1157 |
+
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
| 1158 |
+
"license": "MIT",
|
| 1159 |
+
"dependencies": {
|
| 1160 |
+
"function-bind": "^1.1.2"
|
| 1161 |
+
},
|
| 1162 |
+
"engines": {
|
| 1163 |
+
"node": ">= 0.4"
|
| 1164 |
+
}
|
| 1165 |
+
},
|
| 1166 |
+
"node_modules/http-errors": {
|
| 1167 |
+
"version": "2.0.1",
|
| 1168 |
+
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
| 1169 |
+
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
| 1170 |
+
"license": "MIT",
|
| 1171 |
+
"dependencies": {
|
| 1172 |
+
"depd": "~2.0.0",
|
| 1173 |
+
"inherits": "~2.0.4",
|
| 1174 |
+
"setprototypeof": "~1.2.0",
|
| 1175 |
+
"statuses": "~2.0.2",
|
| 1176 |
+
"toidentifier": "~1.0.1"
|
| 1177 |
+
},
|
| 1178 |
+
"engines": {
|
| 1179 |
+
"node": ">= 0.8"
|
| 1180 |
+
},
|
| 1181 |
+
"funding": {
|
| 1182 |
+
"type": "opencollective",
|
| 1183 |
+
"url": "https://opencollective.com/express"
|
| 1184 |
+
}
|
| 1185 |
+
},
|
| 1186 |
+
"node_modules/iceberg-js": {
|
| 1187 |
+
"version": "0.8.1",
|
| 1188 |
+
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
| 1189 |
+
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
| 1190 |
+
"license": "MIT",
|
| 1191 |
+
"engines": {
|
| 1192 |
+
"node": ">=20.0.0"
|
| 1193 |
+
}
|
| 1194 |
+
},
|
| 1195 |
+
"node_modules/iconv-lite": {
|
| 1196 |
+
"version": "0.4.24",
|
| 1197 |
+
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
| 1198 |
+
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
| 1199 |
+
"license": "MIT",
|
| 1200 |
+
"dependencies": {
|
| 1201 |
+
"safer-buffer": ">= 2.1.2 < 3"
|
| 1202 |
+
},
|
| 1203 |
+
"engines": {
|
| 1204 |
+
"node": ">=0.10.0"
|
| 1205 |
+
}
|
| 1206 |
+
},
|
| 1207 |
+
"node_modules/inherits": {
|
| 1208 |
+
"version": "2.0.4",
|
| 1209 |
+
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
| 1210 |
+
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
| 1211 |
+
"license": "ISC"
|
| 1212 |
+
},
|
| 1213 |
+
"node_modules/ipaddr.js": {
|
| 1214 |
+
"version": "1.9.1",
|
| 1215 |
+
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
| 1216 |
+
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
| 1217 |
+
"license": "MIT",
|
| 1218 |
+
"engines": {
|
| 1219 |
+
"node": ">= 0.10"
|
| 1220 |
+
}
|
| 1221 |
+
},
|
| 1222 |
+
"node_modules/isarray": {
|
| 1223 |
+
"version": "1.0.0",
|
| 1224 |
+
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
| 1225 |
+
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
| 1226 |
+
"license": "MIT"
|
| 1227 |
+
},
|
| 1228 |
+
"node_modules/math-intrinsics": {
|
| 1229 |
+
"version": "1.1.0",
|
| 1230 |
+
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
| 1231 |
+
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
| 1232 |
+
"license": "MIT",
|
| 1233 |
+
"engines": {
|
| 1234 |
+
"node": ">= 0.4"
|
| 1235 |
+
}
|
| 1236 |
+
},
|
| 1237 |
+
"node_modules/media-typer": {
|
| 1238 |
+
"version": "0.3.0",
|
| 1239 |
+
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
| 1240 |
+
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
| 1241 |
+
"license": "MIT",
|
| 1242 |
+
"engines": {
|
| 1243 |
+
"node": ">= 0.6"
|
| 1244 |
+
}
|
| 1245 |
+
},
|
| 1246 |
+
"node_modules/merge-descriptors": {
|
| 1247 |
+
"version": "1.0.3",
|
| 1248 |
+
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
| 1249 |
+
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
| 1250 |
+
"license": "MIT",
|
| 1251 |
+
"funding": {
|
| 1252 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 1253 |
+
}
|
| 1254 |
+
},
|
| 1255 |
+
"node_modules/methods": {
|
| 1256 |
+
"version": "1.1.2",
|
| 1257 |
+
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
| 1258 |
+
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
| 1259 |
+
"license": "MIT",
|
| 1260 |
+
"engines": {
|
| 1261 |
+
"node": ">= 0.6"
|
| 1262 |
+
}
|
| 1263 |
+
},
|
| 1264 |
+
"node_modules/mime": {
|
| 1265 |
+
"version": "1.6.0",
|
| 1266 |
+
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
| 1267 |
+
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
| 1268 |
+
"license": "MIT",
|
| 1269 |
+
"bin": {
|
| 1270 |
+
"mime": "cli.js"
|
| 1271 |
+
},
|
| 1272 |
+
"engines": {
|
| 1273 |
+
"node": ">=4"
|
| 1274 |
+
}
|
| 1275 |
+
},
|
| 1276 |
+
"node_modules/mime-db": {
|
| 1277 |
+
"version": "1.52.0",
|
| 1278 |
+
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
| 1279 |
+
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
| 1280 |
+
"license": "MIT",
|
| 1281 |
+
"engines": {
|
| 1282 |
+
"node": ">= 0.6"
|
| 1283 |
+
}
|
| 1284 |
+
},
|
| 1285 |
+
"node_modules/mime-types": {
|
| 1286 |
+
"version": "2.1.35",
|
| 1287 |
+
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
| 1288 |
+
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
| 1289 |
+
"license": "MIT",
|
| 1290 |
+
"dependencies": {
|
| 1291 |
+
"mime-db": "1.52.0"
|
| 1292 |
+
},
|
| 1293 |
+
"engines": {
|
| 1294 |
+
"node": ">= 0.6"
|
| 1295 |
+
}
|
| 1296 |
+
},
|
| 1297 |
+
"node_modules/minimist": {
|
| 1298 |
+
"version": "1.2.8",
|
| 1299 |
+
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
| 1300 |
+
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
| 1301 |
+
"license": "MIT",
|
| 1302 |
+
"funding": {
|
| 1303 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1304 |
+
}
|
| 1305 |
+
},
|
| 1306 |
+
"node_modules/mkdirp": {
|
| 1307 |
+
"version": "0.5.6",
|
| 1308 |
+
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
| 1309 |
+
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
| 1310 |
+
"license": "MIT",
|
| 1311 |
+
"dependencies": {
|
| 1312 |
+
"minimist": "^1.2.6"
|
| 1313 |
+
},
|
| 1314 |
+
"bin": {
|
| 1315 |
+
"mkdirp": "bin/cmd.js"
|
| 1316 |
+
}
|
| 1317 |
+
},
|
| 1318 |
+
"node_modules/ms": {
|
| 1319 |
+
"version": "2.0.0",
|
| 1320 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
| 1321 |
+
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
| 1322 |
+
"license": "MIT"
|
| 1323 |
+
},
|
| 1324 |
+
"node_modules/multer": {
|
| 1325 |
+
"version": "1.4.5-lts.2",
|
| 1326 |
+
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
| 1327 |
+
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
| 1328 |
+
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
| 1329 |
+
"license": "MIT",
|
| 1330 |
+
"dependencies": {
|
| 1331 |
+
"append-field": "^1.0.0",
|
| 1332 |
+
"busboy": "^1.0.0",
|
| 1333 |
+
"concat-stream": "^1.5.2",
|
| 1334 |
+
"mkdirp": "^0.5.4",
|
| 1335 |
+
"object-assign": "^4.1.1",
|
| 1336 |
+
"type-is": "^1.6.4",
|
| 1337 |
+
"xtend": "^4.0.0"
|
| 1338 |
+
},
|
| 1339 |
+
"engines": {
|
| 1340 |
+
"node": ">= 6.0.0"
|
| 1341 |
+
}
|
| 1342 |
+
},
|
| 1343 |
+
"node_modules/negotiator": {
|
| 1344 |
+
"version": "0.6.3",
|
| 1345 |
+
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
| 1346 |
+
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
| 1347 |
+
"license": "MIT",
|
| 1348 |
+
"engines": {
|
| 1349 |
+
"node": ">= 0.6"
|
| 1350 |
+
}
|
| 1351 |
+
},
|
| 1352 |
+
"node_modules/node-cron": {
|
| 1353 |
+
"version": "3.0.3",
|
| 1354 |
+
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
| 1355 |
+
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
| 1356 |
+
"license": "ISC",
|
| 1357 |
+
"dependencies": {
|
| 1358 |
+
"uuid": "8.3.2"
|
| 1359 |
+
},
|
| 1360 |
+
"engines": {
|
| 1361 |
+
"node": ">=6.0.0"
|
| 1362 |
+
}
|
| 1363 |
+
},
|
| 1364 |
+
"node_modules/object-assign": {
|
| 1365 |
+
"version": "4.1.1",
|
| 1366 |
+
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
| 1367 |
+
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
| 1368 |
+
"license": "MIT",
|
| 1369 |
+
"engines": {
|
| 1370 |
+
"node": ">=0.10.0"
|
| 1371 |
+
}
|
| 1372 |
+
},
|
| 1373 |
+
"node_modules/object-inspect": {
|
| 1374 |
+
"version": "1.13.4",
|
| 1375 |
+
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
| 1376 |
+
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
| 1377 |
+
"license": "MIT",
|
| 1378 |
+
"engines": {
|
| 1379 |
+
"node": ">= 0.4"
|
| 1380 |
+
},
|
| 1381 |
+
"funding": {
|
| 1382 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1383 |
+
}
|
| 1384 |
+
},
|
| 1385 |
+
"node_modules/on-finished": {
|
| 1386 |
+
"version": "2.4.1",
|
| 1387 |
+
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
| 1388 |
+
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
| 1389 |
+
"license": "MIT",
|
| 1390 |
+
"dependencies": {
|
| 1391 |
+
"ee-first": "1.1.1"
|
| 1392 |
+
},
|
| 1393 |
+
"engines": {
|
| 1394 |
+
"node": ">= 0.8"
|
| 1395 |
+
}
|
| 1396 |
+
},
|
| 1397 |
+
"node_modules/parseurl": {
|
| 1398 |
+
"version": "1.3.3",
|
| 1399 |
+
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
| 1400 |
+
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
| 1401 |
+
"license": "MIT",
|
| 1402 |
+
"engines": {
|
| 1403 |
+
"node": ">= 0.8"
|
| 1404 |
+
}
|
| 1405 |
+
},
|
| 1406 |
+
"node_modules/path-to-regexp": {
|
| 1407 |
+
"version": "0.1.13",
|
| 1408 |
+
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
| 1409 |
+
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
| 1410 |
+
"license": "MIT"
|
| 1411 |
+
},
|
| 1412 |
+
"node_modules/playwright": {
|
| 1413 |
+
"version": "1.61.1",
|
| 1414 |
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
| 1415 |
+
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
| 1416 |
+
"license": "Apache-2.0",
|
| 1417 |
+
"dependencies": {
|
| 1418 |
+
"playwright-core": "1.61.1"
|
| 1419 |
+
},
|
| 1420 |
+
"bin": {
|
| 1421 |
+
"playwright": "cli.js"
|
| 1422 |
+
},
|
| 1423 |
+
"engines": {
|
| 1424 |
+
"node": ">=18"
|
| 1425 |
+
},
|
| 1426 |
+
"optionalDependencies": {
|
| 1427 |
+
"fsevents": "2.3.2"
|
| 1428 |
+
}
|
| 1429 |
+
},
|
| 1430 |
+
"node_modules/playwright-core": {
|
| 1431 |
+
"version": "1.61.1",
|
| 1432 |
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
| 1433 |
+
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
| 1434 |
+
"license": "Apache-2.0",
|
| 1435 |
+
"bin": {
|
| 1436 |
+
"playwright-core": "cli.js"
|
| 1437 |
+
},
|
| 1438 |
+
"engines": {
|
| 1439 |
+
"node": ">=18"
|
| 1440 |
+
}
|
| 1441 |
+
},
|
| 1442 |
+
"node_modules/process-nextick-args": {
|
| 1443 |
+
"version": "2.0.1",
|
| 1444 |
+
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
| 1445 |
+
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
| 1446 |
+
"license": "MIT"
|
| 1447 |
+
},
|
| 1448 |
+
"node_modules/proxy-addr": {
|
| 1449 |
+
"version": "2.0.7",
|
| 1450 |
+
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
| 1451 |
+
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
| 1452 |
+
"license": "MIT",
|
| 1453 |
+
"dependencies": {
|
| 1454 |
+
"forwarded": "0.2.0",
|
| 1455 |
+
"ipaddr.js": "1.9.1"
|
| 1456 |
+
},
|
| 1457 |
+
"engines": {
|
| 1458 |
+
"node": ">= 0.10"
|
| 1459 |
+
}
|
| 1460 |
+
},
|
| 1461 |
+
"node_modules/qs": {
|
| 1462 |
+
"version": "6.15.3",
|
| 1463 |
+
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
| 1464 |
+
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
| 1465 |
+
"license": "BSD-3-Clause",
|
| 1466 |
+
"dependencies": {
|
| 1467 |
+
"es-define-property": "^1.0.1",
|
| 1468 |
+
"side-channel": "^1.1.1"
|
| 1469 |
+
},
|
| 1470 |
+
"engines": {
|
| 1471 |
+
"node": ">=0.6"
|
| 1472 |
+
},
|
| 1473 |
+
"funding": {
|
| 1474 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1475 |
+
}
|
| 1476 |
+
},
|
| 1477 |
+
"node_modules/range-parser": {
|
| 1478 |
+
"version": "1.2.1",
|
| 1479 |
+
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
| 1480 |
+
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
| 1481 |
+
"license": "MIT",
|
| 1482 |
+
"engines": {
|
| 1483 |
+
"node": ">= 0.6"
|
| 1484 |
+
}
|
| 1485 |
+
},
|
| 1486 |
+
"node_modules/raw-body": {
|
| 1487 |
+
"version": "2.5.3",
|
| 1488 |
+
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
| 1489 |
+
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
| 1490 |
+
"license": "MIT",
|
| 1491 |
+
"dependencies": {
|
| 1492 |
+
"bytes": "~3.1.2",
|
| 1493 |
+
"http-errors": "~2.0.1",
|
| 1494 |
+
"iconv-lite": "~0.4.24",
|
| 1495 |
+
"unpipe": "~1.0.0"
|
| 1496 |
+
},
|
| 1497 |
+
"engines": {
|
| 1498 |
+
"node": ">= 0.8"
|
| 1499 |
+
}
|
| 1500 |
+
},
|
| 1501 |
+
"node_modules/readable-stream": {
|
| 1502 |
+
"version": "2.3.8",
|
| 1503 |
+
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
| 1504 |
+
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
| 1505 |
+
"license": "MIT",
|
| 1506 |
+
"dependencies": {
|
| 1507 |
+
"core-util-is": "~1.0.0",
|
| 1508 |
+
"inherits": "~2.0.3",
|
| 1509 |
+
"isarray": "~1.0.0",
|
| 1510 |
+
"process-nextick-args": "~2.0.0",
|
| 1511 |
+
"safe-buffer": "~5.1.1",
|
| 1512 |
+
"string_decoder": "~1.1.1",
|
| 1513 |
+
"util-deprecate": "~1.0.1"
|
| 1514 |
+
}
|
| 1515 |
+
},
|
| 1516 |
+
"node_modules/readable-stream/node_modules/safe-buffer": {
|
| 1517 |
+
"version": "5.1.2",
|
| 1518 |
+
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
| 1519 |
+
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
| 1520 |
+
"license": "MIT"
|
| 1521 |
+
},
|
| 1522 |
+
"node_modules/safe-buffer": {
|
| 1523 |
+
"version": "5.2.1",
|
| 1524 |
+
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
| 1525 |
+
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
| 1526 |
+
"funding": [
|
| 1527 |
+
{
|
| 1528 |
+
"type": "github",
|
| 1529 |
+
"url": "https://github.com/sponsors/feross"
|
| 1530 |
+
},
|
| 1531 |
+
{
|
| 1532 |
+
"type": "patreon",
|
| 1533 |
+
"url": "https://www.patreon.com/feross"
|
| 1534 |
+
},
|
| 1535 |
+
{
|
| 1536 |
+
"type": "consulting",
|
| 1537 |
+
"url": "https://feross.org/support"
|
| 1538 |
+
}
|
| 1539 |
+
],
|
| 1540 |
+
"license": "MIT"
|
| 1541 |
+
},
|
| 1542 |
+
"node_modules/safer-buffer": {
|
| 1543 |
+
"version": "2.1.2",
|
| 1544 |
+
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
| 1545 |
+
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
| 1546 |
+
"license": "MIT"
|
| 1547 |
+
},
|
| 1548 |
+
"node_modules/send": {
|
| 1549 |
+
"version": "0.19.2",
|
| 1550 |
+
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
| 1551 |
+
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
| 1552 |
+
"license": "MIT",
|
| 1553 |
+
"dependencies": {
|
| 1554 |
+
"debug": "2.6.9",
|
| 1555 |
+
"depd": "2.0.0",
|
| 1556 |
+
"destroy": "1.2.0",
|
| 1557 |
+
"encodeurl": "~2.0.0",
|
| 1558 |
+
"escape-html": "~1.0.3",
|
| 1559 |
+
"etag": "~1.8.1",
|
| 1560 |
+
"fresh": "~0.5.2",
|
| 1561 |
+
"http-errors": "~2.0.1",
|
| 1562 |
+
"mime": "1.6.0",
|
| 1563 |
+
"ms": "2.1.3",
|
| 1564 |
+
"on-finished": "~2.4.1",
|
| 1565 |
+
"range-parser": "~1.2.1",
|
| 1566 |
+
"statuses": "~2.0.2"
|
| 1567 |
+
},
|
| 1568 |
+
"engines": {
|
| 1569 |
+
"node": ">= 0.8.0"
|
| 1570 |
+
}
|
| 1571 |
+
},
|
| 1572 |
+
"node_modules/send/node_modules/ms": {
|
| 1573 |
+
"version": "2.1.3",
|
| 1574 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 1575 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
| 1576 |
+
"license": "MIT"
|
| 1577 |
+
},
|
| 1578 |
+
"node_modules/serve-static": {
|
| 1579 |
+
"version": "1.16.3",
|
| 1580 |
+
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
| 1581 |
+
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
| 1582 |
+
"license": "MIT",
|
| 1583 |
+
"dependencies": {
|
| 1584 |
+
"encodeurl": "~2.0.0",
|
| 1585 |
+
"escape-html": "~1.0.3",
|
| 1586 |
+
"parseurl": "~1.3.3",
|
| 1587 |
+
"send": "~0.19.1"
|
| 1588 |
+
},
|
| 1589 |
+
"engines": {
|
| 1590 |
+
"node": ">= 0.8.0"
|
| 1591 |
+
}
|
| 1592 |
+
},
|
| 1593 |
+
"node_modules/setprototypeof": {
|
| 1594 |
+
"version": "1.2.0",
|
| 1595 |
+
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
| 1596 |
+
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
| 1597 |
+
"license": "ISC"
|
| 1598 |
+
},
|
| 1599 |
+
"node_modules/side-channel": {
|
| 1600 |
+
"version": "1.1.1",
|
| 1601 |
+
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
| 1602 |
+
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
| 1603 |
+
"license": "MIT",
|
| 1604 |
+
"dependencies": {
|
| 1605 |
+
"es-errors": "^1.3.0",
|
| 1606 |
+
"object-inspect": "^1.13.4",
|
| 1607 |
+
"side-channel-list": "^1.0.1",
|
| 1608 |
+
"side-channel-map": "^1.0.1",
|
| 1609 |
+
"side-channel-weakmap": "^1.0.2"
|
| 1610 |
+
},
|
| 1611 |
+
"engines": {
|
| 1612 |
+
"node": ">= 0.4"
|
| 1613 |
+
},
|
| 1614 |
+
"funding": {
|
| 1615 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1616 |
+
}
|
| 1617 |
+
},
|
| 1618 |
+
"node_modules/side-channel-list": {
|
| 1619 |
+
"version": "1.0.1",
|
| 1620 |
+
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
| 1621 |
+
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
| 1622 |
+
"license": "MIT",
|
| 1623 |
+
"dependencies": {
|
| 1624 |
+
"es-errors": "^1.3.0",
|
| 1625 |
+
"object-inspect": "^1.13.4"
|
| 1626 |
+
},
|
| 1627 |
+
"engines": {
|
| 1628 |
+
"node": ">= 0.4"
|
| 1629 |
+
},
|
| 1630 |
+
"funding": {
|
| 1631 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1632 |
+
}
|
| 1633 |
+
},
|
| 1634 |
+
"node_modules/side-channel-map": {
|
| 1635 |
+
"version": "1.0.1",
|
| 1636 |
+
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
| 1637 |
+
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
| 1638 |
+
"license": "MIT",
|
| 1639 |
+
"dependencies": {
|
| 1640 |
+
"call-bound": "^1.0.2",
|
| 1641 |
+
"es-errors": "^1.3.0",
|
| 1642 |
+
"get-intrinsic": "^1.2.5",
|
| 1643 |
+
"object-inspect": "^1.13.3"
|
| 1644 |
+
},
|
| 1645 |
+
"engines": {
|
| 1646 |
+
"node": ">= 0.4"
|
| 1647 |
+
},
|
| 1648 |
+
"funding": {
|
| 1649 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1650 |
+
}
|
| 1651 |
+
},
|
| 1652 |
+
"node_modules/side-channel-weakmap": {
|
| 1653 |
+
"version": "1.0.2",
|
| 1654 |
+
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
| 1655 |
+
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
| 1656 |
+
"license": "MIT",
|
| 1657 |
+
"dependencies": {
|
| 1658 |
+
"call-bound": "^1.0.2",
|
| 1659 |
+
"es-errors": "^1.3.0",
|
| 1660 |
+
"get-intrinsic": "^1.2.5",
|
| 1661 |
+
"object-inspect": "^1.13.3",
|
| 1662 |
+
"side-channel-map": "^1.0.1"
|
| 1663 |
+
},
|
| 1664 |
+
"engines": {
|
| 1665 |
+
"node": ">= 0.4"
|
| 1666 |
+
},
|
| 1667 |
+
"funding": {
|
| 1668 |
+
"url": "https://github.com/sponsors/ljharb"
|
| 1669 |
+
}
|
| 1670 |
+
},
|
| 1671 |
+
"node_modules/statuses": {
|
| 1672 |
+
"version": "2.0.2",
|
| 1673 |
+
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
| 1674 |
+
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
| 1675 |
+
"license": "MIT",
|
| 1676 |
+
"engines": {
|
| 1677 |
+
"node": ">= 0.8"
|
| 1678 |
+
}
|
| 1679 |
+
},
|
| 1680 |
+
"node_modules/streamsearch": {
|
| 1681 |
+
"version": "1.1.0",
|
| 1682 |
+
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
| 1683 |
+
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
| 1684 |
+
"engines": {
|
| 1685 |
+
"node": ">=10.0.0"
|
| 1686 |
+
}
|
| 1687 |
+
},
|
| 1688 |
+
"node_modules/string_decoder": {
|
| 1689 |
+
"version": "1.1.1",
|
| 1690 |
+
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
| 1691 |
+
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
| 1692 |
+
"license": "MIT",
|
| 1693 |
+
"dependencies": {
|
| 1694 |
+
"safe-buffer": "~5.1.0"
|
| 1695 |
+
}
|
| 1696 |
+
},
|
| 1697 |
+
"node_modules/string_decoder/node_modules/safe-buffer": {
|
| 1698 |
+
"version": "5.1.2",
|
| 1699 |
+
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
| 1700 |
+
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
| 1701 |
+
"license": "MIT"
|
| 1702 |
+
},
|
| 1703 |
+
"node_modules/toidentifier": {
|
| 1704 |
+
"version": "1.0.1",
|
| 1705 |
+
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
| 1706 |
+
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
| 1707 |
+
"license": "MIT",
|
| 1708 |
+
"engines": {
|
| 1709 |
+
"node": ">=0.6"
|
| 1710 |
+
}
|
| 1711 |
+
},
|
| 1712 |
+
"node_modules/tslib": {
|
| 1713 |
+
"version": "2.8.1",
|
| 1714 |
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
| 1715 |
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
| 1716 |
+
"license": "0BSD"
|
| 1717 |
+
},
|
| 1718 |
+
"node_modules/tsx": {
|
| 1719 |
+
"version": "4.22.5",
|
| 1720 |
+
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz",
|
| 1721 |
+
"integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==",
|
| 1722 |
+
"dev": true,
|
| 1723 |
+
"license": "MIT",
|
| 1724 |
+
"dependencies": {
|
| 1725 |
+
"esbuild": "~0.28.0"
|
| 1726 |
+
},
|
| 1727 |
+
"bin": {
|
| 1728 |
+
"tsx": "dist/cli.mjs"
|
| 1729 |
+
},
|
| 1730 |
+
"engines": {
|
| 1731 |
+
"node": ">=18.0.0"
|
| 1732 |
+
},
|
| 1733 |
+
"optionalDependencies": {
|
| 1734 |
+
"fsevents": "~2.3.3"
|
| 1735 |
+
}
|
| 1736 |
+
},
|
| 1737 |
+
"node_modules/tsx/node_modules/fsevents": {
|
| 1738 |
+
"version": "2.3.3",
|
| 1739 |
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
| 1740 |
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
| 1741 |
+
"dev": true,
|
| 1742 |
+
"hasInstallScript": true,
|
| 1743 |
+
"license": "MIT",
|
| 1744 |
+
"optional": true,
|
| 1745 |
+
"os": [
|
| 1746 |
+
"darwin"
|
| 1747 |
+
],
|
| 1748 |
+
"engines": {
|
| 1749 |
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
| 1750 |
+
}
|
| 1751 |
+
},
|
| 1752 |
+
"node_modules/type-is": {
|
| 1753 |
+
"version": "1.6.18",
|
| 1754 |
+
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
| 1755 |
+
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
| 1756 |
+
"license": "MIT",
|
| 1757 |
+
"dependencies": {
|
| 1758 |
+
"media-typer": "0.3.0",
|
| 1759 |
+
"mime-types": "~2.1.24"
|
| 1760 |
+
},
|
| 1761 |
+
"engines": {
|
| 1762 |
+
"node": ">= 0.6"
|
| 1763 |
+
}
|
| 1764 |
+
},
|
| 1765 |
+
"node_modules/typedarray": {
|
| 1766 |
+
"version": "0.0.6",
|
| 1767 |
+
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
| 1768 |
+
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
| 1769 |
+
"license": "MIT"
|
| 1770 |
+
},
|
| 1771 |
+
"node_modules/typescript": {
|
| 1772 |
+
"version": "5.9.3",
|
| 1773 |
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
| 1774 |
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
| 1775 |
+
"dev": true,
|
| 1776 |
+
"license": "Apache-2.0",
|
| 1777 |
+
"bin": {
|
| 1778 |
+
"tsc": "bin/tsc",
|
| 1779 |
+
"tsserver": "bin/tsserver"
|
| 1780 |
+
},
|
| 1781 |
+
"engines": {
|
| 1782 |
+
"node": ">=14.17"
|
| 1783 |
+
}
|
| 1784 |
+
},
|
| 1785 |
+
"node_modules/undici-types": {
|
| 1786 |
+
"version": "6.21.0",
|
| 1787 |
+
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
| 1788 |
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
| 1789 |
+
"dev": true,
|
| 1790 |
+
"license": "MIT"
|
| 1791 |
+
},
|
| 1792 |
+
"node_modules/unpipe": {
|
| 1793 |
+
"version": "1.0.0",
|
| 1794 |
+
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
| 1795 |
+
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
| 1796 |
+
"license": "MIT",
|
| 1797 |
+
"engines": {
|
| 1798 |
+
"node": ">= 0.8"
|
| 1799 |
+
}
|
| 1800 |
+
},
|
| 1801 |
+
"node_modules/util-deprecate": {
|
| 1802 |
+
"version": "1.0.2",
|
| 1803 |
+
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
| 1804 |
+
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
| 1805 |
+
"license": "MIT"
|
| 1806 |
+
},
|
| 1807 |
+
"node_modules/utils-merge": {
|
| 1808 |
+
"version": "1.0.1",
|
| 1809 |
+
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
| 1810 |
+
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
| 1811 |
+
"license": "MIT",
|
| 1812 |
+
"engines": {
|
| 1813 |
+
"node": ">= 0.4.0"
|
| 1814 |
+
}
|
| 1815 |
+
},
|
| 1816 |
+
"node_modules/uuid": {
|
| 1817 |
+
"version": "8.3.2",
|
| 1818 |
+
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
| 1819 |
+
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
| 1820 |
+
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
| 1821 |
+
"license": "MIT",
|
| 1822 |
+
"bin": {
|
| 1823 |
+
"uuid": "dist/bin/uuid"
|
| 1824 |
+
}
|
| 1825 |
+
},
|
| 1826 |
+
"node_modules/vary": {
|
| 1827 |
+
"version": "1.1.2",
|
| 1828 |
+
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
| 1829 |
+
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
| 1830 |
+
"license": "MIT",
|
| 1831 |
+
"engines": {
|
| 1832 |
+
"node": ">= 0.8"
|
| 1833 |
+
}
|
| 1834 |
+
},
|
| 1835 |
+
"node_modules/xtend": {
|
| 1836 |
+
"version": "4.0.2",
|
| 1837 |
+
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
| 1838 |
+
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
| 1839 |
+
"license": "MIT",
|
| 1840 |
+
"engines": {
|
| 1841 |
+
"node": ">=0.4"
|
| 1842 |
+
}
|
| 1843 |
+
}
|
| 1844 |
+
}
|
| 1845 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "turnitin-worker",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Turnitin automation backend worker with Playwright",
|
| 5 |
+
"main": "dist/index.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "tsx watch src/index.ts",
|
| 8 |
+
"build": "tsc",
|
| 9 |
+
"start": "node dist/index.js",
|
| 10 |
+
"typecheck": "tsc --noEmit"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@supabase/supabase-js": "^2.49.0",
|
| 14 |
+
"dotenv": "^16.4.5",
|
| 15 |
+
"express": "^4.21.0",
|
| 16 |
+
"multer": "^1.4.5-lts.1",
|
| 17 |
+
"node-cron": "^3.0.3",
|
| 18 |
+
"playwright": "^1.49.0"
|
| 19 |
+
},
|
| 20 |
+
"devDependencies": {
|
| 21 |
+
"@types/express": "^5.0.0",
|
| 22 |
+
"@types/multer": "^1.4.12",
|
| 23 |
+
"@types/node": "^22.0.0",
|
| 24 |
+
"@types/node-cron": "^3.0.11",
|
| 25 |
+
"tsx": "^4.19.0",
|
| 26 |
+
"typescript": "^5.7.0"
|
| 27 |
+
},
|
| 28 |
+
"engines": {
|
| 29 |
+
"node": ">=20"
|
| 30 |
+
}
|
| 31 |
+
}
|
src/config.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import 'dotenv/config';
|
| 2 |
+
|
| 3 |
+
export const config = {
|
| 4 |
+
// Supabase
|
| 5 |
+
supabaseUrl: process.env.SUPABASE_URL || '',
|
| 6 |
+
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || '',
|
| 7 |
+
|
| 8 |
+
// Worker
|
| 9 |
+
workerId: process.env.WORKER_ID || `worker-${process.pid}`,
|
| 10 |
+
maxWorkers: parseInt(process.env.MAX_WORKERS || '2', 10),
|
| 11 |
+
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '3000', 10),
|
| 12 |
+
|
| 13 |
+
// Turnitin
|
| 14 |
+
turnitinSharedPassword: process.env.TURNITIN_SHARED_PASSWORD || '',
|
| 15 |
+
credentialEncryptionKey: process.env.TURNITIN_CREDENTIAL_ENCRYPTION_KEY || '',
|
| 16 |
+
turnitinTargetUrl:
|
| 17 |
+
process.env.TARGET_URL ||
|
| 18 |
+
'https://www.turnitin.com/login_page.asp?lang=en_us',
|
| 19 |
+
turnitinClassTitle: process.env.CLASS_TITLE || 'Summer Reading 2026',
|
| 20 |
+
turnitinAssignmentTitle: process.env.ASSIGNMENT_TITLE || 'HTLLP notes',
|
| 21 |
+
|
| 22 |
+
// Playwright
|
| 23 |
+
headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
|
| 24 |
+
|
| 25 |
+
// Storage buckets
|
| 26 |
+
inputBucket: process.env.INPUT_BUCKET || 'turnitin-inputs',
|
| 27 |
+
reportBucket: process.env.REPORT_BUCKET || 'turnitin-reports',
|
| 28 |
+
diagnosticsBucket: process.env.DIAGNOSTICS_BUCKET || 'turnitin-diagnostics',
|
| 29 |
+
sessionBucket: process.env.SESSION_BUCKET || 'turnitin-sessions',
|
| 30 |
+
|
| 31 |
+
// Cron intervals (minutes)
|
| 32 |
+
quotaCheckInterval: parseInt(process.env.QUOTA_CHECK_INTERVAL || '30', 10),
|
| 33 |
+
quotaCheckAccountDelayMs: parseInt(
|
| 34 |
+
process.env.QUOTA_CHECK_ACCOUNT_DELAY_MS || String(5 * 60 * 1000),
|
| 35 |
+
10,
|
| 36 |
+
),
|
| 37 |
+
quotaCheckOrder: process.env.QUOTA_CHECK_ORDER === 'newest' ? 'newest' : 'oldest',
|
| 38 |
+
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || '15', 10),
|
| 39 |
+
staleRecoveryInterval: parseInt(process.env.STALE_RECOVERY_INTERVAL || '5', 10),
|
| 40 |
+
|
| 41 |
+
// Report
|
| 42 |
+
reportRetentionHours: parseInt(process.env.REPORT_RETENTION_HOURS || '24', 10),
|
| 43 |
+
|
| 44 |
+
// Server
|
| 45 |
+
port: parseInt(process.env.PORT || '7860', 10),
|
| 46 |
+
internalSecret: process.env.INTERNAL_SECRET || '',
|
| 47 |
+
allowedOrigins: (process.env.ALLOWED_ORIGINS || process.env.FRONTEND_ORIGIN || '')
|
| 48 |
+
.split(',')
|
| 49 |
+
.map((origin) => origin.trim())
|
| 50 |
+
.filter(Boolean),
|
| 51 |
+
enableWorker: process.env.ENABLE_WORKER !== 'false',
|
| 52 |
+
enableCron: process.env.ENABLE_CRON !== 'false',
|
| 53 |
+
|
| 54 |
+
// Timeouts
|
| 55 |
+
similarityTimeoutMs: parseInt(process.env.SIMILARITY_TIMEOUT_MS || '180000', 10),
|
| 56 |
+
similarityPollMs: parseInt(process.env.SIMILARITY_POLL_MS || '5000', 10),
|
| 57 |
+
similarityRefreshAfterMs: parseInt(process.env.SIMILARITY_REFRESH_AFTER_MS || '90000', 10),
|
| 58 |
+
// BUG-10 FIX: leaseMinutes was hardcoded to 20 min — shorter than the combined
|
| 59 |
+
// time for login + navigate + similarity wait (up to 30+ min). Stale recovery
|
| 60 |
+
// could reset an account that is still being actively used.
|
| 61 |
+
leaseMinutes: parseInt(process.env.LEASE_MINUTES || '35', 10),
|
| 62 |
+
} as const;
|
| 63 |
+
|
| 64 |
+
/** Validate required environment variables at startup */
|
| 65 |
+
export function validateConfig(): void {
|
| 66 |
+
const required: (keyof typeof config)[] = [
|
| 67 |
+
'supabaseUrl',
|
| 68 |
+
'supabaseServiceRoleKey',
|
| 69 |
+
'turnitinSharedPassword',
|
| 70 |
+
];
|
| 71 |
+
|
| 72 |
+
const missing = required.filter((key) => !config[key]);
|
| 73 |
+
if (missing.length > 0) {
|
| 74 |
+
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
| 75 |
+
}
|
| 76 |
+
}
|
src/cron/cleanup-reports.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from '../config';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
import { getExpiredReportJobs, updateJobFields } from '../db/jobs';
|
| 4 |
+
import { deleteReportPdf } from '../db/storage';
|
| 5 |
+
import { insertJobEvent } from '../db/events';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Periodic cleanup of expired PDF reports.
|
| 9 |
+
* Runs every CLEANUP_INTERVAL minutes.
|
| 10 |
+
*
|
| 11 |
+
* Deletes report and Digital Receipt PDFs after their independent expiry time.
|
| 12 |
+
* Deleted paths remain available in job events for the audit trail.
|
| 13 |
+
*/
|
| 14 |
+
export async function runCleanupReportsCron(): Promise<void> {
|
| 15 |
+
const cronLog = logger.child({ cron: 'cleanup-reports' });
|
| 16 |
+
cronLog.info('Starting report cleanup cron');
|
| 17 |
+
|
| 18 |
+
try {
|
| 19 |
+
const expiredJobs = await getExpiredReportJobs();
|
| 20 |
+
|
| 21 |
+
if (expiredJobs.length === 0) {
|
| 22 |
+
cronLog.info('No expired reports to clean up');
|
| 23 |
+
return;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
cronLog.info(`Found ${expiredJobs.length} expired reports to clean up`);
|
| 27 |
+
|
| 28 |
+
let deleted = 0;
|
| 29 |
+
let failed = 0;
|
| 30 |
+
|
| 31 |
+
for (const job of expiredJobs) {
|
| 32 |
+
const now = Date.now();
|
| 33 |
+
|
| 34 |
+
if (
|
| 35 |
+
job.output_pdf_path &&
|
| 36 |
+
job.output_pdf_expires_at &&
|
| 37 |
+
new Date(job.output_pdf_expires_at).getTime() <= now
|
| 38 |
+
) {
|
| 39 |
+
try {
|
| 40 |
+
await deleteReportPdf(job.output_pdf_path);
|
| 41 |
+
deleted++;
|
| 42 |
+
await updateJobFields(job.id, { output_pdf_path: null });
|
| 43 |
+
|
| 44 |
+
await insertJobEvent({
|
| 45 |
+
job_id: job.id,
|
| 46 |
+
identity_id: null,
|
| 47 |
+
level: 'info',
|
| 48 |
+
step: 'report_expired_deleted',
|
| 49 |
+
message: `PDF report deleted after expiry: ${job.output_pdf_path}`,
|
| 50 |
+
metadata: { expired_path: job.output_pdf_path },
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
cronLog.info(`Deleted expired report for job ${job.id}`);
|
| 54 |
+
} catch (err) {
|
| 55 |
+
failed++;
|
| 56 |
+
cronLog.error(`Failed to delete report for job ${job.id}`, {
|
| 57 |
+
error: err instanceof Error ? err.message : String(err),
|
| 58 |
+
path: job.output_pdf_path,
|
| 59 |
+
});
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (
|
| 64 |
+
job.receipt_pdf_path &&
|
| 65 |
+
job.receipt_pdf_expires_at &&
|
| 66 |
+
new Date(job.receipt_pdf_expires_at).getTime() <= now
|
| 67 |
+
) {
|
| 68 |
+
try {
|
| 69 |
+
await deleteReportPdf(job.receipt_pdf_path);
|
| 70 |
+
deleted++;
|
| 71 |
+
await updateJobFields(job.id, { receipt_pdf_path: null });
|
| 72 |
+
|
| 73 |
+
await insertJobEvent({
|
| 74 |
+
job_id: job.id,
|
| 75 |
+
identity_id: null,
|
| 76 |
+
level: 'info',
|
| 77 |
+
step: 'receipt_expired_deleted',
|
| 78 |
+
message: `Digital Receipt deleted after expiry: ${job.receipt_pdf_path}`,
|
| 79 |
+
metadata: { expired_path: job.receipt_pdf_path },
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
cronLog.info(`Deleted expired Digital Receipt for job ${job.id}`);
|
| 83 |
+
} catch (err) {
|
| 84 |
+
failed++;
|
| 85 |
+
cronLog.error(`Failed to delete Digital Receipt for job ${job.id}`, {
|
| 86 |
+
error: err instanceof Error ? err.message : String(err),
|
| 87 |
+
path: job.receipt_pdf_path,
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
cronLog.info('Report cleanup cron completed', { deleted, failed });
|
| 94 |
+
} catch (err) {
|
| 95 |
+
cronLog.error('Report cleanup cron failed', {
|
| 96 |
+
error: err instanceof Error ? err.message : String(err),
|
| 97 |
+
});
|
| 98 |
+
}
|
| 99 |
+
}
|
src/cron/quota-check.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from '../config';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
claimSpecificAccount,
|
| 5 |
+
getAccountsForQuotaCheck,
|
| 6 |
+
releaseAccount,
|
| 7 |
+
type TurnitinAccount,
|
| 8 |
+
updateAccountQuota,
|
| 9 |
+
} from '../db/accounts';
|
| 10 |
+
import { downloadStorageState } from '../db/storage';
|
| 11 |
+
import { getAccountPassword } from '../crypto/password';
|
| 12 |
+
import { runTurnitinJob } from '../engine/turnitin';
|
| 13 |
+
import * as fs from 'fs';
|
| 14 |
+
import * as os from 'os';
|
| 15 |
+
import * as path from 'path';
|
| 16 |
+
|
| 17 |
+
let quotaCheckCronRunning = false;
|
| 18 |
+
|
| 19 |
+
function sleep(ms: number): Promise<void> {
|
| 20 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function safeReleaseStatus(status: string | null | undefined): string {
|
| 24 |
+
if (status === 'cooling_down' || status === 'quota_limited') return status;
|
| 25 |
+
return 'available';
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function isTargetUnavailableError(errorMessage: string): boolean {
|
| 29 |
+
return /waiting for locator\('td\.class_name a|waiting for locator\('tr\.assignment-row|Class title|class.*not found|assignment.*not found|Summer Reading 2026/i.test(
|
| 30 |
+
errorMessage,
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface QuotaCheckAccountOptions {
|
| 35 |
+
source?: 'cron' | 'manual';
|
| 36 |
+
fallbackStatus?: string | null;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Execute the actual Turnitin quota check for an account that has already been
|
| 41 |
+
* leased. Used by both scheduled cron checks and admin-triggered manual checks.
|
| 42 |
+
*/
|
| 43 |
+
export async function runQuotaCheckForAccount(
|
| 44 |
+
account: TurnitinAccount,
|
| 45 |
+
options: QuotaCheckAccountOptions = {},
|
| 46 |
+
): Promise<void> {
|
| 47 |
+
const source = options.source || 'cron';
|
| 48 |
+
const fallbackStatus = safeReleaseStatus(options.fallbackStatus || account.turnitin_status);
|
| 49 |
+
const checkLog = logger.child(
|
| 50 |
+
source === 'manual'
|
| 51 |
+
? { maintenance: 'manual-quota-check', accountId: account.id }
|
| 52 |
+
: { cron: 'quota-check' },
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
let releaseStatus = fallbackStatus;
|
| 56 |
+
let releaseMessage: string | undefined;
|
| 57 |
+
|
| 58 |
+
try {
|
| 59 |
+
checkLog.info(`Checking quota for account ${account.email}`, {
|
| 60 |
+
accountId: account.id,
|
| 61 |
+
source,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
let storageStatePath: string | undefined;
|
| 65 |
+
if (account.turnitin_session_storage_path) {
|
| 66 |
+
const storageState = await downloadStorageState(account.turnitin_session_storage_path);
|
| 67 |
+
if (storageState) {
|
| 68 |
+
const tmpDir = path.join(os.tmpdir(), `turnitin-quota-${account.id}`);
|
| 69 |
+
fs.mkdirSync(tmpDir, { recursive: true });
|
| 70 |
+
storageStatePath = path.join(tmpDir, 'storageState.json');
|
| 71 |
+
fs.writeFileSync(storageStatePath, storageState, 'utf-8');
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const password = getAccountPassword();
|
| 76 |
+
|
| 77 |
+
const result = await runTurnitinJob({
|
| 78 |
+
account: {
|
| 79 |
+
id: account.id,
|
| 80 |
+
email: account.email,
|
| 81 |
+
password,
|
| 82 |
+
},
|
| 83 |
+
assignmentTarget: {
|
| 84 |
+
targetUrl: config.turnitinTargetUrl,
|
| 85 |
+
classTitle: config.turnitinClassTitle,
|
| 86 |
+
assignmentTitle: config.turnitinAssignmentTitle,
|
| 87 |
+
},
|
| 88 |
+
inputFilePath: '',
|
| 89 |
+
outputDir: '',
|
| 90 |
+
mode: 'quota_check',
|
| 91 |
+
filters: {},
|
| 92 |
+
storageStatePath,
|
| 93 |
+
onEvent: async (event) => {
|
| 94 |
+
checkLog.info(`[${account.email}] ${event.step}: ${event.message}`);
|
| 95 |
+
},
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
if (result.quotaLimit) {
|
| 99 |
+
await updateAccountQuota(account.id, {
|
| 100 |
+
turnitin_status: 'quota_limited',
|
| 101 |
+
turnitin_quota_remaining: 0,
|
| 102 |
+
turnitin_quota_message: result.quotaLimit.message,
|
| 103 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 104 |
+
turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 105 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 106 |
+
});
|
| 107 |
+
releaseStatus = 'quota_limited';
|
| 108 |
+
releaseMessage = result.quotaLimit.message;
|
| 109 |
+
checkLog.info(`Account ${account.email} is quota limited`, {
|
| 110 |
+
accountId: account.id,
|
| 111 |
+
source,
|
| 112 |
+
});
|
| 113 |
+
} else {
|
| 114 |
+
await updateAccountQuota(account.id, {
|
| 115 |
+
turnitin_status: 'available',
|
| 116 |
+
turnitin_quota_message: null,
|
| 117 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 118 |
+
});
|
| 119 |
+
releaseStatus = 'available';
|
| 120 |
+
|
| 121 |
+
if (fallbackStatus === 'cooling_down' || fallbackStatus === 'quota_limited') {
|
| 122 |
+
await updateAccountQuota(account.id, {
|
| 123 |
+
turnitin_next_retry_at: null,
|
| 124 |
+
turnitin_quota_remaining: null,
|
| 125 |
+
});
|
| 126 |
+
checkLog.info(`Account ${account.email} recovered from cooldown`, {
|
| 127 |
+
accountId: account.id,
|
| 128 |
+
source,
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
} catch (err) {
|
| 133 |
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
| 134 |
+
checkLog.error(`Quota check failed for account ${account.email}`, {
|
| 135 |
+
accountId: account.id,
|
| 136 |
+
source,
|
| 137 |
+
error: errorMessage,
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
try {
|
| 141 |
+
if (isTargetUnavailableError(errorMessage)) {
|
| 142 |
+
await updateAccountQuota(account.id, {
|
| 143 |
+
turnitin_status: 'quota_limited',
|
| 144 |
+
turnitin_quota_remaining: 0,
|
| 145 |
+
turnitin_quota_message:
|
| 146 |
+
'Target class or assignment is not available. This account is treated as limit because the class may have been dropped.',
|
| 147 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 148 |
+
turnitin_next_retry_at: null,
|
| 149 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 150 |
+
turnitin_last_error: errorMessage,
|
| 151 |
+
});
|
| 152 |
+
releaseStatus = 'quota_limited';
|
| 153 |
+
} else {
|
| 154 |
+
await updateAccountQuota(account.id, {
|
| 155 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 156 |
+
turnitin_last_error: errorMessage,
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
} catch {
|
| 160 |
+
// Ignore update error; release still runs below.
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
releaseMessage = errorMessage;
|
| 164 |
+
} finally {
|
| 165 |
+
await releaseAccount(account.id, releaseStatus, releaseMessage).catch((releaseErr: unknown) => {
|
| 166 |
+
checkLog.error(`Failed to release account ${account.email} after quota check`, {
|
| 167 |
+
accountId: account.id,
|
| 168 |
+
source,
|
| 169 |
+
error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
|
| 170 |
+
});
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Periodic quota check for Turnitin accounts.
|
| 177 |
+
* Runs every QUOTA_CHECK_INTERVAL minutes.
|
| 178 |
+
*
|
| 179 |
+
* Checks:
|
| 180 |
+
* 1. Available accounts not checked recently
|
| 181 |
+
* 2. Cooling-down accounts whose next_retry_at has passed
|
| 182 |
+
*/
|
| 183 |
+
export async function runQuotaCheckCron(): Promise<void> {
|
| 184 |
+
const cronLog = logger.child({ cron: 'quota-check' });
|
| 185 |
+
if (quotaCheckCronRunning) {
|
| 186 |
+
cronLog.info('Previous quota check cron is still running; skipping this tick');
|
| 187 |
+
return;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
quotaCheckCronRunning = true;
|
| 191 |
+
cronLog.info('Starting quota check cron');
|
| 192 |
+
|
| 193 |
+
try {
|
| 194 |
+
const accounts = await getAccountsForQuotaCheck(config.quotaCheckOrder, 'modern_lti');
|
| 195 |
+
|
| 196 |
+
if (accounts.length === 0) {
|
| 197 |
+
cronLog.info('No accounts need quota check');
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
cronLog.info(`Found ${accounts.length} accounts to check`, {
|
| 202 |
+
order: config.quotaCheckOrder,
|
| 203 |
+
accountDelayMs: config.quotaCheckAccountDelayMs,
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
// Process accounts sequentially to avoid overwhelming the browser
|
| 207 |
+
for (let index = 0; index < accounts.length; index++) {
|
| 208 |
+
const account = accounts[index];
|
| 209 |
+
try {
|
| 210 |
+
const claimed = await claimSpecificAccount(
|
| 211 |
+
account.id,
|
| 212 |
+
`${config.workerId}:quota-check`,
|
| 213 |
+
);
|
| 214 |
+
if (!claimed) {
|
| 215 |
+
cronLog.info(`Skipping account already leased ${account.email}`, {
|
| 216 |
+
accountId: account.id,
|
| 217 |
+
});
|
| 218 |
+
continue;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
await runQuotaCheckForAccount(account, {
|
| 222 |
+
source: 'cron',
|
| 223 |
+
fallbackStatus: account.turnitin_status || 'available',
|
| 224 |
+
});
|
| 225 |
+
} catch (outerErr) {
|
| 226 |
+
// This catches errors from claimSpecificAccount itself or variable setup
|
| 227 |
+
cronLog.error(`Outer error processing account ${account.email}`, {
|
| 228 |
+
accountId: account.id,
|
| 229 |
+
error: outerErr instanceof Error ? outerErr.message : String(outerErr),
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
if (index < accounts.length - 1 && config.quotaCheckAccountDelayMs > 0) {
|
| 234 |
+
cronLog.info('Waiting before next quota account check', {
|
| 235 |
+
delayMs: config.quotaCheckAccountDelayMs,
|
| 236 |
+
nextIndex: index + 2,
|
| 237 |
+
total: accounts.length,
|
| 238 |
+
});
|
| 239 |
+
await sleep(config.quotaCheckAccountDelayMs);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
cronLog.info('Quota check cron completed');
|
| 244 |
+
} catch (err) {
|
| 245 |
+
cronLog.error('Quota check cron failed', {
|
| 246 |
+
error: err instanceof Error ? err.message : String(err),
|
| 247 |
+
});
|
| 248 |
+
} finally {
|
| 249 |
+
quotaCheckCronRunning = false;
|
| 250 |
+
}
|
| 251 |
+
}
|
src/cron/stale-recovery.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from '../config';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
import { getStaleJobs, updateJobStatus } from '../db/jobs';
|
| 4 |
+
import {
|
| 5 |
+
claimLegacyCooldownAccountForClassDrop,
|
| 6 |
+
getLegacyCooldownAccountsForClassDrop,
|
| 7 |
+
getStaleAccounts,
|
| 8 |
+
releaseAccount,
|
| 9 |
+
resetStaleAccount,
|
| 10 |
+
updateAccountQuota,
|
| 11 |
+
} from '../db/accounts';
|
| 12 |
+
import { insertJobEvent } from '../db/events';
|
| 13 |
+
import { refundFailedJob } from '../db/tickets';
|
| 14 |
+
import { supabase } from '../db/client';
|
| 15 |
+
import { downloadStorageState } from '../db/storage';
|
| 16 |
+
import { getAccountPassword } from '../crypto/password';
|
| 17 |
+
import { getBrowser } from '../worker/browser-pool';
|
| 18 |
+
import { loginToTurnitin } from '../engine/steps/login';
|
| 19 |
+
import { dropClassByTitle } from '../engine/steps/class-management';
|
| 20 |
+
import * as fs from 'fs';
|
| 21 |
+
import * as os from 'os';
|
| 22 |
+
import * as path from 'path';
|
| 23 |
+
import type { BrowserContext } from 'playwright';
|
| 24 |
+
|
| 25 |
+
async function getLegacyClassTitles(): Promise<string[]> {
|
| 26 |
+
const titles = new Set<string>();
|
| 27 |
+
|
| 28 |
+
const { data, error } = await supabase
|
| 29 |
+
.from('turnitin_assignment_targets')
|
| 30 |
+
.select('class_title, ui_variant, account_pool_key')
|
| 31 |
+
.or('ui_variant.eq.legacy_carta,account_pool_key.eq.legacy_carta');
|
| 32 |
+
|
| 33 |
+
if (error) {
|
| 34 |
+
logger.warn('Failed to read legacy assignment targets for class cleanup; using default class title', {
|
| 35 |
+
error: error.message,
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
for (const row of data || []) {
|
| 40 |
+
const title = typeof row.class_title === 'string' ? row.class_title.trim() : '';
|
| 41 |
+
if (title) titles.add(title);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (titles.size === 0 && config.turnitinClassTitle.trim()) {
|
| 45 |
+
titles.add(config.turnitinClassTitle.trim());
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return Array.from(titles);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function dropLegacyCooldownAccountClass(
|
| 52 |
+
accountId: string,
|
| 53 |
+
classTitles: string[],
|
| 54 |
+
): Promise<boolean> {
|
| 55 |
+
const cleanupLog = logger.child({ cron: 'legacy-cooldown-class-cleanup', accountId });
|
| 56 |
+
const account = await claimLegacyCooldownAccountForClassDrop(
|
| 57 |
+
accountId,
|
| 58 |
+
`${config.workerId}:legacy-class-cleanup`,
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
if (!account) {
|
| 62 |
+
cleanupLog.info('Legacy cooldown account was already claimed or is no longer eligible');
|
| 63 |
+
return false;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
let context: BrowserContext | null = null;
|
| 67 |
+
let storageStatePath: string | undefined;
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
cleanupLog.info('Cleaning up overdue legacy cooldown account', {
|
| 71 |
+
email: account.email,
|
| 72 |
+
classTitles,
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
if (account.turnitin_session_storage_path) {
|
| 76 |
+
const storageState = await downloadStorageState(account.turnitin_session_storage_path);
|
| 77 |
+
if (storageState) {
|
| 78 |
+
const tmpDir = path.join(os.tmpdir(), `turnitin-legacy-class-cleanup-${account.id}`);
|
| 79 |
+
fs.mkdirSync(tmpDir, { recursive: true });
|
| 80 |
+
storageStatePath = path.join(tmpDir, 'storageState.json');
|
| 81 |
+
fs.writeFileSync(storageStatePath, storageState, 'utf-8');
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const browser = await getBrowser();
|
| 86 |
+
context = await browser.newContext({
|
| 87 |
+
userAgent:
|
| 88 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
| 89 |
+
viewport: { width: 1366, height: 768 },
|
| 90 |
+
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
| 91 |
+
...(storageStatePath ? { storageState: storageStatePath } : {}),
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
const page = await context.newPage();
|
| 95 |
+
await loginToTurnitin(
|
| 96 |
+
page,
|
| 97 |
+
account.email,
|
| 98 |
+
getAccountPassword(),
|
| 99 |
+
storageStatePath,
|
| 100 |
+
config.turnitinTargetUrl,
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
const dropResults = [];
|
| 104 |
+
for (const classTitle of classTitles) {
|
| 105 |
+
const result = await dropClassByTitle(page, classTitle).catch((error: unknown) => ({
|
| 106 |
+
attempted: true,
|
| 107 |
+
dropped: false,
|
| 108 |
+
reason: error instanceof Error ? error.message : String(error),
|
| 109 |
+
}));
|
| 110 |
+
dropResults.push({ classTitle, ...result });
|
| 111 |
+
if (result.dropped) break;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const successfulDrop = dropResults.some((result) => result.dropped);
|
| 115 |
+
if (!successfulDrop) {
|
| 116 |
+
const message = `Legacy cooldown class cleanup could not drop any configured class: ${dropResults
|
| 117 |
+
.map((result) => `${result.classTitle}: ${result.reason || 'not dropped'}`)
|
| 118 |
+
.join('; ')}`;
|
| 119 |
+
cleanupLog.warn(message);
|
| 120 |
+
await updateAccountQuota(account.id, {
|
| 121 |
+
turnitin_status: 'cooling_down',
|
| 122 |
+
turnitin_quota_message: 'Legacy cooldown class cleanup pending.',
|
| 123 |
+
turnitin_next_retry_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
| 124 |
+
turnitin_last_error: message,
|
| 125 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 126 |
+
});
|
| 127 |
+
await releaseAccount(account.id, 'cooling_down', message);
|
| 128 |
+
return false;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const message =
|
| 132 |
+
'Legacy account was already cooling down before permanent cleanup; class dropped and account permanently limited.';
|
| 133 |
+
await updateAccountQuota(account.id, {
|
| 134 |
+
turnitin_status: 'quota_limited',
|
| 135 |
+
turnitin_quota_remaining: 0,
|
| 136 |
+
turnitin_quota_message: message,
|
| 137 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 138 |
+
turnitin_next_retry_at: null,
|
| 139 |
+
turnitin_last_error: null,
|
| 140 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 141 |
+
});
|
| 142 |
+
await releaseAccount(account.id, 'quota_limited', message);
|
| 143 |
+
cleanupLog.info('Legacy cooldown account permanently limited after class cleanup', {
|
| 144 |
+
dropResults,
|
| 145 |
+
});
|
| 146 |
+
return true;
|
| 147 |
+
} catch (error) {
|
| 148 |
+
const message = error instanceof Error ? error.message : String(error);
|
| 149 |
+
const isLoginError = /login/i.test(message);
|
| 150 |
+
cleanupLog.error('Legacy cooldown class cleanup failed', { error: message });
|
| 151 |
+
await updateAccountQuota(account.id, {
|
| 152 |
+
turnitin_status: isLoginError ? 'quota_limited' : 'cooling_down',
|
| 153 |
+
turnitin_quota_remaining: isLoginError ? 0 : account.turnitin_quota_remaining,
|
| 154 |
+
turnitin_quota_message: isLoginError
|
| 155 |
+
? 'Legacy class cleanup could not log in; account removed from rotation.'
|
| 156 |
+
: 'Legacy class cleanup failed and will retry later.',
|
| 157 |
+
turnitin_quota_detected_at: isLoginError ? new Date().toISOString() : account.turnitin_quota_detected_at,
|
| 158 |
+
turnitin_next_retry_at: isLoginError
|
| 159 |
+
? null
|
| 160 |
+
: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
| 161 |
+
turnitin_last_error: message,
|
| 162 |
+
turnitin_last_checked_at: new Date().toISOString(),
|
| 163 |
+
});
|
| 164 |
+
await releaseAccount(account.id, isLoginError ? 'quota_limited' : 'cooling_down', message);
|
| 165 |
+
return false;
|
| 166 |
+
} finally {
|
| 167 |
+
await context?.close().catch(() => {});
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async function cleanupLegacyCooldownAccounts(): Promise<number> {
|
| 172 |
+
const cleanupLog = logger.child({ cron: 'legacy-cooldown-class-cleanup' });
|
| 173 |
+
const classTitles = await getLegacyClassTitles();
|
| 174 |
+
if (classTitles.length === 0) {
|
| 175 |
+
cleanupLog.warn('No class titles configured for legacy cooldown cleanup');
|
| 176 |
+
return 0;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
const accounts = await getLegacyCooldownAccountsForClassDrop(Math.max(1, Math.min(2, config.maxWorkers)));
|
| 180 |
+
if (accounts.length === 0) return 0;
|
| 181 |
+
|
| 182 |
+
cleanupLog.warn(`Found ${accounts.length} overdue legacy cooldown accounts for class cleanup`, {
|
| 183 |
+
classTitles,
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
let cleaned = 0;
|
| 187 |
+
for (const account of accounts) {
|
| 188 |
+
if (await dropLegacyCooldownAccountClass(account.id, classTitles)) {
|
| 189 |
+
cleaned += 1;
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
return cleaned;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/**
|
| 197 |
+
* Periodic recovery of stale leases.
|
| 198 |
+
* Runs every STALE_RECOVERY_INTERVAL minutes.
|
| 199 |
+
*
|
| 200 |
+
* Handles two cases:
|
| 201 |
+
* 1. Accounts stuck in 'running' with expired leases (worker died)
|
| 202 |
+
* 2. Jobs stuck in running states for too long (worker died mid-job)
|
| 203 |
+
*/
|
| 204 |
+
export async function runStaleRecoveryCron(): Promise<void> {
|
| 205 |
+
const cronLog = logger.child({ cron: 'stale-recovery' });
|
| 206 |
+
cronLog.info('Starting stale recovery cron');
|
| 207 |
+
|
| 208 |
+
try {
|
| 209 |
+
// 1. Recover stale accounts (lease expired but still 'running')
|
| 210 |
+
const staleAccounts = await getStaleAccounts(config.leaseMinutes + 5);
|
| 211 |
+
|
| 212 |
+
if (staleAccounts.length > 0) {
|
| 213 |
+
cronLog.warn(`Found ${staleAccounts.length} stale accounts to recover`);
|
| 214 |
+
|
| 215 |
+
for (const account of staleAccounts) {
|
| 216 |
+
try {
|
| 217 |
+
await resetStaleAccount(account.id);
|
| 218 |
+
cronLog.info(`Recovered stale account ${account.email}`, {
|
| 219 |
+
accountId: account.id,
|
| 220 |
+
leaseOwner: account.turnitin_lease_owner,
|
| 221 |
+
});
|
| 222 |
+
} catch (err) {
|
| 223 |
+
cronLog.error(`Failed to recover stale account ${account.id}`, {
|
| 224 |
+
error: err instanceof Error ? err.message : String(err),
|
| 225 |
+
});
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
// 2. Recover stale jobs (stuck in running states for too long)
|
| 231 |
+
const staleJobs = await getStaleJobs(config.leaseMinutes + 5);
|
| 232 |
+
|
| 233 |
+
if (staleJobs.length > 0) {
|
| 234 |
+
cronLog.warn(`Found ${staleJobs.length} stale jobs to recover`);
|
| 235 |
+
|
| 236 |
+
for (const job of staleJobs) {
|
| 237 |
+
try {
|
| 238 |
+
const attemptCount = (job.attempt_count as number) || 0;
|
| 239 |
+
const maxAttempts = (job.max_attempts as number) || 3;
|
| 240 |
+
|
| 241 |
+
if (attemptCount < maxAttempts) {
|
| 242 |
+
// Reset to pending for retry
|
| 243 |
+
await updateJobStatus(job.id as string, 'pending', {
|
| 244 |
+
error_message: 'Job recovered from stale state (worker may have died)',
|
| 245 |
+
});
|
| 246 |
+
await insertJobEvent({
|
| 247 |
+
job_id: job.id as string,
|
| 248 |
+
identity_id: null,
|
| 249 |
+
level: 'warning',
|
| 250 |
+
step: 'stale_recovery',
|
| 251 |
+
message: `Job reset to pending after stale detection (attempt ${attemptCount}/${maxAttempts})`,
|
| 252 |
+
metadata: { worker_id: job.worker_id, stale_status: job.status },
|
| 253 |
+
});
|
| 254 |
+
cronLog.info(`Reset stale job ${job.id} to pending`);
|
| 255 |
+
} else {
|
| 256 |
+
// Max attempts reached, mark as failed
|
| 257 |
+
const refundReason = 'Job failed after stale recovery: max attempts reached';
|
| 258 |
+
await updateJobStatus(job.id as string, 'failed', {
|
| 259 |
+
error_message: refundReason,
|
| 260 |
+
finished_at: new Date().toISOString(),
|
| 261 |
+
});
|
| 262 |
+
let refunded = false;
|
| 263 |
+
try {
|
| 264 |
+
refunded = await refundFailedJob(job.id as string, refundReason);
|
| 265 |
+
} catch (refundError) {
|
| 266 |
+
cronLog.warn(`Failed to refund stale job ${job.id}`, {
|
| 267 |
+
error: refundError instanceof Error ? refundError.message : String(refundError),
|
| 268 |
+
});
|
| 269 |
+
}
|
| 270 |
+
await insertJobEvent({
|
| 271 |
+
job_id: job.id as string,
|
| 272 |
+
identity_id: null,
|
| 273 |
+
level: 'error',
|
| 274 |
+
step: 'stale_recovery_failed',
|
| 275 |
+
message: refunded
|
| 276 |
+
? `Job marked as failed after stale recovery (${maxAttempts} attempts exhausted). Ticket refunded.`
|
| 277 |
+
: `Job marked as failed after stale recovery (${maxAttempts} attempts exhausted)`,
|
| 278 |
+
metadata: { worker_id: job.worker_id, refunded },
|
| 279 |
+
});
|
| 280 |
+
cronLog.warn(`Marked stale job ${job.id} as failed (max attempts reached)`);
|
| 281 |
+
}
|
| 282 |
+
} catch (err) {
|
| 283 |
+
cronLog.error(`Failed to recover stale job ${job.id}`, {
|
| 284 |
+
error: err instanceof Error ? err.message : String(err),
|
| 285 |
+
});
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const cleanedLegacyCooldownAccounts = await cleanupLegacyCooldownAccounts();
|
| 291 |
+
|
| 292 |
+
if (staleAccounts.length === 0 && staleJobs.length === 0 && cleanedLegacyCooldownAccounts === 0) {
|
| 293 |
+
cronLog.info('No stale resources found');
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
cronLog.info('Stale recovery cron completed');
|
| 297 |
+
} catch (err) {
|
| 298 |
+
cronLog.error('Stale recovery cron failed', {
|
| 299 |
+
error: err instanceof Error ? err.message : String(err),
|
| 300 |
+
});
|
| 301 |
+
}
|
| 302 |
+
}
|
src/crypto/password.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as crypto from 'crypto';
|
| 2 |
+
import { config } from '../config';
|
| 3 |
+
|
| 4 |
+
const ALGORITHM = 'aes-256-gcm';
|
| 5 |
+
const IV_LENGTH = 16;
|
| 6 |
+
const AUTH_TAG_LENGTH = 16;
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Derive a 32-byte key from the encryption key string.
|
| 10 |
+
* Uses SHA-256 so any length passphrase works.
|
| 11 |
+
*/
|
| 12 |
+
function deriveKey(): Buffer {
|
| 13 |
+
return crypto.createHash('sha256').update(config.credentialEncryptionKey).digest();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Encrypt a plaintext password.
|
| 18 |
+
* Returns base64 string: iv(16) + authTag(16) + ciphertext
|
| 19 |
+
*/
|
| 20 |
+
export function encryptPassword(plaintext: string): string {
|
| 21 |
+
const key = deriveKey();
|
| 22 |
+
const iv = crypto.randomBytes(IV_LENGTH);
|
| 23 |
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
| 24 |
+
|
| 25 |
+
const encrypted = Buffer.concat([
|
| 26 |
+
cipher.update(plaintext, 'utf8'),
|
| 27 |
+
cipher.final(),
|
| 28 |
+
]);
|
| 29 |
+
|
| 30 |
+
const authTag = cipher.getAuthTag();
|
| 31 |
+
|
| 32 |
+
// Format: iv + authTag + ciphertext
|
| 33 |
+
const combined = Buffer.concat([iv, authTag, encrypted]);
|
| 34 |
+
return combined.toString('base64');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Decrypt an encrypted password string.
|
| 39 |
+
* Input is base64 string: iv(16) + authTag(16) + ciphertext
|
| 40 |
+
*/
|
| 41 |
+
export function decryptPassword(encrypted: string): string {
|
| 42 |
+
const key = deriveKey();
|
| 43 |
+
const combined = Buffer.from(encrypted, 'base64');
|
| 44 |
+
|
| 45 |
+
const iv = combined.subarray(0, IV_LENGTH);
|
| 46 |
+
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
| 47 |
+
const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
| 48 |
+
|
| 49 |
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
| 50 |
+
decipher.setAuthTag(authTag);
|
| 51 |
+
|
| 52 |
+
const decrypted = Buffer.concat([
|
| 53 |
+
decipher.update(ciphertext),
|
| 54 |
+
decipher.final(),
|
| 55 |
+
]);
|
| 56 |
+
|
| 57 |
+
return decrypted.toString('utf8');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Get the Turnitin password for an account.
|
| 62 |
+
* If encryption key is set, tries to decrypt. Otherwise uses shared password.
|
| 63 |
+
*/
|
| 64 |
+
export function getAccountPassword(encryptedPassword?: string | null): string {
|
| 65 |
+
if (encryptedPassword && config.credentialEncryptionKey) {
|
| 66 |
+
try {
|
| 67 |
+
return decryptPassword(encryptedPassword);
|
| 68 |
+
} catch {
|
| 69 |
+
// Fallback to shared password if decryption fails
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
return config.turnitinSharedPassword;
|
| 73 |
+
}
|
src/db/accounts.ts
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { supabase } from './client';
|
| 2 |
+
import { config } from '../config';
|
| 3 |
+
import { logger } from '../utils/logger';
|
| 4 |
+
|
| 5 |
+
export interface TurnitinAccount {
|
| 6 |
+
id: string;
|
| 7 |
+
email: string;
|
| 8 |
+
password?: string | null;
|
| 9 |
+
turnitin_status: string;
|
| 10 |
+
turnitin_quota_limit: number | null;
|
| 11 |
+
turnitin_quota_remaining: number | null;
|
| 12 |
+
turnitin_quota_message: string | null;
|
| 13 |
+
turnitin_quota_detected_at: string | null;
|
| 14 |
+
turnitin_first_submission_at: string | null;
|
| 15 |
+
turnitin_next_retry_at: string | null;
|
| 16 |
+
turnitin_lease_owner: string | null;
|
| 17 |
+
turnitin_lease_until: string | null;
|
| 18 |
+
turnitin_last_checked_at: string | null;
|
| 19 |
+
turnitin_last_login_at: string | null;
|
| 20 |
+
turnitin_last_success_at: string | null;
|
| 21 |
+
turnitin_last_error: string | null;
|
| 22 |
+
turnitin_session_storage_path: string | null;
|
| 23 |
+
turnitin_pool_key: string | null;
|
| 24 |
+
created_at: string;
|
| 25 |
+
updated_at: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export interface AccountQuotaUpdate {
|
| 29 |
+
turnitin_status?: string;
|
| 30 |
+
turnitin_quota_limit?: number | null;
|
| 31 |
+
turnitin_quota_remaining?: number | null;
|
| 32 |
+
turnitin_quota_message?: string | null;
|
| 33 |
+
turnitin_quota_detected_at?: string | null;
|
| 34 |
+
turnitin_next_retry_at?: string | null;
|
| 35 |
+
turnitin_last_checked_at?: string | null;
|
| 36 |
+
turnitin_last_success_at?: string | null;
|
| 37 |
+
turnitin_last_error?: string | null;
|
| 38 |
+
turnitin_session_storage_path?: string | null;
|
| 39 |
+
turnitin_pool_key?: string | null;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export interface AccountPoolState {
|
| 43 |
+
total: number;
|
| 44 |
+
available: number;
|
| 45 |
+
running: number;
|
| 46 |
+
coolingDown: number;
|
| 47 |
+
quotaLimited: number;
|
| 48 |
+
loginFailed: number;
|
| 49 |
+
disabled: number;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function isLegacyHardLimitMessage(account: {
|
| 53 |
+
turnitin_quota_message?: string | null;
|
| 54 |
+
turnitin_last_error?: string | null;
|
| 55 |
+
}): boolean {
|
| 56 |
+
const text = `${account.turnitin_quota_message || ''} ${account.turnitin_last_error || ''}`.toLowerCase();
|
| 57 |
+
return (
|
| 58 |
+
text.includes('target class or assignment is not available') ||
|
| 59 |
+
text.includes('login failed') ||
|
| 60 |
+
text.includes('could not log in') ||
|
| 61 |
+
text.includes('credential is invalid') ||
|
| 62 |
+
text.includes('account no longer exists') ||
|
| 63 |
+
/class.*drop/.test(text) ||
|
| 64 |
+
/class.*not.*found/.test(text) ||
|
| 65 |
+
/assignment.*not.*found/.test(text) ||
|
| 66 |
+
text.includes('permanently limited') ||
|
| 67 |
+
text.includes('reached 4 submissions') ||
|
| 68 |
+
text.includes('4-submission limit')
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function isClaimableLegacyAccount(account: TurnitinAccount, nowMs = Date.now()): boolean {
|
| 73 |
+
const status = account.turnitin_status || 'available';
|
| 74 |
+
const quotaLimit = account.turnitin_quota_limit;
|
| 75 |
+
const quotaRemaining = account.turnitin_quota_remaining;
|
| 76 |
+
if (typeof quotaLimit === 'number' && quotaLimit <= 0) return false;
|
| 77 |
+
const hasQuota =
|
| 78 |
+
typeof quotaRemaining === 'number'
|
| 79 |
+
? quotaRemaining > 0
|
| 80 |
+
: status !== 'quota_limited';
|
| 81 |
+
const leaseReady =
|
| 82 |
+
!account.turnitin_lease_until || new Date(account.turnitin_lease_until).getTime() <= nowMs;
|
| 83 |
+
const retryReady =
|
| 84 |
+
!account.turnitin_next_retry_at || new Date(account.turnitin_next_retry_at).getTime() <= nowMs;
|
| 85 |
+
|
| 86 |
+
if (!hasQuota || !leaseReady || !retryReady) return false;
|
| 87 |
+
if (status === 'available' || status === 'cooling_down' || status === 'running') return true;
|
| 88 |
+
if (status === 'quota_limited') return typeof quotaRemaining === 'number' && !isLegacyHardLimitMessage(account);
|
| 89 |
+
return false;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Atomically claim an available Turnitin account via database RPC.
|
| 94 |
+
* Returns the claimed account row, or null if none are available.
|
| 95 |
+
*/
|
| 96 |
+
export async function claimAvailableAccount(
|
| 97 |
+
workerId: string,
|
| 98 |
+
poolKey = 'modern_lti',
|
| 99 |
+
): Promise<TurnitinAccount | null> {
|
| 100 |
+
// NEW-BUG-2 FIX: Reduced from 20 to 3. When all accounts are stale/cooldown,
|
| 101 |
+
// spinning 20 times just wastes DB calls. The manager's wait-for-account loop
|
| 102 |
+
// handles the retry with proper backoff.
|
| 103 |
+
const MAX_STALE_RETRIES = 3;
|
| 104 |
+
for (let attempt = 0; attempt < MAX_STALE_RETRIES; attempt++) {
|
| 105 |
+
let { data, error } = await supabase.rpc('claim_turnitin_identity', {
|
| 106 |
+
p_worker_id: workerId,
|
| 107 |
+
p_pool_key: poolKey,
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
if (
|
| 111 |
+
error &&
|
| 112 |
+
poolKey === 'modern_lti' &&
|
| 113 |
+
/function|schema cache|p_pool_key|claim_turnitin_identity/i.test(error.message)
|
| 114 |
+
) {
|
| 115 |
+
logger.warn('Pool-aware claim RPC is not migrated yet; falling back to legacy modern claim RPC', {
|
| 116 |
+
error: error.message,
|
| 117 |
+
});
|
| 118 |
+
const fallback = await supabase.rpc('claim_turnitin_identity', {
|
| 119 |
+
p_worker_id: workerId,
|
| 120 |
+
});
|
| 121 |
+
data = fallback.data;
|
| 122 |
+
error = fallback.error;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if (error) {
|
| 126 |
+
logger.error('Failed to claim available account', { poolKey, error: error.message });
|
| 127 |
+
throw error;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if (!data || (Array.isArray(data) && data.length === 0)) {
|
| 131 |
+
return null;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const account = Array.isArray(data) ? data[0] : data;
|
| 135 |
+
const nextRetryAt = account.turnitin_next_retry_at
|
| 136 |
+
? new Date(account.turnitin_next_retry_at).getTime()
|
| 137 |
+
: null;
|
| 138 |
+
const quotaRemaining = account.turnitin_quota_remaining;
|
| 139 |
+
const quotaLimit = account.turnitin_quota_limit;
|
| 140 |
+
const staleAvailable =
|
| 141 |
+
(typeof quotaRemaining === 'number' && quotaRemaining <= 0) ||
|
| 142 |
+
(typeof quotaLimit === 'number' && quotaLimit <= 0) ||
|
| 143 |
+
(nextRetryAt !== null && nextRetryAt > Date.now());
|
| 144 |
+
|
| 145 |
+
if (!staleAvailable) {
|
| 146 |
+
return account;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const exhaustedQuota =
|
| 150 |
+
(typeof quotaRemaining === 'number' && quotaRemaining <= 0) ||
|
| 151 |
+
(typeof quotaLimit === 'number' && quotaLimit <= 0);
|
| 152 |
+
const nextStatus = exhaustedQuota ? 'quota_limited' : 'cooling_down';
|
| 153 |
+
const message = exhaustedQuota
|
| 154 |
+
? 'Account has no remaining Turnitin quota and was removed from rotation.'
|
| 155 |
+
: 'Account quota is not currently available.';
|
| 156 |
+
|
| 157 |
+
logger.warn('Claimed stale unavailable account; removing from immediate rotation', {
|
| 158 |
+
accountId: account.id,
|
| 159 |
+
poolKey,
|
| 160 |
+
quotaLimit,
|
| 161 |
+
quotaRemaining,
|
| 162 |
+
nextRetryAt: account.turnitin_next_retry_at,
|
| 163 |
+
nextStatus,
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
await updateAccountQuota(account.id, {
|
| 167 |
+
turnitin_status: nextStatus,
|
| 168 |
+
turnitin_quota_remaining: 0,
|
| 169 |
+
turnitin_quota_message: message,
|
| 170 |
+
turnitin_next_retry_at: exhaustedQuota
|
| 171 |
+
? null
|
| 172 |
+
: account.turnitin_next_retry_at ||
|
| 173 |
+
new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 174 |
+
turnitin_last_error: 'Skipped stale account during claim.',
|
| 175 |
+
});
|
| 176 |
+
await releaseAccount(account.id, nextStatus, 'Skipped stale account during claim.');
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return null;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Claim the exact account that already owns a submitted job.
|
| 184 |
+
* This is used only for post-submit retries where the worker must reopen the
|
| 185 |
+
* same Turnitin report viewer instead of submitting the file again. A
|
| 186 |
+
* quota_limited account is still valid here because report access does not
|
| 187 |
+
* consume another submission; explicitly disabled/login_failed rows remain blocked.
|
| 188 |
+
*/
|
| 189 |
+
export async function claimSpecificAccountForResume(
|
| 190 |
+
identityId: string,
|
| 191 |
+
workerId: string,
|
| 192 |
+
): Promise<TurnitinAccount | null> {
|
| 193 |
+
const now = new Date().toISOString();
|
| 194 |
+
const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
|
| 195 |
+
|
| 196 |
+
const { data, error } = await supabase
|
| 197 |
+
.from('generated_identities')
|
| 198 |
+
.update({
|
| 199 |
+
turnitin_status: 'running',
|
| 200 |
+
turnitin_lease_owner: workerId,
|
| 201 |
+
turnitin_lease_until: leaseUntil,
|
| 202 |
+
updated_at: now,
|
| 203 |
+
})
|
| 204 |
+
.eq('id', identityId)
|
| 205 |
+
.not('turnitin_status', 'in', '(disabled,login_failed)')
|
| 206 |
+
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now},turnitin_lease_owner.eq.${workerId}`)
|
| 207 |
+
.select('*')
|
| 208 |
+
.maybeSingle();
|
| 209 |
+
|
| 210 |
+
if (error) {
|
| 211 |
+
logger.error('Failed to claim specific resume account', {
|
| 212 |
+
identityId,
|
| 213 |
+
error: error.message,
|
| 214 |
+
});
|
| 215 |
+
throw error;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return (data as TurnitinAccount | null) || null;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Lease a specific account for maintenance/quota checks. Returns false when
|
| 223 |
+
* another worker already owns the lease or the account is not claimable.
|
| 224 |
+
*/
|
| 225 |
+
export async function claimSpecificAccount(
|
| 226 |
+
identityId: string,
|
| 227 |
+
workerId: string,
|
| 228 |
+
): Promise<boolean> {
|
| 229 |
+
const now = new Date().toISOString();
|
| 230 |
+
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
| 231 |
+
const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
|
| 232 |
+
|
| 233 |
+
const { data, error } = await supabase
|
| 234 |
+
.from('generated_identities')
|
| 235 |
+
.update({
|
| 236 |
+
turnitin_status: 'running',
|
| 237 |
+
turnitin_lease_owner: workerId,
|
| 238 |
+
turnitin_lease_until: leaseUntil,
|
| 239 |
+
turnitin_last_checked_at: now,
|
| 240 |
+
updated_at: now,
|
| 241 |
+
})
|
| 242 |
+
.eq('id', identityId)
|
| 243 |
+
.in('turnitin_status', ['available', 'cooling_down'])
|
| 244 |
+
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`)
|
| 245 |
+
.or(
|
| 246 |
+
[
|
| 247 |
+
`and(turnitin_status.eq.available,or(turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0),or(turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}))`,
|
| 248 |
+
`and(turnitin_status.eq.cooling_down,turnitin_next_retry_at.lte.${now})`,
|
| 249 |
+
].join(','),
|
| 250 |
+
)
|
| 251 |
+
.select('id')
|
| 252 |
+
.maybeSingle();
|
| 253 |
+
|
| 254 |
+
if (error) {
|
| 255 |
+
logger.error('Failed to claim specific account', {
|
| 256 |
+
identityId,
|
| 257 |
+
error: error.message,
|
| 258 |
+
});
|
| 259 |
+
throw error;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
return Boolean(data);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/**
|
| 266 |
+
* Read legacy accounts that were already cooling down before the permanent-limit
|
| 267 |
+
* cleanup existed. Only accounts whose cooldown window has elapsed are returned,
|
| 268 |
+
* so we do not interfere with an active post-submit retry.
|
| 269 |
+
*/
|
| 270 |
+
export async function getLegacyCooldownAccountsForClassDrop(
|
| 271 |
+
limit = 2,
|
| 272 |
+
): Promise<TurnitinAccount[]> {
|
| 273 |
+
const now = new Date().toISOString();
|
| 274 |
+
|
| 275 |
+
const { data, error } = await supabase
|
| 276 |
+
.from('generated_identities')
|
| 277 |
+
.select('*')
|
| 278 |
+
.eq('turnitin_pool_key', 'legacy_carta')
|
| 279 |
+
.eq('turnitin_status', 'cooling_down')
|
| 280 |
+
.or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
|
| 281 |
+
.order('turnitin_next_retry_at', { ascending: true, nullsFirst: true })
|
| 282 |
+
.limit(limit);
|
| 283 |
+
|
| 284 |
+
if (error) {
|
| 285 |
+
logger.error('Failed to read legacy cooldown accounts for class cleanup', {
|
| 286 |
+
error: error.message,
|
| 287 |
+
});
|
| 288 |
+
throw error;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
return (data as TurnitinAccount[]) || [];
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/**
|
| 295 |
+
* Lease one overdue legacy cooldown account for permanent cleanup. This is
|
| 296 |
+
* intentionally stricter than generic resume claiming and only targets
|
| 297 |
+
* legacy_carta/cooling_down rows whose cooldown has elapsed.
|
| 298 |
+
*/
|
| 299 |
+
export async function claimLegacyCooldownAccountForClassDrop(
|
| 300 |
+
identityId: string,
|
| 301 |
+
workerId: string,
|
| 302 |
+
): Promise<TurnitinAccount | null> {
|
| 303 |
+
const now = new Date().toISOString();
|
| 304 |
+
const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
|
| 305 |
+
|
| 306 |
+
const { data, error } = await supabase
|
| 307 |
+
.from('generated_identities')
|
| 308 |
+
.update({
|
| 309 |
+
turnitin_status: 'running',
|
| 310 |
+
turnitin_lease_owner: workerId,
|
| 311 |
+
turnitin_lease_until: leaseUntil,
|
| 312 |
+
turnitin_last_checked_at: now,
|
| 313 |
+
updated_at: now,
|
| 314 |
+
})
|
| 315 |
+
.eq('id', identityId)
|
| 316 |
+
.eq('turnitin_pool_key', 'legacy_carta')
|
| 317 |
+
.eq('turnitin_status', 'cooling_down')
|
| 318 |
+
.or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
|
| 319 |
+
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`)
|
| 320 |
+
.select('*')
|
| 321 |
+
.maybeSingle();
|
| 322 |
+
|
| 323 |
+
if (error) {
|
| 324 |
+
logger.error('Failed to claim legacy cooldown account for class cleanup', {
|
| 325 |
+
identityId,
|
| 326 |
+
error: error.message,
|
| 327 |
+
});
|
| 328 |
+
throw error;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return (data as TurnitinAccount | null) || null;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/**
|
| 335 |
+
* Read one Turnitin account by id. Used by admin maintenance routes before
|
| 336 |
+
* attempting a manual lease so the previous status can be restored on failure.
|
| 337 |
+
*/
|
| 338 |
+
export async function getTurnitinAccountById(
|
| 339 |
+
identityId: string,
|
| 340 |
+
): Promise<TurnitinAccount | null> {
|
| 341 |
+
const { data, error } = await supabase
|
| 342 |
+
.from('generated_identities')
|
| 343 |
+
.select('*')
|
| 344 |
+
.eq('id', identityId)
|
| 345 |
+
.maybeSingle();
|
| 346 |
+
|
| 347 |
+
if (error) {
|
| 348 |
+
logger.error('Failed to read Turnitin account', {
|
| 349 |
+
identityId,
|
| 350 |
+
error: error.message,
|
| 351 |
+
});
|
| 352 |
+
throw error;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
return (data as TurnitinAccount | null) || null;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/**
|
| 359 |
+
* Lease a specific account for an admin-triggered manual quota check.
|
| 360 |
+
*
|
| 361 |
+
* This intentionally bypasses the "checked in the last 30 minutes" guard used by
|
| 362 |
+
* scheduled quota checks, but it still refuses disabled/login-failed accounts and
|
| 363 |
+
* any account with an active lease.
|
| 364 |
+
*/
|
| 365 |
+
export async function claimSpecificAccountForManualQuota(
|
| 366 |
+
identityId: string,
|
| 367 |
+
workerId: string,
|
| 368 |
+
): Promise<TurnitinAccount | null> {
|
| 369 |
+
const now = new Date().toISOString();
|
| 370 |
+
const leaseUntil = new Date(Date.now() + config.leaseMinutes * 60 * 1000).toISOString();
|
| 371 |
+
|
| 372 |
+
const { data, error } = await supabase
|
| 373 |
+
.from('generated_identities')
|
| 374 |
+
.update({
|
| 375 |
+
turnitin_status: 'running',
|
| 376 |
+
turnitin_lease_owner: workerId,
|
| 377 |
+
turnitin_lease_until: leaseUntil,
|
| 378 |
+
turnitin_last_checked_at: now,
|
| 379 |
+
updated_at: now,
|
| 380 |
+
})
|
| 381 |
+
.eq('id', identityId)
|
| 382 |
+
.not('turnitin_status', 'in', '(disabled,login_failed)')
|
| 383 |
+
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`)
|
| 384 |
+
.select('*')
|
| 385 |
+
.maybeSingle();
|
| 386 |
+
|
| 387 |
+
if (error) {
|
| 388 |
+
logger.error('Failed to claim account for manual quota check', {
|
| 389 |
+
identityId,
|
| 390 |
+
error: error.message,
|
| 391 |
+
});
|
| 392 |
+
throw error;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
return (data as TurnitinAccount | null) || null;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
/**
|
| 399 |
+
* Count accounts that are immediately claimable. Used to avoid hot-looping a
|
| 400 |
+
* job when every account is in cooldown/quota_limited state.
|
| 401 |
+
*/
|
| 402 |
+
export async function countAvailableAccounts(poolKey?: string): Promise<number> {
|
| 403 |
+
const now = new Date().toISOString();
|
| 404 |
+
if (poolKey === 'legacy_carta') {
|
| 405 |
+
const { data, error } = await supabase
|
| 406 |
+
.from('generated_identities')
|
| 407 |
+
.select('*')
|
| 408 |
+
.eq('turnitin_pool_key', poolKey)
|
| 409 |
+
.in('turnitin_status', ['available', 'cooling_down', 'running', 'quota_limited'])
|
| 410 |
+
.or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
|
| 411 |
+
.or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
|
| 412 |
+
.or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
|
| 413 |
+
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`);
|
| 414 |
+
|
| 415 |
+
if (error) {
|
| 416 |
+
logger.error('Failed to count legacy available accounts', { poolKey, error: error.message });
|
| 417 |
+
throw error;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
const nowMs = Date.now();
|
| 421 |
+
return ((data as TurnitinAccount[]) || []).filter((account) =>
|
| 422 |
+
isClaimableLegacyAccount(account, nowMs),
|
| 423 |
+
).length;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
let query = supabase
|
| 427 |
+
.from('generated_identities')
|
| 428 |
+
.select('id', { count: 'exact', head: true })
|
| 429 |
+
.eq('turnitin_status', 'available')
|
| 430 |
+
.or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
|
| 431 |
+
.or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
|
| 432 |
+
.or(`turnitin_next_retry_at.is.null,turnitin_next_retry_at.lte.${now}`)
|
| 433 |
+
.or(`turnitin_lease_until.is.null,turnitin_lease_until.lte.${now}`);
|
| 434 |
+
|
| 435 |
+
if (poolKey) {
|
| 436 |
+
query = query.eq('turnitin_pool_key', poolKey);
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
const { count, error } = await query;
|
| 440 |
+
|
| 441 |
+
if (error) {
|
| 442 |
+
if (
|
| 443 |
+
poolKey === 'modern_lti' &&
|
| 444 |
+
/turnitin_pool_key|column/i.test(error.message)
|
| 445 |
+
) {
|
| 446 |
+
logger.warn('turnitin_pool_key column is not migrated yet; counting all modern accounts without pool filter');
|
| 447 |
+
return countAvailableAccounts();
|
| 448 |
+
}
|
| 449 |
+
logger.error('Failed to count available accounts', { poolKey, error: error.message });
|
| 450 |
+
throw error;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
return count || 0;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
/**
|
| 457 |
+
* Read a compact account pool snapshot for scheduling decisions. The
|
| 458 |
+
* `available` value is the claimable count, not merely rows with status
|
| 459 |
+
* available, so stale zero-quota accounts do not keep jobs looping.
|
| 460 |
+
*/
|
| 461 |
+
export async function getAccountPoolState(poolKey?: string): Promise<AccountPoolState> {
|
| 462 |
+
let rowsQuery = supabase
|
| 463 |
+
.from('generated_identities')
|
| 464 |
+
.select('turnitin_status');
|
| 465 |
+
|
| 466 |
+
if (poolKey) {
|
| 467 |
+
rowsQuery = rowsQuery.eq('turnitin_pool_key', poolKey);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
const [available, { data, error }] = await Promise.all([
|
| 471 |
+
countAvailableAccounts(poolKey),
|
| 472 |
+
rowsQuery,
|
| 473 |
+
]);
|
| 474 |
+
|
| 475 |
+
if (error) {
|
| 476 |
+
if (
|
| 477 |
+
poolKey === 'modern_lti' &&
|
| 478 |
+
/turnitin_pool_key|column/i.test(error.message)
|
| 479 |
+
) {
|
| 480 |
+
logger.warn('turnitin_pool_key column is not migrated yet; reading global account pool state');
|
| 481 |
+
return getAccountPoolState();
|
| 482 |
+
}
|
| 483 |
+
logger.error('Failed to read account pool state', { poolKey, error: error.message });
|
| 484 |
+
throw error;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const rows = (data || []) as Array<{ turnitin_status: string | null }>;
|
| 488 |
+
const counts: AccountPoolState = {
|
| 489 |
+
total: rows.length,
|
| 490 |
+
available,
|
| 491 |
+
running: 0,
|
| 492 |
+
coolingDown: 0,
|
| 493 |
+
quotaLimited: 0,
|
| 494 |
+
loginFailed: 0,
|
| 495 |
+
disabled: 0,
|
| 496 |
+
};
|
| 497 |
+
|
| 498 |
+
for (const row of rows) {
|
| 499 |
+
const status = row.turnitin_status || 'available';
|
| 500 |
+
if (status === 'running') counts.running++;
|
| 501 |
+
else if (status === 'cooling_down') counts.coolingDown++;
|
| 502 |
+
else if (status === 'quota_limited') counts.quotaLimited++;
|
| 503 |
+
else if (status === 'login_failed') counts.loginFailed++;
|
| 504 |
+
else if (status === 'disabled') counts.disabled++;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
return counts;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
/**
|
| 511 |
+
* Release a claimed account back to the pool via database RPC.
|
| 512 |
+
* Sets the next status and optionally records an error message.
|
| 513 |
+
*/
|
| 514 |
+
export async function releaseAccount(
|
| 515 |
+
identityId: string,
|
| 516 |
+
nextStatus: string,
|
| 517 |
+
errorMessage?: string,
|
| 518 |
+
): Promise<void> {
|
| 519 |
+
const { error } = await supabase.rpc('release_turnitin_identity', {
|
| 520 |
+
p_identity_id: identityId,
|
| 521 |
+
p_next_status: nextStatus,
|
| 522 |
+
p_error_message: errorMessage || null,
|
| 523 |
+
});
|
| 524 |
+
|
| 525 |
+
if (error) {
|
| 526 |
+
logger.error('Failed to release account', { identityId, error: error.message });
|
| 527 |
+
throw error;
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/**
|
| 532 |
+
* Update quota-related fields on a Turnitin account.
|
| 533 |
+
*/
|
| 534 |
+
export async function updateAccountQuota(
|
| 535 |
+
identityId: string,
|
| 536 |
+
update: Partial<AccountQuotaUpdate>,
|
| 537 |
+
): Promise<void> {
|
| 538 |
+
const { error } = await supabase
|
| 539 |
+
.from('generated_identities')
|
| 540 |
+
.update({ ...update, updated_at: new Date().toISOString() })
|
| 541 |
+
.eq('id', identityId);
|
| 542 |
+
|
| 543 |
+
if (error) {
|
| 544 |
+
logger.error('Failed to update account quota', { identityId, error: error.message });
|
| 545 |
+
throw error;
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/**
|
| 550 |
+
* Get accounts that are due for a quota check:
|
| 551 |
+
* - Available accounts not checked in the last 30 minutes
|
| 552 |
+
* - Cooling-down accounts whose next_retry_at has passed
|
| 553 |
+
*/
|
| 554 |
+
export async function getAccountsForQuotaCheck(
|
| 555 |
+
order: 'oldest' | 'newest' = 'oldest',
|
| 556 |
+
poolKey = 'modern_lti',
|
| 557 |
+
): Promise<TurnitinAccount[]> {
|
| 558 |
+
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
| 559 |
+
const now = new Date().toISOString();
|
| 560 |
+
|
| 561 |
+
// Available accounts not checked recently
|
| 562 |
+
let availableQuery = supabase
|
| 563 |
+
.from('generated_identities')
|
| 564 |
+
.select('*')
|
| 565 |
+
.eq('turnitin_status', 'available')
|
| 566 |
+
.or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
|
| 567 |
+
.or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
|
| 568 |
+
.or(`turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}`);
|
| 569 |
+
|
| 570 |
+
if (poolKey) {
|
| 571 |
+
availableQuery = availableQuery.eq('turnitin_pool_key', poolKey);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
let { data: available, error: err1 } = await availableQuery;
|
| 575 |
+
|
| 576 |
+
if (
|
| 577 |
+
err1 &&
|
| 578 |
+
poolKey === 'modern_lti' &&
|
| 579 |
+
/turnitin_pool_key|column/i.test(err1.message)
|
| 580 |
+
) {
|
| 581 |
+
logger.warn('turnitin_pool_key column is not migrated yet; quota check will use global modern account query');
|
| 582 |
+
const fallback = await supabase
|
| 583 |
+
.from('generated_identities')
|
| 584 |
+
.select('*')
|
| 585 |
+
.eq('turnitin_status', 'available')
|
| 586 |
+
.or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
|
| 587 |
+
.or('turnitin_quota_remaining.is.null,turnitin_quota_remaining.gt.0')
|
| 588 |
+
.or(`turnitin_last_checked_at.is.null,turnitin_last_checked_at.lt.${thirtyMinAgo}`);
|
| 589 |
+
available = fallback.data;
|
| 590 |
+
err1 = fallback.error;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
if (err1) {
|
| 594 |
+
logger.error('Failed to get available accounts for quota check', { error: err1.message });
|
| 595 |
+
throw err1;
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
// Cooling-down accounts ready to retry
|
| 599 |
+
let coolingDownQuery = supabase
|
| 600 |
+
.from('generated_identities')
|
| 601 |
+
.select('*')
|
| 602 |
+
.eq('turnitin_status', 'cooling_down')
|
| 603 |
+
.or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
|
| 604 |
+
.lte('turnitin_next_retry_at', now);
|
| 605 |
+
|
| 606 |
+
if (poolKey) {
|
| 607 |
+
coolingDownQuery = coolingDownQuery.eq('turnitin_pool_key', poolKey);
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
let { data: coolingDown, error: err2 } = await coolingDownQuery;
|
| 611 |
+
|
| 612 |
+
if (
|
| 613 |
+
err2 &&
|
| 614 |
+
poolKey === 'modern_lti' &&
|
| 615 |
+
/turnitin_pool_key|column/i.test(err2.message)
|
| 616 |
+
) {
|
| 617 |
+
const fallback = await supabase
|
| 618 |
+
.from('generated_identities')
|
| 619 |
+
.select('*')
|
| 620 |
+
.eq('turnitin_status', 'cooling_down')
|
| 621 |
+
.or('turnitin_quota_limit.is.null,turnitin_quota_limit.gt.0')
|
| 622 |
+
.lte('turnitin_next_retry_at', now);
|
| 623 |
+
coolingDown = fallback.data;
|
| 624 |
+
err2 = fallback.error;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
if (err2) {
|
| 628 |
+
logger.error('Failed to get cooling-down accounts for quota check', { error: err2.message });
|
| 629 |
+
throw err2;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
const accounts = [
|
| 633 |
+
...((available as TurnitinAccount[]) || []),
|
| 634 |
+
...((coolingDown as TurnitinAccount[]) || []),
|
| 635 |
+
];
|
| 636 |
+
|
| 637 |
+
return accounts.sort((a, b) => {
|
| 638 |
+
const aChecked = a.turnitin_last_checked_at
|
| 639 |
+
? new Date(a.turnitin_last_checked_at).getTime()
|
| 640 |
+
: null;
|
| 641 |
+
const bChecked = b.turnitin_last_checked_at
|
| 642 |
+
? new Date(b.turnitin_last_checked_at).getTime()
|
| 643 |
+
: null;
|
| 644 |
+
|
| 645 |
+
if (aChecked === null && bChecked === null) {
|
| 646 |
+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
if (order === 'oldest') {
|
| 650 |
+
if (aChecked === null) return -1;
|
| 651 |
+
if (bChecked === null) return 1;
|
| 652 |
+
return aChecked - bChecked;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
if (aChecked === null) return 1;
|
| 656 |
+
if (bChecked === null) return -1;
|
| 657 |
+
return bChecked - aChecked;
|
| 658 |
+
});
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
/**
|
| 662 |
+
* Find accounts with expired leases that are still in 'running' status.
|
| 663 |
+
*/
|
| 664 |
+
export async function getStaleAccounts(staleSinceMinutes: number): Promise<TurnitinAccount[]> {
|
| 665 |
+
const cutoff = new Date(Date.now() - staleSinceMinutes * 60 * 1000).toISOString();
|
| 666 |
+
|
| 667 |
+
const { data, error } = await supabase
|
| 668 |
+
.from('generated_identities')
|
| 669 |
+
.select('*')
|
| 670 |
+
.eq('turnitin_status', 'running')
|
| 671 |
+
.lt('turnitin_lease_until', cutoff);
|
| 672 |
+
|
| 673 |
+
if (error) {
|
| 674 |
+
logger.error('Failed to get stale accounts', { error: error.message });
|
| 675 |
+
throw error;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
return (data as TurnitinAccount[]) || [];
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
/**
|
| 682 |
+
* Reset a stale account back to 'available' status.
|
| 683 |
+
*/
|
| 684 |
+
export async function resetStaleAccount(identityId: string): Promise<void> {
|
| 685 |
+
const { error } = await supabase
|
| 686 |
+
.from('generated_identities')
|
| 687 |
+
.update({
|
| 688 |
+
turnitin_status: 'available',
|
| 689 |
+
turnitin_lease_owner: null,
|
| 690 |
+
turnitin_lease_until: null,
|
| 691 |
+
turnitin_last_error: null,
|
| 692 |
+
updated_at: new Date().toISOString(),
|
| 693 |
+
})
|
| 694 |
+
.eq('id', identityId);
|
| 695 |
+
|
| 696 |
+
if (error) {
|
| 697 |
+
logger.error('Failed to reset stale account', { identityId, error: error.message });
|
| 698 |
+
throw error;
|
| 699 |
+
}
|
| 700 |
+
}
|
src/db/client.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createClient } from '@supabase/supabase-js';
|
| 2 |
+
import { config } from '../config';
|
| 3 |
+
|
| 4 |
+
export const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
| 5 |
+
auth: { autoRefreshToken: false, persistSession: false },
|
| 6 |
+
});
|
src/db/events.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { supabase } from './client';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
|
| 4 |
+
export interface JobEvent {
|
| 5 |
+
id: string;
|
| 6 |
+
job_id: string;
|
| 7 |
+
identity_id: string | null;
|
| 8 |
+
level: 'info' | 'warning' | 'error';
|
| 9 |
+
step: string;
|
| 10 |
+
message: string;
|
| 11 |
+
metadata: Record<string, unknown> | null;
|
| 12 |
+
created_at: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export interface JobEventInsert {
|
| 16 |
+
job_id: string;
|
| 17 |
+
identity_id: string | null;
|
| 18 |
+
level: 'info' | 'warning' | 'error';
|
| 19 |
+
step: string;
|
| 20 |
+
message: string;
|
| 21 |
+
metadata?: Record<string, unknown>;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Insert a new event into the job event log.
|
| 26 |
+
*/
|
| 27 |
+
export async function insertJobEvent(event: JobEventInsert): Promise<void> {
|
| 28 |
+
const { error } = await supabase
|
| 29 |
+
.from('turnitin_job_events')
|
| 30 |
+
.insert({
|
| 31 |
+
job_id: event.job_id,
|
| 32 |
+
identity_id: event.identity_id,
|
| 33 |
+
level: event.level,
|
| 34 |
+
step: event.step,
|
| 35 |
+
message: event.message,
|
| 36 |
+
metadata: event.metadata || {},
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
if (error) {
|
| 40 |
+
logger.error('Failed to insert job event', {
|
| 41 |
+
jobId: event.job_id,
|
| 42 |
+
step: event.step,
|
| 43 |
+
error: error.message,
|
| 44 |
+
});
|
| 45 |
+
throw error;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Retrieve all events for a given job, ordered by creation time.
|
| 51 |
+
*/
|
| 52 |
+
export async function getJobEvents(jobId: string): Promise<JobEvent[]> {
|
| 53 |
+
const { data, error } = await supabase
|
| 54 |
+
.from('turnitin_job_events')
|
| 55 |
+
.select('*')
|
| 56 |
+
.eq('job_id', jobId)
|
| 57 |
+
.order('created_at', { ascending: true });
|
| 58 |
+
|
| 59 |
+
if (error) {
|
| 60 |
+
logger.error('Failed to get job events', { jobId, error: error.message });
|
| 61 |
+
throw error;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return (data as JobEvent[]) || [];
|
| 65 |
+
}
|
src/db/jobs.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { supabase } from './client';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
|
| 4 |
+
export interface TurnitinJob {
|
| 5 |
+
id: string;
|
| 6 |
+
user_id: string;
|
| 7 |
+
identity_id: string | null;
|
| 8 |
+
assignment_target_id: string;
|
| 9 |
+
mode: string;
|
| 10 |
+
status: string;
|
| 11 |
+
input_file_path: string;
|
| 12 |
+
input_file_name: string;
|
| 13 |
+
input_file_size: number | null;
|
| 14 |
+
input_file_sha256: string | null;
|
| 15 |
+
submission_request_id: string | null;
|
| 16 |
+
output_pdf_path: string | null;
|
| 17 |
+
output_pdf_expires_at: string | null;
|
| 18 |
+
receipt_pdf_path: string | null;
|
| 19 |
+
receipt_pdf_expires_at: string | null;
|
| 20 |
+
ticket_refunded_at: string | null;
|
| 21 |
+
ticket_refund_reason: string | null;
|
| 22 |
+
viewer_url: string | null;
|
| 23 |
+
similarity_percent: number | null;
|
| 24 |
+
last_completed_step: string | null;
|
| 25 |
+
submission_details: Record<string, unknown> | null;
|
| 26 |
+
filters: Record<string, unknown>;
|
| 27 |
+
attempt_count: number;
|
| 28 |
+
max_attempts: number;
|
| 29 |
+
next_retry_at: string | null;
|
| 30 |
+
error_message: string | null;
|
| 31 |
+
worker_id: string | null;
|
| 32 |
+
started_at: string | null;
|
| 33 |
+
finished_at: string | null;
|
| 34 |
+
created_at: string;
|
| 35 |
+
updated_at: string;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Atomically claim a pending job using the database RPC.
|
| 40 |
+
* Returns the claimed job row, or null if no jobs are available.
|
| 41 |
+
*/
|
| 42 |
+
export async function claimPendingJob(workerId: string): Promise<TurnitinJob | null> {
|
| 43 |
+
const { data, error } = await supabase.rpc('claim_turnitin_job', {
|
| 44 |
+
p_worker_id: workerId,
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
if (error) {
|
| 48 |
+
logger.error('Failed to claim pending job', { error: error.message });
|
| 49 |
+
throw error;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (!data || (Array.isArray(data) && data.length === 0)) {
|
| 53 |
+
return null;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
return Array.isArray(data) ? data[0] : data;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Update a job's status and optionally merge additional column values.
|
| 61 |
+
*/
|
| 62 |
+
export async function updateJobStatus(
|
| 63 |
+
jobId: string,
|
| 64 |
+
status: string,
|
| 65 |
+
extra?: Partial<TurnitinJob>,
|
| 66 |
+
): Promise<void> {
|
| 67 |
+
const update: Record<string, unknown> = {
|
| 68 |
+
status,
|
| 69 |
+
updated_at: new Date().toISOString(),
|
| 70 |
+
...extra,
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const { error } = await supabase
|
| 74 |
+
.from('turnitin_jobs')
|
| 75 |
+
.update(update)
|
| 76 |
+
.eq('id', jobId);
|
| 77 |
+
|
| 78 |
+
if (error) {
|
| 79 |
+
logger.error('Failed to update job status', { jobId, status, error: error.message });
|
| 80 |
+
throw error;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Mark a job completed without reviving a job that an administrator or user
|
| 86 |
+
* already moved to a terminal failed/cancelled state while Playwright was
|
| 87 |
+
* finishing in the background.
|
| 88 |
+
*/
|
| 89 |
+
export async function completeJobIfActive(
|
| 90 |
+
jobId: string,
|
| 91 |
+
fields: Partial<TurnitinJob>,
|
| 92 |
+
): Promise<boolean> {
|
| 93 |
+
const update: Record<string, unknown> = {
|
| 94 |
+
...fields,
|
| 95 |
+
status: 'completed',
|
| 96 |
+
updated_at: new Date().toISOString(),
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const { data, error } = await supabase
|
| 100 |
+
.from('turnitin_jobs')
|
| 101 |
+
.update(update)
|
| 102 |
+
.eq('id', jobId)
|
| 103 |
+
.not('status', 'in', '(failed,cancelled)')
|
| 104 |
+
.select('id')
|
| 105 |
+
.maybeSingle();
|
| 106 |
+
|
| 107 |
+
if (error) {
|
| 108 |
+
logger.error('Failed to complete active job', { jobId, error: error.message });
|
| 109 |
+
throw error;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return Boolean(data);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Patch arbitrary job fields without changing status.
|
| 117 |
+
* Used for incremental progress checkpoints so retries can resume safely.
|
| 118 |
+
*/
|
| 119 |
+
export async function updateJobFields(
|
| 120 |
+
jobId: string,
|
| 121 |
+
fields: Partial<TurnitinJob>,
|
| 122 |
+
): Promise<void> {
|
| 123 |
+
if (Object.keys(fields).length === 0) return;
|
| 124 |
+
|
| 125 |
+
const update: Record<string, unknown> = {
|
| 126 |
+
...fields,
|
| 127 |
+
updated_at: new Date().toISOString(),
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const { error } = await supabase
|
| 131 |
+
.from('turnitin_jobs')
|
| 132 |
+
.update(update)
|
| 133 |
+
.eq('id', jobId);
|
| 134 |
+
|
| 135 |
+
if (error) {
|
| 136 |
+
logger.error('Failed to update job fields', {
|
| 137 |
+
jobId,
|
| 138 |
+
fields: Object.keys(fields),
|
| 139 |
+
error: error.message,
|
| 140 |
+
});
|
| 141 |
+
throw error;
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/**
|
| 146 |
+
* Fetch a single job by its ID.
|
| 147 |
+
*/
|
| 148 |
+
export async function getJobById(jobId: string): Promise<TurnitinJob | null> {
|
| 149 |
+
const { data, error } = await supabase
|
| 150 |
+
.from('turnitin_jobs')
|
| 151 |
+
.select('*')
|
| 152 |
+
.eq('id', jobId)
|
| 153 |
+
.single();
|
| 154 |
+
|
| 155 |
+
if (error) {
|
| 156 |
+
if (error.code === 'PGRST116') return null; // Row not found
|
| 157 |
+
logger.error('Failed to get job by id', { jobId, error: error.message });
|
| 158 |
+
throw error;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
return data as TurnitinJob;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Find jobs whose report PDF has expired and should be cleaned up.
|
| 166 |
+
*/
|
| 167 |
+
export async function getExpiredReportJobs(): Promise<TurnitinJob[]> {
|
| 168 |
+
const now = new Date().toISOString();
|
| 169 |
+
const { data, error } = await supabase
|
| 170 |
+
.from('turnitin_jobs')
|
| 171 |
+
.select('*')
|
| 172 |
+
.or(
|
| 173 |
+
`and(output_pdf_path.not.is.null,output_pdf_expires_at.lte.${now}),` +
|
| 174 |
+
`and(receipt_pdf_path.not.is.null,receipt_pdf_expires_at.lte.${now})`,
|
| 175 |
+
);
|
| 176 |
+
|
| 177 |
+
if (error) {
|
| 178 |
+
logger.error('Failed to get expired report jobs', { error: error.message });
|
| 179 |
+
throw error;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
return (data as TurnitinJob[]) || [];
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* Atomically increment the attempt_count for a job and return the new value.
|
| 187 |
+
*
|
| 188 |
+
* BUG-1 FIX: The previous Read-Modify-Write approach was a race condition —
|
| 189 |
+
* two Space workers could both read attempt_count=0, compute 1, and both
|
| 190 |
+
* write 1, meaning the limit was never properly enforced.
|
| 191 |
+
*
|
| 192 |
+
* Fix: Use optimistic locking. We UPDATE with a WHERE attempt_count = expected.
|
| 193 |
+
* If the row was modified by another worker concurrently, the update matches
|
| 194 |
+
* 0 rows and we re-read the current value (which already contains the
|
| 195 |
+
* concurrent increment) and return that.
|
| 196 |
+
*/
|
| 197 |
+
export async function incrementJobAttempt(jobId: string): Promise<number> {
|
| 198 |
+
const job = await getJobById(jobId);
|
| 199 |
+
if (!job) {
|
| 200 |
+
throw new Error(`Job not found: ${jobId}`);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const expectedCount = (job.attempt_count as number) || 0;
|
| 204 |
+
const newCount = expectedCount + 1;
|
| 205 |
+
|
| 206 |
+
const { data, error } = await supabase
|
| 207 |
+
.from('turnitin_jobs')
|
| 208 |
+
.update({ attempt_count: newCount, updated_at: new Date().toISOString() })
|
| 209 |
+
.eq('id', jobId)
|
| 210 |
+
.eq('attempt_count', expectedCount) // optimistic lock
|
| 211 |
+
.select('attempt_count');
|
| 212 |
+
|
| 213 |
+
if (error) {
|
| 214 |
+
logger.error('Failed to increment job attempt', { jobId, error: error.message });
|
| 215 |
+
throw error;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// If no row was updated another worker already incremented; re-read true value.
|
| 219 |
+
if (!data || data.length === 0) {
|
| 220 |
+
logger.warn('incrementJobAttempt: concurrent update detected; re-reading actual count', { jobId });
|
| 221 |
+
const fresh = await getJobById(jobId);
|
| 222 |
+
return (fresh?.attempt_count as number) || newCount;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
return (data[0] as { attempt_count: number }).attempt_count;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/**
|
| 229 |
+
* Find jobs that are stuck in running-like states for longer than the given threshold.
|
| 230 |
+
*/
|
| 231 |
+
export async function getStaleJobs(staleSinceMinutes: number): Promise<TurnitinJob[]> {
|
| 232 |
+
const cutoff = new Date(Date.now() - staleSinceMinutes * 60 * 1000).toISOString();
|
| 233 |
+
|
| 234 |
+
// In our scheme, jobs in running states have started_at or updated_at, we can check updated_at
|
| 235 |
+
const { data, error } = await supabase
|
| 236 |
+
.from('turnitin_jobs')
|
| 237 |
+
.select('*')
|
| 238 |
+
.in('status', [
|
| 239 |
+
'claiming_account',
|
| 240 |
+
'waiting_account',
|
| 241 |
+
'running',
|
| 242 |
+
'uploading',
|
| 243 |
+
'submitted',
|
| 244 |
+
'waiting_similarity',
|
| 245 |
+
'opening_viewer',
|
| 246 |
+
'applying_filters',
|
| 247 |
+
'downloading',
|
| 248 |
+
])
|
| 249 |
+
.lt('updated_at', cutoff);
|
| 250 |
+
|
| 251 |
+
if (error) {
|
| 252 |
+
logger.error('Failed to get stale jobs', { error: error.message });
|
| 253 |
+
throw error;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
return (data as TurnitinJob[]) || [];
|
| 257 |
+
}
|
src/db/storage.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as fs from 'fs';
|
| 2 |
+
import * as path from 'path';
|
| 3 |
+
import { supabase } from './client';
|
| 4 |
+
import { config } from '../config';
|
| 5 |
+
import { logger } from '../utils/logger';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Upload a user's input file to the turnitin-inputs bucket.
|
| 9 |
+
* Returns the storage path within the bucket.
|
| 10 |
+
*/
|
| 11 |
+
export async function uploadInputFile(
|
| 12 |
+
userId: string,
|
| 13 |
+
storageKey: string,
|
| 14 |
+
fileName: string,
|
| 15 |
+
fileBuffer: Buffer,
|
| 16 |
+
upsert = false,
|
| 17 |
+
): Promise<string> {
|
| 18 |
+
const ext = path.extname(fileName);
|
| 19 |
+
const storagePath = `${userId}/${storageKey}/input${ext}`;
|
| 20 |
+
|
| 21 |
+
const { error } = await supabase.storage
|
| 22 |
+
.from(config.inputBucket)
|
| 23 |
+
.upload(storagePath, fileBuffer, {
|
| 24 |
+
contentType: getMimeType(ext),
|
| 25 |
+
upsert,
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
if (error) {
|
| 29 |
+
logger.error('Failed to upload input file', { storagePath, error: error.message });
|
| 30 |
+
throw error;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
return storagePath;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/**
|
| 37 |
+
* Download a file from Supabase Storage to a local path.
|
| 38 |
+
*/
|
| 39 |
+
export async function downloadInputFile(storagePath: string, localPath: string): Promise<void> {
|
| 40 |
+
const { data, error } = await supabase.storage
|
| 41 |
+
.from(config.inputBucket)
|
| 42 |
+
.download(storagePath);
|
| 43 |
+
|
| 44 |
+
if (error) {
|
| 45 |
+
logger.error('Failed to download input file', { storagePath, error: error.message });
|
| 46 |
+
throw error;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const buffer = Buffer.from(await data.arrayBuffer());
|
| 50 |
+
const dir = path.dirname(localPath);
|
| 51 |
+
if (!fs.existsSync(dir)) {
|
| 52 |
+
fs.mkdirSync(dir, { recursive: true });
|
| 53 |
+
}
|
| 54 |
+
fs.writeFileSync(localPath, buffer);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Upload a generated report PDF to the turnitin-reports bucket.
|
| 59 |
+
* Returns the storage path and expiry timestamp.
|
| 60 |
+
*/
|
| 61 |
+
export async function uploadReportPdf(
|
| 62 |
+
userId: string,
|
| 63 |
+
jobId: string,
|
| 64 |
+
localPdfPath: string,
|
| 65 |
+
): Promise<{ storagePath: string; expiresAt: string }> {
|
| 66 |
+
const storagePath = `${userId}/${jobId}/report.pdf`;
|
| 67 |
+
const fileBuffer = fs.readFileSync(localPdfPath);
|
| 68 |
+
|
| 69 |
+
const { error } = await supabase.storage
|
| 70 |
+
.from(config.reportBucket)
|
| 71 |
+
.upload(storagePath, fileBuffer, {
|
| 72 |
+
contentType: 'application/pdf',
|
| 73 |
+
upsert: true,
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
if (error) {
|
| 77 |
+
logger.error('Failed to upload report PDF', { storagePath, error: error.message });
|
| 78 |
+
throw error;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const expiresAt = new Date(
|
| 82 |
+
Date.now() + config.reportRetentionHours * 60 * 60 * 1000,
|
| 83 |
+
).toISOString();
|
| 84 |
+
|
| 85 |
+
return { storagePath, expiresAt };
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Upload a legacy Turnitin Digital Receipt PDF with the same retention policy
|
| 90 |
+
* as the similarity report.
|
| 91 |
+
*/
|
| 92 |
+
export async function uploadReceiptPdf(
|
| 93 |
+
userId: string,
|
| 94 |
+
jobId: string,
|
| 95 |
+
localPdfPath: string,
|
| 96 |
+
): Promise<{ storagePath: string; expiresAt: string }> {
|
| 97 |
+
const storagePath = `${userId}/${jobId}/receipt.pdf`;
|
| 98 |
+
const fileBuffer = fs.readFileSync(localPdfPath);
|
| 99 |
+
|
| 100 |
+
const { error } = await supabase.storage
|
| 101 |
+
.from(config.reportBucket)
|
| 102 |
+
.upload(storagePath, fileBuffer, {
|
| 103 |
+
contentType: 'application/pdf',
|
| 104 |
+
upsert: true,
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
if (error) {
|
| 108 |
+
logger.error('Failed to upload Digital Receipt PDF', {
|
| 109 |
+
storagePath,
|
| 110 |
+
error: error.message,
|
| 111 |
+
});
|
| 112 |
+
throw error;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const expiresAt = new Date(
|
| 116 |
+
Date.now() + config.reportRetentionHours * 60 * 60 * 1000,
|
| 117 |
+
).toISOString();
|
| 118 |
+
|
| 119 |
+
return { storagePath, expiresAt };
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Delete a report PDF from Supabase Storage.
|
| 124 |
+
*/
|
| 125 |
+
export async function deleteReportPdf(storagePath: string): Promise<void> {
|
| 126 |
+
const { error } = await supabase.storage
|
| 127 |
+
.from(config.reportBucket)
|
| 128 |
+
.remove([storagePath]);
|
| 129 |
+
|
| 130 |
+
if (error) {
|
| 131 |
+
logger.error('Failed to delete report PDF', { storagePath, error: error.message });
|
| 132 |
+
throw error;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* Upload a Playwright browser storage state to the sessions bucket.
|
| 138 |
+
* Returns the storage path.
|
| 139 |
+
*/
|
| 140 |
+
export async function uploadStorageState(
|
| 141 |
+
accountId: string,
|
| 142 |
+
stateJson: string,
|
| 143 |
+
): Promise<string> {
|
| 144 |
+
const storagePath = `${accountId}/state.json`;
|
| 145 |
+
|
| 146 |
+
const { error } = await supabase.storage
|
| 147 |
+
.from(config.sessionBucket)
|
| 148 |
+
.upload(storagePath, Buffer.from(stateJson, 'utf-8'), {
|
| 149 |
+
contentType: 'application/json',
|
| 150 |
+
upsert: true,
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
if (error) {
|
| 154 |
+
logger.error('Failed to upload storage state', { accountId, error: error.message });
|
| 155 |
+
throw error;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
return storagePath;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* Download a previously saved storage state.
|
| 163 |
+
* Returns the JSON string, or null if not found.
|
| 164 |
+
*/
|
| 165 |
+
export async function downloadStorageState(storagePath: string): Promise<string | null> {
|
| 166 |
+
const { data, error } = await supabase.storage
|
| 167 |
+
.from(config.sessionBucket)
|
| 168 |
+
.download(storagePath);
|
| 169 |
+
|
| 170 |
+
if (error) {
|
| 171 |
+
// Not found is not fatal — the account may not have a saved session
|
| 172 |
+
if (error.message?.includes('not found') || error.message?.includes('Object not found')) {
|
| 173 |
+
return null;
|
| 174 |
+
}
|
| 175 |
+
logger.error('Failed to download storage state', { storagePath, error: error.message });
|
| 176 |
+
throw error;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return await data.text();
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Create a time-limited signed URL for a file in any bucket.
|
| 184 |
+
*/
|
| 185 |
+
export async function createSignedUrl(
|
| 186 |
+
bucket: string,
|
| 187 |
+
filePath: string,
|
| 188 |
+
expiresInSeconds: number,
|
| 189 |
+
downloadFileName?: string,
|
| 190 |
+
): Promise<string> {
|
| 191 |
+
const { data, error } = await supabase.storage
|
| 192 |
+
.from(bucket)
|
| 193 |
+
.createSignedUrl(
|
| 194 |
+
filePath,
|
| 195 |
+
expiresInSeconds,
|
| 196 |
+
downloadFileName ? { download: downloadFileName } : undefined,
|
| 197 |
+
);
|
| 198 |
+
|
| 199 |
+
if (error) {
|
| 200 |
+
logger.error('Failed to create signed URL', { bucket, filePath, error: error.message });
|
| 201 |
+
throw error;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
return data.signedUrl;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/** Map file extensions to MIME types */
|
| 208 |
+
function getMimeType(ext: string): string {
|
| 209 |
+
const mimeTypes: Record<string, string> = {
|
| 210 |
+
'.pdf': 'application/pdf',
|
| 211 |
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 212 |
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 213 |
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
| 214 |
+
'.ps': 'application/postscript',
|
| 215 |
+
'.html': 'text/html',
|
| 216 |
+
'.txt': 'text/plain',
|
| 217 |
+
'.rtf': 'application/rtf',
|
| 218 |
+
'.odt': 'application/vnd.oasis.opendocument.text',
|
| 219 |
+
'.hwp': 'application/x-hwp',
|
| 220 |
+
};
|
| 221 |
+
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
|
| 222 |
+
}
|
src/db/tickets.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { supabase } from './client';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
import type { TurnitinJob } from './jobs';
|
| 4 |
+
|
| 5 |
+
export interface CreateJobParams {
|
| 6 |
+
userId: string;
|
| 7 |
+
assignmentTargetId: string;
|
| 8 |
+
mode: string;
|
| 9 |
+
filters: Record<string, unknown>;
|
| 10 |
+
inputFileName: string;
|
| 11 |
+
inputStoragePath: string;
|
| 12 |
+
inputFileSize?: number;
|
| 13 |
+
inputFileSha256?: string;
|
| 14 |
+
submissionRequestId: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface CreateJobResult {
|
| 18 |
+
jobId: string;
|
| 19 |
+
created: boolean;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface UserProfile {
|
| 23 |
+
id: string;
|
| 24 |
+
email: string;
|
| 25 |
+
display_name: string | null;
|
| 26 |
+
ticket_balance: number;
|
| 27 |
+
role: string;
|
| 28 |
+
created_at: string;
|
| 29 |
+
updated_at: string;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Create a new Turnitin job while atomically deducting a ticket.
|
| 34 |
+
* Uses the database RPC to ensure ticket balance is checked and decremented in one transaction.
|
| 35 |
+
* Returns the new job ID.
|
| 36 |
+
*/
|
| 37 |
+
export async function createJobWithTicket(params: CreateJobParams): Promise<CreateJobResult> {
|
| 38 |
+
const { data, error } = await supabase.rpc('create_job_with_ticket_idempotent', {
|
| 39 |
+
p_user_id: params.userId,
|
| 40 |
+
p_assignment_target_id: params.assignmentTargetId,
|
| 41 |
+
p_mode: params.mode,
|
| 42 |
+
p_filters: params.filters,
|
| 43 |
+
p_input_file_name: params.inputFileName,
|
| 44 |
+
p_input_file_path: params.inputStoragePath,
|
| 45 |
+
p_input_file_size: params.inputFileSize ?? null,
|
| 46 |
+
p_input_file_sha256: params.inputFileSha256 ?? null,
|
| 47 |
+
p_submission_request_id: params.submissionRequestId,
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
if (error) {
|
| 51 |
+
logger.error('Failed to create job with ticket', { error: error.message, userId: params.userId });
|
| 52 |
+
throw error;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const row = Array.isArray(data) ? data[0] : data;
|
| 56 |
+
if (!row?.job_id) {
|
| 57 |
+
throw new Error('Idempotent job creation returned no job ID');
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return {
|
| 61 |
+
jobId: row.job_id as string,
|
| 62 |
+
created: row.created === true,
|
| 63 |
+
};
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function getJobBySubmissionRequestId(
|
| 67 |
+
userId: string,
|
| 68 |
+
submissionRequestId: string,
|
| 69 |
+
): Promise<TurnitinJob | null> {
|
| 70 |
+
const { data, error } = await supabase
|
| 71 |
+
.from('turnitin_jobs')
|
| 72 |
+
.select('*')
|
| 73 |
+
.eq('user_id', userId)
|
| 74 |
+
.eq('submission_request_id', submissionRequestId)
|
| 75 |
+
.maybeSingle();
|
| 76 |
+
|
| 77 |
+
if (error) {
|
| 78 |
+
logger.error('Failed to find job by submission request ID', {
|
| 79 |
+
userId,
|
| 80 |
+
error: error.message,
|
| 81 |
+
});
|
| 82 |
+
throw error;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
return data as TurnitinJob | null;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Fetch a user's profile including their current ticket balance.
|
| 90 |
+
*/
|
| 91 |
+
export async function getUserProfile(userId: string): Promise<UserProfile | null> {
|
| 92 |
+
const { data, error } = await supabase
|
| 93 |
+
.from('user_profiles')
|
| 94 |
+
.select('*')
|
| 95 |
+
.eq('id', userId)
|
| 96 |
+
.single();
|
| 97 |
+
|
| 98 |
+
if (error) {
|
| 99 |
+
if (error.code === 'PGRST116') return null; // Row not found
|
| 100 |
+
logger.error('Failed to get user profile', { userId, error: error.message });
|
| 101 |
+
throw error;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return data as UserProfile;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* Admin operation: top up a user's ticket balance.
|
| 109 |
+
* Uses database RPC for atomic increment. Returns the new balance.
|
| 110 |
+
*/
|
| 111 |
+
export async function adminTopupTickets(
|
| 112 |
+
adminId: string,
|
| 113 |
+
targetUserId: string,
|
| 114 |
+
amount: number,
|
| 115 |
+
): Promise<number> {
|
| 116 |
+
const { data, error } = await supabase.rpc('admin_topup_tickets', {
|
| 117 |
+
p_admin_id: adminId,
|
| 118 |
+
p_target_user_id: targetUserId,
|
| 119 |
+
p_amount: amount,
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
if (error) {
|
| 123 |
+
logger.error('Failed to top up tickets', {
|
| 124 |
+
adminId,
|
| 125 |
+
targetUserId,
|
| 126 |
+
amount,
|
| 127 |
+
error: error.message,
|
| 128 |
+
});
|
| 129 |
+
throw error;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
return data as number;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Cancel a pending job and refund the ticket.
|
| 137 |
+
* Uses database RPC for atomic status change + ticket refund.
|
| 138 |
+
* Returns true if the job was successfully cancelled.
|
| 139 |
+
*/
|
| 140 |
+
export async function cancelJob(userId: string, jobId: string): Promise<boolean> {
|
| 141 |
+
const { data, error } = await supabase.rpc('cancel_job', {
|
| 142 |
+
p_user_id: userId,
|
| 143 |
+
p_job_id: jobId,
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
if (error) {
|
| 147 |
+
logger.error('Failed to cancel job', { userId, jobId, error: error.message });
|
| 148 |
+
throw error;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
return data as boolean;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* Refund a terminal failed job exactly once.
|
| 156 |
+
*
|
| 157 |
+
* The database RPC locks the job row and checks the ticket ledger before
|
| 158 |
+
* incrementing balance, so this remains idempotent across multiple workers,
|
| 159 |
+
* stale recovery, and manual/admin retries.
|
| 160 |
+
*/
|
| 161 |
+
export async function refundFailedJob(jobId: string, reason: string): Promise<boolean> {
|
| 162 |
+
const { data, error } = await supabase.rpc('refund_failed_job', {
|
| 163 |
+
p_job_id: jobId,
|
| 164 |
+
p_reason: reason,
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
if (error) {
|
| 168 |
+
logger.error('Failed to refund failed job', {
|
| 169 |
+
jobId,
|
| 170 |
+
reason,
|
| 171 |
+
error: error.message,
|
| 172 |
+
});
|
| 173 |
+
throw error;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
return data as boolean;
|
| 177 |
+
}
|
src/engine/legacy.ts
ADDED
|
@@ -0,0 +1,1917 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { BrowserContext, Download, Page } from 'playwright';
|
| 2 |
+
import * as fs from 'fs';
|
| 3 |
+
import * as path from 'path';
|
| 4 |
+
import { config } from '../config';
|
| 5 |
+
import { logger } from '../utils/logger';
|
| 6 |
+
import { getBrowser } from '../worker/browser-pool';
|
| 7 |
+
import { loginToTurnitin, acceptEulaEverywhere } from './steps/login';
|
| 8 |
+
import { navigateToAssignment } from './steps/navigate';
|
| 9 |
+
import { dropClassByTitle } from './steps/class-management';
|
| 10 |
+
import { hasActiveFilters } from './steps/filters';
|
| 11 |
+
import type { FilterOptions } from './steps/filters';
|
| 12 |
+
import type { RunTurnitinJobInput, RunTurnitinJobResult } from './turnitin';
|
| 13 |
+
import type { SubmissionDetails } from './steps/submission-details';
|
| 14 |
+
import type { Frame } from 'playwright';
|
| 15 |
+
|
| 16 |
+
type Scope = Page | Frame;
|
| 17 |
+
|
| 18 |
+
type EngineEvent = NonNullable<RunTurnitinJobInput['onEvent']>;
|
| 19 |
+
const LEGACY_ACCOUNT_QUOTA_LIMIT = 4;
|
| 20 |
+
|
| 21 |
+
async function emit(
|
| 22 |
+
onEvent: RunTurnitinJobInput['onEvent'],
|
| 23 |
+
level: 'info' | 'warning' | 'error',
|
| 24 |
+
step: string,
|
| 25 |
+
message: string,
|
| 26 |
+
metadata?: Record<string, unknown>,
|
| 27 |
+
): Promise<void> {
|
| 28 |
+
if (onEvent) {
|
| 29 |
+
await onEvent({ level, step, message, metadata }).catch(() => {});
|
| 30 |
+
}
|
| 31 |
+
if (level === 'error') logger.error(message, { step, ...metadata });
|
| 32 |
+
else if (level === 'warning') logger.warn(message, { step, ...metadata });
|
| 33 |
+
else logger.info(message, { step, ...metadata });
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function compactErrorMessage(message: string): string {
|
| 37 |
+
const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message;
|
| 38 |
+
if (/locator\.waitFor: Timeout/i.test(firstLine)) {
|
| 39 |
+
const selector = firstLine.match(/locator\('([^']+)'/i)?.[1];
|
| 40 |
+
return selector
|
| 41 |
+
? `Turnitin legacy page element did not appear in time: ${selector}`
|
| 42 |
+
: 'Turnitin legacy page element did not appear in time.';
|
| 43 |
+
}
|
| 44 |
+
return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function parseNumber(value: string | null | undefined): number | undefined {
|
| 48 |
+
const numeric = Number(String(value || '').replace(/[^\d]/g, ''));
|
| 49 |
+
return Number.isFinite(numeric) ? numeric : undefined;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function parsePercent(value: string | null | undefined): number | null {
|
| 53 |
+
const match = String(value || '').match(/(\d{1,3})\s*%?/);
|
| 54 |
+
if (!match) return null;
|
| 55 |
+
const parsed = Number(match[1]);
|
| 56 |
+
return Number.isFinite(parsed) ? parsed : null;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function parseFileSizeBytes(value: string | null | undefined): number | undefined {
|
| 60 |
+
const text = String(value || '').trim();
|
| 61 |
+
const match = text.match(/([\d.,]+)\s*(bytes?|b|kb|kib|mb|mib)?/i);
|
| 62 |
+
if (!match) return parseNumber(text);
|
| 63 |
+
const amount = Number(match[1].replace(/,/g, ''));
|
| 64 |
+
if (!Number.isFinite(amount)) return parseNumber(text);
|
| 65 |
+
const unit = (match[2] || 'b').toLowerCase();
|
| 66 |
+
if (unit === 'mb' || unit === 'mib') return Math.round(amount * 1024 * 1024);
|
| 67 |
+
if (unit === 'kb' || unit === 'kib') return Math.round(amount * 1024);
|
| 68 |
+
return Math.round(amount);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function normalizeLabel(label: string): keyof SubmissionDetails | null {
|
| 72 |
+
const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim();
|
| 73 |
+
if (normalized === 'student id') return 'studentId';
|
| 74 |
+
if (normalized === 'class name') return 'className';
|
| 75 |
+
if (normalized === 'class id') return 'classId';
|
| 76 |
+
if (normalized === 'submission id') return 'submissionId';
|
| 77 |
+
if (normalized === 'submission date') return 'submissionDate';
|
| 78 |
+
if (normalized === 'submission count') return 'submissionCount';
|
| 79 |
+
if (normalized === 'file name') return 'fileName';
|
| 80 |
+
if (normalized === 'file extension') return 'fileExtension';
|
| 81 |
+
if (normalized === 'file size') return 'fileSize';
|
| 82 |
+
if (normalized === 'character count') return 'charCount';
|
| 83 |
+
if (normalized === 'char count') return 'charCount';
|
| 84 |
+
if (normalized === 'word count') return 'wordCount';
|
| 85 |
+
if (normalized === 'page count') return 'pageCount';
|
| 86 |
+
return null;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function waitForAnyDownload(
|
| 90 |
+
context: BrowserContext,
|
| 91 |
+
timeoutMs: number,
|
| 92 |
+
): Promise<Download> {
|
| 93 |
+
return new Promise((resolve, reject) => {
|
| 94 |
+
const timer = setTimeout(
|
| 95 |
+
() => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)),
|
| 96 |
+
timeoutMs,
|
| 97 |
+
);
|
| 98 |
+
const pageListeners = new Map<Page, (d: Download) => void>();
|
| 99 |
+
|
| 100 |
+
const onDownload = (download: Download) => cleanup(null, download);
|
| 101 |
+
const onPage = (p: Page) => attach(p);
|
| 102 |
+
|
| 103 |
+
function attach(p: Page) {
|
| 104 |
+
p.on('download', onDownload);
|
| 105 |
+
pageListeners.set(p, onDownload);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function cleanup(error: Error | null, download?: Download) {
|
| 109 |
+
clearTimeout(timer);
|
| 110 |
+
context.off('page', onPage);
|
| 111 |
+
for (const [p, listener] of pageListeners) p.off('download', listener);
|
| 112 |
+
if (error) reject(error);
|
| 113 |
+
else resolve(download!);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
for (const p of context.pages()) attach(p);
|
| 117 |
+
context.on('page', onPage);
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
async function visible(page: Scope, selector: string, timeout = 2500): Promise<boolean> {
|
| 122 |
+
try {
|
| 123 |
+
await page.locator(selector).first().waitFor({ state: 'visible', timeout });
|
| 124 |
+
return true;
|
| 125 |
+
} catch {
|
| 126 |
+
return false;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
async function findLegacyAssignmentScope(
|
| 131 |
+
page: Page,
|
| 132 |
+
selector: string,
|
| 133 |
+
timeoutMs = 20000,
|
| 134 |
+
): Promise<Page | Frame | null> {
|
| 135 |
+
const deadline = Date.now() + timeoutMs;
|
| 136 |
+
while (true) {
|
| 137 |
+
if (await page.locator(selector).first().isVisible().catch(() => false)) {
|
| 138 |
+
return page;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
for (const frame of page.frames()) {
|
| 142 |
+
if (frame.url().includes('cookie-shim')) continue;
|
| 143 |
+
if (await frame.locator(selector).first().isVisible().catch(() => false)) {
|
| 144 |
+
return frame;
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
if (Date.now() >= deadline) break;
|
| 149 |
+
await page.waitForTimeout(500).catch(() => {});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return null;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
async function clickFirstVisible(
|
| 156 |
+
page: Scope,
|
| 157 |
+
selectors: string[],
|
| 158 |
+
timeout = 5000,
|
| 159 |
+
): Promise<boolean> {
|
| 160 |
+
const deadline = Date.now() + timeout;
|
| 161 |
+
while (true) {
|
| 162 |
+
for (const selector of selectors) {
|
| 163 |
+
const locator = page.locator(selector).first();
|
| 164 |
+
if (await locator.isVisible().catch(() => false)) {
|
| 165 |
+
await locator.click({ force: true });
|
| 166 |
+
return true;
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
if (Date.now() >= deadline) break;
|
| 170 |
+
await page.waitForTimeout(250).catch(() => {});
|
| 171 |
+
}
|
| 172 |
+
return false;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
async function clickFirstEnabled(
|
| 176 |
+
page: Scope,
|
| 177 |
+
selectors: string[],
|
| 178 |
+
timeout = 30000,
|
| 179 |
+
): Promise<boolean> {
|
| 180 |
+
const deadline = Date.now() + timeout;
|
| 181 |
+
while (Date.now() < deadline) {
|
| 182 |
+
for (const selector of selectors) {
|
| 183 |
+
const locator = page.locator(selector).first();
|
| 184 |
+
const visible = await locator.isVisible().catch(() => false);
|
| 185 |
+
if (!visible) continue;
|
| 186 |
+
const disabled = await locator
|
| 187 |
+
.evaluate((element: Element) => {
|
| 188 |
+
const button = element as HTMLButtonElement;
|
| 189 |
+
return Boolean(
|
| 190 |
+
button.disabled ||
|
| 191 |
+
element.getAttribute('disabled') !== null ||
|
| 192 |
+
element.classList.contains('disabled') ||
|
| 193 |
+
element.getAttribute('aria-disabled') === 'true',
|
| 194 |
+
);
|
| 195 |
+
})
|
| 196 |
+
.catch(() => false);
|
| 197 |
+
if (!disabled) {
|
| 198 |
+
await locator.click({ force: true });
|
| 199 |
+
return true;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
await page.waitForTimeout(750);
|
| 203 |
+
}
|
| 204 |
+
return false;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* Navigate to the legacy Turnitin assignment dashboard.
|
| 209 |
+
*
|
| 210 |
+
* After logging into the legacy student account Turnitin may redirect the user
|
| 211 |
+
* directly to the assignment dashboard (common for accounts enrolled in a
|
| 212 |
+
* single class with a single assignment). We detect this and skip navigation.
|
| 213 |
+
*
|
| 214 |
+
* Three recovery paths (in order):
|
| 215 |
+
* 1. Already on assignment dashboard → skip entirely.
|
| 216 |
+
* 2. On the class / assignment-list page → click the assignment Open button.
|
| 217 |
+
* 3. On the home page → full class → assignment navigation.
|
| 218 |
+
*/
|
| 219 |
+
async function navigateLegacyAssignment(
|
| 220 |
+
page: Page,
|
| 221 |
+
classTitle: string,
|
| 222 |
+
assignmentTitle: string | null | undefined,
|
| 223 |
+
onEvent?: EngineEvent,
|
| 224 |
+
): Promise<void> {
|
| 225 |
+
// Allow the post-login redirect to settle.
|
| 226 |
+
await page.waitForTimeout(2500);
|
| 227 |
+
|
| 228 |
+
// ── Case 1: Already on the assignment dashboard ─────────────────────────
|
| 229 |
+
const onDashboard = await visible(
|
| 230 |
+
page,
|
| 231 |
+
[
|
| 232 |
+
'#dashboard-table',
|
| 233 |
+
'.empty-assignment.student',
|
| 234 |
+
'button.paper-upload[data-px="uploadSubmissionClicked"]',
|
| 235 |
+
'button.paper-upload-modal',
|
| 236 |
+
'.student-submission-button',
|
| 237 |
+
].join(', '),
|
| 238 |
+
5000,
|
| 239 |
+
);
|
| 240 |
+
if (onDashboard) {
|
| 241 |
+
await emit(
|
| 242 |
+
onEvent,
|
| 243 |
+
'info',
|
| 244 |
+
'navigate',
|
| 245 |
+
'Legacy account landed on assignment dashboard after login; skipping class navigation',
|
| 246 |
+
);
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
// ── Case 2: On the class / assignment-list page ─────────────────────────
|
| 251 |
+
if (assignmentTitle) {
|
| 252 |
+
const assignmentRow = page
|
| 253 |
+
.locator('tr.assignment-row')
|
| 254 |
+
.filter({ hasText: assignmentTitle })
|
| 255 |
+
.locator('a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")')
|
| 256 |
+
.first();
|
| 257 |
+
const onAssignmentList = await assignmentRow
|
| 258 |
+
.waitFor({ state: 'visible', timeout: 3000 })
|
| 259 |
+
.then(() => true)
|
| 260 |
+
.catch(() => false);
|
| 261 |
+
if (onAssignmentList) {
|
| 262 |
+
await emit(onEvent, 'info', 'navigate', 'On class assignment list; clicking assignment Open button', {
|
| 263 |
+
assignmentTitle,
|
| 264 |
+
});
|
| 265 |
+
await Promise.all([
|
| 266 |
+
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => null),
|
| 267 |
+
assignmentRow.click({ force: true }),
|
| 268 |
+
]);
|
| 269 |
+
await page.waitForTimeout(4000);
|
| 270 |
+
await acceptEulaEverywhere(page);
|
| 271 |
+
return;
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// ── Case 3: Full home-page → class → assignment navigation ───────────────
|
| 276 |
+
await navigateToAssignment(page, classTitle ?? '', assignmentTitle);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
async function getLegacySubmissionState(page: Page): Promise<{
|
| 280 |
+
hasExistingSubmission: boolean;
|
| 281 |
+
hasResubmitAction: boolean;
|
| 282 |
+
hasInitialUpload: boolean;
|
| 283 |
+
}> {
|
| 284 |
+
// Wait a moment for any dynamic content to render before checking state
|
| 285 |
+
await page.waitForTimeout(1500);
|
| 286 |
+
|
| 287 |
+
// Broad check for existing submission history table or similarity link
|
| 288 |
+
const hasExistingSubmission =
|
| 289 |
+
(await visible(page, '#dashboard-table a.default-open, #dashboard-table td[data-title="Similarity Score"], a.default-open[data-paper-title]', 6000)) ||
|
| 290 |
+
(await page
|
| 291 |
+
.locator('#dashboard-table, table')
|
| 292 |
+
.filter({ hasText: /Assignment Submissions|Similarity Score/i })
|
| 293 |
+
.first()
|
| 294 |
+
.waitFor({ state: 'visible', timeout: 3000 })
|
| 295 |
+
.then(() => true)
|
| 296 |
+
.catch(() => false)) ||
|
| 297 |
+
// Also check for resubmit action or submission rows that indicate a prior submission exists
|
| 298 |
+
(await visible(page, 'button.paper-upload-modal[title*="Resubmit"], a.paper-upload-modal:has-text("Resubmit"), button:has-text("Resubmit paper")', 3000));
|
| 299 |
+
|
| 300 |
+
const hasResubmitAction = await visible(
|
| 301 |
+
page,
|
| 302 |
+
'button.paper-upload-modal[title*="Resubmit"], a.paper-upload-modal:has-text("Resubmit"), button:has-text("Resubmit paper"), .dropdown-toggle + .dropdown-menu a:has-text("Resubmit")',
|
| 303 |
+
3000,
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
const hasInitialUpload = await visible(
|
| 307 |
+
page,
|
| 308 |
+
'button.paper-upload[data-px="uploadSubmissionClicked"], button.paper-upload:has-text("Upload Submission"), button:has-text("Upload Submission"), a.paper-upload:has-text("Upload Submission")',
|
| 309 |
+
3000,
|
| 310 |
+
);
|
| 311 |
+
|
| 312 |
+
return { hasExistingSubmission, hasResubmitAction, hasInitialUpload };
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
async function openLegacyUploadModal(
|
| 316 |
+
page: Page,
|
| 317 |
+
mode: 'upload' | 'resubmit',
|
| 318 |
+
onEvent?: EngineEvent,
|
| 319 |
+
): Promise<{ effectiveMode: 'upload' | 'resubmit'; scope: Scope }> {
|
| 320 |
+
const state = await getLegacySubmissionState(page);
|
| 321 |
+
let effectiveMode = mode;
|
| 322 |
+
|
| 323 |
+
if (mode === 'upload' && (state.hasExistingSubmission || state.hasResubmitAction)) {
|
| 324 |
+
effectiveMode = 'resubmit';
|
| 325 |
+
await emit(onEvent, 'warning', 'mode_switch', 'Existing legacy submission detected; switching to resubmit mode', {
|
| 326 |
+
requestedMode: mode,
|
| 327 |
+
effectiveMode,
|
| 328 |
+
});
|
| 329 |
+
} else if (mode === 'resubmit' && !state.hasResubmitAction && state.hasInitialUpload) {
|
| 330 |
+
effectiveMode = 'upload';
|
| 331 |
+
await emit(onEvent, 'warning', 'mode_switch', 'No legacy submission detected; switching to first submission mode', {
|
| 332 |
+
requestedMode: mode,
|
| 333 |
+
effectiveMode,
|
| 334 |
+
});
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
if (effectiveMode === 'resubmit') {
|
| 338 |
+
let opened = await clickFirstVisible(page, [
|
| 339 |
+
'button.paper-upload-modal[title*="Resubmit"]',
|
| 340 |
+
'a.paper-upload-modal:has-text("Resubmit paper")',
|
| 341 |
+
'button:has-text("Resubmit paper")',
|
| 342 |
+
'a:has-text("Resubmit paper")',
|
| 343 |
+
], 5000);
|
| 344 |
+
|
| 345 |
+
if (!opened) {
|
| 346 |
+
// Try opening dropdown menus that might contain the resubmit option
|
| 347 |
+
await clickFirstVisible(page, [
|
| 348 |
+
'#dashboard-table button.dropdown-toggle',
|
| 349 |
+
'#dashboard-table .dropdown-toggle',
|
| 350 |
+
'button.dropdown-toggle',
|
| 351 |
+
'.btn-group .dropdown-toggle',
|
| 352 |
+
], 3000);
|
| 353 |
+
await page.waitForTimeout(800);
|
| 354 |
+
const openedFromMenu = await clickFirstVisible(page, [
|
| 355 |
+
'a.paper-upload-modal:has-text("Resubmit paper")',
|
| 356 |
+
'button:has-text("Resubmit paper")',
|
| 357 |
+
'a:has-text("Resubmit paper")',
|
| 358 |
+
'.dropdown-menu a:has-text("Resubmit")',
|
| 359 |
+
], 5000);
|
| 360 |
+
if (!openedFromMenu) {
|
| 361 |
+
// If no resubmit found but there's an Upload Submission button, the
|
| 362 |
+
// account may not have a prior submission — fall back to initial upload.
|
| 363 |
+
const hasInitialUpload = await visible(page,
|
| 364 |
+
'button.paper-upload[data-px="uploadSubmissionClicked"], button.paper-upload:has-text("Upload Submission"), button:has-text("Upload Submission")',
|
| 365 |
+
3000,
|
| 366 |
+
);
|
| 367 |
+
if (hasInitialUpload) {
|
| 368 |
+
effectiveMode = 'upload';
|
| 369 |
+
await emit(onEvent, 'warning', 'mode_switch',
|
| 370 |
+
'Legacy resubmit action not found but Upload Submission button is present; falling back to initial upload', {});
|
| 371 |
+
} else {
|
| 372 |
+
throw new Error('Legacy resubmit action was not found and no Upload Submission fallback is available.');
|
| 373 |
+
}
|
| 374 |
+
} else {
|
| 375 |
+
opened = true;
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
if (effectiveMode === 'resubmit') {
|
| 380 |
+
// After clicking resubmit, a confirmation dialog may appear
|
| 381 |
+
await page.waitForTimeout(800);
|
| 382 |
+
const confirmed = await clickFirstVisible(page, [
|
| 383 |
+
'button.paper-upload[id^="upload_type-"]:has-text("Confirm")',
|
| 384 |
+
'button.btn-primary.paper-upload:has-text("Confirm")',
|
| 385 |
+
'button.paper-upload:has-text("Confirm")',
|
| 386 |
+
'button:has-text("Confirm")',
|
| 387 |
+
], 10000);
|
| 388 |
+
if (!confirmed) {
|
| 389 |
+
// Some Turnitin builds skip the confirm step — check if file input
|
| 390 |
+
// is already present without confirmation.
|
| 391 |
+
const fileInputPresent = await visible(
|
| 392 |
+
page,
|
| 393 |
+
'input[data-test="submission-file-select"], input#file, input[type="file"]',
|
| 394 |
+
3000,
|
| 395 |
+
);
|
| 396 |
+
if (!fileInputPresent) {
|
| 397 |
+
throw new Error('Legacy resubmit confirmation button was not found.');
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
if (effectiveMode === 'upload') {
|
| 404 |
+
const opened = await clickFirstVisible(page, [
|
| 405 |
+
'button.paper-upload[data-px="uploadSubmissionClicked"]',
|
| 406 |
+
'button.paper-upload:has-text("Upload Submission")',
|
| 407 |
+
'button:has-text("Upload Submission")',
|
| 408 |
+
'a.paper-upload:has-text("Upload Submission")',
|
| 409 |
+
], 10000);
|
| 410 |
+
if (!opened) {
|
| 411 |
+
throw new Error('Legacy Upload Submission button was not found.');
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// Accept any EULA modals that might pop up after clicking submit/resubmit
|
| 416 |
+
await page.waitForTimeout(2000);
|
| 417 |
+
await acceptEulaEverywhere(page);
|
| 418 |
+
|
| 419 |
+
// Resolve the frame scope that contains the file input (could be page or iframe)
|
| 420 |
+
const fileInputSelector = 'input[data-test="submission-file-select"], input#file, input[type="file"]';
|
| 421 |
+
let scope: Scope | null = null;
|
| 422 |
+
let inputAttached = false;
|
| 423 |
+
let lastErrorMessage = 'Legacy upload file input did not appear.';
|
| 424 |
+
|
| 425 |
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
| 426 |
+
scope =
|
| 427 |
+
(await findLegacyAssignmentScope(
|
| 428 |
+
page,
|
| 429 |
+
fileInputSelector,
|
| 430 |
+
attempt === 1 ? 25000 : 45000,
|
| 431 |
+
)) || page;
|
| 432 |
+
|
| 433 |
+
inputAttached = await scope
|
| 434 |
+
.locator(fileInputSelector)
|
| 435 |
+
.first()
|
| 436 |
+
.waitFor({ state: 'attached', timeout: attempt === 1 ? 25000 : 45000 })
|
| 437 |
+
.then(() => true)
|
| 438 |
+
.catch((error) => {
|
| 439 |
+
lastErrorMessage = error instanceof Error ? error.message : String(error);
|
| 440 |
+
return false;
|
| 441 |
+
});
|
| 442 |
+
|
| 443 |
+
if (inputAttached) break;
|
| 444 |
+
|
| 445 |
+
logger.warn('Legacy upload file input did not appear after opening modal; retrying upload dialog', {
|
| 446 |
+
attempt,
|
| 447 |
+
effectiveMode,
|
| 448 |
+
error: lastErrorMessage,
|
| 449 |
+
});
|
| 450 |
+
await page.keyboard.press('Escape').catch(() => {});
|
| 451 |
+
await page.waitForTimeout(1500);
|
| 452 |
+
|
| 453 |
+
if (effectiveMode === 'upload') {
|
| 454 |
+
await clickFirstVisible(page, [
|
| 455 |
+
'button.paper-upload[data-px="uploadSubmissionClicked"]',
|
| 456 |
+
'button.paper-upload:has-text("Upload Submission")',
|
| 457 |
+
'button:has-text("Upload Submission")',
|
| 458 |
+
'a.paper-upload:has-text("Upload Submission")',
|
| 459 |
+
], 10000);
|
| 460 |
+
} else {
|
| 461 |
+
await clickFirstVisible(page, [
|
| 462 |
+
'button.paper-upload-modal[title*="Resubmit"]',
|
| 463 |
+
'a.paper-upload-modal:has-text("Resubmit paper")',
|
| 464 |
+
'button:has-text("Resubmit paper")',
|
| 465 |
+
'a:has-text("Resubmit paper")',
|
| 466 |
+
], 10000);
|
| 467 |
+
}
|
| 468 |
+
await page.waitForTimeout(2500);
|
| 469 |
+
await acceptEulaEverywhere(page);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
if (!inputAttached || !scope) {
|
| 473 |
+
throw new Error(lastErrorMessage);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
return { effectiveMode, scope };
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
async function readLegacyReviewDetails(page: Scope): Promise<SubmissionDetails | null> {
|
| 480 |
+
const rows = await page
|
| 481 |
+
.evaluate(() => {
|
| 482 |
+
const pick = (selector: string) =>
|
| 483 |
+
document.querySelector(selector)?.textContent?.trim() || '';
|
| 484 |
+
return {
|
| 485 |
+
fileName: pick('dd[data-test="submission-review-title"]'),
|
| 486 |
+
fileSize: pick('dd[data-test="submission-review-filesize"]'),
|
| 487 |
+
wordCount: pick('dd[data-test="submission-review-wordcount"]'),
|
| 488 |
+
};
|
| 489 |
+
})
|
| 490 |
+
.catch(() => ({ fileName: '', fileSize: '', wordCount: '' }));
|
| 491 |
+
|
| 492 |
+
const details: SubmissionDetails = {};
|
| 493 |
+
if (rows.fileName) details.fileName = rows.fileName;
|
| 494 |
+
if (rows.fileSize) details.fileSize = parseFileSizeBytes(rows.fileSize);
|
| 495 |
+
if (rows.wordCount) details.wordCount = parseNumber(rows.wordCount);
|
| 496 |
+
return Object.keys(details).length > 0 ? details : null;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
async function completeLegacyUpload(
|
| 500 |
+
scope: Scope,
|
| 501 |
+
inputFilePath: string,
|
| 502 |
+
inputFileName?: string,
|
| 503 |
+
): Promise<SubmissionDetails | null> {
|
| 504 |
+
const page = 'page' in scope ? (scope as any).page() : (scope as Page);
|
| 505 |
+
|
| 506 |
+
const fileInput = scope
|
| 507 |
+
.locator('input[data-test="submission-file-select"], input#file, input[type="file"]')
|
| 508 |
+
.first();
|
| 509 |
+
await fileInput.setInputFiles(inputFilePath);
|
| 510 |
+
await scope.waitForTimeout(1500);
|
| 511 |
+
|
| 512 |
+
const titleInput = scope
|
| 513 |
+
.locator('input[data-test="submission-title"], input[name="title"], input#title')
|
| 514 |
+
.first();
|
| 515 |
+
if (inputFileName && await titleInput.waitFor({ state: 'visible', timeout: 2000 }).then(() => true).catch(() => false)) {
|
| 516 |
+
await titleInput.fill(path.parse(inputFileName).name).catch(() => {});
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
const reviewClicked = await clickFirstEnabled(scope, [
|
| 520 |
+
'button[data-test="submission-file-submit"]',
|
| 521 |
+
'.upload-and-review-btn',
|
| 522 |
+
'button:has-text("Upload and Review")',
|
| 523 |
+
'button:has-text("Upload & Review")',
|
| 524 |
+
], 30000);
|
| 525 |
+
if (!reviewClicked) {
|
| 526 |
+
throw new Error('Legacy Upload and Review button was not found after selecting the file.');
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// Wait for the review/confirmation panel to appear.
|
| 530 |
+
// Some Turnitin legacy builds skip the review panel and go straight to the
|
| 531 |
+
// dashboard when the file is already processed. We accept both paths.
|
| 532 |
+
const submitLocator = scope
|
| 533 |
+
.locator([
|
| 534 |
+
'button[data-test="submission-review-button-submit"]',
|
| 535 |
+
'button[data-test*="confirm"]',
|
| 536 |
+
'button[data-test*="submit"]',
|
| 537 |
+
'button:has-text("Submit to Turnitin")',
|
| 538 |
+
'button:has-text("Confirm")',
|
| 539 |
+
].join(', '))
|
| 540 |
+
.first();
|
| 541 |
+
|
| 542 |
+
// Wait up to 150 s for the submit button to appear, indicating file upload is complete and ready for confirmation
|
| 543 |
+
try {
|
| 544 |
+
await submitLocator.waitFor({ state: 'visible', timeout: 150000 });
|
| 545 |
+
} catch (err) {
|
| 546 |
+
throw new Error('Legacy review panel (Submit to Turnitin / Confirm) did not appear within 150 seconds.');
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
const reviewDetails = await readLegacyReviewDetails(scope);
|
| 550 |
+
|
| 551 |
+
const submitClicked = await clickFirstVisible(scope, [
|
| 552 |
+
'button[data-test="submission-review-button-submit"]',
|
| 553 |
+
'button[data-test*="confirm"]',
|
| 554 |
+
'button[data-test*="submit"]',
|
| 555 |
+
'button:has-text("Submit to Turnitin")',
|
| 556 |
+
'button:has-text("Confirm")',
|
| 557 |
+
], 30000);
|
| 558 |
+
if (!submitClicked) {
|
| 559 |
+
throw new Error('Legacy Submit to Turnitin / Confirm button was not found.');
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
// Wait for the Close / Go to Assignment Dashboard button and click it
|
| 563 |
+
const closeClicked = await clickFirstVisible(scope, [
|
| 564 |
+
'button[data-test="submission-complete-button-close"]',
|
| 565 |
+
'button:has-text("Close")',
|
| 566 |
+
'button:has-text("Go to Assignment Dashboard")',
|
| 567 |
+
], 60000);
|
| 568 |
+
|
| 569 |
+
if (closeClicked) {
|
| 570 |
+
logger.info('Legacy submission completed, closed modal.');
|
| 571 |
+
} else {
|
| 572 |
+
logger.warn('Legacy Close/Go to Assignment Dashboard button not found, continuing...');
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
// Wait for submission to appear on dashboard (up to 3 minutes)
|
| 576 |
+
await page
|
| 577 |
+
.locator('#dashboard-table a.default-open, #dashboard-table td[data-title="Similarity Score"], a.default-open[data-paper-title]')
|
| 578 |
+
.first()
|
| 579 |
+
.waitFor({ state: 'visible', timeout: 180000 });
|
| 580 |
+
|
| 581 |
+
return reviewDetails;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
async function waitForLegacySimilarity(page: Page): Promise<number | null> {
|
| 585 |
+
const startedAt = Date.now();
|
| 586 |
+
const timeoutMs = Math.max(config.similarityTimeoutMs || 180000, 120000);
|
| 587 |
+
const reloadIntervalMs = 30000; // Reload every 30 seconds
|
| 588 |
+
let lastReloadAt = startedAt;
|
| 589 |
+
|
| 590 |
+
while (Date.now() - startedAt < timeoutMs) {
|
| 591 |
+
// Check both specific similarity score cells and any visible percentage
|
| 592 |
+
const text = await page
|
| 593 |
+
.locator('td[data-title="Similarity Score"] a.similarity-open, td[data-title="Similarity Score"], a.similarity-open')
|
| 594 |
+
.first()
|
| 595 |
+
.textContent({ timeout: 5000 })
|
| 596 |
+
.catch(() => null);
|
| 597 |
+
const percent = parsePercent(text);
|
| 598 |
+
if (percent !== null) return percent;
|
| 599 |
+
|
| 600 |
+
// Also check if similarity column shows a number without %
|
| 601 |
+
const rawText = await page
|
| 602 |
+
.locator('td[data-title="Similarity Score"]')
|
| 603 |
+
.first()
|
| 604 |
+
.textContent({ timeout: 2000 })
|
| 605 |
+
.catch(() => null);
|
| 606 |
+
const rawPercent = parsePercent(rawText);
|
| 607 |
+
if (rawPercent !== null) return rawPercent;
|
| 608 |
+
|
| 609 |
+
// Reload the page periodically to fetch the updated status
|
| 610 |
+
if (Date.now() - lastReloadAt >= reloadIntervalMs) {
|
| 611 |
+
logger.info('Similarity score not ready yet, reloading legacy assignment dashboard...');
|
| 612 |
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
|
| 613 |
+
await page.waitForTimeout(5000);
|
| 614 |
+
lastReloadAt = Date.now();
|
| 615 |
+
} else {
|
| 616 |
+
await page.waitForTimeout(config.similarityPollMs || 5000);
|
| 617 |
+
}
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
return null;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
/**
|
| 624 |
+
* Viewer URL patterns accepted for legacy carta reports.
|
| 625 |
+
* Turnitin serves the viewer from multiple subdomains depending on the region
|
| 626 |
+
* and account type — accept any of them.
|
| 627 |
+
*/
|
| 628 |
+
const VIEWER_URL_PATTERNS = [
|
| 629 |
+
/ev\.turnitin\.com\/app\/carta/i,
|
| 630 |
+
/reports\.integrity\.turnitin\.com/i,
|
| 631 |
+
/submission-viewer/i,
|
| 632 |
+
];
|
| 633 |
+
|
| 634 |
+
function isViewerUrl(url: string): boolean {
|
| 635 |
+
return VIEWER_URL_PATTERNS.some((re) => re.test(url));
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
async function openLegacyViewer(page: Page, context: BrowserContext): Promise<Page> {
|
| 639 |
+
const opener = page
|
| 640 |
+
.locator('td[data-title="Similarity Score"] a.similarity-open, a.default-open[data-paper-title], a.default-open')
|
| 641 |
+
.first();
|
| 642 |
+
|
| 643 |
+
await opener.waitFor({ state: 'visible', timeout: 30000 });
|
| 644 |
+
const newPagePromise = context.waitForEvent('page', { timeout: 25000 }).catch(() => null);
|
| 645 |
+
await opener.click({ force: true });
|
| 646 |
+
const newPage = await newPagePromise;
|
| 647 |
+
const viewerPage = newPage || page;
|
| 648 |
+
|
| 649 |
+
await viewerPage.waitForLoadState('domcontentloaded', { timeout: 60000 }).catch(() => {});
|
| 650 |
+
// The legacy carta viewer (ev.turnitin.com/app/carta) is a heavy JavaScript
|
| 651 |
+
// application that may take 10+ seconds to render sidebar controls.
|
| 652 |
+
// Wait for the URL to settle and the viewer content to appear.
|
| 653 |
+
await viewerPage.waitForTimeout(5000);
|
| 654 |
+
|
| 655 |
+
if (!isViewerUrl(viewerPage.url())) {
|
| 656 |
+
// Poll for up to 30 s for the viewer URL to settle
|
| 657 |
+
await viewerPage
|
| 658 |
+
.waitForURL((url) => isViewerUrl(url.toString()), { timeout: 30000 })
|
| 659 |
+
.catch(() => {});
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
if (!isViewerUrl(viewerPage.url())) {
|
| 663 |
+
throw new Error(`Legacy report viewer did not open. Current URL: ${viewerPage.url()}`);
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
// Wait for the carta viewer sidebar to render filter controls (up to 20 s).
|
| 667 |
+
await visible(
|
| 668 |
+
viewerPage,
|
| 669 |
+
'.apply-changes-button, .osi-score, .sidebar-paper-info-button, .exclude-quotes-checkbox',
|
| 670 |
+
20000,
|
| 671 |
+
).catch(() => {});
|
| 672 |
+
|
| 673 |
+
return viewerPage;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
async function readLegacySubmissionDetails(page: Page): Promise<SubmissionDetails | null> {
|
| 677 |
+
await clickFirstVisible(page, [
|
| 678 |
+
'[data-px="EVSimReportSubmissionInformationClicked"]',
|
| 679 |
+
'.sidebar-paper-info-button',
|
| 680 |
+
'[title="Submission Information"]',
|
| 681 |
+
'button:has-text("Submission Information")',
|
| 682 |
+
'button:has-text("Details")',
|
| 683 |
+
], 10000);
|
| 684 |
+
await page.waitForTimeout(1200);
|
| 685 |
+
|
| 686 |
+
const rows = await page
|
| 687 |
+
.evaluate(() => {
|
| 688 |
+
const wanted = [
|
| 689 |
+
'Student ID',
|
| 690 |
+
'Class Name',
|
| 691 |
+
'Class ID',
|
| 692 |
+
'Submission ID',
|
| 693 |
+
'Submission Date',
|
| 694 |
+
'Submission Count',
|
| 695 |
+
'File Name',
|
| 696 |
+
'File Extension',
|
| 697 |
+
'File Size',
|
| 698 |
+
'Character Count',
|
| 699 |
+
'Char Count',
|
| 700 |
+
'Word Count',
|
| 701 |
+
'Page Count',
|
| 702 |
+
];
|
| 703 |
+
const out: Array<{ term: string; value: string }> = [];
|
| 704 |
+
const all = Array.from(document.querySelectorAll('li, dd, div, span'));
|
| 705 |
+
const clean = (value: string | null | undefined) =>
|
| 706 |
+
String(value || '').replace(/\s+/g, ' ').trim();
|
| 707 |
+
|
| 708 |
+
for (const label of wanted) {
|
| 709 |
+
const labelNode = all.find((node) => clean(node.textContent) === label);
|
| 710 |
+
if (!labelNode) continue;
|
| 711 |
+
const container = labelNode.closest('li, dl, .submission-details-item, .paper-info-item') || labelNode.parentElement;
|
| 712 |
+
const explicitValue =
|
| 713 |
+
container?.querySelector('[role="definition"], .submission-details-value, dd, .value, .paper-info-value')?.textContent ||
|
| 714 |
+
labelNode.nextElementSibling?.textContent ||
|
| 715 |
+
'';
|
| 716 |
+
const value = clean(explicitValue);
|
| 717 |
+
if (value && value !== label) out.push({ term: label, value });
|
| 718 |
+
}
|
| 719 |
+
return out;
|
| 720 |
+
})
|
| 721 |
+
.catch(() => []);
|
| 722 |
+
|
| 723 |
+
const details: SubmissionDetails = {};
|
| 724 |
+
for (const row of rows) {
|
| 725 |
+
const key = normalizeLabel(row.term);
|
| 726 |
+
if (!key) continue;
|
| 727 |
+
|
| 728 |
+
if (
|
| 729 |
+
key === 'fileName' ||
|
| 730 |
+
key === 'fileExtension' ||
|
| 731 |
+
key === 'studentId' ||
|
| 732 |
+
key === 'className' ||
|
| 733 |
+
key === 'classId' ||
|
| 734 |
+
key === 'submissionId' ||
|
| 735 |
+
key === 'submissionDate'
|
| 736 |
+
) {
|
| 737 |
+
details[key] = row.value;
|
| 738 |
+
} else if (key === 'fileSize') {
|
| 739 |
+
const parsed = parseFileSizeBytes(row.value);
|
| 740 |
+
if (parsed !== undefined) details.fileSize = parsed;
|
| 741 |
+
} else {
|
| 742 |
+
const parsed = parseNumber(row.value);
|
| 743 |
+
if (parsed !== undefined) details[key] = parsed;
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
await page.keyboard.press('Escape').catch(() => {});
|
| 748 |
+
await page.waitForTimeout(700).catch(() => {});
|
| 749 |
+
return Object.keys(details).length > 0 ? details : null;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
async function openLegacyFilters(page: Page): Promise<boolean> {
|
| 753 |
+
// In the legacy carta viewer (ev.turnitin.com/app/carta) the filter controls
|
| 754 |
+
// are rendered directly in the sidebar — no button click is needed.
|
| 755 |
+
// Wait generously for the JavaScript to finish rendering.
|
| 756 |
+
const alreadyVisible = await legacyFilterPanelVisible(page, 15000);
|
| 757 |
+
if (alreadyVisible) return true;
|
| 758 |
+
|
| 759 |
+
// Some carta builds may require clicking a sidebar toggle; try common selectors.
|
| 760 |
+
const clicked = await clickFirstVisible(page, [
|
| 761 |
+
'[data-px="EVSimReportFiltersClicked"]',
|
| 762 |
+
'[title="Filters and Settings"]',
|
| 763 |
+
'[data-px*="Filter"]',
|
| 764 |
+
'.sidebar-filter-button',
|
| 765 |
+
'[title="Filters"]',
|
| 766 |
+
'button:has-text("Filters")',
|
| 767 |
+
], 5000) || await clickLegacyCartaElement(page, [
|
| 768 |
+
'[data-px="EVSimReportFiltersClicked"]',
|
| 769 |
+
'[title="Filters and Settings"]',
|
| 770 |
+
'[role="button"][title*="Filters"]',
|
| 771 |
+
'.tii-icon-funnel',
|
| 772 |
+
'.sc-segment-view',
|
| 773 |
+
], ['filters and settings', 'filters'], 8000);
|
| 774 |
+
|
| 775 |
+
if (clicked) {
|
| 776 |
+
await resetLegacyViewerZoom(page);
|
| 777 |
+
await page.waitForTimeout(2500);
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
return await legacyFilterPanelVisible(page, 10000);
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
async function closeLegacyFilters(page: Page): Promise<void> {
|
| 784 |
+
await resetLegacyViewerZoom(page);
|
| 785 |
+
await clickLegacyCartaElement(page, [
|
| 786 |
+
'[title="Match Overview"]',
|
| 787 |
+
'[data-px*="MatchOverview"]',
|
| 788 |
+
'[data-px*="SimilarityReport"]',
|
| 789 |
+
'.tii-icon-match-overview',
|
| 790 |
+
'.sc-segment-view',
|
| 791 |
+
], ['match overview', 'similarity report', 'similarity'], 5000).catch(() => false);
|
| 792 |
+
await page.keyboard.press('Escape').catch(() => {});
|
| 793 |
+
await page.waitForTimeout(1000).catch(() => {});
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
async function legacyFilterPanelVisible(page: Page, timeoutMs = 8000): Promise<boolean> {
|
| 797 |
+
const deadline = Date.now() + timeoutMs;
|
| 798 |
+
while (Date.now() < deadline) {
|
| 799 |
+
const found = await page.evaluate(() => {
|
| 800 |
+
const isVisible = (element: Element): boolean => {
|
| 801 |
+
if (!(element instanceof HTMLElement)) return false;
|
| 802 |
+
const style = window.getComputedStyle(element);
|
| 803 |
+
const rect = element.getBoundingClientRect();
|
| 804 |
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
| 805 |
+
};
|
| 806 |
+
|
| 807 |
+
const directControls = Array.from(
|
| 808 |
+
document.querySelectorAll(
|
| 809 |
+
'.apply-changes-button, .exclude-quotes-checkbox, .exclude-biblio-checkbox, .small-matches-radio-group, .filter-inputs input',
|
| 810 |
+
),
|
| 811 |
+
);
|
| 812 |
+
if (directControls.some(isVisible)) return true;
|
| 813 |
+
|
| 814 |
+
const clean = (value: string | null | undefined) =>
|
| 815 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 816 |
+
const textFor = (element: Element): string => {
|
| 817 |
+
const labelledBy = element.getAttribute('aria-labelledby');
|
| 818 |
+
const labelText = labelledBy
|
| 819 |
+
?.split(/\s+/)
|
| 820 |
+
.map((id) => document.getElementById(id)?.textContent || '')
|
| 821 |
+
.join(' ');
|
| 822 |
+
return clean([
|
| 823 |
+
element.textContent || '',
|
| 824 |
+
element.getAttribute('aria-label') || '',
|
| 825 |
+
element.getAttribute('title') || '',
|
| 826 |
+
labelText || '',
|
| 827 |
+
].join(' '));
|
| 828 |
+
};
|
| 829 |
+
|
| 830 |
+
const controls = Array.from(
|
| 831 |
+
document.querySelectorAll<HTMLElement>('[role="checkbox"], [role="radio"][index], .sc-checkbox-control, .sc-radio-button'),
|
| 832 |
+
).filter(isVisible);
|
| 833 |
+
const hasQuotes = controls.some((element) => textFor(element).includes('exclude quotes'));
|
| 834 |
+
const hasBibliography = controls.some((element) => textFor(element).includes('exclude bibliography'));
|
| 835 |
+
const hasSmallMatches = controls.some((element) =>
|
| 836 |
+
textFor(element).includes('words') ||
|
| 837 |
+
textFor(element).includes('%') ||
|
| 838 |
+
textFor(element).includes("don't exclude by size"),
|
| 839 |
+
);
|
| 840 |
+
|
| 841 |
+
return hasQuotes || hasBibliography || hasSmallMatches;
|
| 842 |
+
}).catch(() => false);
|
| 843 |
+
if (found) return true;
|
| 844 |
+
await page.waitForTimeout(400).catch(() => {});
|
| 845 |
+
}
|
| 846 |
+
return false;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
async function resetLegacyViewerZoom(page: Page): Promise<void> {
|
| 850 |
+
await page.keyboard.press('Control+0').catch(() => {});
|
| 851 |
+
await page
|
| 852 |
+
.evaluate(() => {
|
| 853 |
+
if (document.activeElement instanceof HTMLElement) {
|
| 854 |
+
document.activeElement.blur();
|
| 855 |
+
}
|
| 856 |
+
document.documentElement.style.zoom = '1';
|
| 857 |
+
if (document.body) document.body.style.zoom = '1';
|
| 858 |
+
window.scrollTo(0, 0);
|
| 859 |
+
})
|
| 860 |
+
.catch(() => {});
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
async function setLegacyCheckboxByText(
|
| 864 |
+
page: Page,
|
| 865 |
+
label: string,
|
| 866 |
+
enabled: boolean,
|
| 867 |
+
): Promise<boolean> {
|
| 868 |
+
return page.evaluate(
|
| 869 |
+
({ labelText, desired }) => {
|
| 870 |
+
const clean = (value: string | null | undefined) =>
|
| 871 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 872 |
+
const desiredLabel = clean(labelText);
|
| 873 |
+
|
| 874 |
+
const textFor = (element: Element): string => {
|
| 875 |
+
const labelledBy = element.getAttribute('aria-labelledby');
|
| 876 |
+
const labelText = labelledBy
|
| 877 |
+
?.split(/\s+/)
|
| 878 |
+
.map((id) => document.getElementById(id)?.textContent || '')
|
| 879 |
+
.join(' ');
|
| 880 |
+
return clean([
|
| 881 |
+
element.textContent || '',
|
| 882 |
+
element.getAttribute('aria-label') || '',
|
| 883 |
+
element.getAttribute('title') || '',
|
| 884 |
+
labelText || '',
|
| 885 |
+
].join(' '));
|
| 886 |
+
};
|
| 887 |
+
|
| 888 |
+
const findCheckbox = (): HTMLElement | HTMLInputElement | null => {
|
| 889 |
+
const direct = desiredLabel.includes('quotes')
|
| 890 |
+
? document.querySelector('.exclude-quotes-checkbox')
|
| 891 |
+
: desiredLabel.includes('bibliography')
|
| 892 |
+
? document.querySelector('.exclude-biblio-checkbox')
|
| 893 |
+
: null;
|
| 894 |
+
if (direct) return direct as HTMLElement;
|
| 895 |
+
|
| 896 |
+
const candidates = Array.from(
|
| 897 |
+
document.querySelectorAll<HTMLElement | HTMLInputElement>(
|
| 898 |
+
'[role="checkbox"], .sc-checkbox-control, input[type="checkbox"]',
|
| 899 |
+
),
|
| 900 |
+
);
|
| 901 |
+
const matched = candidates.find((element) => textFor(element).includes(desiredLabel));
|
| 902 |
+
if (matched) return matched;
|
| 903 |
+
|
| 904 |
+
const labelCandidates = Array.from(document.querySelectorAll('label, span, div'));
|
| 905 |
+
const labelNode = labelCandidates.find((node) => clean(node.textContent).includes(desiredLabel));
|
| 906 |
+
if (!labelNode) return null;
|
| 907 |
+
const container = labelNode.closest('fieldset, li, label, div, tr') || labelNode.parentElement;
|
| 908 |
+
return (
|
| 909 |
+
container?.querySelector('input[type="checkbox"], [role="checkbox"], .sc-checkbox-control') ||
|
| 910 |
+
labelNode.closest('[role="checkbox"], .sc-checkbox-control')
|
| 911 |
+
) as HTMLElement | HTMLInputElement | null;
|
| 912 |
+
};
|
| 913 |
+
|
| 914 |
+
const checkbox = findCheckbox();
|
| 915 |
+
if (!checkbox) return false;
|
| 916 |
+
|
| 917 |
+
const current =
|
| 918 |
+
checkbox instanceof HTMLInputElement
|
| 919 |
+
? checkbox.checked
|
| 920 |
+
: checkbox.getAttribute('aria-checked') === 'true' ||
|
| 921 |
+
checkbox.classList.contains('sel') ||
|
| 922 |
+
checkbox.classList.contains('selected') ||
|
| 923 |
+
checkbox.classList.contains('checked');
|
| 924 |
+
|
| 925 |
+
if (current !== desired) checkbox.click();
|
| 926 |
+
return true;
|
| 927 |
+
},
|
| 928 |
+
{ labelText: label, desired: enabled },
|
| 929 |
+
).catch(() => false);
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
async function setLegacySmallMatches(page: Page, filters: FilterOptions): Promise<void> {
|
| 933 |
+
const mode = filters.smallMatchMode || 'words';
|
| 934 |
+
const enabled = Boolean(filters.excludeSmallMatches) && mode !== 'off';
|
| 935 |
+
|
| 936 |
+
if (!enabled) {
|
| 937 |
+
await clickLegacySmallMatchRadio(page, '2');
|
| 938 |
+
await resetLegacyViewerZoom(page);
|
| 939 |
+
return;
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
await clickLegacySmallMatchRadio(page, mode === 'percent' ? '1' : '0');
|
| 943 |
+
await page.waitForTimeout(500);
|
| 944 |
+
|
| 945 |
+
const rawThreshold = Number(filters.smallMatchThreshold) || 8;
|
| 946 |
+
const max = mode === 'percent' ? 100 : 40;
|
| 947 |
+
const threshold = String(Math.min(max, Math.max(1, Math.round(rawThreshold))));
|
| 948 |
+
|
| 949 |
+
const targetSelector = await page.evaluate((desiredMode) => {
|
| 950 |
+
const inputs = Array.from(
|
| 951 |
+
document.querySelectorAll<HTMLInputElement>(
|
| 952 |
+
'.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[aria-label*="source"], input[aria-label*="match"]',
|
| 953 |
+
),
|
| 954 |
+
)
|
| 955 |
+
.filter((input) => {
|
| 956 |
+
const style = window.getComputedStyle(input);
|
| 957 |
+
const rect = input.getBoundingClientRect();
|
| 958 |
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
| 959 |
+
})
|
| 960 |
+
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
|
| 961 |
+
|
| 962 |
+
const input = inputs[desiredMode === 'percent' ? 1 : 0] || inputs[0];
|
| 963 |
+
if (!input) return null;
|
| 964 |
+
document
|
| 965 |
+
.querySelectorAll('[data-relv-small-match-target]')
|
| 966 |
+
.forEach((element) => element.removeAttribute('data-relv-small-match-target'));
|
| 967 |
+
input.setAttribute('data-relv-small-match-target', desiredMode);
|
| 968 |
+
return `[data-relv-small-match-target="${desiredMode}"]`;
|
| 969 |
+
}, mode).catch(() => null);
|
| 970 |
+
|
| 971 |
+
let keyboardSet = false;
|
| 972 |
+
let inputName: string | null = null;
|
| 973 |
+
if (targetSelector) {
|
| 974 |
+
const target = page.locator(targetSelector).first();
|
| 975 |
+
inputName = await target
|
| 976 |
+
.evaluate((input: HTMLInputElement) => input.name || input.id || '')
|
| 977 |
+
.catch(() => null);
|
| 978 |
+
await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
| 979 |
+
await target.click({ force: true, clickCount: 3, timeout: 3000 }).catch(() => {});
|
| 980 |
+
await page.keyboard.press('Control+A').catch(() => {});
|
| 981 |
+
await page.keyboard.press('Backspace').catch(() => {});
|
| 982 |
+
await page.keyboard.type(threshold, { delay: 45 }).catch(() => {});
|
| 983 |
+
await page.keyboard.press('Tab').catch(() => {});
|
| 984 |
+
await page.waitForTimeout(300);
|
| 985 |
+
const valueAfterKeyboard = await target
|
| 986 |
+
.evaluate((input: HTMLInputElement) => input.value || input.getAttribute('value') || '')
|
| 987 |
+
.catch(() => '');
|
| 988 |
+
keyboardSet = valueAfterKeyboard.replace(/[^\d]/g, '') === threshold;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
let setInDom = false;
|
| 992 |
+
if (!keyboardSet) {
|
| 993 |
+
setInDom = await page.evaluate(
|
| 994 |
+
({ desiredMode, desiredValue }) => {
|
| 995 |
+
const isVisible = (element: Element): boolean => {
|
| 996 |
+
if (!(element instanceof HTMLElement)) return false;
|
| 997 |
+
const style = window.getComputedStyle(element);
|
| 998 |
+
const rect = element.getBoundingClientRect();
|
| 999 |
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
| 1000 |
+
};
|
| 1001 |
+
|
| 1002 |
+
const inputs = Array.from(
|
| 1003 |
+
document.querySelectorAll<HTMLInputElement>(
|
| 1004 |
+
'.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[type="number"], input[aria-label*="source"], input[aria-label*="match"]',
|
| 1005 |
+
),
|
| 1006 |
+
).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
|
| 1007 |
+
if (inputs.length === 0) return false;
|
| 1008 |
+
|
| 1009 |
+
const index = desiredMode === 'percent' ? 1 : 0;
|
| 1010 |
+
const visibleInputs = inputs.filter(isVisible);
|
| 1011 |
+
const input = inputs[index] || visibleInputs[0] || inputs[0];
|
| 1012 |
+
if (!input) return false;
|
| 1013 |
+
|
| 1014 |
+
input.focus();
|
| 1015 |
+
input.select?.();
|
| 1016 |
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
| 1017 |
+
window.HTMLInputElement.prototype,
|
| 1018 |
+
'value',
|
| 1019 |
+
)?.set;
|
| 1020 |
+
nativeSetter?.call(input, desiredValue);
|
| 1021 |
+
input.value = desiredValue;
|
| 1022 |
+
input.setAttribute('value', desiredValue);
|
| 1023 |
+
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Backspace' }));
|
| 1024 |
+
input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Backspace' }));
|
| 1025 |
+
for (const char of desiredValue) {
|
| 1026 |
+
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: char }));
|
| 1027 |
+
input.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, key: char }));
|
| 1028 |
+
input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: char }));
|
| 1029 |
+
}
|
| 1030 |
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
| 1031 |
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
| 1032 |
+
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
| 1033 |
+
input.closest('.filter-inputs')?.dispatchEvent(new Event('change', { bubbles: true }));
|
| 1034 |
+
return true;
|
| 1035 |
+
},
|
| 1036 |
+
{ desiredMode: mode, desiredValue: threshold },
|
| 1037 |
+
).catch(() => false);
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
await resetLegacyViewerZoom(page);
|
| 1041 |
+
|
| 1042 |
+
if (!keyboardSet && !setInDom) {
|
| 1043 |
+
logger.warn('Legacy small matches threshold input was not visible', { mode, threshold });
|
| 1044 |
+
} else {
|
| 1045 |
+
logger.info('Legacy small matches threshold set', {
|
| 1046 |
+
mode,
|
| 1047 |
+
threshold,
|
| 1048 |
+
input: inputName,
|
| 1049 |
+
usedKeyboard: keyboardSet,
|
| 1050 |
+
usedDomFallback: setInDom,
|
| 1051 |
+
});
|
| 1052 |
+
}
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
async function clickLegacySmallMatchRadio(page: Page, index: '0' | '1' | '2'): Promise<boolean> {
|
| 1056 |
+
return page.evaluate((radioIndex) => {
|
| 1057 |
+
const clean = (value: string | null | undefined) =>
|
| 1058 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 1059 |
+
const dispatchClick = (element: HTMLElement): void => {
|
| 1060 |
+
element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
|
| 1061 |
+
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
|
| 1062 |
+
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
|
| 1063 |
+
element.click();
|
| 1064 |
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
| 1065 |
+
};
|
| 1066 |
+
|
| 1067 |
+
const direct = document.querySelector<HTMLElement>(
|
| 1068 |
+
`.small-matches-radio-group [role="radio"][index="${radioIndex}"], [role="radio"][index="${radioIndex}"], .radio-${radioIndex}`,
|
| 1069 |
+
);
|
| 1070 |
+
if (direct) {
|
| 1071 |
+
dispatchClick(direct);
|
| 1072 |
+
return true;
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
const label =
|
| 1076 |
+
radioIndex === '0'
|
| 1077 |
+
? 'words'
|
| 1078 |
+
: radioIndex === '1'
|
| 1079 |
+
? '%'
|
| 1080 |
+
: "don't exclude by size";
|
| 1081 |
+
const candidate = Array.from(document.querySelectorAll<HTMLElement>('[role="radio"], .sc-radio-button')).find(
|
| 1082 |
+
(element) => clean(element.textContent).includes(label),
|
| 1083 |
+
);
|
| 1084 |
+
if (!candidate) return false;
|
| 1085 |
+
dispatchClick(candidate);
|
| 1086 |
+
return true;
|
| 1087 |
+
}, index).catch(() => false);
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
type LegacyFilterState = {
|
| 1091 |
+
excludeQuotes: boolean | null;
|
| 1092 |
+
excludeBibliography: boolean | null;
|
| 1093 |
+
smallMatchMode: 'words' | 'percent' | 'off' | null;
|
| 1094 |
+
wordsThreshold: number | null;
|
| 1095 |
+
percentThreshold: number | null;
|
| 1096 |
+
};
|
| 1097 |
+
|
| 1098 |
+
function getDesiredLegacySmallMatch(filters: FilterOptions): {
|
| 1099 |
+
mode: 'words' | 'percent' | 'off';
|
| 1100 |
+
threshold: number | null;
|
| 1101 |
+
} {
|
| 1102 |
+
if (!filters.excludeSmallMatches || filters.smallMatchMode === 'off') {
|
| 1103 |
+
return { mode: 'off', threshold: null };
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
const mode = filters.smallMatchMode === 'percent' ? 'percent' : 'words';
|
| 1107 |
+
const max = mode === 'percent' ? 100 : 40;
|
| 1108 |
+
const threshold = Math.min(
|
| 1109 |
+
max,
|
| 1110 |
+
Math.max(1, Math.round(Number(filters.smallMatchThreshold) || 8)),
|
| 1111 |
+
);
|
| 1112 |
+
|
| 1113 |
+
return { mode, threshold };
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
async function readLegacyFilterState(page: Page): Promise<LegacyFilterState> {
|
| 1117 |
+
return page.evaluate(() => {
|
| 1118 |
+
const clean = (value: string | null | undefined) =>
|
| 1119 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 1120 |
+
|
| 1121 |
+
const textFor = (element: Element): string => {
|
| 1122 |
+
const labelledBy = element.getAttribute('aria-labelledby');
|
| 1123 |
+
const labelText = labelledBy
|
| 1124 |
+
?.split(/\s+/)
|
| 1125 |
+
.map((id) => document.getElementById(id)?.textContent || '')
|
| 1126 |
+
.join(' ');
|
| 1127 |
+
return clean([
|
| 1128 |
+
element.textContent || '',
|
| 1129 |
+
element.getAttribute('aria-label') || '',
|
| 1130 |
+
element.getAttribute('title') || '',
|
| 1131 |
+
labelText || '',
|
| 1132 |
+
].join(' '));
|
| 1133 |
+
};
|
| 1134 |
+
|
| 1135 |
+
const isVisible = (element: Element): boolean => {
|
| 1136 |
+
if (!(element instanceof HTMLElement)) return false;
|
| 1137 |
+
const style = window.getComputedStyle(element);
|
| 1138 |
+
const rect = element.getBoundingClientRect();
|
| 1139 |
+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
|
| 1140 |
+
};
|
| 1141 |
+
|
| 1142 |
+
const stateForToggle = (element: HTMLElement | HTMLInputElement): boolean | null => {
|
| 1143 |
+
if (element instanceof HTMLInputElement) return element.checked;
|
| 1144 |
+
const input = element.querySelector('input[type="checkbox"], input[type="radio"]') as HTMLInputElement | null;
|
| 1145 |
+
if (input) return input.checked;
|
| 1146 |
+
const aria = element.getAttribute('aria-checked');
|
| 1147 |
+
if (aria === 'true') return true;
|
| 1148 |
+
if (aria === 'false') return false;
|
| 1149 |
+
if (
|
| 1150 |
+
element.classList.contains('sel') ||
|
| 1151 |
+
element.classList.contains('selected') ||
|
| 1152 |
+
element.classList.contains('checked')
|
| 1153 |
+
) {
|
| 1154 |
+
return true;
|
| 1155 |
+
}
|
| 1156 |
+
return null;
|
| 1157 |
+
};
|
| 1158 |
+
|
| 1159 |
+
const findByLabel = (
|
| 1160 |
+
directSelector: string,
|
| 1161 |
+
roleSelector: string,
|
| 1162 |
+
label: string,
|
| 1163 |
+
): HTMLElement | HTMLInputElement | null => {
|
| 1164 |
+
const direct = document.querySelector(directSelector) as HTMLElement | HTMLInputElement | null;
|
| 1165 |
+
if (direct) return direct;
|
| 1166 |
+
|
| 1167 |
+
const desiredLabel = clean(label);
|
| 1168 |
+
const candidates = Array.from(
|
| 1169 |
+
document.querySelectorAll<HTMLElement | HTMLInputElement>(roleSelector),
|
| 1170 |
+
);
|
| 1171 |
+
const matched = candidates.find((element) => textFor(element).includes(desiredLabel));
|
| 1172 |
+
if (matched) return matched;
|
| 1173 |
+
|
| 1174 |
+
const labels = Array.from(document.querySelectorAll('label, span, div'));
|
| 1175 |
+
const labelNode = labels.find((node) => clean(node.textContent).includes(desiredLabel));
|
| 1176 |
+
const container = labelNode?.closest('fieldset, li, label, div, tr') || labelNode?.parentElement;
|
| 1177 |
+
return (
|
| 1178 |
+
container?.querySelector(roleSelector) ||
|
| 1179 |
+
labelNode?.closest(roleSelector)
|
| 1180 |
+
) as HTMLElement | HTMLInputElement | null;
|
| 1181 |
+
};
|
| 1182 |
+
|
| 1183 |
+
const readChecked = (directSelector: string, label: string): boolean | null => {
|
| 1184 |
+
const element = findByLabel(
|
| 1185 |
+
directSelector,
|
| 1186 |
+
'[role="checkbox"], .sc-checkbox-control, input[type="checkbox"]',
|
| 1187 |
+
label,
|
| 1188 |
+
);
|
| 1189 |
+
if (!element) return null;
|
| 1190 |
+
return stateForToggle(element);
|
| 1191 |
+
};
|
| 1192 |
+
|
| 1193 |
+
const readRadio = (index: string, label: string): boolean => {
|
| 1194 |
+
const element = (
|
| 1195 |
+
document.querySelector(
|
| 1196 |
+
`.small-matches-radio-group [role="radio"][index="${index}"], [role="radio"][index="${index}"], .radio-${index}`,
|
| 1197 |
+
) ||
|
| 1198 |
+
Array.from(document.querySelectorAll<HTMLElement>('[role="radio"], .sc-radio-button')).find(
|
| 1199 |
+
(candidate) => textFor(candidate).includes(clean(label)),
|
| 1200 |
+
)
|
| 1201 |
+
) as HTMLElement | HTMLInputElement | null;
|
| 1202 |
+
if (!element) return false;
|
| 1203 |
+
return stateForToggle(element) === true;
|
| 1204 |
+
};
|
| 1205 |
+
|
| 1206 |
+
const thresholdInputs = (Array.from(
|
| 1207 |
+
document.querySelectorAll(
|
| 1208 |
+
'.small-matches-radio-group ~ .filter-inputs input.field, .filter-inputs input.field, .filter-inputs input, input[type="number"], input[aria-label*="source"], input[aria-label*="match"]',
|
| 1209 |
+
),
|
| 1210 |
+
) as HTMLInputElement[]).sort(
|
| 1211 |
+
(a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top,
|
| 1212 |
+
);
|
| 1213 |
+
|
| 1214 |
+
const parseThresholdInput = (input: HTMLInputElement | undefined): number | null => {
|
| 1215 |
+
if (!input) return null;
|
| 1216 |
+
const raw = String(input.value || '').replace(/[^\d]/g, '');
|
| 1217 |
+
if (!raw) return null;
|
| 1218 |
+
const numeric = Number(raw);
|
| 1219 |
+
return Number.isFinite(numeric) ? numeric : null;
|
| 1220 |
+
};
|
| 1221 |
+
|
| 1222 |
+
const parseInput = (index: number): number | null => parseThresholdInput(thresholdInputs[index]);
|
| 1223 |
+
|
| 1224 |
+
const visibleThreshold = parseThresholdInput(
|
| 1225 |
+
thresholdInputs.find((input) => isVisible(input)),
|
| 1226 |
+
);
|
| 1227 |
+
|
| 1228 |
+
let smallMatchMode: LegacyFilterState['smallMatchMode'] = null;
|
| 1229 |
+
if (readRadio('0', 'words')) smallMatchMode = 'words';
|
| 1230 |
+
else if (readRadio('1', '%')) smallMatchMode = 'percent';
|
| 1231 |
+
else if (readRadio('2', "don't exclude by size")) smallMatchMode = 'off';
|
| 1232 |
+
|
| 1233 |
+
let wordsThreshold = parseInput(0);
|
| 1234 |
+
let percentThreshold = parseInput(1);
|
| 1235 |
+
if (smallMatchMode === 'words' && wordsThreshold === null) {
|
| 1236 |
+
wordsThreshold = visibleThreshold;
|
| 1237 |
+
}
|
| 1238 |
+
if (smallMatchMode === 'percent' && percentThreshold === null) {
|
| 1239 |
+
percentThreshold = visibleThreshold;
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
return {
|
| 1243 |
+
excludeQuotes: readChecked('.exclude-quotes-checkbox', 'Exclude Quotes'),
|
| 1244 |
+
excludeBibliography: readChecked('.exclude-biblio-checkbox', 'Exclude Bibliography'),
|
| 1245 |
+
smallMatchMode,
|
| 1246 |
+
wordsThreshold,
|
| 1247 |
+
percentThreshold,
|
| 1248 |
+
};
|
| 1249 |
+
});
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
async function verifyLegacyFilterStates(
|
| 1253 |
+
page: Page,
|
| 1254 |
+
filters: FilterOptions,
|
| 1255 |
+
): Promise<{ state: LegacyFilterState; mismatches: string[] }> {
|
| 1256 |
+
const desiredSmallMatch = getDesiredLegacySmallMatch(filters);
|
| 1257 |
+
const state = await readLegacyFilterState(page);
|
| 1258 |
+
const mismatches: string[] = [];
|
| 1259 |
+
|
| 1260 |
+
const expectedQuotes = Boolean(filters.excludeQuotes);
|
| 1261 |
+
const expectedBibliography = Boolean(filters.excludeBibliography);
|
| 1262 |
+
|
| 1263 |
+
if (expectedQuotes ? state.excludeQuotes !== true : state.excludeQuotes === true) {
|
| 1264 |
+
mismatches.push(`Exclude Quotes expected ${expectedQuotes}, got ${state.excludeQuotes}`);
|
| 1265 |
+
}
|
| 1266 |
+
if (expectedBibliography ? state.excludeBibliography !== true : state.excludeBibliography === true) {
|
| 1267 |
+
mismatches.push(
|
| 1268 |
+
`Exclude Bibliography expected ${expectedBibliography}, got ${state.excludeBibliography}`,
|
| 1269 |
+
);
|
| 1270 |
+
}
|
| 1271 |
+
if (
|
| 1272 |
+
desiredSmallMatch.mode !== 'off' &&
|
| 1273 |
+
state.smallMatchMode !== desiredSmallMatch.mode
|
| 1274 |
+
) {
|
| 1275 |
+
mismatches.push(
|
| 1276 |
+
`Small matches mode expected ${desiredSmallMatch.mode}, got ${state.smallMatchMode}`,
|
| 1277 |
+
);
|
| 1278 |
+
}
|
| 1279 |
+
if (
|
| 1280 |
+
desiredSmallMatch.mode === 'off' &&
|
| 1281 |
+
(state.smallMatchMode === 'words' || state.smallMatchMode === 'percent')
|
| 1282 |
+
) {
|
| 1283 |
+
mismatches.push(
|
| 1284 |
+
`Small matches mode expected off, got ${state.smallMatchMode}`,
|
| 1285 |
+
);
|
| 1286 |
+
}
|
| 1287 |
+
if (desiredSmallMatch.mode === 'words' && state.wordsThreshold !== desiredSmallMatch.threshold) {
|
| 1288 |
+
mismatches.push(
|
| 1289 |
+
`Small matches words threshold expected ${desiredSmallMatch.threshold}, got ${state.wordsThreshold}`,
|
| 1290 |
+
);
|
| 1291 |
+
}
|
| 1292 |
+
if (desiredSmallMatch.mode === 'percent' && state.percentThreshold !== desiredSmallMatch.threshold) {
|
| 1293 |
+
mismatches.push(
|
| 1294 |
+
`Small matches percent threshold expected ${desiredSmallMatch.threshold}, got ${state.percentThreshold}`,
|
| 1295 |
+
);
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
return { state, mismatches };
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
async function applyLegacyFilters(page: Page, filters: FilterOptions): Promise<void> {
|
| 1302 |
+
const ownerPage = page;
|
| 1303 |
+
const activeFilters = hasActiveFilters(filters);
|
| 1304 |
+
|
| 1305 |
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
| 1306 |
+
const opened = await openLegacyFilters(page);
|
| 1307 |
+
if (!opened) {
|
| 1308 |
+
if (attempt < 3) {
|
| 1309 |
+
// Reload and give the carta viewer extra time to re-render its sidebar.
|
| 1310 |
+
await ownerPage.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
| 1311 |
+
await ownerPage.waitForTimeout(10000);
|
| 1312 |
+
continue;
|
| 1313 |
+
}
|
| 1314 |
+
if (activeFilters) {
|
| 1315 |
+
throw new Error('Legacy filter panel could not be opened after 3 viewer refresh attempts.');
|
| 1316 |
+
}
|
| 1317 |
+
return;
|
| 1318 |
+
}
|
| 1319 |
+
|
| 1320 |
+
const initialVerification = await verifyLegacyFilterStates(page, filters).catch(() => null);
|
| 1321 |
+
const alreadyApplied =
|
| 1322 |
+
initialVerification !== null && initialVerification.mismatches.length === 0;
|
| 1323 |
+
|
| 1324 |
+
if (alreadyApplied) {
|
| 1325 |
+
logger.info('Legacy filters already matched requested state; skipping Apply Changes', {
|
| 1326 |
+
state: initialVerification.state,
|
| 1327 |
+
filters,
|
| 1328 |
+
});
|
| 1329 |
+
await closeLegacyFilters(page);
|
| 1330 |
+
return;
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
await setLegacyCheckboxByText(page, 'Exclude Quotes', Boolean(filters.excludeQuotes));
|
| 1334 |
+
await setLegacyCheckboxByText(page, 'Exclude Bibliography', Boolean(filters.excludeBibliography));
|
| 1335 |
+
await setLegacySmallMatches(page, filters);
|
| 1336 |
+
await page.waitForTimeout(700);
|
| 1337 |
+
|
| 1338 |
+
let verification = await verifyLegacyFilterStates(page, filters);
|
| 1339 |
+
if (verification.mismatches.length > 0) {
|
| 1340 |
+
logger.warn('Legacy filter verification failed after first set; retrying filter state update', {
|
| 1341 |
+
mismatches: verification.mismatches,
|
| 1342 |
+
state: verification.state,
|
| 1343 |
+
filters,
|
| 1344 |
+
});
|
| 1345 |
+
|
| 1346 |
+
await setLegacyCheckboxByText(page, 'Exclude Quotes', Boolean(filters.excludeQuotes));
|
| 1347 |
+
await setLegacyCheckboxByText(page, 'Exclude Bibliography', Boolean(filters.excludeBibliography));
|
| 1348 |
+
await setLegacySmallMatches(page, filters);
|
| 1349 |
+
await page.waitForTimeout(700);
|
| 1350 |
+
verification = await verifyLegacyFilterStates(page, filters);
|
| 1351 |
+
}
|
| 1352 |
+
|
| 1353 |
+
if (verification.mismatches.length > 0) {
|
| 1354 |
+
throw new Error(
|
| 1355 |
+
`Legacy filters could not be verified before applying changes: ${verification.mismatches.join('; ')}`,
|
| 1356 |
+
);
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
logger.info('Legacy filter verification passed', {
|
| 1360 |
+
state: verification.state,
|
| 1361 |
+
filters,
|
| 1362 |
+
});
|
| 1363 |
+
|
| 1364 |
+
await resetLegacyViewerZoom(page);
|
| 1365 |
+
const appliedByDom = await clickLegacyApplyChanges(page);
|
| 1366 |
+
if (appliedByDom) {
|
| 1367 |
+
await page.waitForTimeout(3500);
|
| 1368 |
+
return;
|
| 1369 |
+
}
|
| 1370 |
+
|
| 1371 |
+
if (!activeFilters) {
|
| 1372 |
+
await closeLegacyFilters(page);
|
| 1373 |
+
return;
|
| 1374 |
+
}
|
| 1375 |
+
if (attempt < 3) {
|
| 1376 |
+
await ownerPage.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
| 1377 |
+
await ownerPage.waitForTimeout(5000);
|
| 1378 |
+
}
|
| 1379 |
+
}
|
| 1380 |
+
|
| 1381 |
+
throw new Error('Legacy Apply Changes button was not visible after 3 viewer refresh attempts.');
|
| 1382 |
+
}
|
| 1383 |
+
|
| 1384 |
+
async function clickLegacyApplyChanges(page: Page): Promise<boolean> {
|
| 1385 |
+
const deadline = Date.now() + 12000;
|
| 1386 |
+
let foundDisabled = false;
|
| 1387 |
+
|
| 1388 |
+
while (Date.now() < deadline) {
|
| 1389 |
+
const result = await page.evaluate(() => {
|
| 1390 |
+
const clean = (value: string | null | undefined) =>
|
| 1391 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 1392 |
+
const textFor = (element: Element): string => {
|
| 1393 |
+
const labelledBy = element.getAttribute('aria-labelledby');
|
| 1394 |
+
const labelText = labelledBy
|
| 1395 |
+
?.split(/\s+/)
|
| 1396 |
+
.map((id) => document.getElementById(id)?.textContent || '')
|
| 1397 |
+
.join(' ');
|
| 1398 |
+
return clean([
|
| 1399 |
+
element.textContent || '',
|
| 1400 |
+
element.getAttribute('aria-label') || '',
|
| 1401 |
+
element.getAttribute('title') || '',
|
| 1402 |
+
labelText || '',
|
| 1403 |
+
].join(' '));
|
| 1404 |
+
};
|
| 1405 |
+
|
| 1406 |
+
const button = (
|
| 1407 |
+
document.querySelector<HTMLElement>('.apply-changes-button') ||
|
| 1408 |
+
Array.from(document.querySelectorAll<HTMLElement>('[role="button"], button, .sc-button-view')).find((element) =>
|
| 1409 |
+
textFor(element).includes('apply changes'),
|
| 1410 |
+
)
|
| 1411 |
+
) as HTMLElement | null;
|
| 1412 |
+
|
| 1413 |
+
if (!button) return { found: false, disabled: false, clicked: false };
|
| 1414 |
+
|
| 1415 |
+
const disabled =
|
| 1416 |
+
button.classList.contains('disabled') ||
|
| 1417 |
+
button.getAttribute('aria-disabled') === 'true' ||
|
| 1418 |
+
button.hasAttribute('disabled') ||
|
| 1419 |
+
(button as HTMLButtonElement).disabled === true;
|
| 1420 |
+
|
| 1421 |
+
if (disabled) return { found: true, disabled: true, clicked: false };
|
| 1422 |
+
|
| 1423 |
+
button.scrollIntoView({ block: 'center', inline: 'center' });
|
| 1424 |
+
button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
|
| 1425 |
+
button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
|
| 1426 |
+
button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
|
| 1427 |
+
button.click();
|
| 1428 |
+
return { found: true, disabled: false, clicked: true };
|
| 1429 |
+
}).catch(() => ({ found: false, disabled: false, clicked: false }));
|
| 1430 |
+
|
| 1431 |
+
if (result.clicked) return true;
|
| 1432 |
+
if (result.found && result.disabled) foundDisabled = true;
|
| 1433 |
+
await page.waitForTimeout(500).catch(() => {});
|
| 1434 |
+
}
|
| 1435 |
+
|
| 1436 |
+
if (foundDisabled) {
|
| 1437 |
+
const forceClicked = await page.evaluate(() => {
|
| 1438 |
+
const clean = (value: string | null | undefined) =>
|
| 1439 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 1440 |
+
const textFor = (element: Element): string => {
|
| 1441 |
+
const labelledBy = element.getAttribute('aria-labelledby');
|
| 1442 |
+
const labelText = labelledBy
|
| 1443 |
+
?.split(/\s+/)
|
| 1444 |
+
.map((id) => document.getElementById(id)?.textContent || '')
|
| 1445 |
+
.join(' ');
|
| 1446 |
+
return clean([
|
| 1447 |
+
element.textContent || '',
|
| 1448 |
+
element.getAttribute('aria-label') || '',
|
| 1449 |
+
element.getAttribute('title') || '',
|
| 1450 |
+
labelText || '',
|
| 1451 |
+
].join(' '));
|
| 1452 |
+
};
|
| 1453 |
+
const button = (
|
| 1454 |
+
document.querySelector<HTMLElement>('.apply-changes-button') ||
|
| 1455 |
+
Array.from(document.querySelectorAll<HTMLElement>('[role="button"], button, .sc-button-view')).find((element) =>
|
| 1456 |
+
textFor(element).includes('apply changes'),
|
| 1457 |
+
)
|
| 1458 |
+
) as HTMLElement | null;
|
| 1459 |
+
if (!button) return false;
|
| 1460 |
+
button.classList.remove('disabled');
|
| 1461 |
+
button.removeAttribute('disabled');
|
| 1462 |
+
button.setAttribute('aria-disabled', 'false');
|
| 1463 |
+
button.scrollIntoView({ block: 'center', inline: 'center' });
|
| 1464 |
+
button.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
|
| 1465 |
+
button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
|
| 1466 |
+
button.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
|
| 1467 |
+
button.click();
|
| 1468 |
+
return true;
|
| 1469 |
+
}).catch(() => false);
|
| 1470 |
+
|
| 1471 |
+
if (forceClicked) {
|
| 1472 |
+
logger.warn('Legacy Apply Changes button stayed disabled; force-clicked after verified filter values');
|
| 1473 |
+
await page.waitForTimeout(2500).catch(() => {});
|
| 1474 |
+
const stillOpen = await page
|
| 1475 |
+
.locator('.apply-changes-button')
|
| 1476 |
+
.first()
|
| 1477 |
+
.waitFor({ state: 'visible', timeout: 1000 })
|
| 1478 |
+
.then(() => true)
|
| 1479 |
+
.catch(() => false);
|
| 1480 |
+
return !stillOpen;
|
| 1481 |
+
}
|
| 1482 |
+
|
| 1483 |
+
logger.warn('Legacy Apply Changes button stayed disabled after filter state verification');
|
| 1484 |
+
}
|
| 1485 |
+
|
| 1486 |
+
return false;
|
| 1487 |
+
}
|
| 1488 |
+
|
| 1489 |
+
async function readLegacyViewerSimilarity(page: Page): Promise<number | null> {
|
| 1490 |
+
await page.waitForTimeout(1000);
|
| 1491 |
+
const text = await page
|
| 1492 |
+
.locator('.osi-score, label[title="Match Overview"], [title="Match Overview"]')
|
| 1493 |
+
.first()
|
| 1494 |
+
.textContent({ timeout: 10000 })
|
| 1495 |
+
.catch(() => null);
|
| 1496 |
+
return parsePercent(text);
|
| 1497 |
+
}
|
| 1498 |
+
|
| 1499 |
+
async function clickLegacyCartaElement(
|
| 1500 |
+
page: Page,
|
| 1501 |
+
selectors: string[],
|
| 1502 |
+
textIncludes: string[],
|
| 1503 |
+
timeoutMs = 10000,
|
| 1504 |
+
): Promise<boolean> {
|
| 1505 |
+
const deadline = Date.now() + timeoutMs;
|
| 1506 |
+
while (Date.now() < deadline) {
|
| 1507 |
+
const clicked = await page.evaluate(
|
| 1508 |
+
({ selectors: rawSelectors, textIncludes: rawTextIncludes }) => {
|
| 1509 |
+
const clean = (value: string | null | undefined) =>
|
| 1510 |
+
String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
| 1511 |
+
|
| 1512 |
+
const textFor = (element: Element): string => {
|
| 1513 |
+
const labelledBy = element.getAttribute('aria-labelledby');
|
| 1514 |
+
const labelText = labelledBy
|
| 1515 |
+
?.split(/\s+/)
|
| 1516 |
+
.map((id) => document.getElementById(id)?.textContent || '')
|
| 1517 |
+
.join(' ');
|
| 1518 |
+
return clean([
|
| 1519 |
+
element.textContent || '',
|
| 1520 |
+
element.getAttribute('aria-label') || '',
|
| 1521 |
+
element.getAttribute('title') || '',
|
| 1522 |
+
labelText || '',
|
| 1523 |
+
element.getAttribute('data-px') || '',
|
| 1524 |
+
].join(' '));
|
| 1525 |
+
};
|
| 1526 |
+
|
| 1527 |
+
const visibleScore = (element: Element): number => {
|
| 1528 |
+
if (!(element instanceof HTMLElement)) return 0;
|
| 1529 |
+
const style = window.getComputedStyle(element);
|
| 1530 |
+
const rect = element.getBoundingClientRect();
|
| 1531 |
+
if (style.display === 'none' || style.visibility === 'hidden') return 0;
|
| 1532 |
+
if (rect.width > 0 && rect.height > 0) return 2;
|
| 1533 |
+
return 1;
|
| 1534 |
+
};
|
| 1535 |
+
|
| 1536 |
+
const textNeedles = rawTextIncludes.map(clean).filter(Boolean);
|
| 1537 |
+
const candidates: HTMLElement[] = [];
|
| 1538 |
+
for (const selector of rawSelectors) {
|
| 1539 |
+
try {
|
| 1540 |
+
candidates.push(...Array.from(document.querySelectorAll<HTMLElement>(selector)));
|
| 1541 |
+
} catch {
|
| 1542 |
+
// Ignore invalid selector fallbacks.
|
| 1543 |
+
}
|
| 1544 |
+
}
|
| 1545 |
+
|
| 1546 |
+
if (textNeedles.length > 0) {
|
| 1547 |
+
const controls = Array.from(
|
| 1548 |
+
document.querySelectorAll<HTMLElement>(
|
| 1549 |
+
'[role="button"], button, a, .sc-button-view, .sc-segment-view, .sc-list-item-view, .btn-link',
|
| 1550 |
+
),
|
| 1551 |
+
);
|
| 1552 |
+
candidates.push(
|
| 1553 |
+
...controls.filter((element) => {
|
| 1554 |
+
const text = textFor(element);
|
| 1555 |
+
return textNeedles.some((needle) => text.includes(needle));
|
| 1556 |
+
}),
|
| 1557 |
+
);
|
| 1558 |
+
}
|
| 1559 |
+
|
| 1560 |
+
const unique = Array.from(new Set(candidates));
|
| 1561 |
+
const target = unique
|
| 1562 |
+
.filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-disabled') !== 'true')
|
| 1563 |
+
.sort((a, b) => visibleScore(b) - visibleScore(a))[0];
|
| 1564 |
+
if (!target) return false;
|
| 1565 |
+
|
| 1566 |
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
| 1567 |
+
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
|
| 1568 |
+
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
|
| 1569 |
+
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
|
| 1570 |
+
target.click();
|
| 1571 |
+
return true;
|
| 1572 |
+
},
|
| 1573 |
+
{ selectors, textIncludes },
|
| 1574 |
+
).catch(() => false);
|
| 1575 |
+
|
| 1576 |
+
if (clicked) return true;
|
| 1577 |
+
await page.waitForTimeout(250).catch(() => {});
|
| 1578 |
+
}
|
| 1579 |
+
return false;
|
| 1580 |
+
}
|
| 1581 |
+
|
| 1582 |
+
type LegacyDownloadOption = 'current_view' | 'digital_receipt';
|
| 1583 |
+
|
| 1584 |
+
async function downloadLegacyPdf(
|
| 1585 |
+
page: Page,
|
| 1586 |
+
context: BrowserContext,
|
| 1587 |
+
outputPath: string,
|
| 1588 |
+
option: LegacyDownloadOption,
|
| 1589 |
+
): Promise<string> {
|
| 1590 |
+
let lastError: Error | null = null;
|
| 1591 |
+
const isReceipt = option === 'digital_receipt';
|
| 1592 |
+
const optionLabel = isReceipt ? 'Digital Receipt' : 'Current View';
|
| 1593 |
+
const optionSelectors = isReceipt
|
| 1594 |
+
? [
|
| 1595 |
+
'[data-px="EVSimReportDownloadDigitalReceipt"]',
|
| 1596 |
+
'[aria-label="Digital Receipt"]',
|
| 1597 |
+
'.print-download-items [role="button"]:has-text("Digital Receipt")',
|
| 1598 |
+
'.print-download-btn:has-text("Digital Receipt")',
|
| 1599 |
+
'button:has-text("Digital Receipt")',
|
| 1600 |
+
'a:has-text("Digital Receipt")',
|
| 1601 |
+
'[role="menuitem"]:has-text("Digital Receipt")',
|
| 1602 |
+
]
|
| 1603 |
+
: [
|
| 1604 |
+
'[data-px="EVSimReportDownloadCurrentView"]',
|
| 1605 |
+
'[aria-label="Current View"]',
|
| 1606 |
+
'.print-download-items [role="button"]:has-text("Current View")',
|
| 1607 |
+
'.print-download-btn:has-text("Current View")',
|
| 1608 |
+
'button:has-text("Current View")',
|
| 1609 |
+
'a:has-text("Current View")',
|
| 1610 |
+
'[role="menuitem"]:has-text("Current View")',
|
| 1611 |
+
];
|
| 1612 |
+
const cartaSelectors = isReceipt
|
| 1613 |
+
? [
|
| 1614 |
+
'[data-px="EVSimReportDownloadDigitalReceipt"]',
|
| 1615 |
+
'[aria-label="Digital Receipt"]',
|
| 1616 |
+
]
|
| 1617 |
+
: [
|
| 1618 |
+
'[data-px="EVSimReportDownloadCurrentView"]',
|
| 1619 |
+
'[aria-label="Current View"]',
|
| 1620 |
+
];
|
| 1621 |
+
|
| 1622 |
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
| 1623 |
+
try {
|
| 1624 |
+
await page.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {});
|
| 1625 |
+
await page.waitForTimeout(attempt === 1 ? 1000 : 4000);
|
| 1626 |
+
|
| 1627 |
+
const opened = await clickFirstVisible(page, [
|
| 1628 |
+
'[data-px="EVSimReportDownloadClicked"]',
|
| 1629 |
+
'.sidebar-download-button',
|
| 1630 |
+
'[title="Download"]',
|
| 1631 |
+
'button:has-text("Download")',
|
| 1632 |
+
], 8000) || await clickLegacyCartaElement(page, [
|
| 1633 |
+
'[data-px="EVSimReportDownloadClicked"]',
|
| 1634 |
+
'.sidebar-download-button',
|
| 1635 |
+
'[title="Download"]',
|
| 1636 |
+
'[role="button"][title*="Download"]',
|
| 1637 |
+
'.tii-icon-download',
|
| 1638 |
+
], ['download'], 8000);
|
| 1639 |
+
|
| 1640 |
+
if (!opened) {
|
| 1641 |
+
throw new Error('Legacy download menu button was not found.');
|
| 1642 |
+
}
|
| 1643 |
+
await page.waitForTimeout(1200);
|
| 1644 |
+
|
| 1645 |
+
const downloadPromise = waitForAnyDownload(context, 120000);
|
| 1646 |
+
const selected = await clickFirstVisible(page, optionSelectors, 8000) ||
|
| 1647 |
+
await clickLegacyCartaElement(
|
| 1648 |
+
page,
|
| 1649 |
+
cartaSelectors,
|
| 1650 |
+
[optionLabel.toLowerCase()],
|
| 1651 |
+
12000,
|
| 1652 |
+
);
|
| 1653 |
+
|
| 1654 |
+
if (!selected) {
|
| 1655 |
+
void downloadPromise.catch(() => {});
|
| 1656 |
+
throw new Error(`Legacy ${optionLabel} download option was not found.`);
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
const download = await downloadPromise;
|
| 1660 |
+
await download.saveAs(outputPath);
|
| 1661 |
+
const stat = fs.statSync(outputPath);
|
| 1662 |
+
if (stat.size <= 0) {
|
| 1663 |
+
try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
|
| 1664 |
+
throw new Error(`Downloaded legacy PDF is empty: ${outputPath}`);
|
| 1665 |
+
}
|
| 1666 |
+
return outputPath;
|
| 1667 |
+
} catch (error) {
|
| 1668 |
+
lastError = error instanceof Error ? error : new Error(String(error));
|
| 1669 |
+
logger.warn('Legacy PDF download attempt failed', {
|
| 1670 |
+
attempt,
|
| 1671 |
+
option,
|
| 1672 |
+
error: lastError.message,
|
| 1673 |
+
});
|
| 1674 |
+
if (attempt < 3) {
|
| 1675 |
+
await page.keyboard.press('Escape').catch(() => {});
|
| 1676 |
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
| 1677 |
+
}
|
| 1678 |
+
}
|
| 1679 |
+
}
|
| 1680 |
+
|
| 1681 |
+
throw lastError || new Error(`Legacy ${optionLabel} PDF download failed.`);
|
| 1682 |
+
}
|
| 1683 |
+
|
| 1684 |
+
export async function runLegacyTurnitinJob(
|
| 1685 |
+
input: RunTurnitinJobInput,
|
| 1686 |
+
): Promise<RunTurnitinJobResult> {
|
| 1687 |
+
const {
|
| 1688 |
+
account,
|
| 1689 |
+
assignmentTarget,
|
| 1690 |
+
inputFilePath,
|
| 1691 |
+
inputFileName,
|
| 1692 |
+
outputDir,
|
| 1693 |
+
mode: requestedMode,
|
| 1694 |
+
filters,
|
| 1695 |
+
storageStatePath,
|
| 1696 |
+
resumeAfterStep,
|
| 1697 |
+
resumeViewerUrl,
|
| 1698 |
+
onEvent,
|
| 1699 |
+
} = input;
|
| 1700 |
+
|
| 1701 |
+
const result: RunTurnitinJobResult = {};
|
| 1702 |
+
let page: Page | null = null;
|
| 1703 |
+
const browser = await getBrowser();
|
| 1704 |
+
const contextOptions: Record<string, unknown> = {
|
| 1705 |
+
userAgent:
|
| 1706 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
| 1707 |
+
viewport: { width: 1366, height: 768 },
|
| 1708 |
+
acceptDownloads: true,
|
| 1709 |
+
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
| 1710 |
+
};
|
| 1711 |
+
|
| 1712 |
+
if (storageStatePath && fs.existsSync(storageStatePath)) {
|
| 1713 |
+
contextOptions.storageState = storageStatePath;
|
| 1714 |
+
}
|
| 1715 |
+
|
| 1716 |
+
const context = await browser.newContext(contextOptions as any);
|
| 1717 |
+
|
| 1718 |
+
try {
|
| 1719 |
+
await emit(onEvent, 'info', 'browser', 'Creating legacy browser context');
|
| 1720 |
+
page = await context.newPage();
|
| 1721 |
+
|
| 1722 |
+
await emit(onEvent, 'info', 'login', 'Logging in to Turnitin', { email: account.email });
|
| 1723 |
+
await loginToTurnitin(page, account.email, account.password, storageStatePath, assignmentTarget.targetUrl);
|
| 1724 |
+
result.lastCompletedStep = 'login';
|
| 1725 |
+
|
| 1726 |
+
const resumeFromViewer =
|
| 1727 |
+
resumeAfterStep &&
|
| 1728 |
+
['viewer', 'filters', 'download', 'receipt'].includes(resumeAfterStep) &&
|
| 1729 |
+
resumeViewerUrl;
|
| 1730 |
+
|
| 1731 |
+
if (resumeFromViewer) {
|
| 1732 |
+
await emit(onEvent, 'info', 'viewer', 'Reopening legacy viewer from previous attempt', {
|
| 1733 |
+
viewerUrl: resumeViewerUrl,
|
| 1734 |
+
});
|
| 1735 |
+
page = await context.newPage();
|
| 1736 |
+
await page.goto(resumeViewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
| 1737 |
+
await page.waitForTimeout(3000);
|
| 1738 |
+
result.viewerUrl = page.url();
|
| 1739 |
+
result.lastCompletedStep = 'viewer';
|
| 1740 |
+
} else {
|
| 1741 |
+
await emit(onEvent, 'info', 'navigate', 'Navigating to legacy assignment', {
|
| 1742 |
+
classTitle: assignmentTarget.classTitle,
|
| 1743 |
+
assignmentTitle: assignmentTarget.assignmentTitle,
|
| 1744 |
+
});
|
| 1745 |
+
await navigateLegacyAssignment(
|
| 1746 |
+
page,
|
| 1747 |
+
assignmentTarget.classTitle,
|
| 1748 |
+
assignmentTarget.assignmentTitle,
|
| 1749 |
+
onEvent,
|
| 1750 |
+
);
|
| 1751 |
+
// Persist 'navigate' step immediately so that on failure the retry
|
| 1752 |
+
// engine knows we reached the assignment page and can resume correctly.
|
| 1753 |
+
result.lastCompletedStep = 'navigate';
|
| 1754 |
+
await emit(onEvent, 'info', 'navigate', 'Legacy assignment page loaded');
|
| 1755 |
+
|
| 1756 |
+
if (requestedMode === 'quota_check') {
|
| 1757 |
+
const details = await openLegacyViewer(page, context)
|
| 1758 |
+
.then((viewer) => readLegacySubmissionDetails(viewer))
|
| 1759 |
+
.catch(() => null);
|
| 1760 |
+
if (details) result.submissionDetails = details;
|
| 1761 |
+
result.submissionCount = details?.submissionCount;
|
| 1762 |
+
result.lastCompletedStep = 'quota_check';
|
| 1763 |
+
return result;
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
if (resumeAfterStep && ['submitted', 'similarity'].includes(resumeAfterStep)) {
|
| 1767 |
+
await emit(onEvent, 'info', 'resume', 'Resuming legacy job after previous upload; skipping upload step', {
|
| 1768 |
+
resumeAfterStep,
|
| 1769 |
+
});
|
| 1770 |
+
result.lastCompletedStep = 'submitted';
|
| 1771 |
+
} else {
|
| 1772 |
+
const { effectiveMode, scope } = await openLegacyUploadModal(page, requestedMode, onEvent);
|
| 1773 |
+
await emit(onEvent, 'info', effectiveMode, effectiveMode === 'resubmit' ? 'Resubmitting legacy file' : 'Uploading legacy file', {
|
| 1774 |
+
filePath: inputFilePath,
|
| 1775 |
+
});
|
| 1776 |
+
const reviewDetails = await completeLegacyUpload(scope, inputFilePath, inputFileName || path.basename(inputFilePath));
|
| 1777 |
+
if (reviewDetails) {
|
| 1778 |
+
result.submissionDetails = reviewDetails;
|
| 1779 |
+
await emit(onEvent, 'info', 'submission_details', 'Legacy review details captured', reviewDetails as Record<string, unknown>);
|
| 1780 |
+
}
|
| 1781 |
+
result.submittedAt = new Date().toISOString();
|
| 1782 |
+
result.lastCompletedStep = 'submitted';
|
| 1783 |
+
await emit(onEvent, 'info', 'submitted', 'File submitted successfully', {
|
| 1784 |
+
submittedAt: result.submittedAt,
|
| 1785 |
+
});
|
| 1786 |
+
}
|
| 1787 |
+
|
| 1788 |
+
await emit(onEvent, 'info', 'similarity', 'Waiting for legacy similarity score');
|
| 1789 |
+
const tableSimilarity = await waitForLegacySimilarity(page);
|
| 1790 |
+
if (tableSimilarity !== null) result.similarityPercent = tableSimilarity;
|
| 1791 |
+
result.lastCompletedStep = 'similarity';
|
| 1792 |
+
await emit(onEvent, 'info', 'similarity', `Similarity: ${result.similarityPercent ?? 'pending'}%`, {
|
| 1793 |
+
similarityPercent: result.similarityPercent,
|
| 1794 |
+
});
|
| 1795 |
+
|
| 1796 |
+
await emit(onEvent, 'info', 'viewer', 'Opening legacy report viewer');
|
| 1797 |
+
page = await openLegacyViewer(page, context);
|
| 1798 |
+
result.viewerUrl = page.url();
|
| 1799 |
+
result.lastCompletedStep = 'viewer';
|
| 1800 |
+
await emit(onEvent, 'info', 'viewer', 'Legacy report viewer opened', {
|
| 1801 |
+
viewerUrl: result.viewerUrl,
|
| 1802 |
+
});
|
| 1803 |
+
}
|
| 1804 |
+
|
| 1805 |
+
const details = await readLegacySubmissionDetails(page);
|
| 1806 |
+
if (details) {
|
| 1807 |
+
result.submissionDetails = { ...(result.submissionDetails || {}), ...details };
|
| 1808 |
+
result.submissionCount = details.submissionCount;
|
| 1809 |
+
await emit(onEvent, 'info', 'submission_details', 'Legacy submission details captured', result.submissionDetails as Record<string, unknown>);
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
+
await emit(onEvent, 'info', 'filters', 'Applying legacy filters', { filters });
|
| 1813 |
+
await applyLegacyFilters(page, filters);
|
| 1814 |
+
result.lastCompletedStep = 'filters';
|
| 1815 |
+
|
| 1816 |
+
const viewerSimilarity = await readLegacyViewerSimilarity(page);
|
| 1817 |
+
if (viewerSimilarity !== null) {
|
| 1818 |
+
result.similarityPercent = viewerSimilarity;
|
| 1819 |
+
await emit(onEvent, 'info', 'similarity', `Viewer similarity (post-filter): ${viewerSimilarity}%`, {
|
| 1820 |
+
similarityPercent: viewerSimilarity,
|
| 1821 |
+
filtered: hasActiveFilters(filters),
|
| 1822 |
+
});
|
| 1823 |
+
}
|
| 1824 |
+
|
| 1825 |
+
await emit(onEvent, 'info', 'download', 'Downloading legacy PDF report');
|
| 1826 |
+
fs.mkdirSync(outputDir, { recursive: true });
|
| 1827 |
+
const outputPdfPath = path.join(outputDir, `turnitin_legacy_report_${Date.now()}.pdf`);
|
| 1828 |
+
result.outputPdfPath = await downloadLegacyPdf(
|
| 1829 |
+
page,
|
| 1830 |
+
context,
|
| 1831 |
+
outputPdfPath,
|
| 1832 |
+
'current_view',
|
| 1833 |
+
);
|
| 1834 |
+
result.lastCompletedStep = 'download';
|
| 1835 |
+
await emit(onEvent, 'info', 'download', 'PDF downloaded successfully', {
|
| 1836 |
+
outputPdfPath: result.outputPdfPath,
|
| 1837 |
+
});
|
| 1838 |
+
|
| 1839 |
+
// The Carta download modal closes after Current View is selected. Open the
|
| 1840 |
+
// Download panel again and fetch Digital Receipt as a separate PDF.
|
| 1841 |
+
await page.keyboard.press('Escape').catch(() => {});
|
| 1842 |
+
await page.waitForTimeout(500);
|
| 1843 |
+
await emit(onEvent, 'info', 'receipt', 'Downloading legacy Digital Receipt');
|
| 1844 |
+
const receiptPdfPath = path.join(outputDir, `turnitin_legacy_receipt_${Date.now()}.pdf`);
|
| 1845 |
+
result.receiptPdfPath = await downloadLegacyPdf(
|
| 1846 |
+
page,
|
| 1847 |
+
context,
|
| 1848 |
+
receiptPdfPath,
|
| 1849 |
+
'digital_receipt',
|
| 1850 |
+
);
|
| 1851 |
+
result.lastCompletedStep = 'receipt';
|
| 1852 |
+
await emit(onEvent, 'info', 'receipt', 'Digital Receipt downloaded successfully', {
|
| 1853 |
+
receiptPdfPath: result.receiptPdfPath,
|
| 1854 |
+
});
|
| 1855 |
+
|
| 1856 |
+
const accountQuotaRemaining =
|
| 1857 |
+
typeof input.account.quotaRemaining === 'number'
|
| 1858 |
+
? input.account.quotaRemaining
|
| 1859 |
+
: null;
|
| 1860 |
+
const shouldPermanentlyLimitLegacy =
|
| 1861 |
+
(typeof result.submissionCount === 'number' && result.submissionCount >= LEGACY_ACCOUNT_QUOTA_LIMIT) ||
|
| 1862 |
+
accountQuotaRemaining === 1;
|
| 1863 |
+
|
| 1864 |
+
if (shouldPermanentlyLimitLegacy) {
|
| 1865 |
+
const message =
|
| 1866 |
+
'Legacy Turnitin account reached its 4-submission limit. Class will be dropped and account will be permanently limited.';
|
| 1867 |
+
await emit(onEvent, 'warning', 'class_cleanup', 'Legacy submission limit reached; dropping class from account', {
|
| 1868 |
+
classTitle: assignmentTarget.classTitle,
|
| 1869 |
+
submissionCount: result.submissionCount ?? null,
|
| 1870 |
+
accountQuotaRemaining,
|
| 1871 |
+
});
|
| 1872 |
+
const dropClassResult = await dropClassByTitle(page, assignmentTarget.classTitle).catch((dropError: unknown) => ({
|
| 1873 |
+
attempted: true,
|
| 1874 |
+
dropped: false,
|
| 1875 |
+
reason: dropError instanceof Error ? dropError.message : String(dropError),
|
| 1876 |
+
}));
|
| 1877 |
+
await emit(
|
| 1878 |
+
onEvent,
|
| 1879 |
+
dropClassResult.dropped ? 'info' : 'warning',
|
| 1880 |
+
'class_cleanup',
|
| 1881 |
+
dropClassResult.dropped
|
| 1882 |
+
? 'Legacy class dropped or already absent after submission limit'
|
| 1883 |
+
: 'Legacy class could not be dropped automatically after submission limit',
|
| 1884 |
+
{ ...dropClassResult },
|
| 1885 |
+
);
|
| 1886 |
+
result.permanentLimit = {
|
| 1887 |
+
message,
|
| 1888 |
+
submissionCount: result.submissionCount,
|
| 1889 |
+
dropClassResult,
|
| 1890 |
+
};
|
| 1891 |
+
}
|
| 1892 |
+
|
| 1893 |
+
if (storageStatePath) {
|
| 1894 |
+
await context.storageState({ path: storageStatePath }).catch(() => {});
|
| 1895 |
+
}
|
| 1896 |
+
|
| 1897 |
+
return result;
|
| 1898 |
+
} catch (error) {
|
| 1899 |
+
const message = error instanceof Error ? error.message : String(error);
|
| 1900 |
+
if (page) {
|
| 1901 |
+
const screenshotPath = path.join(outputDir || '/tmp', `legacy_error_${Date.now()}.png`);
|
| 1902 |
+
await page.screenshot({ path: screenshotPath }).catch(() => {});
|
| 1903 |
+
logger.info(`Saved error screenshot to ${screenshotPath}`);
|
| 1904 |
+
}
|
| 1905 |
+
await emit(onEvent, 'error', 'error', compactErrorMessage(message), {
|
| 1906 |
+
errorName: error instanceof Error ? error.name : 'UnknownError',
|
| 1907 |
+
});
|
| 1908 |
+
if (error && typeof error === 'object') {
|
| 1909 |
+
(error as any).lastCompletedStep = result.lastCompletedStep;
|
| 1910 |
+
(error as any).viewerUrl = result.viewerUrl;
|
| 1911 |
+
(error as any).similarityPercent = result.similarityPercent;
|
| 1912 |
+
}
|
| 1913 |
+
throw error;
|
| 1914 |
+
} finally {
|
| 1915 |
+
await context.close().catch(() => {});
|
| 1916 |
+
}
|
| 1917 |
+
}
|
src/engine/selectors.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
|
| 3 |
+
// ---------------------------------------------------------------------------
|
| 4 |
+
// CSS Selectors – grouped by workflow area
|
| 5 |
+
// ---------------------------------------------------------------------------
|
| 6 |
+
|
| 7 |
+
export const SELECTORS = {
|
| 8 |
+
// ---- Login ----
|
| 9 |
+
login: {
|
| 10 |
+
emailInput: '#email',
|
| 11 |
+
passwordInput: '#password',
|
| 12 |
+
submitButton: 'input.submit[type="submit"]',
|
| 13 |
+
},
|
| 14 |
+
|
| 15 |
+
// ---- EULA ----
|
| 16 |
+
eula: {
|
| 17 |
+
agreementModalSaveBtn: '#eulaAgreementModalSaveBtn',
|
| 18 |
+
iAgreeButton: 'button:has-text("I Agree")',
|
| 19 |
+
acceptButton: 'button:has-text("Accept")',
|
| 20 |
+
tdlIAgreeButton: 'tdl-button:has-text("I Agree")',
|
| 21 |
+
tdlAcceptButton: 'tdl-button:has-text("Accept")',
|
| 22 |
+
iAgreeContinueLink: 'a:has-text("I Agree -- Continue")',
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
// ---- Class ----
|
| 26 |
+
class: {
|
| 27 |
+
classNameLink: 'td.class_name a, a.ib-class-link, a',
|
| 28 |
+
},
|
| 29 |
+
|
| 30 |
+
// ---- Assignment ----
|
| 31 |
+
assignment: {
|
| 32 |
+
assignmentRow: 'tr.assignment-row',
|
| 33 |
+
openButton: 'a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")',
|
| 34 |
+
allOpenButtons:
|
| 35 |
+
'tr.assignment-row a.btn-open, a.btn-open, a.btn-primary, button:has-text("Open"), a:has-text("Open")',
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
// ---- Upload ----
|
| 39 |
+
upload: {
|
| 40 |
+
fileInput: 'input[type="file"]',
|
| 41 |
+
browseButton:
|
| 42 |
+
// New UI: tii-ing-upload-button > tdl-button[part="upload-form-container-button"]
|
| 43 |
+
// Old UI: tdl-button with "Browse Files" text
|
| 44 |
+
'tdl-button[part="upload-form-container-button"], tii-ing-upload-button tdl-button, button:has-text("Browse Files"), button:has-text("Browse"), tdl-button:has-text("Browse")',
|
| 45 |
+
uploadFromDevice:
|
| 46 |
+
'tdl-button[part="upload-form-container-menu-browse-button"], .actions-menu tdl-button[part="upload-form-container-menu-browse-button"], button:has-text("Your device"), tdl-button:has-text("Your device"), button:has-text("Upload from this device"), tdl-button:has-text("Upload from this device")',
|
| 47 |
+
submitButton:
|
| 48 |
+
// New UI: tdl-button with slot="form-submit-button-label" containing "Upload and Preview"
|
| 49 |
+
// Also handles plain Submit/Confirm after preview, plus accept-button/tii-grn-button
|
| 50 |
+
'tdl-button[part="submit-button"], tdl-button:has-text("Upload and Preview"), tdl-button.accept-button, tii-grn-button, button:has-text("Upload"), tdl-button:has-text("Upload"), button:has-text("Confirm"), tdl-button:has-text("Confirm"), button:has-text("Submit"), tdl-button:has-text("Submit")',
|
| 51 |
+
// New UI container for upload step
|
| 52 |
+
uploadStepContainer: 'div.upload-step, tii-ing-dropzone',
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
// ---- Resubmit ----
|
| 56 |
+
resubmit: {
|
| 57 |
+
// New UI: tii-grn-button with text "Resubmit" (inside tii-workflow-student-summary-panel-new)
|
| 58 |
+
// Old UI: tdl-button or button with text "Resubmit"
|
| 59 |
+
resubmitButton:
|
| 60 |
+
'tii-grn-button:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"]',
|
| 61 |
+
// New submission action wrapper (contains Submit/Resubmit button)
|
| 62 |
+
submissionActionPanel: 'tii-workflow-student-summary-panel-new, div.submission-action',
|
| 63 |
+
// New resubmission confirmation modal (dialog element)
|
| 64 |
+
resubmissionConfirmModal: 'dialog#tii-resubmission-last-submission-modal',
|
| 65 |
+
// Accept button inside the new modal (slot-based)
|
| 66 |
+
resubmissionConfirmAccept:
|
| 67 |
+
'dialog#tii-resubmission-last-submission-modal [slot="accept-button"] tdl-button, dialog#tii-resubmission-last-submission-modal .accept-button, dialog#tii-resubmission-last-submission-modal tdl-button.accept-button',
|
| 68 |
+
},
|
| 69 |
+
|
| 70 |
+
// ---- Submission Card / Similarity ----
|
| 71 |
+
similarity: {
|
| 72 |
+
viewSubmissionButton:
|
| 73 |
+
'button.link-button[aria-label*="View submission"], button.link-button, a[aria-label*="View submission"], a.view-mark, .similarity-button',
|
| 74 |
+
similarityDisplay: 'tii-mpdd-similarity-display',
|
| 75 |
+
similarityBadge: 'span[part="tii-grn-badge-label"]',
|
| 76 |
+
similaritySpan: 'tii-mpdd-similarity-display span:has-text("Similarity:")',
|
| 77 |
+
bodyContainer: '.body-container',
|
| 78 |
+
linkButtons: 'button.link-button',
|
| 79 |
+
},
|
| 80 |
+
|
| 81 |
+
// ---- Viewer ----
|
| 82 |
+
viewer: {
|
| 83 |
+
titleButton:
|
| 84 |
+
'button.link-button[aria-label*="View submission"], button.link-button, a[aria-label*="View submission"], a.view-mark, .similarity-button, [part="tii-grn-badge-label"]',
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
// ---- Filters ----
|
| 88 |
+
filters: {
|
| 89 |
+
similarityTab:
|
| 90 |
+
[
|
| 91 |
+
'tii-sws-tab-button#tab-similarity',
|
| 92 |
+
'#tab-similarity',
|
| 93 |
+
'tii-sws-tab-button[aria-controls="tii-sws-main"]:has-text("Similarity")',
|
| 94 |
+
'tii-sws-tab-button:has-text("Similarity")',
|
| 95 |
+
'button.tab-button[data-px="SimilarityTabClicked"]',
|
| 96 |
+
'[data-px="SimilarityTabClicked"]',
|
| 97 |
+
'[with-data-px="SimilarityTabClicked"]',
|
| 98 |
+
'[withdatapx="SimilarityTabClicked"]',
|
| 99 |
+
'[data-px*="SimilarityTab"]',
|
| 100 |
+
'[with-data-px*="SimilarityTab"]',
|
| 101 |
+
'[withdatapx*="SimilarityTab"]',
|
| 102 |
+
'[role="tab"]:has-text("Similarity")',
|
| 103 |
+
'button:has-text("Similarity")',
|
| 104 |
+
'tdl-button:has-text("Similarity")',
|
| 105 |
+
'tdl-labeled-button:has-text("Similarity")',
|
| 106 |
+
'[aria-label*="Similarity"]',
|
| 107 |
+
].join(', '),
|
| 108 |
+
filterButton:
|
| 109 |
+
[
|
| 110 |
+
'.tii-SimilarityReportPanel[aria-hidden="false"] .tii-SimilarityReportPanelHeader__SettingsButton',
|
| 111 |
+
'.tii-SimilarityReportPanel[aria-hidden="false"] tdl-button[with-data-px="SettingsClicked"]',
|
| 112 |
+
'.tii-filters-and-exclusions-container tdl-button[with-data-px="SettingsClicked"]',
|
| 113 |
+
'tdl-button[with-data-px="SettingsClicked"]',
|
| 114 |
+
'tdl-button[withdatapx="SettingsClicked"]',
|
| 115 |
+
'[with-data-px="SettingsClicked"]',
|
| 116 |
+
'[withdatapx="SettingsClicked"]',
|
| 117 |
+
'[data-px="SettingsClicked"]',
|
| 118 |
+
'[data-px*="Settings"]',
|
| 119 |
+
'[with-data-px*="Settings"]',
|
| 120 |
+
'[withdatapx*="Settings"]',
|
| 121 |
+
'.tii-SimilarityReportPanelHeader__SettingsButton',
|
| 122 |
+
'tdl-button:has-text("Filters")',
|
| 123 |
+
'tdl-labeled-button:has-text("Filters")',
|
| 124 |
+
'tii-grn-button:has-text("Filters")',
|
| 125 |
+
'button:has-text("Filters")',
|
| 126 |
+
'[role="button"]:has-text("Filters")',
|
| 127 |
+
'[aria-label*="Filter"]',
|
| 128 |
+
'[title*="Filter"]',
|
| 129 |
+
'[part*="filter"]',
|
| 130 |
+
].join(', '),
|
| 131 |
+
excludeBibliography: '#excludeBibliography, [with-id="excludeBibliography"]',
|
| 132 |
+
excludeQuotes: '#excludeQuotes, [with-id="excludeQuotes"]',
|
| 133 |
+
excludeCitations: '#excludeCitations, [with-id="excludeCitations"]',
|
| 134 |
+
excludeSmallMatches:
|
| 135 |
+
'tdl-checkbox[with-px-label="ExcludeSmallMatches"], tdl-checkbox:has-text("Exclude small matches")',
|
| 136 |
+
smallMatchesInput:
|
| 137 |
+
'tdl-number-input.small-matches-input, tdl-number-input[label="Set match exclusion threshold"]',
|
| 138 |
+
applyFilters:
|
| 139 |
+
[
|
| 140 |
+
'tdl-button[with-data-px="ApplyFiltersClicked"]',
|
| 141 |
+
'tdl-button[withdatapx="ApplyFiltersClicked"]',
|
| 142 |
+
'[with-data-px="ApplyFiltersClicked"]',
|
| 143 |
+
'[withdatapx="ApplyFiltersClicked"]',
|
| 144 |
+
'[data-px="ApplyFiltersClicked"]',
|
| 145 |
+
'tdl-button[slot="accept-button"]:has-text("Apply Filters")',
|
| 146 |
+
'tdl-button:has-text("Apply Filters")',
|
| 147 |
+
'button:has-text("Apply Filters")',
|
| 148 |
+
].join(', '),
|
| 149 |
+
backToReport:
|
| 150 |
+
[
|
| 151 |
+
'[id="supplement-container.viewSettings"] button.tii-similarity-supplement--back-button',
|
| 152 |
+
'.tii-panel-overlay--is-open button.tii-similarity-supplement--back-button',
|
| 153 |
+
'button.tii-similarity-supplement--back-button',
|
| 154 |
+
'[data-px="FiltersBackToReportClicked"]',
|
| 155 |
+
'[with-data-px="FiltersBackToReportClicked"]',
|
| 156 |
+
'[withdatapx="FiltersBackToReportClicked"]',
|
| 157 |
+
'button:has-text("Back to Similarity Report")',
|
| 158 |
+
'tdl-button:has-text("Back to Similarity Report")',
|
| 159 |
+
].join(', '),
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
// ---- Download ----
|
| 163 |
+
download: {
|
| 164 |
+
downloadMenu:
|
| 165 |
+
[
|
| 166 |
+
'tii-sws-download-btn-mfe tdl-labeled-button',
|
| 167 |
+
'tii-sws-download-btn-mfe',
|
| 168 |
+
'tii-sws-header [slot="download-btn"]',
|
| 169 |
+
'tdl-labeled-button[withdatapx="DownloadMenuClicked"]',
|
| 170 |
+
'[withdatapx="DownloadMenuClicked"]',
|
| 171 |
+
'tdl-labeled-button:has-text("Download")',
|
| 172 |
+
'button:has-text("Download")',
|
| 173 |
+
].join(', '),
|
| 174 |
+
currentViewOption:
|
| 175 |
+
[
|
| 176 |
+
'[with-data-px="DownloadOptionCurrentViewClicked"]',
|
| 177 |
+
'[withdatapx="DownloadOptionCurrentViewClicked"]',
|
| 178 |
+
'[data-px="DownloadOptionCurrentViewClicked"]',
|
| 179 |
+
].join(', '),
|
| 180 |
+
},
|
| 181 |
+
|
| 182 |
+
// ---- Quota ----
|
| 183 |
+
quota: {
|
| 184 |
+
warningConfirmButton:
|
| 185 |
+
'tii-grn-button:has-text("Continue"), tii-grn-button.accept-button, tdl-button[slot="accept-button"], tdl-button[variety="filled"], tii-grn-button',
|
| 186 |
+
},
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
// ---------------------------------------------------------------------------
|
| 190 |
+
// EULA selector list (iterated during acceptance)
|
| 191 |
+
// ---------------------------------------------------------------------------
|
| 192 |
+
|
| 193 |
+
export const EULA_SELECTORS = [
|
| 194 |
+
SELECTORS.eula.agreementModalSaveBtn,
|
| 195 |
+
SELECTORS.eula.iAgreeButton,
|
| 196 |
+
SELECTORS.eula.acceptButton,
|
| 197 |
+
SELECTORS.eula.tdlIAgreeButton,
|
| 198 |
+
SELECTORS.eula.tdlAcceptButton,
|
| 199 |
+
SELECTORS.eula.iAgreeContinueLink,
|
| 200 |
+
];
|
| 201 |
+
|
| 202 |
+
export const EULA_BODY_REGEX =
|
| 203 |
+
/End User License Agreement|Terms of Use|I Agree|I Accept/i;
|
| 204 |
+
|
| 205 |
+
export const EULA_DEEP_CLICK_TEXTS = [
|
| 206 |
+
'i agree',
|
| 207 |
+
'i accept',
|
| 208 |
+
'accept',
|
| 209 |
+
'continue',
|
| 210 |
+
'save',
|
| 211 |
+
];
|
| 212 |
+
|
| 213 |
+
export const EULA_BLOCK_REGEX =
|
| 214 |
+
/Could not access the assignment|End User License Agreement needs to be accepted|EULA needs to be accepted/i;
|
| 215 |
+
|
| 216 |
+
// ---------------------------------------------------------------------------
|
| 217 |
+
// Quota warning confirmation labels (in priority order)
|
| 218 |
+
// ---------------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
export const QUOTA_CONFIRM_LABELS = [
|
| 221 |
+
'Continue to File Upload',
|
| 222 |
+
'Continue',
|
| 223 |
+
'Confirm',
|
| 224 |
+
'I understand',
|
| 225 |
+
'OK',
|
| 226 |
+
'Yes',
|
| 227 |
+
];
|
| 228 |
+
|
| 229 |
+
// ---------------------------------------------------------------------------
|
| 230 |
+
// Shadow DOM helpers
|
| 231 |
+
// ---------------------------------------------------------------------------
|
| 232 |
+
|
| 233 |
+
type Scope = Page | Frame;
|
| 234 |
+
|
| 235 |
+
/**
|
| 236 |
+
* Read all text from a scope, traversing into shadow DOMs.
|
| 237 |
+
*/
|
| 238 |
+
export async function readScopeDeepText(scope: Scope): Promise<string> {
|
| 239 |
+
return scope.evaluate(() => {
|
| 240 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 241 |
+
const seen = new Set<Element>();
|
| 242 |
+
|
| 243 |
+
for (let i = 0; i < roots.length; i++) {
|
| 244 |
+
const root = roots[i];
|
| 245 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 246 |
+
if (seen.has(el)) continue;
|
| 247 |
+
seen.add(el);
|
| 248 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
return roots
|
| 253 |
+
.map((r) => (r as any).body?.innerText || (r as any).host?.innerText || r.textContent || '')
|
| 254 |
+
.join(' ')
|
| 255 |
+
.replace(/\s+/g, ' ')
|
| 256 |
+
.trim();
|
| 257 |
+
}).catch(() => '');
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* Read only visible text from a scope, traversing into shadow DOMs.
|
| 262 |
+
*/
|
| 263 |
+
export async function readScopeVisibleDeepText(scope: Scope): Promise<string> {
|
| 264 |
+
return scope.evaluate(() => {
|
| 265 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 266 |
+
const seen = new Set<Element>();
|
| 267 |
+
|
| 268 |
+
for (let i = 0; i < roots.length; i++) {
|
| 269 |
+
const root = roots[i];
|
| 270 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 271 |
+
if (seen.has(el)) continue;
|
| 272 |
+
seen.add(el);
|
| 273 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
const isVisible = (el: Element): boolean => {
|
| 278 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 279 |
+
const style = window.getComputedStyle(el);
|
| 280 |
+
const rect = el.getBoundingClientRect();
|
| 281 |
+
return (
|
| 282 |
+
style.visibility !== 'hidden' &&
|
| 283 |
+
style.display !== 'none' &&
|
| 284 |
+
rect.width > 0 &&
|
| 285 |
+
rect.height > 0
|
| 286 |
+
);
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
const chunks: string[] = [];
|
| 290 |
+
for (const root of roots) {
|
| 291 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 292 |
+
if (!isVisible(el)) continue;
|
| 293 |
+
const text = (el as HTMLElement).innerText || '';
|
| 294 |
+
if (text) chunks.push(text);
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return chunks.join(' ').replace(/\s+/g, ' ').trim();
|
| 299 |
+
}).catch(() => '');
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/**
|
| 303 |
+
* Check if any of textMatches appear in deep text of the scope.
|
| 304 |
+
*/
|
| 305 |
+
export async function scopeHasDeepText(
|
| 306 |
+
scope: Scope,
|
| 307 |
+
textMatches: string[],
|
| 308 |
+
): Promise<boolean> {
|
| 309 |
+
const text = await readScopeDeepText(scope);
|
| 310 |
+
const normalized = text.toLowerCase();
|
| 311 |
+
return textMatches.some((m) => normalized.includes(m.toLowerCase()));
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/**
|
| 315 |
+
* Check if any of textMatches appear in visible deep text of the scope.
|
| 316 |
+
* This avoids matching hidden web-component templates that Turnitin keeps in
|
| 317 |
+
* the DOM for states that are not currently active.
|
| 318 |
+
*/
|
| 319 |
+
export async function scopeHasVisibleDeepText(
|
| 320 |
+
scope: Scope,
|
| 321 |
+
textMatches: string[],
|
| 322 |
+
): Promise<boolean> {
|
| 323 |
+
const text = await readScopeVisibleDeepText(scope);
|
| 324 |
+
const normalized = text.toLowerCase();
|
| 325 |
+
return textMatches.some((m) => normalized.includes(m.toLowerCase()));
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* Click a visible checkbox in the given scope.
|
| 330 |
+
*/
|
| 331 |
+
export async function clickVisibleCheckbox(scope: Scope): Promise<boolean> {
|
| 332 |
+
return scope
|
| 333 |
+
.evaluate(() => {
|
| 334 |
+
const isVisible = (el: Element): boolean => {
|
| 335 |
+
const style = window.getComputedStyle(el);
|
| 336 |
+
const rect = el.getBoundingClientRect();
|
| 337 |
+
return (
|
| 338 |
+
style.visibility !== 'hidden' &&
|
| 339 |
+
style.display !== 'none' &&
|
| 340 |
+
rect.width > 0 &&
|
| 341 |
+
rect.height > 0
|
| 342 |
+
);
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
const cb = Array.from(
|
| 346 |
+
document.querySelectorAll(
|
| 347 |
+
'input[type="checkbox"], tdl-checkbox, tii-grn-checkbox',
|
| 348 |
+
),
|
| 349 |
+
).find(isVisible);
|
| 350 |
+
if (!cb) return false;
|
| 351 |
+
(cb as HTMLElement).click();
|
| 352 |
+
return true;
|
| 353 |
+
})
|
| 354 |
+
.catch(() => false);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/**
|
| 358 |
+
* Traverse shadow DOM looking for clickable elements whose text matches one of
|
| 359 |
+
* the given strings, then click the first match.
|
| 360 |
+
*/
|
| 361 |
+
export async function deepClickByText(
|
| 362 |
+
scope: Scope,
|
| 363 |
+
textMatches: string[],
|
| 364 |
+
): Promise<boolean> {
|
| 365 |
+
return scope
|
| 366 |
+
.evaluate((matches: string[]) => {
|
| 367 |
+
const normalizedMatches = matches.map((v) => v.toLowerCase());
|
| 368 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 369 |
+
const seen = new Set<Element>();
|
| 370 |
+
|
| 371 |
+
for (let i = 0; i < roots.length; i++) {
|
| 372 |
+
const root = roots[i];
|
| 373 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 374 |
+
if (seen.has(el)) continue;
|
| 375 |
+
seen.add(el);
|
| 376 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const isVisible = (el: Element): boolean => {
|
| 381 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 382 |
+
const style = window.getComputedStyle(el);
|
| 383 |
+
const rect = el.getBoundingClientRect();
|
| 384 |
+
return (
|
| 385 |
+
style.visibility !== 'hidden' &&
|
| 386 |
+
style.display !== 'none' &&
|
| 387 |
+
rect.width > 0 &&
|
| 388 |
+
rect.height > 0
|
| 389 |
+
);
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
const candidates: Element[] = [];
|
| 393 |
+
for (const root of roots) {
|
| 394 |
+
candidates.push(
|
| 395 |
+
...Array.from(
|
| 396 |
+
root.querySelectorAll(
|
| 397 |
+
'button, a, input, tdl-button, tii-grn-button, tdl-labeled-button',
|
| 398 |
+
),
|
| 399 |
+
),
|
| 400 |
+
);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
const target = candidates.find((el) => {
|
| 404 |
+
if (!isVisible(el)) return false;
|
| 405 |
+
const text = [
|
| 406 |
+
(el as HTMLElement).innerText,
|
| 407 |
+
el.textContent,
|
| 408 |
+
el.getAttribute('aria-label'),
|
| 409 |
+
el.getAttribute('value'),
|
| 410 |
+
]
|
| 411 |
+
.filter(Boolean)
|
| 412 |
+
.join(' ')
|
| 413 |
+
.replace(/\s+/g, ' ')
|
| 414 |
+
.trim()
|
| 415 |
+
.toLowerCase();
|
| 416 |
+
return normalizedMatches.some((m) => text.includes(m));
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
if (!target) return false;
|
| 420 |
+
(target as HTMLElement).click();
|
| 421 |
+
return true;
|
| 422 |
+
}, textMatches)
|
| 423 |
+
.catch(() => false);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/**
|
| 427 |
+
* Find the scope (page or frame) that contains a visible element matching the
|
| 428 |
+
* given selector.
|
| 429 |
+
*/
|
| 430 |
+
export async function findAssignmentScope(
|
| 431 |
+
page: Page,
|
| 432 |
+
selector: string,
|
| 433 |
+
timeoutMs = 1000,
|
| 434 |
+
): Promise<Page | Frame | null> {
|
| 435 |
+
if (
|
| 436 |
+
await page
|
| 437 |
+
.locator(selector)
|
| 438 |
+
.first()
|
| 439 |
+
.isVisible({ timeout: timeoutMs })
|
| 440 |
+
.catch(() => false)
|
| 441 |
+
)
|
| 442 |
+
return page;
|
| 443 |
+
|
| 444 |
+
for (const frame of page.frames()) {
|
| 445 |
+
if (frame.url().includes('cookie-shim')) continue;
|
| 446 |
+
if (
|
| 447 |
+
await frame
|
| 448 |
+
.locator(selector)
|
| 449 |
+
.first()
|
| 450 |
+
.isVisible({ timeout: timeoutMs })
|
| 451 |
+
.catch(() => false)
|
| 452 |
+
)
|
| 453 |
+
return frame;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
return null;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
/**
|
| 460 |
+
* Find the scope (page or frame) that contains deep text matching any of the
|
| 461 |
+
* given strings, polling until timeoutMs.
|
| 462 |
+
*/
|
| 463 |
+
export async function findScopeByDeepText(
|
| 464 |
+
page: Page,
|
| 465 |
+
textMatches: string[],
|
| 466 |
+
timeoutMs = 30000,
|
| 467 |
+
): Promise<Page | Frame | null> {
|
| 468 |
+
const deadline = Date.now() + timeoutMs;
|
| 469 |
+
while (Date.now() < deadline) {
|
| 470 |
+
if (await scopeHasDeepText(page, textMatches)) return page;
|
| 471 |
+
|
| 472 |
+
for (const frame of page.frames()) {
|
| 473 |
+
if (frame.url().includes('cookie-shim')) continue;
|
| 474 |
+
if (await scopeHasDeepText(frame, textMatches)) return frame;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
await page.waitForTimeout(1000);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
return null;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* Resolve the best scope for the assignment page.
|
| 485 |
+
*/
|
| 486 |
+
export async function resolveAssignmentScope(
|
| 487 |
+
page: Page,
|
| 488 |
+
): Promise<Page | Frame> {
|
| 489 |
+
return (
|
| 490 |
+
(await findAssignmentScope(
|
| 491 |
+
page,
|
| 492 |
+
'button.link-button[aria-label*="View submission"], tii-mpdd-similarity-display',
|
| 493 |
+
1000,
|
| 494 |
+
)) ||
|
| 495 |
+
(await findAssignmentScope(
|
| 496 |
+
page,
|
| 497 |
+
'tdl-button[part="upload-form-container-button"], button:has-text("Browse Files"), input[type="file"]',
|
| 498 |
+
1000,
|
| 499 |
+
)) ||
|
| 500 |
+
page
|
| 501 |
+
);
|
| 502 |
+
}
|
src/engine/steps/class-management.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import { SELECTORS } from '../selectors';
|
| 4 |
+
import { acceptEulaEverywhere } from './login';
|
| 5 |
+
|
| 6 |
+
export interface DropClassResult {
|
| 7 |
+
attempted: boolean;
|
| 8 |
+
dropped: boolean;
|
| 9 |
+
reason?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Drop a class from the Turnitin student home page. This is used only after a
|
| 14 |
+
* hard submission quota limit so exhausted accounts do not keep showing stale
|
| 15 |
+
* class entries during future quota scans.
|
| 16 |
+
*/
|
| 17 |
+
export async function dropClassByTitle(
|
| 18 |
+
page: Page,
|
| 19 |
+
classTitle: string,
|
| 20 |
+
): Promise<DropClassResult> {
|
| 21 |
+
if (!classTitle || classTitle.trim() === '') {
|
| 22 |
+
return {
|
| 23 |
+
attempted: false,
|
| 24 |
+
dropped: false,
|
| 25 |
+
reason: 'Class title is empty',
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
logger.info('Attempting to drop quota-limited class', { classTitle });
|
| 30 |
+
|
| 31 |
+
await page.goto('https://www.turnitin.com/s_home.asp?lang=en_us', {
|
| 32 |
+
waitUntil: 'domcontentloaded',
|
| 33 |
+
timeout: 60000,
|
| 34 |
+
});
|
| 35 |
+
await page.waitForTimeout(1500);
|
| 36 |
+
await acceptEulaEverywhere(page);
|
| 37 |
+
|
| 38 |
+
const classLink = page
|
| 39 |
+
.locator(SELECTORS.class.classNameLink)
|
| 40 |
+
.filter({ hasText: classTitle })
|
| 41 |
+
.first();
|
| 42 |
+
|
| 43 |
+
if (!(await classLink.isVisible({ timeout: 10000 }).catch(() => false))) {
|
| 44 |
+
logger.info('Class is already absent from Turnitin home', { classTitle });
|
| 45 |
+
return {
|
| 46 |
+
attempted: true,
|
| 47 |
+
dropped: true,
|
| 48 |
+
reason: 'Class already absent',
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const classRow = page.locator('tr').filter({ hasText: classTitle }).first();
|
| 53 |
+
const deleteLink = classRow
|
| 54 |
+
.locator('td.class_delete a, a[href*="confirmDropClass"]')
|
| 55 |
+
.first();
|
| 56 |
+
|
| 57 |
+
if (!(await deleteLink.isVisible({ timeout: 5000 }).catch(() => false))) {
|
| 58 |
+
logger.warn('Class delete link was not found', { classTitle });
|
| 59 |
+
return {
|
| 60 |
+
attempted: true,
|
| 61 |
+
dropped: false,
|
| 62 |
+
reason: 'Delete link not found',
|
| 63 |
+
};
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
let dialogAccepted = false;
|
| 67 |
+
const dialogHandler = async (dialog: any) => {
|
| 68 |
+
dialogAccepted = true;
|
| 69 |
+
await dialog.accept().catch(() => {});
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
page.once('dialog', dialogHandler);
|
| 73 |
+
await Promise.all([
|
| 74 |
+
page
|
| 75 |
+
.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 })
|
| 76 |
+
.catch(() => null),
|
| 77 |
+
deleteLink.click({ force: true }),
|
| 78 |
+
]);
|
| 79 |
+
await page.waitForTimeout(2500);
|
| 80 |
+
|
| 81 |
+
if (!dialogAccepted) {
|
| 82 |
+
logger.warn('Drop class confirmation dialog was not observed', {
|
| 83 |
+
classTitle,
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
await page.goto('https://www.turnitin.com/s_home.asp?lang=en_us', {
|
| 88 |
+
waitUntil: 'domcontentloaded',
|
| 89 |
+
timeout: 60000,
|
| 90 |
+
});
|
| 91 |
+
await page.waitForTimeout(1500);
|
| 92 |
+
|
| 93 |
+
const stillVisible = await page
|
| 94 |
+
.locator(SELECTORS.class.classNameLink)
|
| 95 |
+
.filter({ hasText: classTitle })
|
| 96 |
+
.first()
|
| 97 |
+
.isVisible({ timeout: 5000 })
|
| 98 |
+
.catch(() => false);
|
| 99 |
+
|
| 100 |
+
if (stillVisible) {
|
| 101 |
+
logger.warn('Class still visible after delete attempt', { classTitle });
|
| 102 |
+
return {
|
| 103 |
+
attempted: true,
|
| 104 |
+
dropped: false,
|
| 105 |
+
reason: 'Class still visible after delete',
|
| 106 |
+
};
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
logger.info('Quota-limited class dropped successfully', { classTitle });
|
| 110 |
+
return { attempted: true, dropped: true };
|
| 111 |
+
}
|
src/engine/steps/download.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame, BrowserContext, Download } from 'playwright';
|
| 2 |
+
import * as fs from 'fs';
|
| 3 |
+
import { logger } from '../../utils/logger';
|
| 4 |
+
import { SELECTORS, deepClickByText } from '../selectors';
|
| 5 |
+
|
| 6 |
+
type Scope = Page | Frame;
|
| 7 |
+
|
| 8 |
+
async function clickFirstVisible(
|
| 9 |
+
page: Scope,
|
| 10 |
+
selector: string,
|
| 11 |
+
limit = 20,
|
| 12 |
+
): Promise<boolean> {
|
| 13 |
+
const locator = page.locator(selector);
|
| 14 |
+
const count = Math.min(await locator.count().catch(() => 0), limit);
|
| 15 |
+
|
| 16 |
+
for (let index = 0; index < count; index++) {
|
| 17 |
+
const candidate = locator.nth(index);
|
| 18 |
+
if (!(await candidate.isVisible({ timeout: 800 }).catch(() => false))) {
|
| 19 |
+
continue;
|
| 20 |
+
}
|
| 21 |
+
await candidate.click({ force: true });
|
| 22 |
+
return true;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return false;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
// Context-level download listener
|
| 30 |
+
// ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Wait for a download event across all pages in the context.
|
| 34 |
+
* Turnitin may open/close temporary pages during export, so we listen at the
|
| 35 |
+
* context level rather than on a single page.
|
| 36 |
+
*/
|
| 37 |
+
function waitForAnyDownload(
|
| 38 |
+
context: BrowserContext,
|
| 39 |
+
timeoutMs: number,
|
| 40 |
+
): Promise<Download> {
|
| 41 |
+
return new Promise((resolve, reject) => {
|
| 42 |
+
const timer = setTimeout(
|
| 43 |
+
() => cleanup(new Error(`Timed out waiting ${timeoutMs}ms for download`)),
|
| 44 |
+
timeoutMs,
|
| 45 |
+
);
|
| 46 |
+
const pageListeners = new Map<Page, (d: Download) => void>();
|
| 47 |
+
|
| 48 |
+
const onDownload = (download: Download) => cleanup(null, download);
|
| 49 |
+
const onPage = (p: Page) => attach(p);
|
| 50 |
+
|
| 51 |
+
function attach(p: Page) {
|
| 52 |
+
p.on('download', onDownload);
|
| 53 |
+
pageListeners.set(p, onDownload);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function cleanup(error: Error | null, download?: Download) {
|
| 57 |
+
clearTimeout(timer);
|
| 58 |
+
context.off('page', onPage);
|
| 59 |
+
for (const [p, listener] of pageListeners) {
|
| 60 |
+
p.off('download', listener);
|
| 61 |
+
}
|
| 62 |
+
if (error) reject(error);
|
| 63 |
+
else resolve(download!);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
for (const p of context.pages()) attach(p);
|
| 67 |
+
context.on('page', onPage);
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// ---------------------------------------------------------------------------
|
| 72 |
+
// Public API
|
| 73 |
+
// ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Download the Turnitin report as a PDF from the viewer page.
|
| 77 |
+
*
|
| 78 |
+
* Steps:
|
| 79 |
+
* 1. Set up context-level download listener (not page-level)
|
| 80 |
+
* 2. Click download menu
|
| 81 |
+
* 3. Select Current View / Similarity Report
|
| 82 |
+
* 4. Wait for download to complete
|
| 83 |
+
* 5. Save to outputPath
|
| 84 |
+
*
|
| 85 |
+
* @returns The actual file path where the PDF was saved.
|
| 86 |
+
*/
|
| 87 |
+
export async function downloadPdf(
|
| 88 |
+
page: Scope,
|
| 89 |
+
context: BrowserContext,
|
| 90 |
+
outputPath: string,
|
| 91 |
+
): Promise<string> {
|
| 92 |
+
const ownerPage =
|
| 93 |
+
typeof (page as any).page === 'function'
|
| 94 |
+
? (page as Frame).page()
|
| 95 |
+
: (page as Page);
|
| 96 |
+
|
| 97 |
+
// Validate that we are on the correct viewer URL.
|
| 98 |
+
// Check both the scope URL (Frame or Page) and the owner page URL —
|
| 99 |
+
// the report domain may only appear on one of them.
|
| 100 |
+
const scopeUrl = page.url();
|
| 101 |
+
const ownerUrl = ownerPage.url();
|
| 102 |
+
const isValidViewerUrl = (url: string) =>
|
| 103 |
+
url.includes('reports.integrity.turnitin.com') &&
|
| 104 |
+
/submission-viewer|\/submission\//i.test(url);
|
| 105 |
+
|
| 106 |
+
if (!isValidViewerUrl(scopeUrl) && !isValidViewerUrl(ownerUrl)) {
|
| 107 |
+
throw new Error(
|
| 108 |
+
`Cannot download report because viewer page is not active. ` +
|
| 109 |
+
`scope URL: ${scopeUrl}, owner URL: ${ownerUrl}`,
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// ── BUG-5 FIX: Attach download listener BEFORE clicking any menu ──
|
| 114 |
+
// Previously the listener was set up after a 1-second wait post-click.
|
| 115 |
+
// If the browser starts the download faster than 1s the event was missed
|
| 116 |
+
// and the job timed out after 120 seconds.
|
| 117 |
+
const downloadPromise = waitForAnyDownload(context, 120000);
|
| 118 |
+
|
| 119 |
+
// Click download menu
|
| 120 |
+
let clickedDownloadMenu = false;
|
| 121 |
+
const downloadMenuDeadline = Date.now() + 30_000;
|
| 122 |
+
while (Date.now() < downloadMenuDeadline && !clickedDownloadMenu) {
|
| 123 |
+
clickedDownloadMenu = await clickFirstVisible(
|
| 124 |
+
page,
|
| 125 |
+
SELECTORS.download.downloadMenu,
|
| 126 |
+
20,
|
| 127 |
+
);
|
| 128 |
+
if (!clickedDownloadMenu) {
|
| 129 |
+
await ownerPage.waitForTimeout(1000);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
if (!clickedDownloadMenu) {
|
| 133 |
+
const clicked = await deepClickByText(page, ['download']);
|
| 134 |
+
if (!clicked) {
|
| 135 |
+
throw new Error('Download menu button was not found in report viewer');
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
await ownerPage.waitForTimeout(1000);
|
| 139 |
+
|
| 140 |
+
const optionSelectors = [
|
| 141 |
+
SELECTORS.download.currentViewOption,
|
| 142 |
+
'[with-data-px="DownloadOptionCurrentViewClicked"]',
|
| 143 |
+
'[data-px="DownloadOptionCurrentViewClicked"]',
|
| 144 |
+
'button:has-text("Current View")',
|
| 145 |
+
'tdl-button:has-text("Current View")',
|
| 146 |
+
'[role="menuitem"]:has-text("Current View")',
|
| 147 |
+
'button:has-text("Similarity Report")',
|
| 148 |
+
'tdl-button:has-text("Similarity Report")',
|
| 149 |
+
'[role="menuitem"]:has-text("Similarity Report")',
|
| 150 |
+
];
|
| 151 |
+
|
| 152 |
+
let clickedOption = false;
|
| 153 |
+
for (const selector of optionSelectors) {
|
| 154 |
+
if (await clickFirstVisible(page, selector, 10)) {
|
| 155 |
+
clickedOption = true;
|
| 156 |
+
break;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
if (!clickedOption) {
|
| 161 |
+
clickedOption = await deepClickByText(page, [
|
| 162 |
+
'current view',
|
| 163 |
+
'download current view',
|
| 164 |
+
'similarity report',
|
| 165 |
+
'download similarity report',
|
| 166 |
+
]);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
if (!clickedOption) {
|
| 170 |
+
throw new Error('Download option was not found after opening download menu');
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
const download = await downloadPromise;
|
| 174 |
+
await download.saveAs(outputPath);
|
| 175 |
+
|
| 176 |
+
const stat = fs.statSync(outputPath);
|
| 177 |
+
if (stat.size <= 0) {
|
| 178 |
+
// BUG-12 FIX: Remove the empty file so it doesn't accumulate in tmpDir
|
| 179 |
+
try { fs.unlinkSync(outputPath); } catch { /* ignore */ }
|
| 180 |
+
throw new Error(`Downloaded PDF is empty: ${outputPath}`);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
logger.info('PDF downloaded', {
|
| 184 |
+
outputPath,
|
| 185 |
+
suggestedFilename: download.suggestedFilename(),
|
| 186 |
+
size: stat.size,
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
return outputPath;
|
| 190 |
+
}
|
src/engine/steps/filters.ts
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
SELECTORS,
|
| 5 |
+
deepClickByText,
|
| 6 |
+
readScopeVisibleDeepText,
|
| 7 |
+
} from '../selectors';
|
| 8 |
+
|
| 9 |
+
// ---------------------------------------------------------------------------
|
| 10 |
+
// Types
|
| 11 |
+
// ---------------------------------------------------------------------------
|
| 12 |
+
|
| 13 |
+
export interface FilterOptions {
|
| 14 |
+
excludeBibliography?: boolean;
|
| 15 |
+
excludeQuotes?: boolean;
|
| 16 |
+
excludeCitations?: boolean;
|
| 17 |
+
excludeSmallMatches?: boolean;
|
| 18 |
+
smallMatchMode?: 'words' | 'percent' | 'off' | null;
|
| 19 |
+
smallMatchThreshold?: number | null;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Returns `true` when the user has enabled at least one filter option.
|
| 24 |
+
* Used by the orchestrator to decide whether filter application is mandatory.
|
| 25 |
+
*/
|
| 26 |
+
export function hasActiveFilters(filters: FilterOptions): boolean {
|
| 27 |
+
return Boolean(
|
| 28 |
+
filters.excludeBibliography ||
|
| 29 |
+
filters.excludeQuotes ||
|
| 30 |
+
filters.excludeCitations ||
|
| 31 |
+
filters.excludeSmallMatches,
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
type Scope = Page | Frame;
|
| 36 |
+
|
| 37 |
+
// ---------------------------------------------------------------------------
|
| 38 |
+
// Helpers
|
| 39 |
+
// ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Resolve the owner Page from a Scope (Page or Frame).
|
| 43 |
+
* Needed for reload operations which are only available on Page.
|
| 44 |
+
*/
|
| 45 |
+
function resolveOwnerPage(scope: Scope): Page {
|
| 46 |
+
return typeof (scope as any).page === 'function'
|
| 47 |
+
? (scope as Frame).page()
|
| 48 |
+
: (scope as Page);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function clickFirstVisible(
|
| 52 |
+
page: Scope,
|
| 53 |
+
selector: string,
|
| 54 |
+
label: string,
|
| 55 |
+
limit = 30,
|
| 56 |
+
): Promise<boolean> {
|
| 57 |
+
const locator = page.locator(selector);
|
| 58 |
+
const count = Math.min(await locator.count().catch(() => 0), limit);
|
| 59 |
+
|
| 60 |
+
for (let index = 0; index < count; index++) {
|
| 61 |
+
const candidate = locator.nth(index);
|
| 62 |
+
if (!(await candidate.isVisible({ timeout: 800 }).catch(() => false))) {
|
| 63 |
+
continue;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
await candidate.click({ force: true }).catch((error: unknown) => {
|
| 67 |
+
logger.debug('Visible candidate click failed', {
|
| 68 |
+
label,
|
| 69 |
+
index,
|
| 70 |
+
error: error instanceof Error ? error.message : String(error),
|
| 71 |
+
});
|
| 72 |
+
throw error;
|
| 73 |
+
});
|
| 74 |
+
logger.debug('Clicked visible candidate', { label, index });
|
| 75 |
+
return true;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return false;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function isFilterPanelVisible(page: Scope): Promise<boolean> {
|
| 82 |
+
return (
|
| 83 |
+
(await page
|
| 84 |
+
.locator(
|
| 85 |
+
[
|
| 86 |
+
'[id="supplement-container.viewSettings"].tii-panel-overlay--is-open',
|
| 87 |
+
'.tii-panel-overlay--is-open .filters-wrapper',
|
| 88 |
+
'.tii-similarity-supplement--view-settings .filters-wrapper',
|
| 89 |
+
'.filters-wrapper',
|
| 90 |
+
'fieldset.filter-section',
|
| 91 |
+
'[data-px="FiltersBackToReportClicked"]',
|
| 92 |
+
].join(', '),
|
| 93 |
+
)
|
| 94 |
+
.first()
|
| 95 |
+
.isVisible({ timeout: 1000 })
|
| 96 |
+
.catch(() => false)) ||
|
| 97 |
+
(await page
|
| 98 |
+
.locator(SELECTORS.filters.excludeBibliography)
|
| 99 |
+
.first()
|
| 100 |
+
.isVisible({ timeout: 1000 })
|
| 101 |
+
.catch(() => false))
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
async function clickFilterIconByDom(page: Scope): Promise<boolean> {
|
| 106 |
+
return page
|
| 107 |
+
.evaluate(() => {
|
| 108 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 109 |
+
const seen = new Set<Element>();
|
| 110 |
+
|
| 111 |
+
for (let i = 0; i < roots.length; i++) {
|
| 112 |
+
const root = roots[i];
|
| 113 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 114 |
+
if (seen.has(el)) continue;
|
| 115 |
+
seen.add(el);
|
| 116 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const isVisible = (el: Element): boolean => {
|
| 121 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 122 |
+
const style = window.getComputedStyle(el);
|
| 123 |
+
const rect = el.getBoundingClientRect();
|
| 124 |
+
return (
|
| 125 |
+
style.visibility !== 'hidden' &&
|
| 126 |
+
style.display !== 'none' &&
|
| 127 |
+
rect.width > 0 &&
|
| 128 |
+
rect.height > 0
|
| 129 |
+
);
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const clickableSelector =
|
| 133 |
+
'button, a, tdl-button, tdl-labeled-button, tii-grn-button, [role="button"]';
|
| 134 |
+
|
| 135 |
+
const findClickable = (el: Element): HTMLElement | null => {
|
| 136 |
+
let current: Element | null = el;
|
| 137 |
+
const visited = new Set<Element>();
|
| 138 |
+
|
| 139 |
+
while (current && !visited.has(current)) {
|
| 140 |
+
visited.add(current);
|
| 141 |
+
if ((current as HTMLElement).matches?.(clickableSelector)) {
|
| 142 |
+
return current as HTMLElement;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const parent: Element | null = current.parentElement;
|
| 146 |
+
if (parent) {
|
| 147 |
+
current = parent;
|
| 148 |
+
continue;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const root = current.getRootNode();
|
| 152 |
+
current = root instanceof ShadowRoot ? root.host : null;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return null;
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
for (const root of roots) {
|
| 159 |
+
const candidates = Array.from(
|
| 160 |
+
root.querySelectorAll('tdl-icon, tii-grn-icon, [icon-name], [name], [aria-label], [title]'),
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
for (const candidate of candidates) {
|
| 164 |
+
if (!isVisible(candidate)) continue;
|
| 165 |
+
const text = [
|
| 166 |
+
candidate.getAttribute('icon-name'),
|
| 167 |
+
candidate.getAttribute('name'),
|
| 168 |
+
candidate.getAttribute('aria-label'),
|
| 169 |
+
candidate.getAttribute('title'),
|
| 170 |
+
candidate.textContent,
|
| 171 |
+
]
|
| 172 |
+
.filter(Boolean)
|
| 173 |
+
.join(' ')
|
| 174 |
+
.toLowerCase();
|
| 175 |
+
|
| 176 |
+
if (!text.includes('filter') && !text.includes('setting')) {
|
| 177 |
+
continue;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const target = findClickable(candidate);
|
| 181 |
+
if (!target || !isVisible(target)) continue;
|
| 182 |
+
target.click();
|
| 183 |
+
return true;
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return false;
|
| 188 |
+
})
|
| 189 |
+
.catch(() => false);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
async function collectFilterDiagnostics(page: Scope): Promise<{
|
| 193 |
+
url: string;
|
| 194 |
+
bodySnippet: string;
|
| 195 |
+
controls: string[];
|
| 196 |
+
}> {
|
| 197 |
+
const visibleText = await readScopeVisibleDeepText(page);
|
| 198 |
+
const controls = await page
|
| 199 |
+
.evaluate(() => {
|
| 200 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 201 |
+
const seen = new Set<Element>();
|
| 202 |
+
|
| 203 |
+
for (let i = 0; i < roots.length; i++) {
|
| 204 |
+
const root = roots[i];
|
| 205 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 206 |
+
if (seen.has(el)) continue;
|
| 207 |
+
seen.add(el);
|
| 208 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
const isVisible = (el: Element): boolean => {
|
| 213 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 214 |
+
const style = window.getComputedStyle(el);
|
| 215 |
+
const rect = el.getBoundingClientRect();
|
| 216 |
+
return (
|
| 217 |
+
style.visibility !== 'hidden' &&
|
| 218 |
+
style.display !== 'none' &&
|
| 219 |
+
rect.width > 0 &&
|
| 220 |
+
rect.height > 0
|
| 221 |
+
);
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
const results: string[] = [];
|
| 225 |
+
for (const root of roots) {
|
| 226 |
+
const candidates = Array.from(
|
| 227 |
+
root.querySelectorAll(
|
| 228 |
+
'button, a, input, tdl-button, tdl-labeled-button, tii-grn-button, [role="button"], [data-px], [with-data-px], [withdatapx], [aria-label], [title]',
|
| 229 |
+
),
|
| 230 |
+
);
|
| 231 |
+
for (const el of candidates) {
|
| 232 |
+
if (!isVisible(el)) continue;
|
| 233 |
+
const text = [
|
| 234 |
+
(el as HTMLElement).innerText,
|
| 235 |
+
el.textContent,
|
| 236 |
+
el.getAttribute('aria-label'),
|
| 237 |
+
el.getAttribute('title'),
|
| 238 |
+
el.getAttribute('data-px'),
|
| 239 |
+
el.getAttribute('with-data-px'),
|
| 240 |
+
el.getAttribute('withdatapx'),
|
| 241 |
+
]
|
| 242 |
+
.filter(Boolean)
|
| 243 |
+
.join(' ')
|
| 244 |
+
.replace(/\s+/g, ' ')
|
| 245 |
+
.trim();
|
| 246 |
+
if (!text) continue;
|
| 247 |
+
if (!/filter|setting|similarity|download|detail|report|apply/i.test(text)) {
|
| 248 |
+
continue;
|
| 249 |
+
}
|
| 250 |
+
results.push(`${el.tagName.toLowerCase()}: ${text.slice(0, 140)}`);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
return Array.from(new Set(results)).slice(0, 20);
|
| 254 |
+
})
|
| 255 |
+
.catch(() => []);
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
url: page.url(),
|
| 259 |
+
bodySnippet: visibleText.replace(/\s+/g, ' ').slice(0, 600),
|
| 260 |
+
controls,
|
| 261 |
+
};
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// ---------------------------------------------------------------------------
|
| 265 |
+
// Checkbox helper
|
| 266 |
+
// ---------------------------------------------------------------------------
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* Read the current checked state of a Turnitin custom checkbox.
|
| 270 |
+
* Checks `with-checked` attribute AND the inner shadow DOM input element.
|
| 271 |
+
* Returns null if the checkbox is not visible.
|
| 272 |
+
*/
|
| 273 |
+
async function readCheckboxState(
|
| 274 |
+
page: Scope,
|
| 275 |
+
selector: string,
|
| 276 |
+
): Promise<boolean | null> {
|
| 277 |
+
const checkbox = page.locator(selector).first();
|
| 278 |
+
if (!(await checkbox.isVisible({ timeout: 3000 }).catch(() => false))) {
|
| 279 |
+
return null;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// Try `with-checked` attribute first (custom element outer)
|
| 283 |
+
const attr = await checkbox.getAttribute('with-checked').catch(() => null);
|
| 284 |
+
if (attr !== null) return attr === 'true';
|
| 285 |
+
|
| 286 |
+
// Try inner shadow-DOM input[type=checkbox]
|
| 287 |
+
const innerChecked = await checkbox.evaluate((el: any) => {
|
| 288 |
+
const inp =
|
| 289 |
+
el.shadowRoot?.querySelector('input[type="checkbox"]') ||
|
| 290 |
+
el.querySelector?.('input[type="checkbox"]');
|
| 291 |
+
return inp ? (inp as HTMLInputElement).checked : null;
|
| 292 |
+
}).catch(() => null);
|
| 293 |
+
|
| 294 |
+
if (innerChecked !== null) return innerChecked;
|
| 295 |
+
|
| 296 |
+
// Try aria-checked
|
| 297 |
+
const aria = await checkbox.getAttribute('aria-checked').catch(() => null);
|
| 298 |
+
if (aria !== null) return aria === 'true';
|
| 299 |
+
|
| 300 |
+
return false;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/**
|
| 304 |
+
* Set a Turnitin custom checkbox to the desired state.
|
| 305 |
+
*
|
| 306 |
+
* Strategy:
|
| 307 |
+
* 1. Read current state (with-checked attr + shadow DOM + aria-checked)
|
| 308 |
+
* 2. If state doesn't match desired, click
|
| 309 |
+
* 3. Wait briefly and read again to confirm
|
| 310 |
+
* 4. If still wrong after 2 attempts, try force-setting via JS evaluate
|
| 311 |
+
*
|
| 312 |
+
* Returns true if the checkbox was found (regardless of final state).
|
| 313 |
+
*/
|
| 314 |
+
async function setCheckbox(
|
| 315 |
+
page: Scope,
|
| 316 |
+
selector: string,
|
| 317 |
+
enabled: boolean,
|
| 318 |
+
label = selector,
|
| 319 |
+
): Promise<boolean> {
|
| 320 |
+
const checkbox = page.locator(selector).first();
|
| 321 |
+
if (!(await checkbox.isVisible({ timeout: 3000 }).catch(() => false))) {
|
| 322 |
+
return false;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
for (let attempt = 0; attempt < 3; attempt++) {
|
| 326 |
+
const current = await readCheckboxState(page, selector);
|
| 327 |
+
if (current === null) return false; // vanished
|
| 328 |
+
|
| 329 |
+
if (current === enabled) {
|
| 330 |
+
logger.debug('Checkbox already in desired state', { label, enabled });
|
| 331 |
+
return true;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
// Click to toggle
|
| 335 |
+
await checkbox.click({ force: true });
|
| 336 |
+
logger.debug('Clicked checkbox to toggle', {
|
| 337 |
+
label,
|
| 338 |
+
from: current,
|
| 339 |
+
to: enabled,
|
| 340 |
+
attempt: attempt + 1,
|
| 341 |
+
});
|
| 342 |
+
await page.waitForTimeout(600);
|
| 343 |
+
|
| 344 |
+
// Verify the click took effect
|
| 345 |
+
const afterClick = await readCheckboxState(page, selector);
|
| 346 |
+
if (afterClick === enabled) return true;
|
| 347 |
+
|
| 348 |
+
// State didn't change — try force-setting via JS on last attempt
|
| 349 |
+
if (attempt === 2) {
|
| 350 |
+
logger.warn('Checkbox click did not change state; trying JS force-set', {
|
| 351 |
+
label,
|
| 352 |
+
current: afterClick,
|
| 353 |
+
desired: enabled,
|
| 354 |
+
});
|
| 355 |
+
await checkbox.evaluate((el: any, desiredChecked: boolean) => {
|
| 356 |
+
const targets = [
|
| 357 |
+
el,
|
| 358 |
+
el.shadowRoot?.querySelector('input[type="checkbox"]'),
|
| 359 |
+
el.querySelector?.('input[type="checkbox"]'),
|
| 360 |
+
].filter(Boolean);
|
| 361 |
+
for (const t of targets) {
|
| 362 |
+
if (t.tagName?.toLowerCase() === 'input') {
|
| 363 |
+
(t as HTMLInputElement).checked = desiredChecked;
|
| 364 |
+
t.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
| 365 |
+
t.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
| 366 |
+
} else {
|
| 367 |
+
t.setAttribute?.('with-checked', String(desiredChecked));
|
| 368 |
+
t.dispatchEvent?.(new Event('change', { bubbles: true, composed: true }));
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
}, enabled);
|
| 372 |
+
await page.waitForTimeout(400);
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
return true;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// ---------------------------------------------------------------------------
|
| 380 |
+
// Threshold input helper
|
| 381 |
+
// ---------------------------------------------------------------------------
|
| 382 |
+
|
| 383 |
+
async function setSmallMatchThreshold(
|
| 384 |
+
page: Scope,
|
| 385 |
+
threshold: number,
|
| 386 |
+
): Promise<boolean> {
|
| 387 |
+
const value = String(threshold);
|
| 388 |
+
const input = page.locator(SELECTORS.filters.smallMatchesInput).first();
|
| 389 |
+
if (!(await input.isVisible({ timeout: 5000 }).catch(() => false)))
|
| 390 |
+
return false;
|
| 391 |
+
|
| 392 |
+
await input.evaluate((el: any, nextValue: string) => {
|
| 393 |
+
const candidates = [
|
| 394 |
+
el,
|
| 395 |
+
el.shadowRoot?.querySelector('input'),
|
| 396 |
+
el.querySelector?.('input'),
|
| 397 |
+
].filter(Boolean);
|
| 398 |
+
|
| 399 |
+
for (const candidate of candidates) {
|
| 400 |
+
candidate.value = nextValue;
|
| 401 |
+
candidate.setAttribute?.('with-value', nextValue);
|
| 402 |
+
candidate.dispatchEvent(
|
| 403 |
+
new Event('input', { bubbles: true, composed: true }),
|
| 404 |
+
);
|
| 405 |
+
candidate.dispatchEvent(
|
| 406 |
+
new Event('change', { bubbles: true, composed: true }),
|
| 407 |
+
);
|
| 408 |
+
}
|
| 409 |
+
}, value);
|
| 410 |
+
|
| 411 |
+
await page.waitForTimeout(500);
|
| 412 |
+
return true;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
async function openFiltersPanel(page: Scope): Promise<boolean> {
|
| 416 |
+
const deadline = Date.now() + 45_000;
|
| 417 |
+
let attempt = 0;
|
| 418 |
+
|
| 419 |
+
while (Date.now() < deadline) {
|
| 420 |
+
attempt++;
|
| 421 |
+
|
| 422 |
+
if (await isFilterPanelVisible(page)) {
|
| 423 |
+
return true;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
if (attempt === 1 || attempt % 4 === 0) {
|
| 427 |
+
const clickedSimilarity =
|
| 428 |
+
(await clickFirstVisible(
|
| 429 |
+
page,
|
| 430 |
+
SELECTORS.filters.similarityTab,
|
| 431 |
+
'similarity-tab',
|
| 432 |
+
20,
|
| 433 |
+
)) ||
|
| 434 |
+
(await deepClickByText(page, ['similarity report', 'similarity']).catch(
|
| 435 |
+
() => false,
|
| 436 |
+
));
|
| 437 |
+
if (clickedSimilarity) {
|
| 438 |
+
await page.waitForTimeout(1500);
|
| 439 |
+
if (await isFilterPanelVisible(page)) return true;
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
const clickedFilterButton =
|
| 444 |
+
(await clickFirstVisible(
|
| 445 |
+
page,
|
| 446 |
+
SELECTORS.filters.filterButton,
|
| 447 |
+
'filter-button',
|
| 448 |
+
30,
|
| 449 |
+
)) ||
|
| 450 |
+
(await clickFilterIconByDom(page)) ||
|
| 451 |
+
(await deepClickByText(page, ['filters', 'filter']).catch(() => false));
|
| 452 |
+
|
| 453 |
+
if (clickedFilterButton) {
|
| 454 |
+
await page.waitForTimeout(1800);
|
| 455 |
+
if (await isFilterPanelVisible(page)) {
|
| 456 |
+
return true;
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
if (attempt % 5 === 0) {
|
| 461 |
+
const diagnostics = await collectFilterDiagnostics(page);
|
| 462 |
+
logger.debug('Still waiting for similarity filter panel', {
|
| 463 |
+
attempt,
|
| 464 |
+
url: diagnostics.url,
|
| 465 |
+
controls: diagnostics.controls.slice(0, 8),
|
| 466 |
+
bodySnippet: diagnostics.bodySnippet,
|
| 467 |
+
});
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
await page.waitForTimeout(2500);
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
logger.warn('Filter panel did not open before timeout', {
|
| 474 |
+
...(await collectFilterDiagnostics(page)),
|
| 475 |
+
});
|
| 476 |
+
return false;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
// ---------------------------------------------------------------------------
|
| 480 |
+
// Filter validation
|
| 481 |
+
// ---------------------------------------------------------------------------
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* Verify the current state of all filter checkboxes matches the desired state.
|
| 485 |
+
* Returns a list of filter names that are NOT in the correct state.
|
| 486 |
+
*/
|
| 487 |
+
async function verifyFilterStates(
|
| 488 |
+
page: Scope,
|
| 489 |
+
filters: FilterOptions,
|
| 490 |
+
): Promise<string[]> {
|
| 491 |
+
const mismatches: string[] = [];
|
| 492 |
+
|
| 493 |
+
const checks: Array<{ key: keyof FilterOptions; selector: string; label: string }> = [
|
| 494 |
+
{ key: 'excludeBibliography', selector: SELECTORS.filters.excludeBibliography, label: 'Bibliography' },
|
| 495 |
+
{ key: 'excludeQuotes', selector: SELECTORS.filters.excludeQuotes, label: 'Quotes' },
|
| 496 |
+
{ key: 'excludeCitations', selector: SELECTORS.filters.excludeCitations, label: 'Citations' },
|
| 497 |
+
{ key: 'excludeSmallMatches', selector: SELECTORS.filters.excludeSmallMatches, label: 'Small Matches' },
|
| 498 |
+
];
|
| 499 |
+
|
| 500 |
+
for (const { key, selector, label } of checks) {
|
| 501 |
+
const desired = Boolean(filters[key]);
|
| 502 |
+
if (!desired) continue; // We only validate that active filters ARE enabled
|
| 503 |
+
|
| 504 |
+
const current = await readCheckboxState(page, selector);
|
| 505 |
+
if (current === null) {
|
| 506 |
+
// Checkbox not visible — may not be applicable for this report
|
| 507 |
+
logger.warn(`Filter validation: '${label}' checkbox not visible, cannot verify`, { desired });
|
| 508 |
+
continue;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
if (current !== desired) {
|
| 512 |
+
mismatches.push(label);
|
| 513 |
+
logger.warn(`Filter validation FAILED: '${label}' is ${current} but should be ${desired}`);
|
| 514 |
+
} else {
|
| 515 |
+
logger.info(`Filter validation OK: '${label}' is ${current}`);
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
return mismatches;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
// ---------------------------------------------------------------------------
|
| 523 |
+
// Public API
|
| 524 |
+
// ---------------------------------------------------------------------------
|
| 525 |
+
|
| 526 |
+
/**
|
| 527 |
+
* Apply similarity report filters in the Turnitin viewer.
|
| 528 |
+
*
|
| 529 |
+
* ALL filters can be toggled ON and OFF:
|
| 530 |
+
* - excludeBibliography (true = check, false = uncheck)
|
| 531 |
+
* - excludeQuotes (true = check, false = uncheck)
|
| 532 |
+
* - excludeCitations (true = check, false = uncheck)
|
| 533 |
+
* - excludeSmallMatches (true = check and set threshold, false = uncheck)
|
| 534 |
+
*
|
| 535 |
+
* Steps:
|
| 536 |
+
* 1. Click Similarity tab
|
| 537 |
+
* 2. Click Filters button (with up to 3 page-refresh retries)
|
| 538 |
+
* 3. For each filter: read current state, toggle if needed (with verify loop)
|
| 539 |
+
* 4. For small matches: if enabling, set threshold (clamped 1–40)
|
| 540 |
+
* 5. Click Apply Filters if enabled
|
| 541 |
+
* 6. Verify all active filters are applied — retry once if not
|
| 542 |
+
* 7. Click Back to Similarity Report
|
| 543 |
+
*/
|
| 544 |
+
export async function applyFilters(
|
| 545 |
+
page: Scope,
|
| 546 |
+
filters: FilterOptions,
|
| 547 |
+
): Promise<void> {
|
| 548 |
+
const ownerPage = resolveOwnerPage(page);
|
| 549 |
+
const MAX_REFRESH_ATTEMPTS = 3;
|
| 550 |
+
const activeFilters = hasActiveFilters(filters);
|
| 551 |
+
|
| 552 |
+
for (let attempt = 1; attempt <= MAX_REFRESH_ATTEMPTS; attempt++) {
|
| 553 |
+
// 1-2. Open Similarity tab and Filters panel.
|
| 554 |
+
const opened = await openFiltersPanel(page);
|
| 555 |
+
if (!opened) {
|
| 556 |
+
if (attempt < MAX_REFRESH_ATTEMPTS) {
|
| 557 |
+
logger.warn(
|
| 558 |
+
`Filter panel not visible (attempt ${attempt}/${MAX_REFRESH_ATTEMPTS}); ` +
|
| 559 |
+
'refreshing viewer page and retrying',
|
| 560 |
+
);
|
| 561 |
+
await ownerPage
|
| 562 |
+
.reload({ waitUntil: 'domcontentloaded', timeout: 30000 })
|
| 563 |
+
.catch(() => {});
|
| 564 |
+
await ownerPage.waitForTimeout(8000);
|
| 565 |
+
continue;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
const diagnostics = await collectFilterDiagnostics(page);
|
| 569 |
+
logger.error('Similarity filter panel could not be opened', diagnostics);
|
| 570 |
+
throw new Error(
|
| 571 |
+
`Similarity filter panel could not be opened after ${MAX_REFRESH_ATTEMPTS} ` +
|
| 572 |
+
'page refresh attempts; filters were NOT applied. ' +
|
| 573 |
+
`Visible controls: ${diagnostics.controls.slice(0, 6).join(' | ') || 'none'}`,
|
| 574 |
+
);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
// 3. Set each filter checkbox (handles both ON and OFF) with verification
|
| 578 |
+
await setCheckbox(
|
| 579 |
+
page,
|
| 580 |
+
SELECTORS.filters.excludeBibliography,
|
| 581 |
+
Boolean(filters.excludeBibliography),
|
| 582 |
+
'Bibliography',
|
| 583 |
+
);
|
| 584 |
+
await setCheckbox(
|
| 585 |
+
page,
|
| 586 |
+
SELECTORS.filters.excludeQuotes,
|
| 587 |
+
Boolean(filters.excludeQuotes),
|
| 588 |
+
'Quotes',
|
| 589 |
+
);
|
| 590 |
+
await setCheckbox(
|
| 591 |
+
page,
|
| 592 |
+
SELECTORS.filters.excludeCitations,
|
| 593 |
+
Boolean(filters.excludeCitations),
|
| 594 |
+
'Citations',
|
| 595 |
+
);
|
| 596 |
+
|
| 597 |
+
// 4. Small matches filter
|
| 598 |
+
const smallMatchEnabled = Boolean(filters.excludeSmallMatches);
|
| 599 |
+
const smallMatchesChanged = await setCheckbox(
|
| 600 |
+
page,
|
| 601 |
+
SELECTORS.filters.excludeSmallMatches,
|
| 602 |
+
smallMatchEnabled,
|
| 603 |
+
'Small Matches',
|
| 604 |
+
);
|
| 605 |
+
|
| 606 |
+
if (smallMatchesChanged && smallMatchEnabled && filters.smallMatchThreshold != null) {
|
| 607 |
+
const clamped = Math.min(40, Math.max(1, Math.round(filters.smallMatchThreshold)));
|
| 608 |
+
const thresholdSet = await setSmallMatchThreshold(page, clamped);
|
| 609 |
+
if (!thresholdSet) {
|
| 610 |
+
logger.warn(
|
| 611 |
+
'Exclude small matches was enabled, but the threshold input was not found',
|
| 612 |
+
);
|
| 613 |
+
}
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
// 5. Click Apply Filters — MUST succeed when filters are active.
|
| 617 |
+
const clickedApply = await clickFirstVisible(
|
| 618 |
+
page,
|
| 619 |
+
SELECTORS.filters.applyFilters,
|
| 620 |
+
'apply-filters',
|
| 621 |
+
20,
|
| 622 |
+
);
|
| 623 |
+
if (clickedApply) {
|
| 624 |
+
// Wait for the filter to take effect and the report to re-render.
|
| 625 |
+
await page.waitForTimeout(3000);
|
| 626 |
+
logger.info('Apply Filters button clicked successfully');
|
| 627 |
+
|
| 628 |
+
// 6. Click Back to Similarity Report
|
| 629 |
+
if (
|
| 630 |
+
await clickFirstVisible(
|
| 631 |
+
page,
|
| 632 |
+
SELECTORS.filters.backToReport,
|
| 633 |
+
'back-to-report',
|
| 634 |
+
20,
|
| 635 |
+
)
|
| 636 |
+
) {
|
| 637 |
+
await page.waitForTimeout(1500);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
logger.info('Filters applied', { filters });
|
| 641 |
+
return;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
if (!activeFilters) {
|
| 645 |
+
if (
|
| 646 |
+
await clickFirstVisible(
|
| 647 |
+
page,
|
| 648 |
+
SELECTORS.filters.backToReport,
|
| 649 |
+
'back-to-report',
|
| 650 |
+
20,
|
| 651 |
+
)
|
| 652 |
+
) {
|
| 653 |
+
await page.waitForTimeout(1500);
|
| 654 |
+
}
|
| 655 |
+
logger.warn(
|
| 656 |
+
'Apply Filters button was not visible, but no filters are active; continuing',
|
| 657 |
+
);
|
| 658 |
+
return;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
if (attempt < MAX_REFRESH_ATTEMPTS) {
|
| 662 |
+
logger.warn(
|
| 663 |
+
`Apply Filters button was not visible after opening the filter panel ` +
|
| 664 |
+
`(attempt ${attempt}/${MAX_REFRESH_ATTEMPTS}); refreshing viewer page and retrying`,
|
| 665 |
+
);
|
| 666 |
+
await ownerPage
|
| 667 |
+
.reload({ waitUntil: 'domcontentloaded', timeout: 30000 })
|
| 668 |
+
.catch(() => {});
|
| 669 |
+
await ownerPage.waitForTimeout(8000);
|
| 670 |
+
continue;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
throw new Error(
|
| 674 |
+
'Apply Filters button was not visible after opening the filter panel ' +
|
| 675 |
+
`and refreshing the viewer ${MAX_REFRESH_ATTEMPTS} times, but filters ` +
|
| 676 |
+
'are active. Aborting to prevent an unfiltered report download.',
|
| 677 |
+
);
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
/**
|
| 682 |
+
* Validate that all active filters are currently applied in the viewer.
|
| 683 |
+
* Called after applyFilters() and before downloading the PDF.
|
| 684 |
+
* Returns the list of filter names that failed validation.
|
| 685 |
+
*
|
| 686 |
+
* @param retryApply - if true and validation fails, re-opens filters and retries
|
| 687 |
+
*/
|
| 688 |
+
export async function validateFilters(
|
| 689 |
+
page: Scope,
|
| 690 |
+
filters: FilterOptions,
|
| 691 |
+
retryApply = true,
|
| 692 |
+
): Promise<void> {
|
| 693 |
+
if (!hasActiveFilters(filters)) return; // Nothing to validate
|
| 694 |
+
|
| 695 |
+
// Re-open the filter panel to read current state
|
| 696 |
+
const opened = await openFiltersPanel(page);
|
| 697 |
+
if (!opened) {
|
| 698 |
+
logger.warn('Cannot validate filters: filter panel could not be re-opened');
|
| 699 |
+
return;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
const mismatches = await verifyFilterStates(page, filters);
|
| 703 |
+
|
| 704 |
+
if (mismatches.length === 0) {
|
| 705 |
+
logger.info('Filter validation passed — all active filters confirmed', { filters });
|
| 706 |
+
// Close the panel
|
| 707 |
+
if (
|
| 708 |
+
await clickFirstVisible(
|
| 709 |
+
page,
|
| 710 |
+
SELECTORS.filters.backToReport,
|
| 711 |
+
'back-to-report-validation',
|
| 712 |
+
20,
|
| 713 |
+
)
|
| 714 |
+
) {
|
| 715 |
+
await page.waitForTimeout(1000);
|
| 716 |
+
}
|
| 717 |
+
return;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
// Some filters are not in the correct state
|
| 721 |
+
logger.warn('Filter validation found mismatches', { mismatches });
|
| 722 |
+
|
| 723 |
+
if (retryApply) {
|
| 724 |
+
logger.info('Re-applying filters due to validation failure', { mismatches });
|
| 725 |
+
|
| 726 |
+
// Re-apply the mismatched filters
|
| 727 |
+
for (const label of mismatches) {
|
| 728 |
+
if (label === 'Bibliography') {
|
| 729 |
+
await setCheckbox(page, SELECTORS.filters.excludeBibliography, true, 'Bibliography');
|
| 730 |
+
} else if (label === 'Quotes') {
|
| 731 |
+
await setCheckbox(page, SELECTORS.filters.excludeQuotes, true, 'Quotes');
|
| 732 |
+
} else if (label === 'Citations') {
|
| 733 |
+
await setCheckbox(page, SELECTORS.filters.excludeCitations, true, 'Citations');
|
| 734 |
+
} else if (label === 'Small Matches') {
|
| 735 |
+
await setCheckbox(page, SELECTORS.filters.excludeSmallMatches, true, 'Small Matches');
|
| 736 |
+
}
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
// Click Apply again
|
| 740 |
+
if (
|
| 741 |
+
await clickFirstVisible(
|
| 742 |
+
page,
|
| 743 |
+
SELECTORS.filters.applyFilters,
|
| 744 |
+
'apply-filters-validation',
|
| 745 |
+
20,
|
| 746 |
+
)
|
| 747 |
+
) {
|
| 748 |
+
await page.waitForTimeout(3000);
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
// Final verification — no more retries
|
| 752 |
+
const finalMismatches = await verifyFilterStates(page, filters);
|
| 753 |
+
if (finalMismatches.length > 0) {
|
| 754 |
+
throw new Error(
|
| 755 |
+
`Filters could not be verified after retry. The following filters are NOT active: ` +
|
| 756 |
+
finalMismatches.join(', ') +
|
| 757 |
+
'. Download aborted to prevent unfiltered report.',
|
| 758 |
+
);
|
| 759 |
+
}
|
| 760 |
+
logger.info('Filter validation passed after retry', { filters });
|
| 761 |
+
} else {
|
| 762 |
+
throw new Error(
|
| 763 |
+
`Filter validation failed. The following filters are NOT active: ` +
|
| 764 |
+
mismatches.join(', ') +
|
| 765 |
+
'. Download aborted to prevent unfiltered report.',
|
| 766 |
+
);
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
// Close the panel
|
| 770 |
+
if (
|
| 771 |
+
await clickFirstVisible(
|
| 772 |
+
page,
|
| 773 |
+
SELECTORS.filters.backToReport,
|
| 774 |
+
'back-to-report-validation',
|
| 775 |
+
20,
|
| 776 |
+
)
|
| 777 |
+
) {
|
| 778 |
+
await page.waitForTimeout(1000);
|
| 779 |
+
}
|
| 780 |
+
}
|
src/engine/steps/login.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
EULA_SELECTORS,
|
| 5 |
+
EULA_BODY_REGEX,
|
| 6 |
+
EULA_DEEP_CLICK_TEXTS,
|
| 7 |
+
SELECTORS,
|
| 8 |
+
clickVisibleCheckbox,
|
| 9 |
+
deepClickByText,
|
| 10 |
+
} from '../selectors';
|
| 11 |
+
|
| 12 |
+
// ---------------------------------------------------------------------------
|
| 13 |
+
// EULA acceptance helpers
|
| 14 |
+
// ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
type Scope = Page | Frame;
|
| 17 |
+
|
| 18 |
+
async function acceptEulaInScope(scope: Scope): Promise<boolean> {
|
| 19 |
+
for (const selector of EULA_SELECTORS) {
|
| 20 |
+
const button = scope.locator(selector).first();
|
| 21 |
+
if (await button.isVisible({ timeout: 1500 }).catch(() => false)) {
|
| 22 |
+
await button.click({ force: true });
|
| 23 |
+
await scope.waitForTimeout(2500).catch(() => {});
|
| 24 |
+
return true;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const bodyText = await scope
|
| 29 |
+
.locator('body')
|
| 30 |
+
.innerText({ timeout: 1000 })
|
| 31 |
+
.catch(() => '');
|
| 32 |
+
if (EULA_BODY_REGEX.test(bodyText)) {
|
| 33 |
+
await clickVisibleCheckbox(scope);
|
| 34 |
+
if (await deepClickByText(scope, EULA_DEEP_CLICK_TEXTS)) {
|
| 35 |
+
await scope.waitForTimeout(2500).catch(() => {});
|
| 36 |
+
return true;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export async function acceptEulaEverywhere(page: Page): Promise<boolean> {
|
| 44 |
+
let accepted = await acceptEulaInScope(page);
|
| 45 |
+
for (const frame of page.frames()) {
|
| 46 |
+
accepted =
|
| 47 |
+
(await acceptEulaInScope(frame).catch(() => false)) || accepted;
|
| 48 |
+
}
|
| 49 |
+
return accepted;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export async function acceptEulaUntilSettled(
|
| 53 |
+
page: Page,
|
| 54 |
+
timeoutMs = 15000,
|
| 55 |
+
): Promise<boolean> {
|
| 56 |
+
const deadline = Date.now() + timeoutMs;
|
| 57 |
+
let accepted = false;
|
| 58 |
+
// BUG-8 FIX: Break early when no EULA detected after 2 consecutive checks.
|
| 59 |
+
// Previously the loop always ran for the full timeoutMs (15s), wasting ~30s
|
| 60 |
+
// per job in the common case where no EULA is shown at all.
|
| 61 |
+
let consecutiveNoEula = 0;
|
| 62 |
+
while (Date.now() < deadline) {
|
| 63 |
+
const foundEula = await acceptEulaEverywhere(page);
|
| 64 |
+
accepted = foundEula || accepted;
|
| 65 |
+
if (!foundEula) {
|
| 66 |
+
consecutiveNoEula++;
|
| 67 |
+
if (consecutiveNoEula >= 2) break; // No EULA on 2 consecutive checks — stop
|
| 68 |
+
} else {
|
| 69 |
+
consecutiveNoEula = 0; // Reset counter when EULA was found and accepted
|
| 70 |
+
}
|
| 71 |
+
await page.waitForTimeout(1200);
|
| 72 |
+
}
|
| 73 |
+
return accepted;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// ---------------------------------------------------------------------------
|
| 77 |
+
// Login
|
| 78 |
+
// ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
const DEFAULT_TARGET_URL =
|
| 81 |
+
'https://www.turnitin.com/login_page.asp?lang=en_us';
|
| 82 |
+
const STUDENT_HOME_URL =
|
| 83 |
+
'https://www.turnitin.com/s_home.asp?lang=en_us';
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Log in to Turnitin with email/password, accept EULA if shown,
|
| 87 |
+
* handle redirect to user-type page, and optionally save storage state.
|
| 88 |
+
*/
|
| 89 |
+
export async function loginToTurnitin(
|
| 90 |
+
page: Page,
|
| 91 |
+
email: string,
|
| 92 |
+
password: string,
|
| 93 |
+
storageStatePath?: string,
|
| 94 |
+
targetUrl = DEFAULT_TARGET_URL,
|
| 95 |
+
): Promise<void> {
|
| 96 |
+
logger.info('Navigating to Turnitin login page');
|
| 97 |
+
let emailInputVisible = false;
|
| 98 |
+
for (let attempt = 1; attempt <= 4; attempt++) {
|
| 99 |
+
await page.goto(targetUrl || DEFAULT_TARGET_URL, {
|
| 100 |
+
waitUntil: 'domcontentloaded',
|
| 101 |
+
timeout: 60000,
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
emailInputVisible = await page
|
| 105 |
+
.locator(SELECTORS.login.emailInput)
|
| 106 |
+
.isVisible({ timeout: 15000 })
|
| 107 |
+
.catch(() => false);
|
| 108 |
+
|
| 109 |
+
if (emailInputVisible) break;
|
| 110 |
+
|
| 111 |
+
await acceptEulaEverywhere(page);
|
| 112 |
+
const bodyText = await page
|
| 113 |
+
.locator('body')
|
| 114 |
+
.innerText({ timeout: 3000 })
|
| 115 |
+
.catch(() => '');
|
| 116 |
+
const currentUrl = page.url();
|
| 117 |
+
if (
|
| 118 |
+
!currentUrl.includes('login_page.asp') ||
|
| 119 |
+
/logout|student|class portfolio/i.test(bodyText)
|
| 120 |
+
) {
|
| 121 |
+
logger.info('Login form not shown; continuing with existing Turnitin session', {
|
| 122 |
+
url: currentUrl,
|
| 123 |
+
});
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
if (/403 ERROR|Request blocked|could not be satisfied/i.test(bodyText)) {
|
| 128 |
+
logger.warn('Turnitin login page was temporarily blocked; retrying', {
|
| 129 |
+
attempt,
|
| 130 |
+
});
|
| 131 |
+
await page.waitForTimeout(2500 + attempt * 1500);
|
| 132 |
+
continue;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
await page.waitForTimeout(1500);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
if (!emailInputVisible) {
|
| 139 |
+
throw new Error(
|
| 140 |
+
`Turnitin login form was not visible (URL: ${page.url()})`,
|
| 141 |
+
);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
await page.fill(SELECTORS.login.emailInput, email);
|
| 145 |
+
await page.fill(SELECTORS.login.passwordInput, password);
|
| 146 |
+
|
| 147 |
+
await Promise.all([
|
| 148 |
+
page
|
| 149 |
+
.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 })
|
| 150 |
+
.catch(() => null),
|
| 151 |
+
page.click(SELECTORS.login.submitButton),
|
| 152 |
+
]);
|
| 153 |
+
|
| 154 |
+
await page
|
| 155 |
+
.waitForLoadState('domcontentloaded', { timeout: 30000 })
|
| 156 |
+
.catch(() => {});
|
| 157 |
+
|
| 158 |
+
// Check if we are still on the login page (i.e. login failed)
|
| 159 |
+
const currentUrl = page.url();
|
| 160 |
+
if (currentUrl.includes('login_page.asp')) {
|
| 161 |
+
const errorText = await page
|
| 162 |
+
.locator('.error, .error-message, #error_message_box, #error_box, td.errorText, .errorText')
|
| 163 |
+
.first()
|
| 164 |
+
.innerText({ timeout: 2000 })
|
| 165 |
+
.catch(() => '');
|
| 166 |
+
|
| 167 |
+
const cleanMsg = errorText ? errorText.trim().replace(/\s+/g, ' ') : 'Invalid email or password';
|
| 168 |
+
throw new Error(`Login failed on Turnitin: ${cleanMsg} (URL: ${currentUrl})`);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
await acceptEulaEverywhere(page);
|
| 172 |
+
|
| 173 |
+
// Handle redirect to user type page
|
| 174 |
+
if (page.url().includes('user_user_type.asp')) {
|
| 175 |
+
await page.goto(STUDENT_HOME_URL, {
|
| 176 |
+
waitUntil: 'domcontentloaded',
|
| 177 |
+
timeout: 60000,
|
| 178 |
+
});
|
| 179 |
+
await acceptEulaEverywhere(page);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Double check if we got kicked back to login page
|
| 183 |
+
if (page.url().includes('login_page.asp')) {
|
| 184 |
+
throw new Error(`Login failed on Turnitin: redirected back to login page (URL: ${page.url()})`);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Save storage state if requested
|
| 188 |
+
if (storageStatePath) {
|
| 189 |
+
await page.context().storageState({ path: storageStatePath });
|
| 190 |
+
logger.info('Storage state saved', { path: storageStatePath });
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
logger.info('Login successful', { url: page.url() });
|
| 194 |
+
}
|
src/engine/steps/navigate.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import { SELECTORS } from '../selectors';
|
| 4 |
+
import { acceptEulaEverywhere, acceptEulaUntilSettled } from './login';
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Navigate to the target assignment within a class.
|
| 8 |
+
*
|
| 9 |
+
* 1. Find and click the class by title
|
| 10 |
+
* 2. Find and click the assignment (by title or first available)
|
| 11 |
+
* 3. Return the assignment launch URL for later refresh
|
| 12 |
+
*/
|
| 13 |
+
export async function navigateToAssignment(
|
| 14 |
+
page: Page,
|
| 15 |
+
classTitle: string,
|
| 16 |
+
assignmentTitle?: string | null,
|
| 17 |
+
): Promise<string> {
|
| 18 |
+
// ---- Open class ----
|
| 19 |
+
logger.info('Opening class', { classTitle });
|
| 20 |
+
|
| 21 |
+
if (!classTitle || classTitle.trim() === '') {
|
| 22 |
+
throw new Error('Class title is empty - cannot navigate to assignment.');
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const escapedTitle = classTitle.replace(/"/g, '\\"');
|
| 26 |
+
const exact = page.locator(`a[title="${escapedTitle}"]`).first();
|
| 27 |
+
const fallback = page
|
| 28 |
+
.locator(SELECTORS.class.classNameLink)
|
| 29 |
+
.filter({ hasText: classTitle })
|
| 30 |
+
.first();
|
| 31 |
+
|
| 32 |
+
const link =
|
| 33 |
+
(await exact.isVisible({ timeout: 5000 }).catch(() => false))
|
| 34 |
+
? exact
|
| 35 |
+
: fallback;
|
| 36 |
+
|
| 37 |
+
await link.waitFor({ state: 'visible', timeout: 30000 });
|
| 38 |
+
await Promise.all([
|
| 39 |
+
page
|
| 40 |
+
.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 })
|
| 41 |
+
.catch(() => null),
|
| 42 |
+
link.click({ force: true }),
|
| 43 |
+
]);
|
| 44 |
+
await page.waitForTimeout(1500);
|
| 45 |
+
await acceptEulaEverywhere(page);
|
| 46 |
+
|
| 47 |
+
const classUrl = page.url();
|
| 48 |
+
logger.info('Class page loaded', { classUrl });
|
| 49 |
+
|
| 50 |
+
// ---- Open assignment ----
|
| 51 |
+
// Ensure we're on the class page
|
| 52 |
+
if (page.url() !== classUrl) {
|
| 53 |
+
await page.goto(classUrl, {
|
| 54 |
+
waitUntil: 'domcontentloaded',
|
| 55 |
+
timeout: 60000,
|
| 56 |
+
});
|
| 57 |
+
await page.waitForTimeout(1500);
|
| 58 |
+
}
|
| 59 |
+
await acceptEulaUntilSettled(page, 5000);
|
| 60 |
+
|
| 61 |
+
let assignmentButton;
|
| 62 |
+
if (assignmentTitle) {
|
| 63 |
+
logger.info('Looking for assignment', { assignmentTitle });
|
| 64 |
+
assignmentButton = page
|
| 65 |
+
.locator(SELECTORS.assignment.assignmentRow)
|
| 66 |
+
.filter({ hasText: assignmentTitle })
|
| 67 |
+
.locator(SELECTORS.assignment.openButton)
|
| 68 |
+
.first();
|
| 69 |
+
} else {
|
| 70 |
+
logger.info('Looking for first available assignment');
|
| 71 |
+
assignmentButton = page
|
| 72 |
+
.locator(SELECTORS.assignment.allOpenButtons)
|
| 73 |
+
.first();
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
await assignmentButton.waitFor({ state: 'visible', timeout: 30000 });
|
| 77 |
+
await Promise.all([
|
| 78 |
+
page
|
| 79 |
+
.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 })
|
| 80 |
+
.catch(() => null),
|
| 81 |
+
assignmentButton.click({ force: true }),
|
| 82 |
+
]);
|
| 83 |
+
await page.waitForTimeout(5000);
|
| 84 |
+
await acceptEulaUntilSettled(page, 7000);
|
| 85 |
+
|
| 86 |
+
const assignmentLaunchUrl = page.url();
|
| 87 |
+
logger.info('Assignment page loaded', { assignmentLaunchUrl });
|
| 88 |
+
|
| 89 |
+
return assignmentLaunchUrl;
|
| 90 |
+
}
|
src/engine/steps/quota-detect.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
QUOTA_CONFIRM_LABELS,
|
| 5 |
+
SELECTORS,
|
| 6 |
+
readScopeDeepText,
|
| 7 |
+
readScopeVisibleDeepText,
|
| 8 |
+
} from '../selectors';
|
| 9 |
+
|
| 10 |
+
// ---------------------------------------------------------------------------
|
| 11 |
+
// Types
|
| 12 |
+
// ---------------------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
export interface QuotaLimitResult {
|
| 15 |
+
limited: true;
|
| 16 |
+
limit: number;
|
| 17 |
+
retryText: string;
|
| 18 |
+
message: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface QuotaWarningResult {
|
| 22 |
+
warning: true;
|
| 23 |
+
message: string;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface QuotaCheckResult {
|
| 27 |
+
quotaLimited: boolean;
|
| 28 |
+
limit: number | null;
|
| 29 |
+
retryText: string | null;
|
| 30 |
+
message: string | null;
|
| 31 |
+
warning: string | null;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// ---------------------------------------------------------------------------
|
| 35 |
+
// Parsers
|
| 36 |
+
// ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
function parseSubmissionQuotaLimit(text: string): QuotaLimitResult | null {
|
| 39 |
+
const normalized = String(text || '')
|
| 40 |
+
.replace(/\s+/g, ' ')
|
| 41 |
+
.trim();
|
| 42 |
+
const match = normalized.match(
|
| 43 |
+
/You have reached your limit of\s+(\d+)\s+submissions\.\s+You can submit again\s+(.+?)(?:\.|$)/i,
|
| 44 |
+
);
|
| 45 |
+
if (!match) return null;
|
| 46 |
+
|
| 47 |
+
return {
|
| 48 |
+
limited: true,
|
| 49 |
+
limit: Number(match[1]),
|
| 50 |
+
retryText: match[2],
|
| 51 |
+
message: match[0],
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function parseSubmissionQuotaWarning(
|
| 56 |
+
text: string,
|
| 57 |
+
): QuotaWarningResult | null {
|
| 58 |
+
const normalized = String(text || '')
|
| 59 |
+
.replace(/\s+/g, ' ')
|
| 60 |
+
.trim();
|
| 61 |
+
const patterns = [
|
| 62 |
+
/(?:only\s+)?(?:1|one)\s+submissions?\s+remaining/i,
|
| 63 |
+
/(?:only\s+)?(?:1|one)\s+remaining\s+submissions?/i,
|
| 64 |
+
/(?:only\s+)?(?:1|one)\s+more\s+submissions?\s+left/i,
|
| 65 |
+
/final\s+submission/i,
|
| 66 |
+
/last\s+submission/i,
|
| 67 |
+
/remaining\s+submissions?/i,
|
| 68 |
+
/limited\s+number\s+of\s+submissions/i,
|
| 69 |
+
];
|
| 70 |
+
|
| 71 |
+
if (!patterns.some((p) => p.test(normalized))) return null;
|
| 72 |
+
|
| 73 |
+
return {
|
| 74 |
+
warning: true,
|
| 75 |
+
message: normalized.slice(0, 500),
|
| 76 |
+
};
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// ---------------------------------------------------------------------------
|
| 80 |
+
// Scope helpers
|
| 81 |
+
// ---------------------------------------------------------------------------
|
| 82 |
+
|
| 83 |
+
type Scope = Page | Frame;
|
| 84 |
+
|
| 85 |
+
const QUOTA_ACTION_SELECTOR = [
|
| 86 |
+
'button',
|
| 87 |
+
'input[type="button"]',
|
| 88 |
+
'input[type="submit"]',
|
| 89 |
+
'tdl-button',
|
| 90 |
+
'tii-grn-button',
|
| 91 |
+
'[role="button"]',
|
| 92 |
+
'[slot="accept-button"]',
|
| 93 |
+
'[part*="button"]',
|
| 94 |
+
'[data-px*="Continue"]',
|
| 95 |
+
'[with-data-px*="Continue"]',
|
| 96 |
+
].join(', ');
|
| 97 |
+
|
| 98 |
+
function getScopes(page: Page): Scope[] {
|
| 99 |
+
return [
|
| 100 |
+
page,
|
| 101 |
+
...page.frames().filter((f) => !f.url().includes('cookie-shim')),
|
| 102 |
+
];
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// ---------------------------------------------------------------------------
|
| 106 |
+
// Public API
|
| 107 |
+
// ---------------------------------------------------------------------------
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Detect whether the page shows a hard quota limit (all submissions used).
|
| 111 |
+
*/
|
| 112 |
+
export async function detectQuotaLimit(
|
| 113 |
+
page: Page,
|
| 114 |
+
): Promise<QuotaLimitResult | null> {
|
| 115 |
+
for (const scope of getScopes(page)) {
|
| 116 |
+
const text = await readScopeDeepText(scope);
|
| 117 |
+
const result = parseSubmissionQuotaLimit(text);
|
| 118 |
+
if (result) return result;
|
| 119 |
+
}
|
| 120 |
+
return null;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Detect whether the page shows a quota warning (e.g. 1 submission remaining).
|
| 125 |
+
*/
|
| 126 |
+
export async function detectQuotaWarning(
|
| 127 |
+
page: Page,
|
| 128 |
+
): Promise<QuotaWarningResult | null> {
|
| 129 |
+
for (const scope of getScopes(page)) {
|
| 130 |
+
const text = await readScopeVisibleDeepText(scope);
|
| 131 |
+
const result = parseSubmissionQuotaWarning(text);
|
| 132 |
+
if (result) return result;
|
| 133 |
+
}
|
| 134 |
+
return null;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/**
|
| 138 |
+
* Attempt to confirm/dismiss a quota warning popup by clicking a continue
|
| 139 |
+
* button. Returns true if a warning was found and confirmed.
|
| 140 |
+
*
|
| 141 |
+
* Improvements:
|
| 142 |
+
* - Skips if the warning text is just background info (no active dialog/modal)
|
| 143 |
+
* - Uses dialog.close() as a nuclear fallback to guarantee modal closure
|
| 144 |
+
*/
|
| 145 |
+
export async function confirmQuotaWarning(
|
| 146 |
+
page: Page,
|
| 147 |
+
): Promise<boolean> {
|
| 148 |
+
for (const scope of getScopes(page)) {
|
| 149 |
+
const text = await readScopeVisibleDeepText(scope);
|
| 150 |
+
const warning = parseSubmissionQuotaWarning(text);
|
| 151 |
+
if (!warning) continue;
|
| 152 |
+
|
| 153 |
+
// Check if there is ACTUALLY an interactive modal/dialog open.
|
| 154 |
+
// Without this check, background informational text ("You have 1 more
|
| 155 |
+
// submission left") keeps triggering the handler even when no dialog exists.
|
| 156 |
+
const hasActionableDialog = await scope
|
| 157 |
+
.evaluate(() => {
|
| 158 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 159 |
+
const seen = new Set<Element>();
|
| 160 |
+
for (let i = 0; i < roots.length; i++) {
|
| 161 |
+
const root = roots[i];
|
| 162 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 163 |
+
if (seen.has(el)) continue;
|
| 164 |
+
seen.add(el);
|
| 165 |
+
if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
const isVisible = (el: Element): boolean => {
|
| 169 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 170 |
+
const s = window.getComputedStyle(el);
|
| 171 |
+
const r = el.getBoundingClientRect();
|
| 172 |
+
return s.visibility !== 'hidden' && s.display !== 'none' && r.width > 0 && r.height > 0;
|
| 173 |
+
};
|
| 174 |
+
const hasText = (el: Element, pattern: RegExp): boolean =>
|
| 175 |
+
pattern.test((el as HTMLElement).innerText || el.textContent || '');
|
| 176 |
+
|
| 177 |
+
for (const root of roots) {
|
| 178 |
+
// Native <dialog open>
|
| 179 |
+
for (const d of Array.from(root.querySelectorAll('dialog[open]'))) {
|
| 180 |
+
if (isVisible(d) && hasText(d, /more submission|file upload|continue/i)) return true;
|
| 181 |
+
}
|
| 182 |
+
// Custom modal/overlay elements
|
| 183 |
+
for (const el of Array.from(
|
| 184 |
+
root.querySelectorAll('[role="dialog"], [aria-modal="true"], tii-modal, .tii-modal, [id*="modal"]'),
|
| 185 |
+
)) {
|
| 186 |
+
if (isVisible(el) && hasText(el, /more submission|file upload|continue/i)) return true;
|
| 187 |
+
}
|
| 188 |
+
// tii-grn-button with "Continue to File Upload" visible => quota dialog must be open
|
| 189 |
+
for (const el of Array.from(root.querySelectorAll('tii-grn-button'))) {
|
| 190 |
+
if (!isVisible(el)) continue;
|
| 191 |
+
const t = ((el as HTMLElement).innerText || el.textContent || '').toLowerCase();
|
| 192 |
+
if (t.includes('continue to file upload')) return true;
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
return false;
|
| 196 |
+
})
|
| 197 |
+
.catch(() => false);
|
| 198 |
+
|
| 199 |
+
if (!hasActionableDialog) {
|
| 200 |
+
// Only background informational text — no interactive dialog to dismiss.
|
| 201 |
+
logger.info('Quota warning text is background-only (no active dialog); skipping dismissal');
|
| 202 |
+
continue;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
logger.info('Submission quota warning dialog detected; attempting to dismiss', {
|
| 206 |
+
message: warning.message,
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
const clicked = await clickQuotaWarningContinue(scope);
|
| 210 |
+
if (!clicked) {
|
| 211 |
+
logger.warn('Submission quota warning continue button was not found');
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
await page.waitForTimeout(1500);
|
| 215 |
+
|
| 216 |
+
// If warning is still visible, try coordinate-based click
|
| 217 |
+
const stillVisible = parseSubmissionQuotaWarning(
|
| 218 |
+
await readScopeVisibleDeepText(scope),
|
| 219 |
+
);
|
| 220 |
+
if (stillVisible) {
|
| 221 |
+
await clickQuotaWarningContinueByCoordinates(page, scope);
|
| 222 |
+
await page.waitForTimeout(1500);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Nuclear fallback: forcibly close any open dialog with quota/upload text
|
| 226 |
+
const stillVisibleAfter = parseSubmissionQuotaWarning(
|
| 227 |
+
await readScopeVisibleDeepText(scope),
|
| 228 |
+
);
|
| 229 |
+
if (stillVisibleAfter) {
|
| 230 |
+
logger.warn('Quota warning still visible; using dialog.close() nuclear fallback');
|
| 231 |
+
await scope
|
| 232 |
+
.evaluate(() => {
|
| 233 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 234 |
+
const seen = new Set<Element>();
|
| 235 |
+
for (let i = 0; i < roots.length; i++) {
|
| 236 |
+
const root = roots[i];
|
| 237 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 238 |
+
if (seen.has(el)) continue;
|
| 239 |
+
seen.add(el);
|
| 240 |
+
if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
for (const root of roots) {
|
| 244 |
+
for (const d of Array.from(root.querySelectorAll('dialog[open], dialog'))) {
|
| 245 |
+
if (d instanceof HTMLDialogElement) {
|
| 246 |
+
const t = (d as HTMLElement).innerText || d.textContent || '';
|
| 247 |
+
if (/more submission|file upload|continue/i.test(t)) {
|
| 248 |
+
d.close();
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
})
|
| 254 |
+
.catch(() => {});
|
| 255 |
+
await page.waitForTimeout(1000);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
return true;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
return false;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/**
|
| 265 |
+
* Run a complete quota check: detect limit and/or warning.
|
| 266 |
+
*/
|
| 267 |
+
export async function runQuotaCheck(
|
| 268 |
+
page: Page,
|
| 269 |
+
): Promise<QuotaCheckResult> {
|
| 270 |
+
const limit = await detectQuotaLimit(page);
|
| 271 |
+
if (limit) {
|
| 272 |
+
return {
|
| 273 |
+
quotaLimited: true,
|
| 274 |
+
limit: limit.limit,
|
| 275 |
+
retryText: limit.retryText,
|
| 276 |
+
message: limit.message,
|
| 277 |
+
warning: null,
|
| 278 |
+
};
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
const warning = await detectQuotaWarning(page);
|
| 282 |
+
return {
|
| 283 |
+
quotaLimited: false,
|
| 284 |
+
limit: null,
|
| 285 |
+
retryText: null,
|
| 286 |
+
message: null,
|
| 287 |
+
warning: warning?.message || null,
|
| 288 |
+
};
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
// ---------------------------------------------------------------------------
|
| 292 |
+
// Internal: click quota warning continue buttons
|
| 293 |
+
// ---------------------------------------------------------------------------
|
| 294 |
+
|
| 295 |
+
async function clickQuotaWarningContinue(
|
| 296 |
+
scope: Scope,
|
| 297 |
+
): Promise<boolean> {
|
| 298 |
+
// Try each preferred label in order
|
| 299 |
+
for (const label of QUOTA_CONFIRM_LABELS) {
|
| 300 |
+
const escaped = label.replace(/"/g, '\\"');
|
| 301 |
+
const button = scope
|
| 302 |
+
.locator(
|
| 303 |
+
`tii-grn-button:has-text("${escaped}"), button:has-text("${escaped}"), tdl-button:has-text("${escaped}"), [role="button"]:has-text("${escaped}")`,
|
| 304 |
+
)
|
| 305 |
+
.last();
|
| 306 |
+
if (await button.isVisible({ timeout: 1000 }).catch(() => false)) {
|
| 307 |
+
await button.click({ force: true });
|
| 308 |
+
return true;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
const textTarget = scope.getByText(label, { exact: false }).last();
|
| 312 |
+
if (
|
| 313 |
+
await textTarget.isVisible({ timeout: 1000 }).catch(() => false)
|
| 314 |
+
) {
|
| 315 |
+
await textTarget.click({ force: true });
|
| 316 |
+
return true;
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// Try filled action slot button
|
| 321 |
+
const filledAction = scope
|
| 322 |
+
.locator(SELECTORS.quota.warningConfirmButton)
|
| 323 |
+
.last();
|
| 324 |
+
if (
|
| 325 |
+
await filledAction.isVisible({ timeout: 1000 }).catch(() => false)
|
| 326 |
+
) {
|
| 327 |
+
await filledAction.click({ force: true });
|
| 328 |
+
return true;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// Deep shadow DOM scan fallback. Turnitin often renders the visible label in
|
| 332 |
+
// a shadow/slot child, while the actual clickable target is the host element.
|
| 333 |
+
return scope
|
| 334 |
+
.evaluate(
|
| 335 |
+
({
|
| 336 |
+
labels,
|
| 337 |
+
selector,
|
| 338 |
+
}: {
|
| 339 |
+
labels: string[];
|
| 340 |
+
selector: string;
|
| 341 |
+
}) => {
|
| 342 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 343 |
+
const seen = new Set<Element>();
|
| 344 |
+
|
| 345 |
+
for (let i = 0; i < roots.length; i++) {
|
| 346 |
+
const root = roots[i];
|
| 347 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 348 |
+
if (seen.has(el)) continue;
|
| 349 |
+
seen.add(el);
|
| 350 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
const isVisible = (el: Element): boolean => {
|
| 355 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 356 |
+
const style = window.getComputedStyle(el);
|
| 357 |
+
const rect = el.getBoundingClientRect();
|
| 358 |
+
return (
|
| 359 |
+
style.visibility !== 'hidden' &&
|
| 360 |
+
style.display !== 'none' &&
|
| 361 |
+
rect.width > 0 &&
|
| 362 |
+
rect.height > 0
|
| 363 |
+
);
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
const isDisabled = (el: Element): boolean => {
|
| 367 |
+
if (!(el instanceof HTMLElement)) return true;
|
| 368 |
+
return (
|
| 369 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 370 |
+
el.getAttribute('disabled') !== null ||
|
| 371 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 372 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 373 |
+
el.closest(
|
| 374 |
+
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
|
| 375 |
+
) !== null
|
| 376 |
+
);
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
+
const readText = (el: Element): string =>
|
| 380 |
+
[
|
| 381 |
+
(el as HTMLElement).innerText,
|
| 382 |
+
el.textContent,
|
| 383 |
+
el.getAttribute('aria-label'),
|
| 384 |
+
el.getAttribute('value'),
|
| 385 |
+
el.getAttribute('part'),
|
| 386 |
+
el.getAttribute('slot'),
|
| 387 |
+
el.getAttribute('data-px'),
|
| 388 |
+
el.getAttribute('with-data-px'),
|
| 389 |
+
el.getAttribute('with-px-label'),
|
| 390 |
+
]
|
| 391 |
+
.filter(Boolean)
|
| 392 |
+
.join(' ')
|
| 393 |
+
.replace(/\s+/g, ' ')
|
| 394 |
+
.trim()
|
| 395 |
+
.toLowerCase();
|
| 396 |
+
|
| 397 |
+
const resolveClickable = (el: Element): HTMLElement | null => {
|
| 398 |
+
const direct = (el as HTMLElement).closest?.(
|
| 399 |
+
'button, a, [role="button"], tdl-button, tii-grn-button, [slot="accept-button"]',
|
| 400 |
+
) as HTMLElement | null;
|
| 401 |
+
const root = el.getRootNode();
|
| 402 |
+
const host =
|
| 403 |
+
root instanceof ShadowRoot ? (root.host as HTMLElement) : null;
|
| 404 |
+
const clickable = direct || host || (el as HTMLElement);
|
| 405 |
+
const innerButton = clickable.shadowRoot?.querySelector(
|
| 406 |
+
'button:not([disabled]), input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])',
|
| 407 |
+
);
|
| 408 |
+
return innerButton instanceof HTMLElement
|
| 409 |
+
? innerButton
|
| 410 |
+
: clickable;
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
const candidates: Element[] = [];
|
| 414 |
+
for (const root of roots) {
|
| 415 |
+
candidates.push(...Array.from(root.querySelectorAll(selector)));
|
| 416 |
+
candidates.push(...Array.from(root.querySelectorAll('*')));
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
const normalizedLabels = labels.map((l) => l.toLowerCase());
|
| 420 |
+
const target = candidates
|
| 421 |
+
.filter(isVisible)
|
| 422 |
+
.map((el) => {
|
| 423 |
+
const rect = el.getBoundingClientRect();
|
| 424 |
+
const text = readText(el);
|
| 425 |
+
const labelIndex = normalizedLabels.findIndex((l) =>
|
| 426 |
+
text.includes(l),
|
| 427 |
+
);
|
| 428 |
+
const clickable = resolveClickable(el);
|
| 429 |
+
return {
|
| 430 |
+
element: el,
|
| 431 |
+
clickable,
|
| 432 |
+
labelIndex,
|
| 433 |
+
area: rect.width * rect.height,
|
| 434 |
+
disabled: clickable ? isDisabled(clickable) : true,
|
| 435 |
+
};
|
| 436 |
+
})
|
| 437 |
+
.filter((c) => c.labelIndex >= 0 && !c.disabled && c.clickable)
|
| 438 |
+
.sort(
|
| 439 |
+
(a, b) => a.labelIndex - b.labelIndex || a.area - b.area,
|
| 440 |
+
)[0]?.clickable;
|
| 441 |
+
|
| 442 |
+
if (!target) return false;
|
| 443 |
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
| 444 |
+
target.click();
|
| 445 |
+
return true;
|
| 446 |
+
},
|
| 447 |
+
{ labels: QUOTA_CONFIRM_LABELS, selector: QUOTA_ACTION_SELECTOR },
|
| 448 |
+
)
|
| 449 |
+
.catch(() => false);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
async function clickQuotaWarningContinueByCoordinates(
|
| 453 |
+
page: Page,
|
| 454 |
+
scope: Scope,
|
| 455 |
+
): Promise<boolean> {
|
| 456 |
+
// Try filled action slot button first
|
| 457 |
+
const filledAction = scope
|
| 458 |
+
.locator(SELECTORS.quota.warningConfirmButton)
|
| 459 |
+
.last();
|
| 460 |
+
if (
|
| 461 |
+
await filledAction.isVisible({ timeout: 1000 }).catch(() => false)
|
| 462 |
+
) {
|
| 463 |
+
await filledAction.click({ force: true });
|
| 464 |
+
return true;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// Try coordinate-based click on "Continue to File Upload"
|
| 468 |
+
const label = 'Continue to File Upload';
|
| 469 |
+
const textTarget = scope.getByText(label, { exact: false }).last();
|
| 470 |
+
const box = await textTarget
|
| 471 |
+
.boundingBox({ timeout: 1000 })
|
| 472 |
+
.catch(() => null);
|
| 473 |
+
if (box) {
|
| 474 |
+
await page.mouse
|
| 475 |
+
.click(box.x + box.width / 2, box.y + box.height / 2)
|
| 476 |
+
.catch(() => {});
|
| 477 |
+
return true;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
// Deep shadow DOM coordinate search
|
| 481 |
+
const point = await scope
|
| 482 |
+
.evaluate(() => {
|
| 483 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 484 |
+
const seen = new Set<Element>();
|
| 485 |
+
|
| 486 |
+
for (let i = 0; i < roots.length; i++) {
|
| 487 |
+
const root = roots[i];
|
| 488 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 489 |
+
if (seen.has(el)) continue;
|
| 490 |
+
seen.add(el);
|
| 491 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 492 |
+
}
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
const isVisible = (el: Element): boolean => {
|
| 496 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 497 |
+
const style = window.getComputedStyle(el);
|
| 498 |
+
const rect = el.getBoundingClientRect();
|
| 499 |
+
return (
|
| 500 |
+
style.visibility !== 'hidden' &&
|
| 501 |
+
style.display !== 'none' &&
|
| 502 |
+
rect.width > 0 &&
|
| 503 |
+
rect.height > 0
|
| 504 |
+
);
|
| 505 |
+
};
|
| 506 |
+
|
| 507 |
+
const target = roots
|
| 508 |
+
.flatMap((r) => Array.from(r.querySelectorAll('*')))
|
| 509 |
+
.filter(isVisible)
|
| 510 |
+
.map((el) => {
|
| 511 |
+
const rect = el.getBoundingClientRect();
|
| 512 |
+
const text = [
|
| 513 |
+
(el as HTMLElement).innerText,
|
| 514 |
+
el.textContent,
|
| 515 |
+
]
|
| 516 |
+
.filter(Boolean)
|
| 517 |
+
.join(' ')
|
| 518 |
+
.replace(/\s+/g, ' ')
|
| 519 |
+
.trim()
|
| 520 |
+
.toLowerCase();
|
| 521 |
+
return { rect, text, area: rect.width * rect.height };
|
| 522 |
+
})
|
| 523 |
+
.filter((c) => c.text.includes('continue to file upload'))
|
| 524 |
+
.sort((a, b) => a.area - b.area)[0];
|
| 525 |
+
|
| 526 |
+
if (!target) return null;
|
| 527 |
+
return {
|
| 528 |
+
x: target.rect.x + target.rect.width / 2,
|
| 529 |
+
y: target.rect.y + target.rect.height / 2,
|
| 530 |
+
};
|
| 531 |
+
})
|
| 532 |
+
.catch(() => null);
|
| 533 |
+
|
| 534 |
+
if (point) {
|
| 535 |
+
await page.mouse.click(point.x, point.y).catch(() => {});
|
| 536 |
+
return true;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// Final fallback: click approximate center of common dialog area
|
| 540 |
+
await page.mouse.click(890, 670).catch(() => {});
|
| 541 |
+
return true;
|
| 542 |
+
}
|
src/engine/steps/resubmit.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
SELECTORS,
|
| 5 |
+
deepClickByText,
|
| 6 |
+
findAssignmentScope,
|
| 7 |
+
findScopeByDeepText,
|
| 8 |
+
readScopeVisibleDeepText,
|
| 9 |
+
} from '../selectors';
|
| 10 |
+
import { detectQuotaLimit, confirmQuotaWarning } from './quota-detect';
|
| 11 |
+
import { uploadFile, SubmissionQuotaLimitError, hasSubmissionCard } from './upload';
|
| 12 |
+
|
| 13 |
+
type Scope = Page | Frame;
|
| 14 |
+
|
| 15 |
+
async function clickVisibleResubmit(scope: Scope): Promise<boolean> {
|
| 16 |
+
// New UI: tii-grn-button inside tii-workflow-student-summary-panel-new
|
| 17 |
+
// Old UI: tdl-button or button with text "Resubmit"
|
| 18 |
+
const locator = scope
|
| 19 |
+
.locator(
|
| 20 |
+
'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]',
|
| 21 |
+
)
|
| 22 |
+
.first();
|
| 23 |
+
|
| 24 |
+
if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) {
|
| 25 |
+
await locator.click({ force: true });
|
| 26 |
+
return true;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const clickedByDom = await scope
|
| 30 |
+
.evaluate(() => {
|
| 31 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 32 |
+
const seen = new Set<Element>();
|
| 33 |
+
|
| 34 |
+
for (let i = 0; i < roots.length; i++) {
|
| 35 |
+
const root = roots[i];
|
| 36 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 37 |
+
if (seen.has(el)) continue;
|
| 38 |
+
seen.add(el);
|
| 39 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const isVisible = (el: Element): boolean => {
|
| 44 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 45 |
+
const style = window.getComputedStyle(el);
|
| 46 |
+
const rect = el.getBoundingClientRect();
|
| 47 |
+
return (
|
| 48 |
+
style.visibility !== 'hidden' &&
|
| 49 |
+
style.display !== 'none' &&
|
| 50 |
+
rect.width > 0 &&
|
| 51 |
+
rect.height > 0
|
| 52 |
+
);
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const readText = (el: Element): string =>
|
| 56 |
+
[
|
| 57 |
+
(el as HTMLElement).innerText,
|
| 58 |
+
el.textContent,
|
| 59 |
+
el.getAttribute('aria-label'),
|
| 60 |
+
el.getAttribute('value'),
|
| 61 |
+
el.getAttribute('part'),
|
| 62 |
+
el.getAttribute('data-px'),
|
| 63 |
+
el.getAttribute('with-data-px'),
|
| 64 |
+
]
|
| 65 |
+
.filter(Boolean)
|
| 66 |
+
.join(' ')
|
| 67 |
+
.replace(/\s+/g, ' ')
|
| 68 |
+
.trim()
|
| 69 |
+
.toLowerCase();
|
| 70 |
+
|
| 71 |
+
const candidates: Element[] = [];
|
| 72 |
+
for (const root of roots) {
|
| 73 |
+
candidates.push(
|
| 74 |
+
...Array.from(
|
| 75 |
+
root.querySelectorAll(
|
| 76 |
+
// Include tii-grn-button for new UI
|
| 77 |
+
'button, a, input, tdl-button, tii-grn-button, [role="button"]',
|
| 78 |
+
),
|
| 79 |
+
),
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const target = candidates.find((el) => isVisible(el) && readText(el).includes('resubmit'));
|
| 84 |
+
if (!target) return false;
|
| 85 |
+
|
| 86 |
+
const shadowButton = target.shadowRoot?.querySelector(
|
| 87 |
+
'button:not([disabled]), [role="button"]:not([disabled])',
|
| 88 |
+
);
|
| 89 |
+
const clickTarget =
|
| 90 |
+
shadowButton instanceof HTMLElement ? shadowButton : (target as HTMLElement);
|
| 91 |
+
clickTarget.scrollIntoView({ block: 'center', inline: 'center' });
|
| 92 |
+
clickTarget.click();
|
| 93 |
+
return true;
|
| 94 |
+
})
|
| 95 |
+
.catch(() => false);
|
| 96 |
+
|
| 97 |
+
if (clickedByDom) return true;
|
| 98 |
+
return deepClickByText(scope, ['resubmit']);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Handle the new Turnitin resubmission confirmation modal.
|
| 103 |
+
* The dialog#tii-resubmission-last-submission-modal is rendered INSIDE a
|
| 104 |
+
* web component's shadow DOM, so standard document.querySelector() and even
|
| 105 |
+
* Playwright's page.locator() may not find it.
|
| 106 |
+
*
|
| 107 |
+
* This function uses a full recursive shadow-DOM traversal to locate the
|
| 108 |
+
* dialog and click its "Continue to File Upload" (or any accept-like) button.
|
| 109 |
+
*/
|
| 110 |
+
async function confirmResubmissionModal(page: Page): Promise<boolean> {
|
| 111 |
+
// Helper: find dialog anywhere in the full shadow DOM tree
|
| 112 |
+
const findDialog = () => page.evaluate(() => {
|
| 113 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 114 |
+
const seen = new Set<Element>();
|
| 115 |
+
for (let i = 0; i < roots.length; i++) {
|
| 116 |
+
const root = roots[i];
|
| 117 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 118 |
+
if (seen.has(el)) continue;
|
| 119 |
+
seen.add(el);
|
| 120 |
+
if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
for (const root of roots) {
|
| 124 |
+
if (root.querySelector('dialog#tii-resubmission-last-submission-modal,dialog[open]')) return true;
|
| 125 |
+
}
|
| 126 |
+
return false;
|
| 127 |
+
}).catch(() => false);
|
| 128 |
+
|
| 129 |
+
// Wait up to 6s for the modal to appear (shadow DOM traversal)
|
| 130 |
+
let found = false;
|
| 131 |
+
const deadline = Date.now() + 6000;
|
| 132 |
+
while (!found && Date.now() < deadline) {
|
| 133 |
+
found = await findDialog();
|
| 134 |
+
if (!found) await page.waitForTimeout(400);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (!found) {
|
| 138 |
+
// Also try the standard Playwright locator as a fallback (works when dialog is in light DOM)
|
| 139 |
+
found = await page
|
| 140 |
+
.locator('dialog#tii-resubmission-last-submission-modal, dialog[open]')
|
| 141 |
+
.first()
|
| 142 |
+
.isVisible({ timeout: 2000 })
|
| 143 |
+
.catch(() => false);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (!found) {
|
| 147 |
+
logger.info('No resubmission confirmation modal detected');
|
| 148 |
+
return false;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
logger.info('Resubmission confirmation modal detected; clicking Continue to File Upload...');
|
| 152 |
+
|
| 153 |
+
// Click the accept button via full shadow-DOM traversal
|
| 154 |
+
const clicked = await page.evaluate(() => {
|
| 155 |
+
// Build complete shadow-DOM root list
|
| 156 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 157 |
+
const seen = new Set<Element>();
|
| 158 |
+
for (let i = 0; i < roots.length; i++) {
|
| 159 |
+
const root = roots[i];
|
| 160 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 161 |
+
if (seen.has(el)) continue;
|
| 162 |
+
seen.add(el);
|
| 163 |
+
if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Find the dialog first
|
| 168 |
+
let dialog: Element | null = null;
|
| 169 |
+
for (const root of roots) {
|
| 170 |
+
dialog = root.querySelector('dialog#tii-resubmission-last-submission-modal') ||
|
| 171 |
+
root.querySelector('dialog[open]');
|
| 172 |
+
if (dialog) break;
|
| 173 |
+
}
|
| 174 |
+
if (!dialog) return false;
|
| 175 |
+
|
| 176 |
+
// Traverse dialog's own shadow DOM
|
| 177 |
+
const dialogRoots: (Element | ShadowRoot)[] = [dialog];
|
| 178 |
+
const dSeen = new Set<Element>();
|
| 179 |
+
for (let i = 0; i < dialogRoots.length; i++) {
|
| 180 |
+
const root = dialogRoots[i];
|
| 181 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 182 |
+
if (dSeen.has(el as Element)) continue;
|
| 183 |
+
dSeen.add(el as Element);
|
| 184 |
+
if ((el as any).shadowRoot) dialogRoots.push((el as any).shadowRoot);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// Priority: "Continue to File Upload" > any accept/submit/confirm button
|
| 189 |
+
const priority = (text: string): number => {
|
| 190 |
+
const t = text.toLowerCase();
|
| 191 |
+
if (t.includes('continue to file upload')) return 100;
|
| 192 |
+
if (t.includes('file upload')) return 90;
|
| 193 |
+
if (t.includes('continue')) return 80;
|
| 194 |
+
if (t.includes('accept')) return 70;
|
| 195 |
+
if (t.includes('submit')) return 60;
|
| 196 |
+
if (t.includes('confirm')) return 50;
|
| 197 |
+
return 0;
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const candidates: Array<{ el: Element; score: number }> = [];
|
| 201 |
+
for (const root of dialogRoots) {
|
| 202 |
+
for (const el of Array.from(
|
| 203 |
+
root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], [slot="accept-button"]'),
|
| 204 |
+
)) {
|
| 205 |
+
const text = [
|
| 206 |
+
(el as HTMLElement).innerText,
|
| 207 |
+
el.textContent,
|
| 208 |
+
el.getAttribute('aria-label'),
|
| 209 |
+
el.getAttribute('part'),
|
| 210 |
+
el.getAttribute('slot'),
|
| 211 |
+
el.getAttribute('data-px'),
|
| 212 |
+
el.getAttribute('with-data-px'),
|
| 213 |
+
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
| 214 |
+
|
| 215 |
+
const score = priority(text);
|
| 216 |
+
if (score > 0) candidates.push({ el, score });
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
candidates.sort((a, b) => b.score - a.score);
|
| 221 |
+
const best = candidates[0];
|
| 222 |
+
if (!best) {
|
| 223 |
+
// Last resort: close the dialog directly
|
| 224 |
+
if (dialog instanceof HTMLDialogElement) {
|
| 225 |
+
dialog.close();
|
| 226 |
+
return true;
|
| 227 |
+
}
|
| 228 |
+
return false;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Click: prefer inner shadow <button>, else click host element
|
| 232 |
+
const inner = (best.el as any).shadowRoot?.querySelector('button:not([disabled])');
|
| 233 |
+
const target = inner instanceof HTMLElement ? inner : (best.el as HTMLElement);
|
| 234 |
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
| 235 |
+
target.click();
|
| 236 |
+
return true;
|
| 237 |
+
}).catch(() => false);
|
| 238 |
+
|
| 239 |
+
if (clicked) {
|
| 240 |
+
await page.waitForTimeout(2000);
|
| 241 |
+
|
| 242 |
+
// Verify dialog actually closed (wait up to 3s)
|
| 243 |
+
const stillOpen = await findDialog();
|
| 244 |
+
if (stillOpen) {
|
| 245 |
+
logger.warn('Dialog still open after click; trying dialog.close() fallback');
|
| 246 |
+
await page.evaluate(() => {
|
| 247 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 248 |
+
const seen = new Set<Element>();
|
| 249 |
+
for (let i = 0; i < roots.length; i++) {
|
| 250 |
+
const root = roots[i];
|
| 251 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 252 |
+
if (seen.has(el)) continue;
|
| 253 |
+
seen.add(el);
|
| 254 |
+
if ((el as any).shadowRoot) roots.push((el as any).shadowRoot);
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
for (const root of roots) {
|
| 258 |
+
const d = root.querySelector('dialog#tii-resubmission-last-submission-modal') ||
|
| 259 |
+
root.querySelector('dialog[open]');
|
| 260 |
+
if (d instanceof HTMLDialogElement) { d.close(); return; }
|
| 261 |
+
}
|
| 262 |
+
}).catch(() => {});
|
| 263 |
+
await page.waitForTimeout(1500);
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
return clicked;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
async function hasFirstSubmissionUploadForm(page: Page): Promise<boolean> {
|
| 271 |
+
const scope =
|
| 272 |
+
(await findAssignmentScope(
|
| 273 |
+
page,
|
| 274 |
+
`${SELECTORS.upload.browseButton}, ${SELECTORS.upload.fileInput}, ${SELECTORS.upload.uploadStepContainer}`,
|
| 275 |
+
2000,
|
| 276 |
+
)) || (await findScopeByDeepText(
|
| 277 |
+
page,
|
| 278 |
+
['browse files', 'drag and drop file', 'drag and drop your file', 'your device'],
|
| 279 |
+
2000,
|
| 280 |
+
));
|
| 281 |
+
|
| 282 |
+
if (!scope) return false;
|
| 283 |
+
const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase();
|
| 284 |
+
return (
|
| 285 |
+
(await scope.locator(SELECTORS.upload.fileInput).first().isVisible({ timeout: 500 }).catch(() => false)) ||
|
| 286 |
+
(await scope.locator(SELECTORS.upload.browseButton).first().isVisible({ timeout: 500 }).catch(() => false)) ||
|
| 287 |
+
(await scope.locator(SELECTORS.upload.uploadStepContainer).first().isVisible({ timeout: 500 }).catch(() => false)) ||
|
| 288 |
+
visibleText.includes('browse files') ||
|
| 289 |
+
visibleText.includes('drag and drop file') ||
|
| 290 |
+
visibleText.includes('drag and drop your file') ||
|
| 291 |
+
visibleText.includes('your device')
|
| 292 |
+
);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/**
|
| 296 |
+
* Resubmit a file to an assignment that already has a prior submission.
|
| 297 |
+
*
|
| 298 |
+
* Steps:
|
| 299 |
+
* 1. Assert quota is available
|
| 300 |
+
* 2. Click Resubmit button
|
| 301 |
+
* 3. Handle quota warnings (confirm and continue)
|
| 302 |
+
* 4. Use same upload popup flow (Browse Files -> Upload and Preview -> Submit)
|
| 303 |
+
* 5. If Resubmit button not found, fallback to initial upload
|
| 304 |
+
*/
|
| 305 |
+
export async function resubmitFile(
|
| 306 |
+
page: Page,
|
| 307 |
+
filePath: string,
|
| 308 |
+
): Promise<void> {
|
| 309 |
+
logger.info('Resubmit requested; opening resubmission upload modal');
|
| 310 |
+
|
| 311 |
+
// Check quota before attempting resubmit
|
| 312 |
+
const quotaLimit = await detectQuotaLimit(page);
|
| 313 |
+
if (quotaLimit) {
|
| 314 |
+
throw new SubmissionQuotaLimitError(quotaLimit.message);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
// Find the Resubmit button
|
| 318 |
+
const scope =
|
| 319 |
+
(await findAssignmentScope(
|
| 320 |
+
page,
|
| 321 |
+
'tii-grn-button:has-text("Resubmit"), a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"], [with-data-px*="Resubmit"]',
|
| 322 |
+
5000,
|
| 323 |
+
)) ||
|
| 324 |
+
(await findScopeByDeepText(page, ['resubmit'], 8000));
|
| 325 |
+
if (!scope) {
|
| 326 |
+
if (await hasFirstSubmissionUploadForm(page)) {
|
| 327 |
+
logger.warn(
|
| 328 |
+
'Resubmit button was not found and first-submission upload form is visible; using initial upload flow',
|
| 329 |
+
);
|
| 330 |
+
await uploadFile(page, filePath);
|
| 331 |
+
return;
|
| 332 |
+
}
|
| 333 |
+
throw new Error('Resubmit was requested, but no visible Resubmit button or first-upload form was found');
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
let clicked = await clickVisibleResubmit(scope);
|
| 337 |
+
|
| 338 |
+
if (!clicked) {
|
| 339 |
+
const submissionCardVisible = await hasSubmissionCard(scope);
|
| 340 |
+
if (!submissionCardVisible && await hasFirstSubmissionUploadForm(page)) {
|
| 341 |
+
logger.warn(
|
| 342 |
+
'Resubmit button click failed, but no submission card is visible; using initial upload flow',
|
| 343 |
+
);
|
| 344 |
+
await uploadFile(page, filePath);
|
| 345 |
+
return;
|
| 346 |
+
}
|
| 347 |
+
throw new Error('Resubmit button was found but could not be clicked');
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
await page.waitForTimeout(2000);
|
| 351 |
+
|
| 352 |
+
// Handle NEW UI resubmission confirmation modal
|
| 353 |
+
// (dialog#tii-resubmission-last-submission-modal with accept-button slot)
|
| 354 |
+
const modalConfirmed = await confirmResubmissionModal(page);
|
| 355 |
+
if (modalConfirmed) {
|
| 356 |
+
logger.info('Resubmission confirmation modal accepted');
|
| 357 |
+
await page.waitForTimeout(2000);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// Check quota again after clicking resubmit
|
| 361 |
+
const quotaLimitAfter = await detectQuotaLimit(page);
|
| 362 |
+
if (quotaLimitAfter) {
|
| 363 |
+
throw new SubmissionQuotaLimitError(quotaLimitAfter.message);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
// Handle quota warnings
|
| 367 |
+
await confirmQuotaWarning(page);
|
| 368 |
+
|
| 369 |
+
// Now proceed with the standard upload flow (the resubmit popup is the same)
|
| 370 |
+
await uploadFile(page, filePath, { skipExistingCheck: true });
|
| 371 |
+
}
|
src/engine/steps/similarity.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame, Locator } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
readScopeDeepText,
|
| 5 |
+
resolveAssignmentScope,
|
| 6 |
+
SELECTORS,
|
| 7 |
+
} from '../selectors';
|
| 8 |
+
import { acceptEulaEverywhere } from './login';
|
| 9 |
+
|
| 10 |
+
// ---------------------------------------------------------------------------
|
| 11 |
+
// Types
|
| 12 |
+
// ---------------------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
export interface SimilarityResult {
|
| 15 |
+
similarityPercent: number | null;
|
| 16 |
+
viewerUrl: string | null;
|
| 17 |
+
scope?: Page | Frame;
|
| 18 |
+
locator?: Locator;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface WaitForSimilarityOptions {
|
| 22 |
+
timeoutMs: number;
|
| 23 |
+
pollMs: number;
|
| 24 |
+
refreshAfterMs: number;
|
| 25 |
+
inputFileName?: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
// Public API
|
| 30 |
+
// ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
function getSubmissionButtonLocator(scope: Page | Frame, inputTitle: string): Locator {
|
| 33 |
+
const viewButtons = scope.locator(SELECTORS.similarity.viewSubmissionButton);
|
| 34 |
+
if (inputTitle) {
|
| 35 |
+
return viewButtons.filter({ hasText: inputTitle }).first();
|
| 36 |
+
}
|
| 37 |
+
return viewButtons.first();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Poll for the similarity score to appear on the submission card.
|
| 42 |
+
*
|
| 43 |
+
* After `refreshAfterMs` with no result, refreshes the assignment launch URL
|
| 44 |
+
* once. Returns the similarity percent (if readable) and viewer URL.
|
| 45 |
+
*/
|
| 46 |
+
export async function waitForSimilarity(
|
| 47 |
+
page: Page,
|
| 48 |
+
assignmentLaunchUrl: string,
|
| 49 |
+
options: WaitForSimilarityOptions,
|
| 50 |
+
): Promise<SimilarityResult> {
|
| 51 |
+
const { timeoutMs, pollMs, refreshAfterMs } = options;
|
| 52 |
+
const inputTitle = options.inputFileName
|
| 53 |
+
? options.inputFileName.replace(/\.[^.]+$/, '').trim()
|
| 54 |
+
: '';
|
| 55 |
+
const started = Date.now();
|
| 56 |
+
let lastText = '';
|
| 57 |
+
// BUG-11 FIX: Allow multiple refreshes (every refreshAfterMs from first refresh)
|
| 58 |
+
let refreshCount = 0;
|
| 59 |
+
const MAX_REFRESHES = 2;
|
| 60 |
+
let lastRefreshAt = 0;
|
| 61 |
+
|
| 62 |
+
while (Date.now() - started < timeoutMs) {
|
| 63 |
+
// ---- Deep text scan across all scopes ----
|
| 64 |
+
const scopes: (Page | Frame)[] = [
|
| 65 |
+
page,
|
| 66 |
+
...page.frames().filter((f) => !f.url().includes('cookie-shim')),
|
| 67 |
+
];
|
| 68 |
+
|
| 69 |
+
for (const candidateScope of scopes) {
|
| 70 |
+
const deepText = await readScopeDeepText(candidateScope);
|
| 71 |
+
const percentMatch = deepText.match(
|
| 72 |
+
/\b(?:Similarity:\s*)?(\d{1,3})%\b/i,
|
| 73 |
+
);
|
| 74 |
+
// BUG-4 FIX: Validate bounds — regex now matches 0-999 so we clamp to 0-100
|
| 75 |
+
const percentRaw = percentMatch ? parseInt(percentMatch[1], 10) : NaN;
|
| 76 |
+
const percent = Number.isFinite(percentRaw) && percentRaw >= 0 && percentRaw <= 100
|
| 77 |
+
? percentRaw : NaN;
|
| 78 |
+
const percentOk = !isNaN(percent);
|
| 79 |
+
const titleVisible =
|
| 80 |
+
Boolean(inputTitle) &&
|
| 81 |
+
deepText.toLowerCase().includes(inputTitle.toLowerCase());
|
| 82 |
+
|
| 83 |
+
if (
|
| 84 |
+
percentOk &&
|
| 85 |
+
(titleVisible || /Submitted|Similarity|Your work/i.test(deepText))
|
| 86 |
+
) {
|
| 87 |
+
const text = percentMatch![0].includes('Similarity')
|
| 88 |
+
? percentMatch![0]
|
| 89 |
+
: `Similarity: ${percent}%`;
|
| 90 |
+
logger.info('Similarity detected from deep text', {
|
| 91 |
+
text,
|
| 92 |
+
percent,
|
| 93 |
+
});
|
| 94 |
+
return {
|
| 95 |
+
similarityPercent: percent,
|
| 96 |
+
viewerUrl: null,
|
| 97 |
+
scope: candidateScope,
|
| 98 |
+
locator: getSubmissionButtonLocator(candidateScope, inputTitle),
|
| 99 |
+
};
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// After refresh, if submission row is visible even without percent, proceed
|
| 103 |
+
if (
|
| 104 |
+
refreshCount > 0 &&
|
| 105 |
+
titleVisible &&
|
| 106 |
+
/Your work/i.test(deepText)
|
| 107 |
+
) {
|
| 108 |
+
logger.info(
|
| 109 |
+
'Submission row visible after refresh; proceeding without similarity value',
|
| 110 |
+
);
|
| 111 |
+
return {
|
| 112 |
+
similarityPercent: null,
|
| 113 |
+
viewerUrl: null,
|
| 114 |
+
scope: candidateScope,
|
| 115 |
+
locator: getSubmissionButtonLocator(candidateScope, inputTitle),
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// ---- Locator-based scan ----
|
| 121 |
+
const scope = await resolveAssignmentScope(page);
|
| 122 |
+
const candidates = [
|
| 123 |
+
{
|
| 124 |
+
kind: 'similarity',
|
| 125 |
+
locator: scope
|
| 126 |
+
.locator(
|
| 127 |
+
'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
|
| 128 |
+
)
|
| 129 |
+
.first(),
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
kind: 'similarity',
|
| 133 |
+
locator: scope.locator(SELECTORS.similarity.similaritySpan).first(),
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
kind: 'similarity',
|
| 137 |
+
locator: scope.locator(SELECTORS.similarity.similarityDisplay).first(),
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
kind: 'title',
|
| 141 |
+
locator: scope.locator(SELECTORS.similarity.viewSubmissionButton).first(),
|
| 142 |
+
},
|
| 143 |
+
];
|
| 144 |
+
|
| 145 |
+
for (const candidateEntry of candidates) {
|
| 146 |
+
const candidate = candidateEntry.locator;
|
| 147 |
+
if (
|
| 148 |
+
await candidate.isVisible({ timeout: 1500 }).catch(() => false)
|
| 149 |
+
) {
|
| 150 |
+
const text = (
|
| 151 |
+
await candidate.innerText().catch(() => '')
|
| 152 |
+
).trim();
|
| 153 |
+
const aria = (
|
| 154 |
+
(await candidate
|
| 155 |
+
.getAttribute('aria-label')
|
| 156 |
+
.catch(() => '')) || ''
|
| 157 |
+
).trim();
|
| 158 |
+
const visibleText = text || aria;
|
| 159 |
+
|
| 160 |
+
if (visibleText && visibleText !== lastText) {
|
| 161 |
+
lastText = visibleText;
|
| 162 |
+
logger.info('Submission state', { visibleText });
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if (
|
| 166 |
+
candidateEntry.kind === 'title' &&
|
| 167 |
+
visibleText &&
|
| 168 |
+
refreshCount > 0
|
| 169 |
+
) {
|
| 170 |
+
return {
|
| 171 |
+
similarityPercent: null,
|
| 172 |
+
viewerUrl: null,
|
| 173 |
+
scope,
|
| 174 |
+
locator: candidate,
|
| 175 |
+
};
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const simMatch = visibleText.match(
|
| 179 |
+
/Similarity:\s*(\d+)%/i,
|
| 180 |
+
);
|
| 181 |
+
if (simMatch) {
|
| 182 |
+
const percent = parseInt(simMatch[1], 10);
|
| 183 |
+
logger.info('Similarity detected from locator', {
|
| 184 |
+
visibleText,
|
| 185 |
+
percent,
|
| 186 |
+
});
|
| 187 |
+
return {
|
| 188 |
+
similarityPercent: percent,
|
| 189 |
+
viewerUrl: null,
|
| 190 |
+
scope,
|
| 191 |
+
locator: getSubmissionButtonLocator(scope, inputTitle),
|
| 192 |
+
};
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// ---- Log body text for diagnostics ----
|
| 198 |
+
const bodyText = await scope
|
| 199 |
+
.locator('body')
|
| 200 |
+
.innerText({ timeout: 3000 })
|
| 201 |
+
.catch(() => '');
|
| 202 |
+
const shortText = bodyText.replace(/\s+/g, ' ').trim().slice(0, 240);
|
| 203 |
+
if (shortText && shortText !== lastText) {
|
| 204 |
+
lastText = shortText;
|
| 205 |
+
logger.debug('Waiting for submission/similarity', {
|
| 206 |
+
bodySnippet: shortText,
|
| 207 |
+
});
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// ---- Refresh assignment launch URL periodically ----
|
| 211 |
+
// BUG-11 FIX: Allow up to MAX_REFRESHES refreshes, each triggered after
|
| 212 |
+
// refreshAfterMs has passed since the previous refresh (or since start).
|
| 213 |
+
const elapsedSinceLastRefresh = lastRefreshAt === 0
|
| 214 |
+
? Date.now() - started
|
| 215 |
+
: Date.now() - lastRefreshAt;
|
| 216 |
+
const currentUrl = page.url();
|
| 217 |
+
const urlSeemsFine =
|
| 218 |
+
currentUrl.includes('turnitin.com') &&
|
| 219 |
+
(currentUrl.includes('/assignment/') || currentUrl.includes('/class/'));
|
| 220 |
+
const shouldRefresh =
|
| 221 |
+
refreshCount < MAX_REFRESHES &&
|
| 222 |
+
elapsedSinceLastRefresh >= refreshAfterMs &&
|
| 223 |
+
(!urlSeemsFine || currentUrl.includes('/assignment/type/tool/launch'));
|
| 224 |
+
|
| 225 |
+
if (shouldRefresh) {
|
| 226 |
+
refreshCount++;
|
| 227 |
+
lastRefreshAt = Date.now();
|
| 228 |
+
logger.info(
|
| 229 |
+
`Similarity not visible; refreshing assignment launch URL (refresh ${refreshCount}/${MAX_REFRESHES})`,
|
| 230 |
+
{ currentUrl },
|
| 231 |
+
);
|
| 232 |
+
await page
|
| 233 |
+
.reload({ waitUntil: 'domcontentloaded', timeout: 60000 })
|
| 234 |
+
.catch(() => {});
|
| 235 |
+
await page.waitForTimeout(5000);
|
| 236 |
+
|
| 237 |
+
// ── Handle 502/503/504 error pages after refresh ──
|
| 238 |
+
for (let refreshRetry = 0; refreshRetry < 2; refreshRetry++) {
|
| 239 |
+
const bodyAfterRefresh = await page
|
| 240 |
+
.locator('body')
|
| 241 |
+
.innerText({ timeout: 3000 })
|
| 242 |
+
.catch(() => '');
|
| 243 |
+
if (/502 Bad Gateway|503 Service|504 Gateway/i.test(bodyAfterRefresh)) {
|
| 244 |
+
logger.warn(
|
| 245 |
+
`Server error detected after refresh ${refreshCount} (attempt ${refreshRetry + 1}/2); retrying reload`,
|
| 246 |
+
{ bodySnippet: bodyAfterRefresh.slice(0, 200) },
|
| 247 |
+
);
|
| 248 |
+
await page.waitForTimeout(5000);
|
| 249 |
+
await page
|
| 250 |
+
.reload({ waitUntil: 'domcontentloaded', timeout: 60000 })
|
| 251 |
+
.catch(() => {});
|
| 252 |
+
await page.waitForTimeout(5000);
|
| 253 |
+
} else {
|
| 254 |
+
break;
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
continue;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
await page.waitForTimeout(pollMs);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
throw new Error(
|
| 265 |
+
`Submission card/similarity did not become ready within ${timeoutMs}ms`,
|
| 266 |
+
);
|
| 267 |
+
}
|
src/engine/steps/submission-details.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import { SELECTORS, deepClickByText } from '../selectors';
|
| 4 |
+
|
| 5 |
+
type Scope = Page | Frame;
|
| 6 |
+
|
| 7 |
+
export interface SubmissionDetails {
|
| 8 |
+
studentId?: string;
|
| 9 |
+
className?: string;
|
| 10 |
+
classId?: string;
|
| 11 |
+
submissionId?: string;
|
| 12 |
+
submissionDate?: string;
|
| 13 |
+
submissionCount?: number;
|
| 14 |
+
fileName?: string;
|
| 15 |
+
fileExtension?: string;
|
| 16 |
+
fileSize?: number;
|
| 17 |
+
charCount?: number;
|
| 18 |
+
wordCount?: number;
|
| 19 |
+
pageCount?: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function normalizeKey(label: string): keyof SubmissionDetails | null {
|
| 23 |
+
const normalized = label.toLowerCase().replace(/\s+/g, ' ').trim();
|
| 24 |
+
if (normalized === 'student id') return 'studentId';
|
| 25 |
+
if (normalized === 'class name') return 'className';
|
| 26 |
+
if (normalized === 'class id') return 'classId';
|
| 27 |
+
if (normalized === 'submission id') return 'submissionId';
|
| 28 |
+
if (normalized === 'submission date') return 'submissionDate';
|
| 29 |
+
if (normalized === 'submission count') return 'submissionCount';
|
| 30 |
+
if (normalized === 'file name') return 'fileName';
|
| 31 |
+
if (normalized === 'file extension') return 'fileExtension';
|
| 32 |
+
if (normalized === 'file size') return 'fileSize';
|
| 33 |
+
if (normalized === 'char count') return 'charCount';
|
| 34 |
+
if (normalized === 'character count') return 'charCount';
|
| 35 |
+
if (normalized === 'word count') return 'wordCount';
|
| 36 |
+
if (normalized === 'page count') return 'pageCount';
|
| 37 |
+
return null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function parseNumber(value: string): number | undefined {
|
| 41 |
+
const numeric = Number(String(value || '').replace(/[^\d]/g, ''));
|
| 42 |
+
return Number.isFinite(numeric) ? numeric : undefined;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Open the Turnitin viewer details panel and read file metadata. The worker
|
| 47 |
+
* emits these details to job events so the tracking page can show file facts
|
| 48 |
+
* before/while the final report is downloaded.
|
| 49 |
+
*/
|
| 50 |
+
export async function readSubmissionDetails(
|
| 51 |
+
page: Scope,
|
| 52 |
+
): Promise<SubmissionDetails | null> {
|
| 53 |
+
const detailsButton = page
|
| 54 |
+
.locator(
|
| 55 |
+
[
|
| 56 |
+
'tii-sws-submission-details-btn tdl-labeled-button',
|
| 57 |
+
'tii-sws-submission-details-btn',
|
| 58 |
+
'tii-sws-header [slot="submission-details-btn"]',
|
| 59 |
+
'tdl-labeled-button[withdatapx="SubmissionDetailsMenuClicked"]',
|
| 60 |
+
'[withdatapx="SubmissionDetailsMenuClicked"]',
|
| 61 |
+
'button:has-text("Details")',
|
| 62 |
+
'tdl-labeled-button:has-text("Details")',
|
| 63 |
+
].join(', '),
|
| 64 |
+
)
|
| 65 |
+
.first();
|
| 66 |
+
|
| 67 |
+
if (!(await detailsButton.isVisible({ timeout: 10000 }).catch(() => false))) {
|
| 68 |
+
const clickedByText = await deepClickByText(page, ['details']).catch(
|
| 69 |
+
() => false,
|
| 70 |
+
);
|
| 71 |
+
if (!clickedByText) {
|
| 72 |
+
logger.warn('Submission details button was not visible');
|
| 73 |
+
return null;
|
| 74 |
+
}
|
| 75 |
+
} else {
|
| 76 |
+
await detailsButton.click({ force: true });
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
await page.waitForTimeout(1200).catch(() => {});
|
| 80 |
+
|
| 81 |
+
const fileTab = page
|
| 82 |
+
.locator(
|
| 83 |
+
[
|
| 84 |
+
'tii-sws-tab-button#sub-details-tab-file',
|
| 85 |
+
'#sub-details-tab-file',
|
| 86 |
+
'.submission-details-tab:has-text("File")',
|
| 87 |
+
'[role="tab"]:has-text("File")',
|
| 88 |
+
].join(', '),
|
| 89 |
+
)
|
| 90 |
+
.first();
|
| 91 |
+
if (await fileTab.isVisible({ timeout: 5000 }).catch(() => false)) {
|
| 92 |
+
await fileTab.click({ force: true }).catch(() => {});
|
| 93 |
+
await page.waitForTimeout(700).catch(() => {});
|
| 94 |
+
} else {
|
| 95 |
+
await deepClickByText(page, ['file']).catch(() => false);
|
| 96 |
+
await page.waitForTimeout(700).catch(() => {});
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const details = await page
|
| 100 |
+
.locator('#tii-sws-submission-details-list')
|
| 101 |
+
.first()
|
| 102 |
+
.evaluate((list) => {
|
| 103 |
+
return Array.from(list.querySelectorAll('.submission-details-item')).map(
|
| 104 |
+
(item) => {
|
| 105 |
+
const term = item
|
| 106 |
+
.querySelector('[role="term"]')
|
| 107 |
+
?.textContent?.trim() || '';
|
| 108 |
+
const value = item
|
| 109 |
+
.querySelector('[role="definition"], .submission-details-value')
|
| 110 |
+
?.textContent?.trim() || '';
|
| 111 |
+
return { term, value };
|
| 112 |
+
},
|
| 113 |
+
);
|
| 114 |
+
})
|
| 115 |
+
.catch(() => []);
|
| 116 |
+
|
| 117 |
+
const result: SubmissionDetails = {};
|
| 118 |
+
for (const row of details) {
|
| 119 |
+
const key = normalizeKey(row.term);
|
| 120 |
+
if (!key) continue;
|
| 121 |
+
if (
|
| 122 |
+
key === 'fileName' ||
|
| 123 |
+
key === 'fileExtension' ||
|
| 124 |
+
key === 'studentId' ||
|
| 125 |
+
key === 'className' ||
|
| 126 |
+
key === 'classId' ||
|
| 127 |
+
key === 'submissionId' ||
|
| 128 |
+
key === 'submissionDate'
|
| 129 |
+
) {
|
| 130 |
+
result[key] = row.value;
|
| 131 |
+
} else {
|
| 132 |
+
const parsed = parseNumber(row.value);
|
| 133 |
+
if (parsed !== undefined) result[key] = parsed;
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
await page
|
| 138 |
+
.locator(SELECTORS.filters.similarityTab)
|
| 139 |
+
.first()
|
| 140 |
+
.click({ force: true, timeout: 3000 })
|
| 141 |
+
.catch(() => {});
|
| 142 |
+
await page.waitForTimeout(800).catch(() => {});
|
| 143 |
+
|
| 144 |
+
if (Object.keys(result).length === 0) return null;
|
| 145 |
+
logger.info('Read submission details from viewer', { ...result });
|
| 146 |
+
return result;
|
| 147 |
+
}
|
src/engine/steps/submission-state.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import {
|
| 3 |
+
findAssignmentScope,
|
| 4 |
+
readScopeVisibleDeepText,
|
| 5 |
+
scopeHasVisibleDeepText,
|
| 6 |
+
} from '../selectors';
|
| 7 |
+
import { SELECTORS } from '../selectors';
|
| 8 |
+
|
| 9 |
+
type Scope = Page | Frame;
|
| 10 |
+
|
| 11 |
+
export interface SubmissionState {
|
| 12 |
+
scope: Scope | null;
|
| 13 |
+
hasExistingSubmission: boolean;
|
| 14 |
+
hasResubmitAction: boolean;
|
| 15 |
+
hasUploadForm: boolean;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
async function hasVisible(scope: Scope, selector: string, timeoutMs = 1000): Promise<boolean> {
|
| 19 |
+
return scope.locator(selector).first().isVisible({ timeout: timeoutMs }).catch(() => false);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function hasVisibleActionText(scope: Scope, textMatches: string[]): Promise<boolean> {
|
| 23 |
+
return scope
|
| 24 |
+
.evaluate((matches: string[]) => {
|
| 25 |
+
const normalizedMatches = matches.map((value) => value.toLowerCase());
|
| 26 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 27 |
+
const seen = new Set<Element>();
|
| 28 |
+
|
| 29 |
+
for (let i = 0; i < roots.length; i++) {
|
| 30 |
+
const root = roots[i];
|
| 31 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 32 |
+
if (seen.has(el)) continue;
|
| 33 |
+
seen.add(el);
|
| 34 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const isVisible = (el: Element): boolean => {
|
| 39 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 40 |
+
const style = window.getComputedStyle(el);
|
| 41 |
+
const rect = el.getBoundingClientRect();
|
| 42 |
+
return (
|
| 43 |
+
style.visibility !== 'hidden' &&
|
| 44 |
+
style.display !== 'none' &&
|
| 45 |
+
rect.width > 0 &&
|
| 46 |
+
rect.height > 0
|
| 47 |
+
);
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const candidates: Element[] = [];
|
| 51 |
+
for (const root of roots) {
|
| 52 |
+
candidates.push(
|
| 53 |
+
...Array.from(
|
| 54 |
+
root.querySelectorAll(
|
| 55 |
+
'button, a, input, tdl-button, tii-grn-button, tdl-labeled-button, [role="button"]',
|
| 56 |
+
),
|
| 57 |
+
),
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return candidates.some((el) => {
|
| 62 |
+
if (!isVisible(el)) return false;
|
| 63 |
+
const text = [
|
| 64 |
+
(el as HTMLElement).innerText,
|
| 65 |
+
el.textContent,
|
| 66 |
+
el.getAttribute('aria-label'),
|
| 67 |
+
el.getAttribute('value'),
|
| 68 |
+
el.getAttribute('part'),
|
| 69 |
+
el.getAttribute('data-px'),
|
| 70 |
+
el.getAttribute('with-data-px'),
|
| 71 |
+
el.getAttribute('with-px-label'),
|
| 72 |
+
]
|
| 73 |
+
.filter(Boolean)
|
| 74 |
+
.join(' ')
|
| 75 |
+
.replace(/\s+/g, ' ')
|
| 76 |
+
.trim()
|
| 77 |
+
.toLowerCase();
|
| 78 |
+
if (
|
| 79 |
+
text.includes('setting info') ||
|
| 80 |
+
text.includes('resubmissions are allowed') ||
|
| 81 |
+
text.includes('late submissions are allowed') ||
|
| 82 |
+
text.includes('collapse details') ||
|
| 83 |
+
text.includes('assignment details') ||
|
| 84 |
+
text.includes('rubric') ||
|
| 85 |
+
text.includes('template')
|
| 86 |
+
) {
|
| 87 |
+
return false;
|
| 88 |
+
}
|
| 89 |
+
return normalizedMatches.some((match) => text.includes(match));
|
| 90 |
+
});
|
| 91 |
+
}, textMatches)
|
| 92 |
+
.catch(() => false);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function hasInitialSubmitAction(scope: Scope): Promise<boolean> {
|
| 96 |
+
return scope
|
| 97 |
+
.evaluate(() => {
|
| 98 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 99 |
+
const seen = new Set<Element>();
|
| 100 |
+
|
| 101 |
+
for (let i = 0; i < roots.length; i++) {
|
| 102 |
+
const root = roots[i];
|
| 103 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 104 |
+
if (seen.has(el)) continue;
|
| 105 |
+
seen.add(el);
|
| 106 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const isVisible = (el: Element): boolean => {
|
| 111 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 112 |
+
const style = window.getComputedStyle(el);
|
| 113 |
+
const rect = el.getBoundingClientRect();
|
| 114 |
+
return (
|
| 115 |
+
style.visibility !== 'hidden' &&
|
| 116 |
+
style.display !== 'none' &&
|
| 117 |
+
rect.width > 0 &&
|
| 118 |
+
rect.height > 0
|
| 119 |
+
);
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const hasAncestorSignal = (el: Element): boolean => {
|
| 123 |
+
let current: Element | null = el;
|
| 124 |
+
const visited = new Set<Element>();
|
| 125 |
+
while (current && !visited.has(current)) {
|
| 126 |
+
visited.add(current);
|
| 127 |
+
const className = current.getAttribute('class') || '';
|
| 128 |
+
const slot = current.getAttribute('slot') || '';
|
| 129 |
+
const tag = current.tagName.toLowerCase();
|
| 130 |
+
if (
|
| 131 |
+
slot === 'submission-action' ||
|
| 132 |
+
className.includes('submission-action') ||
|
| 133 |
+
className.includes('submission-slot-container') ||
|
| 134 |
+
tag === 'tii-workflow-student-summary-panel-new' ||
|
| 135 |
+
tag === 'tii-workflow-lfw-student-show-assignment'
|
| 136 |
+
) {
|
| 137 |
+
return true;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
const parent: HTMLElement | null = current.parentElement;
|
| 141 |
+
if (parent) {
|
| 142 |
+
current = parent;
|
| 143 |
+
continue;
|
| 144 |
+
}
|
| 145 |
+
const root = current.getRootNode();
|
| 146 |
+
current = root instanceof ShadowRoot ? root.host : null;
|
| 147 |
+
}
|
| 148 |
+
return false;
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
for (const root of roots) {
|
| 152 |
+
for (const el of Array.from(
|
| 153 |
+
root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]'),
|
| 154 |
+
)) {
|
| 155 |
+
if (!isVisible(el)) continue;
|
| 156 |
+
const text = [
|
| 157 |
+
(el as HTMLElement).innerText,
|
| 158 |
+
el.textContent,
|
| 159 |
+
el.getAttribute('aria-label'),
|
| 160 |
+
el.getAttribute('value'),
|
| 161 |
+
]
|
| 162 |
+
.filter(Boolean)
|
| 163 |
+
.join(' ')
|
| 164 |
+
.replace(/\s+/g, ' ')
|
| 165 |
+
.trim();
|
| 166 |
+
const normalized = text.toLowerCase();
|
| 167 |
+
if (!/(^|\s)submit(\s|$)/i.test(text)) continue;
|
| 168 |
+
if (
|
| 169 |
+
normalized.includes('resubmit') ||
|
| 170 |
+
normalized.includes('submit file') ||
|
| 171 |
+
normalized.includes('upload and preview') ||
|
| 172 |
+
normalized.includes('collapse details') ||
|
| 173 |
+
normalized.includes('setting info')
|
| 174 |
+
) {
|
| 175 |
+
continue;
|
| 176 |
+
}
|
| 177 |
+
if (hasAncestorSignal(el)) return true;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return false;
|
| 182 |
+
})
|
| 183 |
+
.catch(() => false);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* Detect what Turnitin is currently showing after opening an assignment.
|
| 188 |
+
* This is intentionally based on the live page, because account history can
|
| 189 |
+
* differ from the requested job mode.
|
| 190 |
+
*/
|
| 191 |
+
export async function detectSubmissionState(page: Page): Promise<SubmissionState> {
|
| 192 |
+
const scope =
|
| 193 |
+
(await findAssignmentScope(
|
| 194 |
+
page,
|
| 195 |
+
[
|
| 196 |
+
SELECTORS.resubmit.resubmitButton,
|
| 197 |
+
SELECTORS.similarity.viewSubmissionButton,
|
| 198 |
+
SELECTORS.upload.browseButton,
|
| 199 |
+
SELECTORS.upload.fileInput,
|
| 200 |
+
SELECTORS.resubmit.submissionActionPanel,
|
| 201 |
+
'tii-workflow-lfw-student-show-assignment',
|
| 202 |
+
'div[slot="submission-action"].submission-action',
|
| 203 |
+
'tii-grn-button:has-text("Submit")',
|
| 204 |
+
].join(', '),
|
| 205 |
+
3000,
|
| 206 |
+
));
|
| 207 |
+
|
| 208 |
+
if (!scope) {
|
| 209 |
+
return {
|
| 210 |
+
scope: null,
|
| 211 |
+
hasExistingSubmission: false,
|
| 212 |
+
hasResubmitAction: false,
|
| 213 |
+
hasUploadForm: false,
|
| 214 |
+
};
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const visibleText = (await readScopeVisibleDeepText(scope)).toLowerCase();
|
| 218 |
+
const hasSubmitAction = await hasInitialSubmitAction(scope);
|
| 219 |
+
const hasResubmitAction =
|
| 220 |
+
(await hasVisible(scope, SELECTORS.resubmit.resubmitButton, 1500)) ||
|
| 221 |
+
(await hasVisible(
|
| 222 |
+
scope,
|
| 223 |
+
'a:has-text("Resubmit"), button:has-text("Resubmit"), tdl-button:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"]',
|
| 224 |
+
1500,
|
| 225 |
+
)) ||
|
| 226 |
+
(await hasVisibleActionText(scope, ['resubmit']));
|
| 227 |
+
|
| 228 |
+
const hasUploadForm =
|
| 229 |
+
(await hasVisible(scope, SELECTORS.upload.fileInput, 1500)) ||
|
| 230 |
+
(await hasVisible(scope, SELECTORS.upload.browseButton, 1500)) ||
|
| 231 |
+
hasSubmitAction ||
|
| 232 |
+
visibleText.includes('browse files') ||
|
| 233 |
+
visibleText.includes('drag and drop file') ||
|
| 234 |
+
visibleText.includes('your device');
|
| 235 |
+
|
| 236 |
+
const hasViewSubmissionAction =
|
| 237 |
+
(await hasVisible(
|
| 238 |
+
scope,
|
| 239 |
+
'button.link-button[aria-label*="View submission"], a[aria-label*="View submission"], a.view-mark, .similarity-button',
|
| 240 |
+
1500,
|
| 241 |
+
)) ||
|
| 242 |
+
(await hasVisibleActionText(scope, ['view submission']));
|
| 243 |
+
|
| 244 |
+
const hasExistingSubmission =
|
| 245 |
+
hasResubmitAction ||
|
| 246 |
+
hasViewSubmissionAction ||
|
| 247 |
+
(await hasVisible(scope, SELECTORS.similarity.similarityDisplay, 1500)) ||
|
| 248 |
+
(await hasVisible(scope, SELECTORS.similarity.similarityBadge, 1500)) ||
|
| 249 |
+
(visibleText.includes('similarity:') || visibleText.includes('view submission')) ||
|
| 250 |
+
((await scopeHasVisibleDeepText(scope, ['submitted'])) && !hasUploadForm);
|
| 251 |
+
|
| 252 |
+
return {
|
| 253 |
+
scope,
|
| 254 |
+
// If the first-submission upload form is visible and there is no visible
|
| 255 |
+
// submission action, prefer upload. Turnitin web components often keep
|
| 256 |
+
// hidden "Resubmit" text in the DOM before any file exists.
|
| 257 |
+
hasExistingSubmission: hasUploadForm && !hasResubmitAction && !hasViewSubmissionAction
|
| 258 |
+
? false
|
| 259 |
+
: hasExistingSubmission,
|
| 260 |
+
hasResubmitAction,
|
| 261 |
+
hasUploadForm,
|
| 262 |
+
};
|
| 263 |
+
}
|
src/engine/steps/upload.ts
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
SELECTORS,
|
| 5 |
+
findAssignmentScope,
|
| 6 |
+
findScopeByDeepText,
|
| 7 |
+
scopeHasVisibleDeepText,
|
| 8 |
+
} from '../selectors';
|
| 9 |
+
import {
|
| 10 |
+
detectQuotaLimit,
|
| 11 |
+
confirmQuotaWarning,
|
| 12 |
+
} from './quota-detect';
|
| 13 |
+
|
| 14 |
+
type Scope = Page | Frame;
|
| 15 |
+
|
| 16 |
+
const UPLOAD_ACTION_SELECTOR = [
|
| 17 |
+
'button',
|
| 18 |
+
'input[type="submit"]',
|
| 19 |
+
'input[type="button"]',
|
| 20 |
+
'tdl-button',
|
| 21 |
+
'tii-grn-button', // New UI: Submit/Resubmit button
|
| 22 |
+
'[role="button"]',
|
| 23 |
+
'[slot="accept-button"]',
|
| 24 |
+
'[part*="submit"]',
|
| 25 |
+
'[part*="upload"]',
|
| 26 |
+
'[data-px*="Submit"]',
|
| 27 |
+
'[data-px*="Upload"]',
|
| 28 |
+
'[with-data-px*="Submit"]',
|
| 29 |
+
'[with-data-px*="Upload"]',
|
| 30 |
+
].join(', ');
|
| 31 |
+
|
| 32 |
+
// ---------------------------------------------------------------------------
|
| 33 |
+
// Error types
|
| 34 |
+
// ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
export class SubmissionQuotaLimitError extends Error {
|
| 37 |
+
constructor(message = 'Submission quota limit reached') {
|
| 38 |
+
super(message);
|
| 39 |
+
this.name = 'SubmissionQuotaLimitError';
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export class ExistingSubmissionError extends Error {
|
| 44 |
+
constructor(message = 'Existing submission is already visible; resubmit mode is required') {
|
| 45 |
+
super(message);
|
| 46 |
+
this.name = 'ExistingSubmissionError';
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// ---------------------------------------------------------------------------
|
| 51 |
+
// Submission-card check
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
export async function hasSubmissionCard(scope: Scope): Promise<boolean> {
|
| 55 |
+
const cardSelector = [
|
| 56 |
+
SELECTORS.similarity.viewSubmissionButton,
|
| 57 |
+
SELECTORS.similarity.similarityDisplay,
|
| 58 |
+
SELECTORS.resubmit.resubmitButton,
|
| 59 |
+
'a:has-text("Resubmit"), [aria-label*="Resubmit"], [data-px*="Resubmit"]',
|
| 60 |
+
'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
|
| 61 |
+
].join(', ');
|
| 62 |
+
return scope
|
| 63 |
+
.locator(cardSelector)
|
| 64 |
+
.first()
|
| 65 |
+
.isVisible({ timeout: 2000 })
|
| 66 |
+
.catch(() => false);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ---------------------------------------------------------------------------
|
| 70 |
+
// File chooser control
|
| 71 |
+
// ---------------------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
async function setDirectFileInput(scope: Scope, filePath: string): Promise<boolean> {
|
| 74 |
+
const input = scope.locator(SELECTORS.upload.fileInput).first();
|
| 75 |
+
if ((await input.count().catch(() => 0)) === 0) return false;
|
| 76 |
+
await input.setInputFiles(filePath);
|
| 77 |
+
return true;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
async function clickUploadControlByText(
|
| 81 |
+
scope: Scope,
|
| 82 |
+
labels: string[],
|
| 83 |
+
): Promise<boolean> {
|
| 84 |
+
return scope
|
| 85 |
+
.evaluate((textMatches: string[]) => {
|
| 86 |
+
const normalizedMatches = textMatches.map((value) => value.toLowerCase());
|
| 87 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 88 |
+
const seen = new Set<Element>();
|
| 89 |
+
|
| 90 |
+
for (let i = 0; i < roots.length; i++) {
|
| 91 |
+
const root = roots[i];
|
| 92 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 93 |
+
if (seen.has(el)) continue;
|
| 94 |
+
seen.add(el);
|
| 95 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const isVisible = (el: Element): boolean => {
|
| 100 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 101 |
+
const style = window.getComputedStyle(el);
|
| 102 |
+
const rect = el.getBoundingClientRect();
|
| 103 |
+
return (
|
| 104 |
+
style.visibility !== 'hidden' &&
|
| 105 |
+
style.display !== 'none' &&
|
| 106 |
+
rect.width > 0 &&
|
| 107 |
+
rect.height > 0
|
| 108 |
+
);
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const isDisabled = (el: Element): boolean =>
|
| 112 |
+
el instanceof HTMLElement &&
|
| 113 |
+
(
|
| 114 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 115 |
+
el.getAttribute('disabled') !== null ||
|
| 116 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 117 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 118 |
+
el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
const readText = (el: Element): string =>
|
| 122 |
+
[
|
| 123 |
+
(el as HTMLElement).innerText,
|
| 124 |
+
el.textContent,
|
| 125 |
+
el.getAttribute('aria-label'),
|
| 126 |
+
el.getAttribute('value'),
|
| 127 |
+
el.getAttribute('part'),
|
| 128 |
+
el.getAttribute('slot'),
|
| 129 |
+
]
|
| 130 |
+
.filter(Boolean)
|
| 131 |
+
.join(' ')
|
| 132 |
+
.replace(/\s+/g, ' ')
|
| 133 |
+
.trim();
|
| 134 |
+
|
| 135 |
+
const clickElement = (el: Element): void => {
|
| 136 |
+
const shadowButton = el.shadowRoot?.querySelector(
|
| 137 |
+
'button:not([disabled]), [role="button"]:not([disabled])',
|
| 138 |
+
);
|
| 139 |
+
const target =
|
| 140 |
+
shadowButton instanceof HTMLElement
|
| 141 |
+
? shadowButton
|
| 142 |
+
: (el as HTMLElement);
|
| 143 |
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
| 144 |
+
target.click();
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const candidates: Array<{ el: Element; score: number; top: number }> = [];
|
| 148 |
+
for (const root of roots) {
|
| 149 |
+
for (const el of Array.from(
|
| 150 |
+
root.querySelectorAll(
|
| 151 |
+
'tii-ing-upload-button, tdl-button, button, [role="button"], input[type="button"]',
|
| 152 |
+
),
|
| 153 |
+
)) {
|
| 154 |
+
if (!isVisible(el) || isDisabled(el)) continue;
|
| 155 |
+
const text = readText(el).toLowerCase();
|
| 156 |
+
const score = normalizedMatches.findIndex((match) => text.includes(match));
|
| 157 |
+
if (score < 0) continue;
|
| 158 |
+
candidates.push({
|
| 159 |
+
el,
|
| 160 |
+
score: 100 - score,
|
| 161 |
+
top: (el as HTMLElement).getBoundingClientRect().top,
|
| 162 |
+
});
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
candidates.sort((a, b) => b.score - a.score || b.top - a.top);
|
| 167 |
+
const target = candidates[0];
|
| 168 |
+
if (!target) return false;
|
| 169 |
+
clickElement(target.el);
|
| 170 |
+
return true;
|
| 171 |
+
}, labels)
|
| 172 |
+
.catch(() => false);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
async function clickUploadControl(
|
| 176 |
+
page: Page,
|
| 177 |
+
scope: Scope,
|
| 178 |
+
filePath: string,
|
| 179 |
+
): Promise<void> {
|
| 180 |
+
const browseLabels = [
|
| 181 |
+
'browse files',
|
| 182 |
+
'browse from your computer',
|
| 183 |
+
'browse',
|
| 184 |
+
];
|
| 185 |
+
const deviceLabels = [
|
| 186 |
+
'your device',
|
| 187 |
+
'upload from this device',
|
| 188 |
+
'local drive',
|
| 189 |
+
];
|
| 190 |
+
|
| 191 |
+
// New Turnitin UI: "Browse Files" opens a source menu, then "Your device"
|
| 192 |
+
// opens the native file chooser. Setting the hidden input before that menu
|
| 193 |
+
// is opened is unreliable because the upload component wires state on click.
|
| 194 |
+
const directChooserPromise = page
|
| 195 |
+
.waitForEvent('filechooser', { timeout: 7000 })
|
| 196 |
+
.catch(() => null);
|
| 197 |
+
|
| 198 |
+
const browse = scope.locator(SELECTORS.upload.browseButton).first();
|
| 199 |
+
let clickedBrowse = false;
|
| 200 |
+
if (await browse.isVisible({ timeout: 5000 }).catch(() => false)) {
|
| 201 |
+
await browse.click({ force: true });
|
| 202 |
+
clickedBrowse = true;
|
| 203 |
+
} else {
|
| 204 |
+
clickedBrowse = await clickUploadControlByText(scope, browseLabels);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (clickedBrowse) {
|
| 208 |
+
const directChooser = await directChooserPromise;
|
| 209 |
+
if (directChooser) {
|
| 210 |
+
await directChooser.setFiles(filePath);
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
await scope.waitForTimeout(1000).catch(() => {});
|
| 215 |
+
const menuChooserPromise = page
|
| 216 |
+
.waitForEvent('filechooser', { timeout: 10000 })
|
| 217 |
+
.catch(() => null);
|
| 218 |
+
const uploadFromDevice = scope
|
| 219 |
+
.locator(SELECTORS.upload.uploadFromDevice)
|
| 220 |
+
.first();
|
| 221 |
+
let clickedDevice = false;
|
| 222 |
+
if (await uploadFromDevice.isVisible({ timeout: 5000 }).catch(() => false)) {
|
| 223 |
+
await uploadFromDevice.click({ force: true });
|
| 224 |
+
clickedDevice = true;
|
| 225 |
+
} else {
|
| 226 |
+
clickedDevice = await clickUploadControlByText(scope, deviceLabels);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
if (!clickedDevice) {
|
| 230 |
+
throw new Error('Upload source menu opened, but the Your device option was not found');
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const menuChooser = await menuChooserPromise;
|
| 234 |
+
if (!menuChooser) {
|
| 235 |
+
throw new Error('File chooser did not open from Browse Files control');
|
| 236 |
+
}
|
| 237 |
+
await menuChooser.setFiles(filePath);
|
| 238 |
+
return;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
if (await setDirectFileInput(scope, filePath)) return;
|
| 242 |
+
|
| 243 |
+
throw new Error('Browse Files control was not found in the upload form');
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
async function clickInitialSubmitButton(scope: Scope): Promise<boolean> {
|
| 247 |
+
const candidates = scope.locator(
|
| 248 |
+
'tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]',
|
| 249 |
+
);
|
| 250 |
+
const count = Math.min(await candidates.count().catch(() => 0), 40);
|
| 251 |
+
|
| 252 |
+
for (let i = 0; i < count; i++) {
|
| 253 |
+
const candidate = candidates.nth(i);
|
| 254 |
+
if (!(await candidate.isVisible({ timeout: 500 }).catch(() => false))) {
|
| 255 |
+
continue;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const meta = await candidate
|
| 259 |
+
.evaluate((el: Element) => {
|
| 260 |
+
const text = [
|
| 261 |
+
(el as HTMLElement).innerText,
|
| 262 |
+
el.textContent,
|
| 263 |
+
el.getAttribute('aria-label'),
|
| 264 |
+
el.getAttribute('value'),
|
| 265 |
+
el.getAttribute('part'),
|
| 266 |
+
el.getAttribute('slot'),
|
| 267 |
+
]
|
| 268 |
+
.filter(Boolean)
|
| 269 |
+
.join(' ')
|
| 270 |
+
.replace(/\s+/g, ' ')
|
| 271 |
+
.trim();
|
| 272 |
+
const disabled =
|
| 273 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 274 |
+
el.getAttribute('disabled') !== null ||
|
| 275 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 276 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 277 |
+
el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null;
|
| 278 |
+
return { text, disabled };
|
| 279 |
+
})
|
| 280 |
+
.catch(() => null);
|
| 281 |
+
|
| 282 |
+
if (!meta || meta.disabled) continue;
|
| 283 |
+
const normalized = meta.text.toLowerCase();
|
| 284 |
+
if (!/(^|\s)submit(\s|$)/i.test(meta.text)) continue;
|
| 285 |
+
if (
|
| 286 |
+
normalized.includes('resubmit') ||
|
| 287 |
+
normalized.includes('submit file') ||
|
| 288 |
+
normalized.includes('upload and preview') ||
|
| 289 |
+
normalized.includes('setting info') ||
|
| 290 |
+
normalized.includes('collapse details')
|
| 291 |
+
) {
|
| 292 |
+
continue;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
await candidate.click({ force: true });
|
| 296 |
+
return true;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
return scope
|
| 300 |
+
.evaluate(() => {
|
| 301 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 302 |
+
const seen = new Set<Element>();
|
| 303 |
+
|
| 304 |
+
for (let i = 0; i < roots.length; i++) {
|
| 305 |
+
const root = roots[i];
|
| 306 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 307 |
+
if (seen.has(el)) continue;
|
| 308 |
+
seen.add(el);
|
| 309 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
const isVisible = (el: Element): boolean => {
|
| 314 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 315 |
+
const style = window.getComputedStyle(el);
|
| 316 |
+
const rect = el.getBoundingClientRect();
|
| 317 |
+
return (
|
| 318 |
+
style.visibility !== 'hidden' &&
|
| 319 |
+
style.display !== 'none' &&
|
| 320 |
+
rect.width > 0 &&
|
| 321 |
+
rect.height > 0
|
| 322 |
+
);
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
const isDisabled = (el: Element): boolean => {
|
| 326 |
+
if (!(el instanceof HTMLElement)) return true;
|
| 327 |
+
return (
|
| 328 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 329 |
+
el.getAttribute('disabled') !== null ||
|
| 330 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 331 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 332 |
+
el.closest('[disabled], [aria-disabled="true"], [with-disabled="true"]') !== null
|
| 333 |
+
);
|
| 334 |
+
};
|
| 335 |
+
|
| 336 |
+
const readText = (el: Element): string =>
|
| 337 |
+
[
|
| 338 |
+
(el as HTMLElement).innerText,
|
| 339 |
+
el.textContent,
|
| 340 |
+
el.getAttribute('aria-label'),
|
| 341 |
+
el.getAttribute('value'),
|
| 342 |
+
el.getAttribute('part'),
|
| 343 |
+
el.getAttribute('slot'),
|
| 344 |
+
]
|
| 345 |
+
.filter(Boolean)
|
| 346 |
+
.join(' ')
|
| 347 |
+
.replace(/\s+/g, ' ')
|
| 348 |
+
.trim();
|
| 349 |
+
|
| 350 |
+
const hasAncestorSignal = (el: Element): boolean => {
|
| 351 |
+
let current: Element | null = el;
|
| 352 |
+
const visited = new Set<Element>();
|
| 353 |
+
while (current && !visited.has(current)) {
|
| 354 |
+
visited.add(current);
|
| 355 |
+
const className = current.getAttribute('class') || '';
|
| 356 |
+
const slot = current.getAttribute('slot') || '';
|
| 357 |
+
const tag = current.tagName.toLowerCase();
|
| 358 |
+
if (
|
| 359 |
+
slot === 'submission-action' ||
|
| 360 |
+
className.includes('submission-action') ||
|
| 361 |
+
className.includes('submission-slot-container') ||
|
| 362 |
+
tag === 'tii-workflow-student-summary-panel-new' ||
|
| 363 |
+
tag === 'tii-workflow-lfw-student-show-assignment'
|
| 364 |
+
) {
|
| 365 |
+
return true;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
const parent: HTMLElement | null = current.parentElement;
|
| 369 |
+
if (parent) {
|
| 370 |
+
current = parent;
|
| 371 |
+
continue;
|
| 372 |
+
}
|
| 373 |
+
const root = current.getRootNode();
|
| 374 |
+
current = root instanceof ShadowRoot ? root.host : null;
|
| 375 |
+
}
|
| 376 |
+
return false;
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
+
const clickElement = (el: Element): void => {
|
| 380 |
+
const shadowButton = el.shadowRoot?.querySelector(
|
| 381 |
+
'button:not([disabled]), [role="button"]:not([disabled])',
|
| 382 |
+
);
|
| 383 |
+
const target =
|
| 384 |
+
shadowButton instanceof HTMLElement
|
| 385 |
+
? shadowButton
|
| 386 |
+
: (el as HTMLElement);
|
| 387 |
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
| 388 |
+
target.click();
|
| 389 |
+
};
|
| 390 |
+
|
| 391 |
+
const candidates: Array<{ el: Element; score: number; top: number; text: string }> = [];
|
| 392 |
+
for (const root of roots) {
|
| 393 |
+
for (const el of Array.from(
|
| 394 |
+
root.querySelectorAll('tii-grn-button, button, tdl-button, [role="button"], input[type="submit"]'),
|
| 395 |
+
)) {
|
| 396 |
+
if (!isVisible(el) || isDisabled(el)) continue;
|
| 397 |
+
const text = readText(el);
|
| 398 |
+
const normalized = text.toLowerCase();
|
| 399 |
+
|
| 400 |
+
if (!/(^|\s)submit(\s|$)/i.test(text)) continue;
|
| 401 |
+
if (
|
| 402 |
+
normalized.includes('resubmit') ||
|
| 403 |
+
normalized.includes('collapse details') ||
|
| 404 |
+
normalized.includes('setting info') ||
|
| 405 |
+
normalized.includes('submit file') ||
|
| 406 |
+
normalized.includes('upload and preview')
|
| 407 |
+
) {
|
| 408 |
+
continue;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
candidates.push({
|
| 412 |
+
el,
|
| 413 |
+
text,
|
| 414 |
+
score: hasAncestorSignal(el) ? 100 : 50,
|
| 415 |
+
top: (el as HTMLElement).getBoundingClientRect().top,
|
| 416 |
+
});
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
candidates.sort((a, b) => b.score - a.score || b.top - a.top);
|
| 421 |
+
const target = candidates[0];
|
| 422 |
+
if (!target) return false;
|
| 423 |
+
clickElement(target.el);
|
| 424 |
+
return true;
|
| 425 |
+
})
|
| 426 |
+
.catch(() => false);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// ---------------------------------------------------------------------------
|
| 430 |
+
// Submit button – tries labelled buttons then deep shadow DOM scan
|
| 431 |
+
// ---------------------------------------------------------------------------
|
| 432 |
+
|
| 433 |
+
async function collectUploadActionDiagnostics(
|
| 434 |
+
scope: Scope,
|
| 435 |
+
): Promise<Array<Record<string, string | boolean>>> {
|
| 436 |
+
return scope
|
| 437 |
+
.evaluate((selector: string) => {
|
| 438 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 439 |
+
const seen = new Set<Element>();
|
| 440 |
+
|
| 441 |
+
for (let i = 0; i < roots.length; i++) {
|
| 442 |
+
const root = roots[i];
|
| 443 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 444 |
+
if (seen.has(el)) continue;
|
| 445 |
+
seen.add(el);
|
| 446 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
const isVisible = (el: Element): boolean => {
|
| 451 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 452 |
+
const style = window.getComputedStyle(el);
|
| 453 |
+
const rect = el.getBoundingClientRect();
|
| 454 |
+
return (
|
| 455 |
+
style.visibility !== 'hidden' &&
|
| 456 |
+
style.display !== 'none' &&
|
| 457 |
+
rect.width > 0 &&
|
| 458 |
+
rect.height > 0
|
| 459 |
+
);
|
| 460 |
+
};
|
| 461 |
+
|
| 462 |
+
const isDisabled = (el: Element): boolean => {
|
| 463 |
+
if (!(el instanceof HTMLElement)) return true;
|
| 464 |
+
return (
|
| 465 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 466 |
+
el.getAttribute('disabled') !== null ||
|
| 467 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 468 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 469 |
+
el.closest(
|
| 470 |
+
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
|
| 471 |
+
) !== null
|
| 472 |
+
);
|
| 473 |
+
};
|
| 474 |
+
|
| 475 |
+
const rows: Array<Record<string, string | boolean>> = [];
|
| 476 |
+
for (const root of roots) {
|
| 477 |
+
for (const el of Array.from(root.querySelectorAll(selector))) {
|
| 478 |
+
if (!isVisible(el)) continue;
|
| 479 |
+
rows.push({
|
| 480 |
+
tag: el.tagName.toLowerCase(),
|
| 481 |
+
text: [
|
| 482 |
+
(el as HTMLElement).innerText,
|
| 483 |
+
el.textContent,
|
| 484 |
+
el.getAttribute('aria-label'),
|
| 485 |
+
el.getAttribute('value'),
|
| 486 |
+
el.getAttribute('part'),
|
| 487 |
+
el.getAttribute('slot'),
|
| 488 |
+
el.getAttribute('data-px'),
|
| 489 |
+
el.getAttribute('with-data-px'),
|
| 490 |
+
el.getAttribute('with-px-label'),
|
| 491 |
+
]
|
| 492 |
+
.filter(Boolean)
|
| 493 |
+
.join(' ')
|
| 494 |
+
.replace(/\s+/g, ' ')
|
| 495 |
+
.trim()
|
| 496 |
+
.slice(0, 180),
|
| 497 |
+
disabled: isDisabled(el),
|
| 498 |
+
});
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
return rows.slice(0, 20);
|
| 502 |
+
}, UPLOAD_ACTION_SELECTOR)
|
| 503 |
+
.catch(() => []);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
async function clickVisibleUploadActionByLocator(scope: Scope): Promise<string | null> {
|
| 507 |
+
const selectors = [
|
| 508 |
+
{ selector: 'tdl-button:has-text("Upload and Preview")', phase: 'preview' },
|
| 509 |
+
{ selector: 'button:has-text("Upload and Preview")', phase: 'preview' },
|
| 510 |
+
{ selector: 'tdl-button[part="submit-button"]', phase: 'preview' },
|
| 511 |
+
{ selector: 'tdl-button:has-text("Confirm and Submit")', phase: 'final' },
|
| 512 |
+
{ selector: 'button:has-text("Confirm and Submit")', phase: 'final' },
|
| 513 |
+
{ selector: 'tdl-button:has-text("Submit File")', phase: 'final' },
|
| 514 |
+
{ selector: 'button:has-text("Submit File")', phase: 'final' },
|
| 515 |
+
{ selector: 'tdl-button:has-text("Submit")', phase: 'final' },
|
| 516 |
+
{ selector: 'button:has-text("Submit")', phase: 'final' },
|
| 517 |
+
{ selector: 'tii-grn-button:has-text("Submit")', phase: 'final' },
|
| 518 |
+
{ selector: '[slot="accept-button"]:has-text("Submit")', phase: 'final' },
|
| 519 |
+
];
|
| 520 |
+
|
| 521 |
+
for (const { selector, phase } of selectors) {
|
| 522 |
+
const locator = scope.locator(selector);
|
| 523 |
+
const count = Math.min(await locator.count().catch(() => 0), 8);
|
| 524 |
+
for (let index = 0; index < count; index++) {
|
| 525 |
+
const target = locator.nth(index);
|
| 526 |
+
if (!(await target.isVisible({ timeout: 500 }).catch(() => false))) {
|
| 527 |
+
continue;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
const meta = await target
|
| 531 |
+
.evaluate((el: Element) => {
|
| 532 |
+
const text = [
|
| 533 |
+
(el as HTMLElement).innerText,
|
| 534 |
+
el.textContent,
|
| 535 |
+
el.getAttribute('aria-label'),
|
| 536 |
+
el.getAttribute('part'),
|
| 537 |
+
el.getAttribute('slot'),
|
| 538 |
+
]
|
| 539 |
+
.filter(Boolean)
|
| 540 |
+
.join(' ')
|
| 541 |
+
.replace(/\s+/g, ' ')
|
| 542 |
+
.trim();
|
| 543 |
+
const disabled =
|
| 544 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 545 |
+
el.getAttribute('disabled') !== null ||
|
| 546 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 547 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 548 |
+
el.closest(
|
| 549 |
+
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
|
| 550 |
+
) !== null;
|
| 551 |
+
return { text, disabled };
|
| 552 |
+
})
|
| 553 |
+
.catch(() => null);
|
| 554 |
+
|
| 555 |
+
if (!meta || meta.disabled) continue;
|
| 556 |
+
const normalized = meta.text.toLowerCase();
|
| 557 |
+
if (
|
| 558 |
+
normalized.includes('cancel') ||
|
| 559 |
+
normalized.includes('close') ||
|
| 560 |
+
normalized.includes('help') ||
|
| 561 |
+
normalized.includes('collapse') ||
|
| 562 |
+
normalized.includes('setting info') ||
|
| 563 |
+
normalized.includes('assignment details') ||
|
| 564 |
+
normalized.includes('view submission')
|
| 565 |
+
) {
|
| 566 |
+
continue;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
await target.scrollIntoViewIfNeeded().catch(() => {});
|
| 570 |
+
await target.click({ force: true, timeout: 5000 }).catch(async () => {
|
| 571 |
+
const box = await target.boundingBox().catch(() => null);
|
| 572 |
+
if (!box) throw new Error(`Upload action click failed for ${selector}`);
|
| 573 |
+
const ownerPage =
|
| 574 |
+
typeof (scope as any).page === 'function'
|
| 575 |
+
? (scope as Frame).page()
|
| 576 |
+
: (scope as Page);
|
| 577 |
+
await ownerPage.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
| 578 |
+
});
|
| 579 |
+
return `${phase}:${meta.text || selector}`;
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
return null;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
async function clickVisibleSubmit(
|
| 587 |
+
scope: Scope,
|
| 588 |
+
page: Page,
|
| 589 |
+
): Promise<boolean> {
|
| 590 |
+
const deadline = Date.now() + 180000;
|
| 591 |
+
while (Date.now() < deadline) {
|
| 592 |
+
// Accept quota warnings that may appear at each iteration
|
| 593 |
+
await confirmQuotaWarning(page);
|
| 594 |
+
|
| 595 |
+
const visibleText = await scopeHasVisibleDeepText(scope, [
|
| 596 |
+
'we are creating a preview',
|
| 597 |
+
'please wait for us to process a preview',
|
| 598 |
+
]);
|
| 599 |
+
if (visibleText) {
|
| 600 |
+
await scope.waitForTimeout(2000).catch(() => {});
|
| 601 |
+
continue;
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
const locatorClicked = await clickVisibleUploadActionByLocator(scope).catch(() => null);
|
| 605 |
+
if (locatorClicked) {
|
| 606 |
+
await scope.waitForTimeout(2500).catch(() => {});
|
| 607 |
+
if (/^(preview|continue):/i.test(locatorClicked)) {
|
| 608 |
+
await scope.waitForTimeout(1000).catch(() => {});
|
| 609 |
+
} else if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) {
|
| 610 |
+
return true;
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
const clicked = await scope
|
| 615 |
+
.evaluate((selector: string) => {
|
| 616 |
+
const roots: (Document | ShadowRoot)[] = [document];
|
| 617 |
+
const seen = new Set<Element>();
|
| 618 |
+
for (let i = 0; i < roots.length; i++) {
|
| 619 |
+
const root = roots[i];
|
| 620 |
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
| 621 |
+
if (seen.has(el)) continue;
|
| 622 |
+
seen.add(el);
|
| 623 |
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const isVisible = (el: Element): boolean => {
|
| 628 |
+
if (!(el instanceof HTMLElement)) return false;
|
| 629 |
+
const style = window.getComputedStyle(el);
|
| 630 |
+
const rect = el.getBoundingClientRect();
|
| 631 |
+
return (
|
| 632 |
+
style.visibility !== 'hidden' &&
|
| 633 |
+
style.display !== 'none' &&
|
| 634 |
+
rect.width > 0 &&
|
| 635 |
+
rect.height > 0
|
| 636 |
+
);
|
| 637 |
+
};
|
| 638 |
+
|
| 639 |
+
const isDisabled = (el: Element): boolean => {
|
| 640 |
+
if (!(el instanceof HTMLElement)) return true;
|
| 641 |
+
return (
|
| 642 |
+
(el as HTMLButtonElement).disabled === true ||
|
| 643 |
+
el.getAttribute('disabled') !== null ||
|
| 644 |
+
el.getAttribute('aria-disabled') === 'true' ||
|
| 645 |
+
el.getAttribute('with-disabled') === 'true' ||
|
| 646 |
+
el.closest(
|
| 647 |
+
'[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled',
|
| 648 |
+
) !== null
|
| 649 |
+
);
|
| 650 |
+
};
|
| 651 |
+
|
| 652 |
+
const clickElement = (el: Element): void => {
|
| 653 |
+
const shadowButton = el.shadowRoot?.querySelector(
|
| 654 |
+
'button:not([disabled]), input[type="submit"]:not([disabled])',
|
| 655 |
+
);
|
| 656 |
+
const target =
|
| 657 |
+
shadowButton instanceof HTMLElement
|
| 658 |
+
? shadowButton
|
| 659 |
+
: (el as HTMLElement);
|
| 660 |
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
| 661 |
+
target.click();
|
| 662 |
+
};
|
| 663 |
+
|
| 664 |
+
const readText = (el: Element): string =>
|
| 665 |
+
[
|
| 666 |
+
(el as HTMLElement).innerText,
|
| 667 |
+
el.textContent,
|
| 668 |
+
el.getAttribute('aria-label'),
|
| 669 |
+
el.getAttribute('value'),
|
| 670 |
+
el.getAttribute('part'),
|
| 671 |
+
el.getAttribute('slot'),
|
| 672 |
+
el.getAttribute('data-px'),
|
| 673 |
+
el.getAttribute('with-data-px'),
|
| 674 |
+
el.getAttribute('with-px-label'),
|
| 675 |
+
]
|
| 676 |
+
.filter(Boolean)
|
| 677 |
+
.join(' ')
|
| 678 |
+
.replace(/\s+/g, ' ')
|
| 679 |
+
.trim();
|
| 680 |
+
|
| 681 |
+
const priority = (text: string): { score: number; phase: string } => {
|
| 682 |
+
const normalized = text.toLowerCase();
|
| 683 |
+
if (
|
| 684 |
+
normalized.includes('resubmit') ||
|
| 685 |
+
normalized.includes('cancel') ||
|
| 686 |
+
normalized.includes('close') ||
|
| 687 |
+
normalized.includes('expand') ||
|
| 688 |
+
normalized.includes('help') ||
|
| 689 |
+
normalized.includes('setting info') ||
|
| 690 |
+
normalized.includes('view submission') ||
|
| 691 |
+
normalized.includes('assignment details')
|
| 692 |
+
) {
|
| 693 |
+
return { score: 0, phase: '' };
|
| 694 |
+
}
|
| 695 |
+
if (normalized.includes('continue to file upload')) {
|
| 696 |
+
return { score: 120, phase: 'continue' };
|
| 697 |
+
}
|
| 698 |
+
if (normalized.includes('upload and preview')) {
|
| 699 |
+
return { score: 110, phase: 'preview' };
|
| 700 |
+
}
|
| 701 |
+
if (normalized.includes('confirm and submit')) {
|
| 702 |
+
return { score: 100, phase: 'final' };
|
| 703 |
+
}
|
| 704 |
+
if (/(^|\s)submit file(\s|$)/i.test(normalized)) {
|
| 705 |
+
return { score: 95, phase: 'final' };
|
| 706 |
+
}
|
| 707 |
+
if (/(^|\s)submit(\s|$)/i.test(normalized)) {
|
| 708 |
+
return { score: 90, phase: 'final' };
|
| 709 |
+
}
|
| 710 |
+
if (/(^|\s)confirm(\s|$)/i.test(normalized)) {
|
| 711 |
+
return { score: 80, phase: 'final' };
|
| 712 |
+
}
|
| 713 |
+
if (/(^|\s)upload(\s|$)/i.test(normalized)) {
|
| 714 |
+
return { score: 70, phase: 'preview' };
|
| 715 |
+
}
|
| 716 |
+
if (/(^|\s)continue(\s|$)/i.test(normalized)) {
|
| 717 |
+
return { score: 60, phase: 'continue' };
|
| 718 |
+
}
|
| 719 |
+
if (normalized.includes('accept-button')) {
|
| 720 |
+
return { score: 50, phase: 'continue' };
|
| 721 |
+
}
|
| 722 |
+
return { score: 0, phase: '' };
|
| 723 |
+
};
|
| 724 |
+
|
| 725 |
+
const candidates: Array<{
|
| 726 |
+
el: Element;
|
| 727 |
+
text: string;
|
| 728 |
+
score: number;
|
| 729 |
+
phase: string;
|
| 730 |
+
top: number;
|
| 731 |
+
}> = [];
|
| 732 |
+
for (const root of roots) {
|
| 733 |
+
for (const el of Array.from(root.querySelectorAll(selector))) {
|
| 734 |
+
if (!isVisible(el) || isDisabled(el)) continue;
|
| 735 |
+
const text = readText(el);
|
| 736 |
+
const { score, phase } = priority(text);
|
| 737 |
+
if (score > 0) {
|
| 738 |
+
candidates.push({
|
| 739 |
+
el,
|
| 740 |
+
text,
|
| 741 |
+
score,
|
| 742 |
+
phase,
|
| 743 |
+
top: (el as HTMLElement).getBoundingClientRect().top,
|
| 744 |
+
});
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
candidates.sort((a, b) => b.score - a.score || b.top - a.top);
|
| 750 |
+
const target = candidates[0];
|
| 751 |
+
if (!target) return '';
|
| 752 |
+
clickElement(target.el);
|
| 753 |
+
return `${target.phase}:${target.text}`;
|
| 754 |
+
}, UPLOAD_ACTION_SELECTOR)
|
| 755 |
+
.catch(() => '');
|
| 756 |
+
|
| 757 |
+
if (clicked) {
|
| 758 |
+
await scope.waitForTimeout(2500).catch(() => {});
|
| 759 |
+
if (/^(preview|continue):/i.test(clicked)) {
|
| 760 |
+
await scope.waitForTimeout(1000).catch(() => {});
|
| 761 |
+
} else if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) {
|
| 762 |
+
return true;
|
| 763 |
+
}
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
// Handle "Preview Unavailable" state
|
| 767 |
+
if (await scopeHasVisibleDeepText(scope, ['preview unavailable'])) {
|
| 768 |
+
const ownerPage =
|
| 769 |
+
typeof (scope as any).page === 'function'
|
| 770 |
+
? (scope as Frame).page()
|
| 771 |
+
: (scope as Page);
|
| 772 |
+
await ownerPage.mouse.click(890, 670).catch(() => {});
|
| 773 |
+
await scope.waitForTimeout(3500).catch(() => {});
|
| 774 |
+
if (!(await scopeHasVisibleDeepText(scope, ['submit file', 'upload and preview']))) return true;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
await scope.waitForTimeout(1000).catch(() => {});
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
return false;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
// ---------------------------------------------------------------------------
|
| 784 |
+
// Public: uploadFile
|
| 785 |
+
// ---------------------------------------------------------------------------
|
| 786 |
+
|
| 787 |
+
/**
|
| 788 |
+
* Upload a file to an assignment that has no prior submission.
|
| 789 |
+
*
|
| 790 |
+
* Steps:
|
| 791 |
+
* 1. Assert quota is available
|
| 792 |
+
* 2. Find browse/upload form scope
|
| 793 |
+
* 3. Click Browse Files / set file via file chooser
|
| 794 |
+
* 4. Click Upload and Preview
|
| 795 |
+
* 5. Handle Preview Unavailable
|
| 796 |
+
* 6. Click Submit
|
| 797 |
+
* 7. Wait for success toast and submission card
|
| 798 |
+
*/
|
| 799 |
+
export async function uploadFile(
|
| 800 |
+
page: Page,
|
| 801 |
+
filePath: string,
|
| 802 |
+
options: { skipExistingCheck?: boolean } = {},
|
| 803 |
+
): Promise<void> {
|
| 804 |
+
// Check for existing submission
|
| 805 |
+
if (!options.skipExistingCheck) {
|
| 806 |
+
const existingScope = await findAssignmentScope(
|
| 807 |
+
page,
|
| 808 |
+
[
|
| 809 |
+
'button.link-button[aria-label*="View submission"]',
|
| 810 |
+
'a[aria-label*="View submission"]',
|
| 811 |
+
'button:has-text("Resubmit")',
|
| 812 |
+
'tdl-button:has-text("Resubmit")',
|
| 813 |
+
'span[part="tii-grn-badge-label"]:has-text("Similarity:")',
|
| 814 |
+
].join(', '),
|
| 815 |
+
1000,
|
| 816 |
+
);
|
| 817 |
+
if (existingScope && (await hasSubmissionCard(existingScope))) {
|
| 818 |
+
throw new ExistingSubmissionError();
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
// Assert quota is available
|
| 823 |
+
const quotaLimit = await detectQuotaLimit(page);
|
| 824 |
+
if (quotaLimit) {
|
| 825 |
+
throw new SubmissionQuotaLimitError(quotaLimit.message);
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
let workflowScope: Scope = page;
|
| 829 |
+
// Check if we need to click the initial "Submit" button to open the upload form
|
| 830 |
+
const isUploadFormVisible = await page
|
| 831 |
+
.locator(SELECTORS.upload.browseButton + ', ' + SELECTORS.upload.fileInput + ', ' + SELECTORS.upload.uploadStepContainer)
|
| 832 |
+
.first()
|
| 833 |
+
.isVisible({ timeout: 2000 })
|
| 834 |
+
.catch(() => false);
|
| 835 |
+
|
| 836 |
+
if (!isUploadFormVisible) {
|
| 837 |
+
logger.info('Upload form not visible. Searching for initial Submit button...');
|
| 838 |
+
const initialSubmitSelectors = [
|
| 839 |
+
'tii-workflow-lfw-student-show-assignment tii-workflow-student-summary-panel-new [slot="submission-action"] tii-grn-button:has-text("Submit")',
|
| 840 |
+
'tii-workflow-lfw-student-show-assignment .submission-action tii-grn-button:has-text("Submit")',
|
| 841 |
+
'tii-workflow-student-summary-panel-new tii-grn-button:has-text("Submit")',
|
| 842 |
+
'div[slot="submission-action"].submission-action tii-grn-button:has-text("Submit")',
|
| 843 |
+
'.submission-slot-container tii-grn-button:has-text("Submit")',
|
| 844 |
+
'tii-workflow-student-summary-panel-new tii-grn-button',
|
| 845 |
+
'tii-grn-button:has-text("Submit")',
|
| 846 |
+
'button:has-text("Submit")',
|
| 847 |
+
'tdl-button:has-text("Submit")',
|
| 848 |
+
];
|
| 849 |
+
|
| 850 |
+
let clickedInitialSubmit = false;
|
| 851 |
+
const initialSubmitScope =
|
| 852 |
+
(await findAssignmentScope(page, initialSubmitSelectors.join(', '), 3000)) ||
|
| 853 |
+
(await findScopeByDeepText(
|
| 854 |
+
page,
|
| 855 |
+
['submission settings', 'resubmissions are allowed', 'submit'],
|
| 856 |
+
3000,
|
| 857 |
+
)) ||
|
| 858 |
+
page;
|
| 859 |
+
workflowScope = initialSubmitScope;
|
| 860 |
+
|
| 861 |
+
for (const selector of initialSubmitSelectors) {
|
| 862 |
+
const btn = initialSubmitScope.locator(selector).first();
|
| 863 |
+
if (await btn.isVisible({ timeout: 1500 }).catch(() => false)) {
|
| 864 |
+
logger.info(`Clicking initial Submit button: ${selector}`);
|
| 865 |
+
await btn.click({ force: true });
|
| 866 |
+
clickedInitialSubmit = true;
|
| 867 |
+
await page.waitForTimeout(3000);
|
| 868 |
+
break;
|
| 869 |
+
}
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
if (!clickedInitialSubmit) {
|
| 873 |
+
const clickedByDom = await clickInitialSubmitButton(initialSubmitScope);
|
| 874 |
+
|
| 875 |
+
if (clickedByDom) {
|
| 876 |
+
logger.info('Initial Submit button clicked via deep shadow DOM fallback');
|
| 877 |
+
await page.waitForTimeout(3000);
|
| 878 |
+
} else {
|
| 879 |
+
logger.warn('Initial Submit button not found or could not be clicked. Proceeding to find upload form anyway...');
|
| 880 |
+
}
|
| 881 |
+
}
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
// Find upload form
|
| 885 |
+
// New UI: div.upload-step or tii-ing-dropzone
|
| 886 |
+
// Old UI: tdl-button[part="upload-form-container-button"] or input[type="file"]
|
| 887 |
+
let scope: Scope | null =
|
| 888 |
+
(await findAssignmentScope(
|
| 889 |
+
page,
|
| 890 |
+
SELECTORS.upload.browseButton + ', ' + SELECTORS.upload.fileInput + ', ' + SELECTORS.upload.uploadStepContainer,
|
| 891 |
+
5000,
|
| 892 |
+
)) ||
|
| 893 |
+
(await findScopeByDeepText(
|
| 894 |
+
page,
|
| 895 |
+
['browse files', 'drag and drop file', 'drag and drop your file', 'your work'],
|
| 896 |
+
30000,
|
| 897 |
+
));
|
| 898 |
+
|
| 899 |
+
if (!scope) {
|
| 900 |
+
logger.warn('Upload form scope was not resolved; trying workflow scope upload controls');
|
| 901 |
+
scope = workflowScope;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
logger.info('Uploading file', { filePath });
|
| 905 |
+
await clickUploadControl(page, scope, filePath);
|
| 906 |
+
await confirmQuotaWarning(page);
|
| 907 |
+
await page.waitForTimeout(4000);
|
| 908 |
+
|
| 909 |
+
// Re-resolve scope for submit button
|
| 910 |
+
scope =
|
| 911 |
+
(await findAssignmentScope(page, SELECTORS.upload.submitButton, 5000)) ||
|
| 912 |
+
(await findScopeByDeepText(
|
| 913 |
+
page,
|
| 914 |
+
['upload and preview', 'submit file', 'preview unavailable'],
|
| 915 |
+
5000,
|
| 916 |
+
)) ||
|
| 917 |
+
scope;
|
| 918 |
+
|
| 919 |
+
const clickedSubmit = await clickVisibleSubmit(scope, page);
|
| 920 |
+
if (!clickedSubmit) {
|
| 921 |
+
logger.warn('Upload action candidates after file selection', {
|
| 922 |
+
candidates: await collectUploadActionDiagnostics(scope),
|
| 923 |
+
});
|
| 924 |
+
throw new Error(
|
| 925 |
+
'Upload file was selected, but no Upload/Confirm/Submit button was found',
|
| 926 |
+
);
|
| 927 |
+
}
|
| 928 |
+
|
| 929 |
+
await page.waitForTimeout(2500);
|
| 930 |
+
logger.info('File upload completed');
|
| 931 |
+
}
|
src/engine/steps/viewer-similarity.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, Frame } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import { readScopeDeepText } from '../selectors';
|
| 4 |
+
|
| 5 |
+
type Scope = Page | Frame;
|
| 6 |
+
|
| 7 |
+
// ---------------------------------------------------------------------------
|
| 8 |
+
// Parsing helpers
|
| 9 |
+
// ---------------------------------------------------------------------------
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Extract a similarity percentage from text.
|
| 13 |
+
*
|
| 14 |
+
* Handles multiple formats found across Turnitin UI versions:
|
| 15 |
+
* - "45% Overall Similarity"
|
| 16 |
+
* - "Similarity: 45%"
|
| 17 |
+
* - "Overall Similarity 45 %"
|
| 18 |
+
* - Plain "45" or "45%" (when extracted directly from a score element)
|
| 19 |
+
*/
|
| 20 |
+
function parseSimilarityPercent(text: string): number | null {
|
| 21 |
+
const normalized = String(text || '').replace(/\s+/g, ' ').trim();
|
| 22 |
+
|
| 23 |
+
// Pattern 1: "45% Overall Similarity"
|
| 24 |
+
const labelled = normalized.match(
|
| 25 |
+
/(\d{1,3})\s*%\s*Overall Similarity/i,
|
| 26 |
+
);
|
| 27 |
+
// Pattern 2: "Similarity: 45%" or "Overall Similarity: 45%"
|
| 28 |
+
const generic = normalized.match(
|
| 29 |
+
/(?:Overall\s+)?Similarity[:\s]+(\d{1,3})\s*%/i,
|
| 30 |
+
);
|
| 31 |
+
// Pattern 3: "Overall Similarity 45 %" (label before value)
|
| 32 |
+
const reversed = normalized.match(
|
| 33 |
+
/Overall Similarity\s+(\d{1,3})\s*%/i,
|
| 34 |
+
);
|
| 35 |
+
// Pattern 4: Plain number from a score element — "45" or "45%"
|
| 36 |
+
const plain = normalized.match(/^(\d{1,3})\s*%?$/);
|
| 37 |
+
|
| 38 |
+
const value = Number(
|
| 39 |
+
labelled?.[1] ?? generic?.[1] ?? reversed?.[1] ?? plain?.[1] ?? NaN,
|
| 40 |
+
);
|
| 41 |
+
if (!Number.isFinite(value)) return null;
|
| 42 |
+
return Math.max(0, Math.min(100, value));
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ---------------------------------------------------------------------------
|
| 46 |
+
// CSS selectors for the similarity score in the report viewer
|
| 47 |
+
// ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
const SCORE_SELECTORS = [
|
| 50 |
+
// Primary — Turnitin viewer overview score
|
| 51 |
+
'.tii-SimilarityHeader__heading-content .tii-OverviewScore__Value',
|
| 52 |
+
'.tii-OverviewScore__Value',
|
| 53 |
+
'span.tii-OverviewScore__Value',
|
| 54 |
+
// Heading area with live region
|
| 55 |
+
'h1[aria-live="polite"] span',
|
| 56 |
+
'h1[aria-live="polite"]',
|
| 57 |
+
// Alternative UI variants
|
| 58 |
+
'.tii-SimilarityReportPanelHeader__OverviewScore span',
|
| 59 |
+
'.similarity-score',
|
| 60 |
+
'.overall-similarity-score',
|
| 61 |
+
'[data-testid="similarity-score"]',
|
| 62 |
+
// Badge-style display
|
| 63 |
+
'span[part="tii-grn-badge-label"]',
|
| 64 |
+
];
|
| 65 |
+
|
| 66 |
+
// ---------------------------------------------------------------------------
|
| 67 |
+
// Public API
|
| 68 |
+
// ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Read the current overall similarity score from the Turnitin report viewer.
|
| 72 |
+
*
|
| 73 |
+
* Handles:
|
| 74 |
+
* - Multiple CSS selector strategies for different Turnitin UI versions
|
| 75 |
+
* - Shadow DOM deep text fallback
|
| 76 |
+
* - Automatic retry with short wait to handle post-filter score updates
|
| 77 |
+
*
|
| 78 |
+
* This is used both as a fallback for assignment pages that still show
|
| 79 |
+
* similarity as pending, and as the authoritative read after filter
|
| 80 |
+
* application (where the displayed score may differ from the pre-filter value).
|
| 81 |
+
*/
|
| 82 |
+
export async function readViewerSimilarityPercent(
|
| 83 |
+
page: Scope,
|
| 84 |
+
): Promise<number | null> {
|
| 85 |
+
// Allow up to 3 attempts with a short stabilization wait between each.
|
| 86 |
+
// After filters are applied the score element may take a moment to update.
|
| 87 |
+
const MAX_READ_ATTEMPTS = 3;
|
| 88 |
+
|
| 89 |
+
for (let attempt = 1; attempt <= MAX_READ_ATTEMPTS; attempt++) {
|
| 90 |
+
// Small wait for DOM to stabilize (especially after filter application)
|
| 91 |
+
if (attempt > 1) {
|
| 92 |
+
await page.waitForTimeout(2000);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// --- Strategy 1: CSS selector scan ---
|
| 96 |
+
for (const selector of SCORE_SELECTORS) {
|
| 97 |
+
const element = page.locator(selector).first();
|
| 98 |
+
|
| 99 |
+
const rawText = await element
|
| 100 |
+
.textContent({ timeout: 2000 })
|
| 101 |
+
.catch(() => null);
|
| 102 |
+
|
| 103 |
+
if (rawText !== null && rawText.trim() !== '') {
|
| 104 |
+
// Try parsing as-is first (may already contain "Overall Similarity")
|
| 105 |
+
let parsed = parseSimilarityPercent(rawText.trim());
|
| 106 |
+
// Fallback: append context so the labelled regex can match plain numbers
|
| 107 |
+
if (parsed === null) {
|
| 108 |
+
parsed = parseSimilarityPercent(`${rawText.trim()} Overall Similarity`);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (parsed !== null) {
|
| 112 |
+
logger.info('Read similarity score from viewer selector', {
|
| 113 |
+
selector,
|
| 114 |
+
rawText: rawText.trim(),
|
| 115 |
+
similarityPercent: parsed,
|
| 116 |
+
attempt,
|
| 117 |
+
});
|
| 118 |
+
return parsed;
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// --- Strategy 2: Deep text scan (traverses shadow DOM) ---
|
| 124 |
+
const deepText = await readScopeDeepText(page);
|
| 125 |
+
if (deepText) {
|
| 126 |
+
const parsed = parseSimilarityPercent(deepText);
|
| 127 |
+
if (parsed !== null) {
|
| 128 |
+
logger.info('Read similarity score from viewer deep text', {
|
| 129 |
+
similarityPercent: parsed,
|
| 130 |
+
attempt,
|
| 131 |
+
});
|
| 132 |
+
return parsed;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (attempt < MAX_READ_ATTEMPTS) {
|
| 137 |
+
logger.debug(`Similarity score not found on attempt ${attempt}/${MAX_READ_ATTEMPTS}; retrying`);
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
logger.warn('Could not read similarity score from viewer after all attempts');
|
| 142 |
+
return null;
|
| 143 |
+
}
|
src/engine/steps/viewer.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Page, BrowserContext, Frame, Locator } from 'playwright';
|
| 2 |
+
import { logger } from '../../utils/logger';
|
| 3 |
+
import {
|
| 4 |
+
SELECTORS,
|
| 5 |
+
deepClickByText,
|
| 6 |
+
resolveAssignmentScope,
|
| 7 |
+
} from '../selectors';
|
| 8 |
+
|
| 9 |
+
type ViewerScope = Page | Frame;
|
| 10 |
+
|
| 11 |
+
function ownerPage(scope: ViewerScope): Page {
|
| 12 |
+
return typeof (scope as any).page === 'function'
|
| 13 |
+
? (scope as Frame).page()
|
| 14 |
+
: (scope as Page);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function isReportSubmissionUrl(url: string): boolean {
|
| 18 |
+
return (
|
| 19 |
+
url.includes('reports.integrity.turnitin.com') &&
|
| 20 |
+
/submission-viewer|\/submission\//i.test(url)
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function findReportViewerScope(context: BrowserContext): ViewerScope | null {
|
| 25 |
+
for (const candidatePage of context.pages()) {
|
| 26 |
+
if (isReportSubmissionUrl(candidatePage.url())) {
|
| 27 |
+
return candidatePage;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const reportFrame = candidatePage
|
| 31 |
+
.frames()
|
| 32 |
+
.find((frame) => isReportSubmissionUrl(frame.url()));
|
| 33 |
+
if (reportFrame) {
|
| 34 |
+
return reportFrame;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return null;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Click the submission title to open the report viewer.
|
| 43 |
+
* Returns the viewer page after it has navigated to reports.integrity.turnitin.com.
|
| 44 |
+
*/
|
| 45 |
+
export async function openReportViewerPage(
|
| 46 |
+
page: Page,
|
| 47 |
+
context: BrowserContext,
|
| 48 |
+
similarityScope?: ViewerScope,
|
| 49 |
+
similarityLocator?: Locator,
|
| 50 |
+
): Promise<ViewerScope> {
|
| 51 |
+
const scope = similarityScope || (await resolveAssignmentScope(page));
|
| 52 |
+
|
| 53 |
+
const titleButton = scope
|
| 54 |
+
.locator(SELECTORS.viewer.titleButton)
|
| 55 |
+
.first();
|
| 56 |
+
|
| 57 |
+
const specificTargetVisible = similarityLocator
|
| 58 |
+
? await similarityLocator.isVisible({ timeout: 3000 }).catch(() => false)
|
| 59 |
+
: false;
|
| 60 |
+
const clickTarget = specificTargetVisible
|
| 61 |
+
? similarityLocator!
|
| 62 |
+
: (await titleButton.isVisible({ timeout: 3000 }).catch(() => false))
|
| 63 |
+
? titleButton
|
| 64 |
+
: null;
|
| 65 |
+
|
| 66 |
+
const [newPage] = await Promise.all([
|
| 67 |
+
context
|
| 68 |
+
.waitForEvent('page', { timeout: 30000 })
|
| 69 |
+
.catch(() => null),
|
| 70 |
+
clickTarget
|
| 71 |
+
? clickTarget.click({ force: true }).catch(async () => {
|
| 72 |
+
await deepClickByText(scope, ['view submission']);
|
| 73 |
+
})
|
| 74 |
+
: deepClickByText(scope, ['view submission']),
|
| 75 |
+
]);
|
| 76 |
+
|
| 77 |
+
// BUG-6 FIX: Verify the captured page is actually the report viewer before
|
| 78 |
+
// waiting for its load state. context.waitForEvent('page') can fire for any
|
| 79 |
+
// new page (ads, redirects), so we must not blindly wait 30s on a wrong page.
|
| 80 |
+
let viewerScope: ViewerScope;
|
| 81 |
+
if (newPage && isReportSubmissionUrl(newPage.url())) {
|
| 82 |
+
// Correct page — wait for it to load normally
|
| 83 |
+
viewerScope = newPage;
|
| 84 |
+
await (viewerScope as Page)
|
| 85 |
+
.waitForLoadState('domcontentloaded', { timeout: 60000 })
|
| 86 |
+
.catch(() => {});
|
| 87 |
+
} else {
|
| 88 |
+
// Wrong page or no page event — scan existing pages/frames immediately
|
| 89 |
+
if (newPage) {
|
| 90 |
+
logger.warn('waitForEvent(page) captured a non-viewer page; scanning context for viewer', {
|
| 91 |
+
capturedUrl: newPage.url(),
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
// Give Turnitin a short moment to open the real viewer page
|
| 95 |
+
const scopeOwner = typeof (scope as any).page === 'function'
|
| 96 |
+
? (scope as Frame).page()
|
| 97 |
+
: (scope as Page);
|
| 98 |
+
await scopeOwner.waitForTimeout(3000).catch(() => {});
|
| 99 |
+
const existing = findReportViewerScope(context);
|
| 100 |
+
viewerScope = existing ||
|
| 101 |
+
(typeof (scope as any).page === 'function'
|
| 102 |
+
? (scope as Frame).page()
|
| 103 |
+
: (scope as Page));
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const deadline = Date.now() + 30000;
|
| 107 |
+
while (
|
| 108 |
+
Date.now() < deadline &&
|
| 109 |
+
!isReportSubmissionUrl(viewerScope.url())
|
| 110 |
+
) {
|
| 111 |
+
const existing = findReportViewerScope(context);
|
| 112 |
+
if (existing) {
|
| 113 |
+
viewerScope = existing;
|
| 114 |
+
break;
|
| 115 |
+
}
|
| 116 |
+
await ownerPage(viewerScope).waitForTimeout(500).catch(() => {});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
await ownerPage(viewerScope).waitForTimeout(2500);
|
| 120 |
+
|
| 121 |
+
const viewerUrl = viewerScope.url();
|
| 122 |
+
logger.info('Report viewer opened', { viewerUrl });
|
| 123 |
+
|
| 124 |
+
if (!isReportSubmissionUrl(viewerUrl)) {
|
| 125 |
+
throw new Error(
|
| 126 |
+
`Report viewer did not open a submission route correctly. Current URL: ${viewerUrl}`,
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return viewerScope;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export async function openReportViewer(
|
| 134 |
+
page: Page,
|
| 135 |
+
context: BrowserContext,
|
| 136 |
+
): Promise<string> {
|
| 137 |
+
const viewerPage = await openReportViewerPage(page, context);
|
| 138 |
+
return viewerPage.url();
|
| 139 |
+
}
|
src/engine/turnitin.ts
ADDED
|
@@ -0,0 +1,1036 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type Page, type BrowserContext, type Frame } from 'playwright';
|
| 2 |
+
import * as path from 'path';
|
| 3 |
+
import * as fs from 'fs';
|
| 4 |
+
import { config } from '../config';
|
| 5 |
+
import { logger } from '../utils/logger';
|
| 6 |
+
import { getBrowser } from '../worker/browser-pool';
|
| 7 |
+
import { loginToTurnitin } from './steps/login';
|
| 8 |
+
import { navigateToAssignment } from './steps/navigate';
|
| 9 |
+
import { dropClassByTitle } from './steps/class-management';
|
| 10 |
+
import { uploadFile, SubmissionQuotaLimitError } from './steps/upload';
|
| 11 |
+
import { resubmitFile } from './steps/resubmit';
|
| 12 |
+
import {
|
| 13 |
+
detectQuotaLimit,
|
| 14 |
+
runQuotaCheck,
|
| 15 |
+
} from './steps/quota-detect';
|
| 16 |
+
import { waitForSimilarity, type SimilarityResult } from './steps/similarity';
|
| 17 |
+
import { openReportViewerPage } from './steps/viewer';
|
| 18 |
+
import { readViewerSimilarityPercent } from './steps/viewer-similarity';
|
| 19 |
+
import { readSubmissionDetails, type SubmissionDetails } from './steps/submission-details';
|
| 20 |
+
import { detectSubmissionState } from './steps/submission-state';
|
| 21 |
+
import { applyFilters, validateFilters, hasActiveFilters, type FilterOptions } from './steps/filters';
|
| 22 |
+
import { downloadPdf } from './steps/download';
|
| 23 |
+
import { runLegacyTurnitinJob } from './legacy';
|
| 24 |
+
|
| 25 |
+
// ---------------------------------------------------------------------------
|
| 26 |
+
// Types
|
| 27 |
+
// ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
export type { FilterOptions } from './steps/filters';
|
| 30 |
+
export type TurnitinUiVariant = 'modern_lti' | 'legacy_carta';
|
| 31 |
+
export const MODERN_ONE_POOL_KEY = 'modern_one';
|
| 32 |
+
|
| 33 |
+
export interface RunTurnitinJobInput {
|
| 34 |
+
account: {
|
| 35 |
+
id: string;
|
| 36 |
+
email: string;
|
| 37 |
+
password: string;
|
| 38 |
+
quotaLimit?: number | null;
|
| 39 |
+
quotaRemaining?: number | null;
|
| 40 |
+
};
|
| 41 |
+
assignmentTarget: {
|
| 42 |
+
targetUrl: string;
|
| 43 |
+
classTitle: string;
|
| 44 |
+
assignmentTitle?: string | null;
|
| 45 |
+
assignmentLaunchUrl?: string | null;
|
| 46 |
+
uiVariant?: TurnitinUiVariant;
|
| 47 |
+
accountPoolKey?: string | null;
|
| 48 |
+
};
|
| 49 |
+
inputFilePath: string;
|
| 50 |
+
inputFileName?: string;
|
| 51 |
+
inputFileSize?: number;
|
| 52 |
+
outputDir: string;
|
| 53 |
+
mode: 'upload' | 'resubmit' | 'quota_check';
|
| 54 |
+
filters: FilterOptions;
|
| 55 |
+
storageStatePath?: string;
|
| 56 |
+
/**
|
| 57 |
+
* When set, the engine will skip steps that have already been completed and
|
| 58 |
+
* resume from the step after this one. For example, if `resumeAfterStep` is
|
| 59 |
+
* `'submitted'`, the engine will skip upload/resubmit and jump straight to
|
| 60 |
+
* waiting for the similarity score.
|
| 61 |
+
*/
|
| 62 |
+
resumeAfterStep?: string;
|
| 63 |
+
/**
|
| 64 |
+
* Existing report viewer URL from a previous attempt. When the previous
|
| 65 |
+
* attempt already reached the viewer, retries reopen this URL directly
|
| 66 |
+
* instead of uploading/resubmitting the same file again.
|
| 67 |
+
*/
|
| 68 |
+
resumeViewerUrl?: string | null;
|
| 69 |
+
attemptCount?: number;
|
| 70 |
+
onEvent?: (event: {
|
| 71 |
+
level: 'info' | 'warning' | 'error';
|
| 72 |
+
step: string;
|
| 73 |
+
message: string;
|
| 74 |
+
metadata?: Record<string, unknown>;
|
| 75 |
+
}) => Promise<void>;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export interface RunTurnitinJobResult {
|
| 79 |
+
viewerUrl?: string;
|
| 80 |
+
similarityPercent?: number;
|
| 81 |
+
outputPdfPath?: string;
|
| 82 |
+
receiptPdfPath?: string;
|
| 83 |
+
submissionDetails?: SubmissionDetails;
|
| 84 |
+
quotaWarning?: string;
|
| 85 |
+
quotaLimit?: { limit: number; message: string; retryText?: string };
|
| 86 |
+
quotaCooldown?: { message: string; nextRetryAt: string; submissionCount?: number };
|
| 87 |
+
permanentLimit?: {
|
| 88 |
+
message: string;
|
| 89 |
+
submissionCount?: number;
|
| 90 |
+
dropClassResult?: {
|
| 91 |
+
attempted: boolean;
|
| 92 |
+
dropped: boolean;
|
| 93 |
+
reason?: string;
|
| 94 |
+
};
|
| 95 |
+
};
|
| 96 |
+
submittedAt?: string;
|
| 97 |
+
submissionCount?: number;
|
| 98 |
+
/** The last step that completed successfully – persisted so retries can skip. */
|
| 99 |
+
lastCompletedStep?: string;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ---------------------------------------------------------------------------
|
| 103 |
+
// EULA-blocked error (for retry logic)
|
| 104 |
+
// ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
class EulaBlockedError extends Error {
|
| 107 |
+
constructor(message = 'Assignment launch is blocked by EULA') {
|
| 108 |
+
super(message);
|
| 109 |
+
this.name = 'EulaBlockedError';
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// ---------------------------------------------------------------------------
|
| 114 |
+
// Event emitter helper
|
| 115 |
+
// ---------------------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
async function emit(
|
| 118 |
+
onEvent: RunTurnitinJobInput['onEvent'],
|
| 119 |
+
level: 'info' | 'warning' | 'error',
|
| 120 |
+
step: string,
|
| 121 |
+
message: string,
|
| 122 |
+
metadata?: Record<string, unknown>,
|
| 123 |
+
): Promise<void> {
|
| 124 |
+
if (onEvent) {
|
| 125 |
+
await onEvent({ level, step, message, metadata }).catch(() => {});
|
| 126 |
+
}
|
| 127 |
+
if (level === 'error') {
|
| 128 |
+
logger.error(message, { step, ...metadata });
|
| 129 |
+
} else if (level === 'warning') {
|
| 130 |
+
logger.warn(message, { step, ...metadata });
|
| 131 |
+
} else {
|
| 132 |
+
logger.info(message, { step, ...metadata });
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function compactEngineErrorMessage(message: string): string {
|
| 137 |
+
const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message;
|
| 138 |
+
if (/locator\.waitFor: Timeout/i.test(firstLine)) {
|
| 139 |
+
const selector = firstLine.match(/locator\('([^']+)'/i)?.[1];
|
| 140 |
+
return selector
|
| 141 |
+
? `Turnitin page element did not appear in time: ${selector}`
|
| 142 |
+
: 'Turnitin page element did not appear in time.';
|
| 143 |
+
}
|
| 144 |
+
if (/Call log:/i.test(message)) return firstLine;
|
| 145 |
+
return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ---------------------------------------------------------------------------
|
| 149 |
+
// Step ordering — used to determine whether to skip already-completed steps
|
| 150 |
+
// ---------------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
const STEP_ORDER = [
|
| 153 |
+
'login',
|
| 154 |
+
'navigate',
|
| 155 |
+
'quota_check',
|
| 156 |
+
'upload', // covers both upload and resubmit
|
| 157 |
+
'submitted',
|
| 158 |
+
'similarity',
|
| 159 |
+
'viewer',
|
| 160 |
+
'filters',
|
| 161 |
+
'download',
|
| 162 |
+
] as const;
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Returns true when `completedStep` is at or after `targetStep` in the
|
| 166 |
+
* pipeline, meaning `targetStep` can safely be skipped.
|
| 167 |
+
*/
|
| 168 |
+
function isStepCompleted(completedStep: string | undefined, targetStep: string): boolean {
|
| 169 |
+
if (!completedStep) return false;
|
| 170 |
+
const completedIdx = STEP_ORDER.indexOf(completedStep as any);
|
| 171 |
+
const targetIdx = STEP_ORDER.indexOf(targetStep as any);
|
| 172 |
+
if (completedIdx < 0 || targetIdx < 0) return false;
|
| 173 |
+
return completedIdx >= targetIdx;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
function decodeFileNameForCompare(fileName: string): string {
|
| 177 |
+
try {
|
| 178 |
+
return decodeURIComponent(fileName);
|
| 179 |
+
} catch {
|
| 180 |
+
return fileName;
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function normalizeFileNameForCompare(fileName: string | undefined): string {
|
| 185 |
+
return decodeFileNameForCompare(path.basename(String(fileName || '')))
|
| 186 |
+
.normalize('NFKC')
|
| 187 |
+
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
| 188 |
+
.replace(/\s+/g, ' ')
|
| 189 |
+
.trim()
|
| 190 |
+
.toLowerCase();
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function compactFileNameForCompare(fileName: string | undefined): string {
|
| 194 |
+
return normalizeFileNameForCompare(fileName).replace(/\s+/g, '');
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function submissionDetailsMatchInput(
|
| 198 |
+
details: SubmissionDetails | null,
|
| 199 |
+
inputFileName: string,
|
| 200 |
+
inputFileSize?: number,
|
| 201 |
+
): boolean {
|
| 202 |
+
if (!details?.fileName || typeof inputFileSize !== 'number') return false;
|
| 203 |
+
const detailName = normalizeFileNameForCompare(details.fileName);
|
| 204 |
+
const expectedName = normalizeFileNameForCompare(inputFileName);
|
| 205 |
+
const detailCompactName = compactFileNameForCompare(details.fileName);
|
| 206 |
+
const expectedCompactName = compactFileNameForCompare(inputFileName);
|
| 207 |
+
return Boolean(
|
| 208 |
+
details.fileSize === inputFileSize &&
|
| 209 |
+
detailName &&
|
| 210 |
+
expectedName &&
|
| 211 |
+
(detailName === expectedName || detailCompactName === expectedCompactName),
|
| 212 |
+
);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function getScopeOwnerPage(scope: Page | Frame): Page {
|
| 216 |
+
return typeof (scope as any).page === 'function'
|
| 217 |
+
? (scope as Frame).page()
|
| 218 |
+
: (scope as Page);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const MODERN_VIEWER_READY_SELECTOR = [
|
| 222 |
+
'tii-sws-tab-button#tab-similarity',
|
| 223 |
+
'#tab-similarity',
|
| 224 |
+
'.tii-SimilarityReportPanel[aria-hidden="false"]',
|
| 225 |
+
'.tii-OverviewScore__Value',
|
| 226 |
+
'tdl-button[with-data-px="SettingsClicked"]',
|
| 227 |
+
'.tii-SimilarityReportPanelHeader__SettingsButton',
|
| 228 |
+
'tii-sws-download-btn-mfe',
|
| 229 |
+
'tii-sws-submission-details-btn',
|
| 230 |
+
].join(', ');
|
| 231 |
+
|
| 232 |
+
async function waitForModernViewerReady(
|
| 233 |
+
viewerPage: Page,
|
| 234 |
+
viewerUrl: string,
|
| 235 |
+
timeoutMs = 75_000,
|
| 236 |
+
): Promise<boolean> {
|
| 237 |
+
const deadline = Date.now() + timeoutMs;
|
| 238 |
+
let reloads = 0;
|
| 239 |
+
|
| 240 |
+
while (Date.now() < deadline) {
|
| 241 |
+
if (
|
| 242 |
+
await viewerPage
|
| 243 |
+
.locator(MODERN_VIEWER_READY_SELECTOR)
|
| 244 |
+
.first()
|
| 245 |
+
.isVisible({ timeout: 2500 })
|
| 246 |
+
.catch(() => false)
|
| 247 |
+
) {
|
| 248 |
+
return true;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const bodyText = await viewerPage
|
| 252 |
+
.locator('body')
|
| 253 |
+
.innerText({ timeout: 1500 })
|
| 254 |
+
.catch(() => '');
|
| 255 |
+
|
| 256 |
+
if (bodyText.trim().length > 20) {
|
| 257 |
+
await viewerPage.waitForTimeout(2500).catch(() => {});
|
| 258 |
+
continue;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (reloads < 2) {
|
| 262 |
+
reloads++;
|
| 263 |
+
logger.warn('Report viewer body was empty; reloading direct viewer URL', {
|
| 264 |
+
reloads,
|
| 265 |
+
viewerUrl,
|
| 266 |
+
});
|
| 267 |
+
await viewerPage
|
| 268 |
+
.goto(viewerUrl, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
| 269 |
+
.catch(() => {});
|
| 270 |
+
await viewerPage
|
| 271 |
+
.waitForLoadState('networkidle', { timeout: 20000 })
|
| 272 |
+
.catch(() => {});
|
| 273 |
+
await viewerPage.waitForTimeout(5000).catch(() => {});
|
| 274 |
+
continue;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
await viewerPage.waitForTimeout(2500).catch(() => {});
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
return false;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// ---------------------------------------------------------------------------
|
| 284 |
+
// Main orchestrator
|
| 285 |
+
// ---------------------------------------------------------------------------
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* Run a complete Turnitin automation job.
|
| 289 |
+
*
|
| 290 |
+
* Flow:
|
| 291 |
+
* 1. Launch browser context (headless per config)
|
| 292 |
+
* 2. Restore storageState if available
|
| 293 |
+
* 3. Login
|
| 294 |
+
* 4. Navigate to assignment
|
| 295 |
+
* 5. If mode=quota_check: run quota check and return
|
| 296 |
+
* 6. Detect quota limit before upload
|
| 297 |
+
* 7. If already submitted (resume or live detection): skip to step 8
|
| 298 |
+
* Otherwise, if mode=resubmit: call resubmit, else call upload
|
| 299 |
+
* 8. Wait for similarity
|
| 300 |
+
* 9. Open viewer
|
| 301 |
+
* 10. Apply filters
|
| 302 |
+
* 11. Download PDF
|
| 303 |
+
* 12. Save storageState
|
| 304 |
+
* 13. Close context
|
| 305 |
+
* 14. Return result
|
| 306 |
+
*/
|
| 307 |
+
export async function runTurnitinJob(
|
| 308 |
+
input: RunTurnitinJobInput,
|
| 309 |
+
): Promise<RunTurnitinJobResult> {
|
| 310 |
+
if (input.assignmentTarget.uiVariant === 'legacy_carta') {
|
| 311 |
+
return runLegacyTurnitinJob(input);
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const {
|
| 315 |
+
account,
|
| 316 |
+
assignmentTarget,
|
| 317 |
+
inputFilePath,
|
| 318 |
+
inputFileName,
|
| 319 |
+
inputFileSize,
|
| 320 |
+
outputDir,
|
| 321 |
+
mode: requestedMode,
|
| 322 |
+
filters,
|
| 323 |
+
storageStatePath,
|
| 324 |
+
resumeAfterStep,
|
| 325 |
+
resumeViewerUrl,
|
| 326 |
+
attemptCount,
|
| 327 |
+
onEvent,
|
| 328 |
+
} = input;
|
| 329 |
+
|
| 330 |
+
const result: RunTurnitinJobResult = {};
|
| 331 |
+
let similarityResult: SimilarityResult | null = null;
|
| 332 |
+
let quotaClassDropAttempted = false;
|
| 333 |
+
let mode = requestedMode;
|
| 334 |
+
let viewerPage: Page | Frame | null = null;
|
| 335 |
+
|
| 336 |
+
// Track whether we should skip the upload step entirely.
|
| 337 |
+
const skipUpload = isStepCompleted(resumeAfterStep, 'submitted');
|
| 338 |
+
const resumeFromViewer = isStepCompleted(resumeAfterStep, 'viewer') && Boolean(resumeViewerUrl);
|
| 339 |
+
const expectedInputFileName = inputFileName || path.basename(inputFilePath);
|
| 340 |
+
const isModernOnePool = assignmentTarget.accountPoolKey === MODERN_ONE_POOL_KEY;
|
| 341 |
+
if (skipUpload) {
|
| 342 |
+
logger.info('Resuming after previous upload — skipping upload/resubmit step', {
|
| 343 |
+
resumeAfterStep,
|
| 344 |
+
});
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
// --- 1. Launch browser context ---
|
| 348 |
+
// NEW-BUG-1 FIX: Use the shared browser from browser-pool.ts instead of
|
| 349 |
+
// launching a new Chromium process for every job. This reduces memory from
|
| 350 |
+
// N × ~200MB to ~200MB + N × ~30MB (one context per job).
|
| 351 |
+
await emit(onEvent, 'info', 'browser', 'Creating browser context');
|
| 352 |
+
|
| 353 |
+
const browser = await getBrowser();
|
| 354 |
+
|
| 355 |
+
// --- 2. Create context (restore storageState if available) ---
|
| 356 |
+
const contextOptions: Record<string, unknown> = {
|
| 357 |
+
userAgent:
|
| 358 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
| 359 |
+
viewport: { width: 1366, height: 768 },
|
| 360 |
+
acceptDownloads: true,
|
| 361 |
+
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
|
| 362 |
+
};
|
| 363 |
+
|
| 364 |
+
if (storageStatePath && fs.existsSync(storageStatePath)) {
|
| 365 |
+
contextOptions.storageState = storageStatePath;
|
| 366 |
+
logger.info('Restoring storage state', { path: storageStatePath });
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
const context = await browser.newContext(contextOptions as any);
|
| 370 |
+
let page: Page | null = null;
|
| 371 |
+
|
| 372 |
+
const dropClassAfterQuotaLimit = async (
|
| 373 |
+
quotaMessage: string,
|
| 374 |
+
): Promise<void> => {
|
| 375 |
+
if (quotaClassDropAttempted || !page) return;
|
| 376 |
+
quotaClassDropAttempted = true;
|
| 377 |
+
|
| 378 |
+
await emit(
|
| 379 |
+
onEvent,
|
| 380 |
+
'warning',
|
| 381 |
+
'class_cleanup',
|
| 382 |
+
'Quota limit detected; dropping class from account',
|
| 383 |
+
{
|
| 384 |
+
classTitle: assignmentTarget.classTitle,
|
| 385 |
+
quotaMessage,
|
| 386 |
+
},
|
| 387 |
+
);
|
| 388 |
+
|
| 389 |
+
const dropResult = await dropClassByTitle(
|
| 390 |
+
page,
|
| 391 |
+
assignmentTarget.classTitle,
|
| 392 |
+
);
|
| 393 |
+
|
| 394 |
+
await emit(
|
| 395 |
+
onEvent,
|
| 396 |
+
dropResult.dropped ? 'info' : 'warning',
|
| 397 |
+
'class_cleanup',
|
| 398 |
+
dropResult.dropped
|
| 399 |
+
? 'Quota-limited class dropped or already absent'
|
| 400 |
+
: 'Quota-limited class could not be dropped automatically',
|
| 401 |
+
{ ...dropResult },
|
| 402 |
+
);
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
try {
|
| 406 |
+
page = await context.newPage();
|
| 407 |
+
|
| 408 |
+
// --- 3. Login ---
|
| 409 |
+
await emit(onEvent, 'info', 'login', 'Logging in to Turnitin', {
|
| 410 |
+
email: account.email,
|
| 411 |
+
});
|
| 412 |
+
await loginToTurnitin(
|
| 413 |
+
page,
|
| 414 |
+
account.email,
|
| 415 |
+
account.password,
|
| 416 |
+
storageStatePath,
|
| 417 |
+
assignmentTarget.targetUrl,
|
| 418 |
+
);
|
| 419 |
+
result.lastCompletedStep = 'login';
|
| 420 |
+
|
| 421 |
+
let assignmentLaunchUrl = ''; // initialized to avoid undefined crash
|
| 422 |
+
|
| 423 |
+
if (resumeFromViewer && resumeViewerUrl) {
|
| 424 |
+
await emit(
|
| 425 |
+
onEvent,
|
| 426 |
+
'info',
|
| 427 |
+
'viewer',
|
| 428 |
+
'Reopening report viewer from previous attempt',
|
| 429 |
+
{ viewerUrl: resumeViewerUrl, resumeAfterStep },
|
| 430 |
+
);
|
| 431 |
+
|
| 432 |
+
const resumedViewerPage = await context.newPage();
|
| 433 |
+
await resumedViewerPage.goto(resumeViewerUrl, {
|
| 434 |
+
waitUntil: 'domcontentloaded',
|
| 435 |
+
timeout: 60000,
|
| 436 |
+
});
|
| 437 |
+
await resumedViewerPage
|
| 438 |
+
.waitForLoadState('networkidle', { timeout: 30000 })
|
| 439 |
+
.catch(() => {});
|
| 440 |
+
const viewerReady = await waitForModernViewerReady(
|
| 441 |
+
resumedViewerPage,
|
| 442 |
+
resumeViewerUrl,
|
| 443 |
+
);
|
| 444 |
+
|
| 445 |
+
if (viewerReady) {
|
| 446 |
+
viewerPage = resumedViewerPage;
|
| 447 |
+
result.viewerUrl = resumedViewerPage.url();
|
| 448 |
+
result.lastCompletedStep = 'viewer';
|
| 449 |
+
|
| 450 |
+
await emit(
|
| 451 |
+
onEvent,
|
| 452 |
+
'info',
|
| 453 |
+
'viewer',
|
| 454 |
+
'Report viewer opened',
|
| 455 |
+
{ viewerUrl: result.viewerUrl, resumed: true },
|
| 456 |
+
);
|
| 457 |
+
} else {
|
| 458 |
+
await emit(
|
| 459 |
+
onEvent,
|
| 460 |
+
'warning',
|
| 461 |
+
'viewer',
|
| 462 |
+
'Direct report viewer did not hydrate; reopening through assignment page',
|
| 463 |
+
{ viewerUrl: resumeViewerUrl, resumed: true },
|
| 464 |
+
);
|
| 465 |
+
await resumedViewerPage.close().catch(() => {});
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
if (!viewerPage) {
|
| 470 |
+
// --- 4. Navigate to assignment ---
|
| 471 |
+
await emit(
|
| 472 |
+
onEvent,
|
| 473 |
+
'info',
|
| 474 |
+
'navigate',
|
| 475 |
+
'Navigating to assignment',
|
| 476 |
+
{
|
| 477 |
+
classTitle: assignmentTarget.classTitle,
|
| 478 |
+
assignmentTitle: assignmentTarget.assignmentTitle,
|
| 479 |
+
},
|
| 480 |
+
);
|
| 481 |
+
|
| 482 |
+
// Retry loop for EULA-blocked assignment access (up to 3 attempts)
|
| 483 |
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
| 484 |
+
try {
|
| 485 |
+
assignmentLaunchUrl = await navigateToAssignment(
|
| 486 |
+
page,
|
| 487 |
+
assignmentTarget.classTitle,
|
| 488 |
+
assignmentTarget.assignmentTitle,
|
| 489 |
+
);
|
| 490 |
+
result.lastCompletedStep = 'navigate';
|
| 491 |
+
|
| 492 |
+
// --- 5. Quota check mode ---
|
| 493 |
+
if (mode === 'quota_check') {
|
| 494 |
+
await emit(
|
| 495 |
+
onEvent,
|
| 496 |
+
'info',
|
| 497 |
+
'quota_check',
|
| 498 |
+
'Running quota check',
|
| 499 |
+
);
|
| 500 |
+
const quotaResult = await runQuotaCheck(page);
|
| 501 |
+
|
| 502 |
+
if (quotaResult.quotaLimited) {
|
| 503 |
+
result.quotaLimit = {
|
| 504 |
+
limit: quotaResult.limit!,
|
| 505 |
+
message: quotaResult.message!,
|
| 506 |
+
retryText: quotaResult.retryText || undefined,
|
| 507 |
+
};
|
| 508 |
+
await dropClassAfterQuotaLimit(result.quotaLimit.message);
|
| 509 |
+
}
|
| 510 |
+
if (quotaResult.warning) {
|
| 511 |
+
result.quotaWarning = quotaResult.warning;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
await emit(
|
| 515 |
+
onEvent,
|
| 516 |
+
'info',
|
| 517 |
+
'quota_check',
|
| 518 |
+
'Quota check complete',
|
| 519 |
+
{ quotaResult },
|
| 520 |
+
);
|
| 521 |
+
|
| 522 |
+
return result;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// --- 6. Detect quota limit before upload ---
|
| 526 |
+
// A quota-limit banner can still be visible after the third successful
|
| 527 |
+
// submission. If this job is resuming after upload/submission, do not
|
| 528 |
+
// abort here: no additional submission is needed, and the worker only
|
| 529 |
+
// has to open/download the existing report.
|
| 530 |
+
const preUploadQuota = skipUpload
|
| 531 |
+
? null
|
| 532 |
+
: await detectQuotaLimit(page);
|
| 533 |
+
if (preUploadQuota) {
|
| 534 |
+
result.quotaLimit = {
|
| 535 |
+
limit: preUploadQuota.limit,
|
| 536 |
+
message: preUploadQuota.message,
|
| 537 |
+
retryText: preUploadQuota.retryText,
|
| 538 |
+
};
|
| 539 |
+
await dropClassAfterQuotaLimit(preUploadQuota.message);
|
| 540 |
+
throw new SubmissionQuotaLimitError(preUploadQuota.message);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// --- 7. Upload / Resubmit / Skip ---
|
| 544 |
+
// Detect live page state to decide what action is needed.
|
| 545 |
+
const submissionState = await detectSubmissionState(page);
|
| 546 |
+
|
| 547 |
+
if (!skipUpload && isModernOnePool && submissionState.hasExistingSubmission) {
|
| 548 |
+
const message =
|
| 549 |
+
'Modern one-use account already has a submission for this assignment. Marking account as permanently limited and trying another account.';
|
| 550 |
+
result.quotaLimit = {
|
| 551 |
+
limit: 1,
|
| 552 |
+
message,
|
| 553 |
+
};
|
| 554 |
+
await dropClassAfterQuotaLimit(message);
|
| 555 |
+
throw new SubmissionQuotaLimitError(message);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
if (
|
| 559 |
+
!skipUpload &&
|
| 560 |
+
!viewerPage &&
|
| 561 |
+
(attemptCount || 0) > 1 &&
|
| 562 |
+
submissionState.hasExistingSubmission &&
|
| 563 |
+
typeof inputFileSize === 'number'
|
| 564 |
+
) {
|
| 565 |
+
await emit(
|
| 566 |
+
onEvent,
|
| 567 |
+
'info',
|
| 568 |
+
'recovery_check',
|
| 569 |
+
'Existing submission found on retry; verifying file details before deciding whether to skip upload',
|
| 570 |
+
{
|
| 571 |
+
inputFileName: expectedInputFileName,
|
| 572 |
+
inputFileSize,
|
| 573 |
+
attemptCount,
|
| 574 |
+
},
|
| 575 |
+
);
|
| 576 |
+
|
| 577 |
+
const candidateViewer = await openReportViewerPage(
|
| 578 |
+
page,
|
| 579 |
+
context,
|
| 580 |
+
submissionState.scope || undefined,
|
| 581 |
+
).catch(async (error: unknown) => {
|
| 582 |
+
await emit(
|
| 583 |
+
onEvent,
|
| 584 |
+
'warning',
|
| 585 |
+
'recovery_check',
|
| 586 |
+
'Existing submission could not be opened for recovery verification',
|
| 587 |
+
{
|
| 588 |
+
error: error instanceof Error ? error.message : String(error),
|
| 589 |
+
},
|
| 590 |
+
);
|
| 591 |
+
return null;
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
if (candidateViewer) {
|
| 595 |
+
const recoveredDetails = await readSubmissionDetails(candidateViewer);
|
| 596 |
+
if (recoveredDetails) {
|
| 597 |
+
result.submissionDetails = recoveredDetails;
|
| 598 |
+
await emit(
|
| 599 |
+
onEvent,
|
| 600 |
+
'info',
|
| 601 |
+
'submission_details',
|
| 602 |
+
'Submission details captured',
|
| 603 |
+
recoveredDetails as Record<string, unknown>,
|
| 604 |
+
);
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
if (
|
| 608 |
+
submissionDetailsMatchInput(
|
| 609 |
+
recoveredDetails,
|
| 610 |
+
expectedInputFileName,
|
| 611 |
+
inputFileSize,
|
| 612 |
+
)
|
| 613 |
+
) {
|
| 614 |
+
viewerPage = candidateViewer;
|
| 615 |
+
result.viewerUrl = candidateViewer.url();
|
| 616 |
+
result.lastCompletedStep = 'viewer';
|
| 617 |
+
|
| 618 |
+
await emit(
|
| 619 |
+
onEvent,
|
| 620 |
+
'info',
|
| 621 |
+
'viewer',
|
| 622 |
+
'Existing submission matches current file; resuming from report viewer',
|
| 623 |
+
{
|
| 624 |
+
viewerUrl: result.viewerUrl,
|
| 625 |
+
recovered: true,
|
| 626 |
+
matchBasis: 'fileName+fileSize',
|
| 627 |
+
},
|
| 628 |
+
);
|
| 629 |
+
|
| 630 |
+
break;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
await emit(
|
| 634 |
+
onEvent,
|
| 635 |
+
'warning',
|
| 636 |
+
'recovery_check',
|
| 637 |
+
'Existing submission does not match current input file; continuing with requested upload flow',
|
| 638 |
+
{
|
| 639 |
+
inputFileName: expectedInputFileName,
|
| 640 |
+
inputFileSize,
|
| 641 |
+
turnitinFileName: recoveredDetails?.fileName || null,
|
| 642 |
+
turnitinFileSize: recoveredDetails?.fileSize || null,
|
| 643 |
+
},
|
| 644 |
+
);
|
| 645 |
+
|
| 646 |
+
const candidateOwner = getScopeOwnerPage(candidateViewer);
|
| 647 |
+
if (candidateOwner !== page) {
|
| 648 |
+
await candidateOwner.close().catch(() => {});
|
| 649 |
+
} else {
|
| 650 |
+
await page.goto(assignmentLaunchUrl, {
|
| 651 |
+
waitUntil: 'domcontentloaded',
|
| 652 |
+
timeout: 60000,
|
| 653 |
+
}).catch(() => {});
|
| 654 |
+
await page.waitForTimeout(1500).catch(() => {});
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
if (viewerPage) {
|
| 660 |
+
break;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
// ── A: File was already submitted (resume scenario) ──
|
| 664 |
+
// If the caller told us to skip upload, ALWAYS skip — do not
|
| 665 |
+
// re-check the live page because the submission card may not be
|
| 666 |
+
// visible yet (Turnitin is still processing it).
|
| 667 |
+
if (skipUpload) {
|
| 668 |
+
await emit(
|
| 669 |
+
onEvent,
|
| 670 |
+
'info',
|
| 671 |
+
'resume',
|
| 672 |
+
'Resuming after previous upload — skipping upload step',
|
| 673 |
+
{ resumeAfterStep, hasExistingSubmission: submissionState.hasExistingSubmission },
|
| 674 |
+
);
|
| 675 |
+
result.lastCompletedStep = 'submitted';
|
| 676 |
+
result.submittedAt = new Date().toISOString(); // approximate
|
| 677 |
+
} else {
|
| 678 |
+
// ── B: Normal upload / resubmit ──
|
| 679 |
+
if (mode === 'upload' && submissionState.hasExistingSubmission) {
|
| 680 |
+
mode = 'resubmit';
|
| 681 |
+
await emit(
|
| 682 |
+
onEvent,
|
| 683 |
+
'warning',
|
| 684 |
+
'mode_switch',
|
| 685 |
+
'Existing submission detected; switching to resubmit mode',
|
| 686 |
+
{
|
| 687 |
+
requestedMode,
|
| 688 |
+
effectiveMode: mode,
|
| 689 |
+
hasResubmitAction: submissionState.hasResubmitAction,
|
| 690 |
+
},
|
| 691 |
+
);
|
| 692 |
+
} else if (
|
| 693 |
+
mode === 'resubmit' &&
|
| 694 |
+
!submissionState.hasExistingSubmission &&
|
| 695 |
+
submissionState.hasUploadForm
|
| 696 |
+
) {
|
| 697 |
+
mode = 'upload';
|
| 698 |
+
await emit(
|
| 699 |
+
onEvent,
|
| 700 |
+
'warning',
|
| 701 |
+
'mode_switch',
|
| 702 |
+
'No existing submission detected; switching to first submission mode',
|
| 703 |
+
{
|
| 704 |
+
requestedMode,
|
| 705 |
+
effectiveMode: mode,
|
| 706 |
+
},
|
| 707 |
+
);
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
if (mode === 'resubmit') {
|
| 711 |
+
await emit(
|
| 712 |
+
onEvent,
|
| 713 |
+
'info',
|
| 714 |
+
'resubmit',
|
| 715 |
+
'Resubmitting file',
|
| 716 |
+
{ filePath: inputFilePath },
|
| 717 |
+
);
|
| 718 |
+
await resubmitFile(page, inputFilePath);
|
| 719 |
+
} else {
|
| 720 |
+
await emit(onEvent, 'info', 'upload', 'Uploading file', {
|
| 721 |
+
filePath: inputFilePath,
|
| 722 |
+
});
|
| 723 |
+
await uploadFile(page, inputFilePath);
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
result.submittedAt = new Date().toISOString();
|
| 727 |
+
result.lastCompletedStep = 'submitted';
|
| 728 |
+
await emit(
|
| 729 |
+
onEvent,
|
| 730 |
+
'info',
|
| 731 |
+
'submitted',
|
| 732 |
+
'File submitted successfully',
|
| 733 |
+
{ submittedAt: result.submittedAt },
|
| 734 |
+
);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
// --- 8. Wait for similarity ---
|
| 738 |
+
await emit(
|
| 739 |
+
onEvent,
|
| 740 |
+
'info',
|
| 741 |
+
'similarity',
|
| 742 |
+
'Waiting for similarity score',
|
| 743 |
+
);
|
| 744 |
+
similarityResult = await waitForSimilarity(
|
| 745 |
+
page,
|
| 746 |
+
assignmentLaunchUrl!,
|
| 747 |
+
{
|
| 748 |
+
timeoutMs: config.similarityTimeoutMs,
|
| 749 |
+
pollMs: config.similarityPollMs,
|
| 750 |
+
refreshAfterMs: config.similarityRefreshAfterMs,
|
| 751 |
+
inputFileName: path.basename(inputFilePath),
|
| 752 |
+
},
|
| 753 |
+
);
|
| 754 |
+
|
| 755 |
+
if (similarityResult.similarityPercent !== null) {
|
| 756 |
+
result.similarityPercent = similarityResult.similarityPercent;
|
| 757 |
+
}
|
| 758 |
+
if (similarityResult.viewerUrl) {
|
| 759 |
+
result.viewerUrl = similarityResult.viewerUrl;
|
| 760 |
+
}
|
| 761 |
+
result.lastCompletedStep = 'similarity';
|
| 762 |
+
|
| 763 |
+
await emit(
|
| 764 |
+
onEvent,
|
| 765 |
+
'info',
|
| 766 |
+
'similarity',
|
| 767 |
+
`Similarity: ${result.similarityPercent ?? 'pending'}%`,
|
| 768 |
+
{ similarityPercent: result.similarityPercent },
|
| 769 |
+
);
|
| 770 |
+
|
| 771 |
+
break; // Success – exit retry loop
|
| 772 |
+
} catch (error) {
|
| 773 |
+
if (
|
| 774 |
+
error instanceof EulaBlockedError &&
|
| 775 |
+
attempt < 3
|
| 776 |
+
) {
|
| 777 |
+
await emit(
|
| 778 |
+
onEvent,
|
| 779 |
+
'warning',
|
| 780 |
+
'navigate',
|
| 781 |
+
'Assignment launch returned EULA block; retrying',
|
| 782 |
+
{ attempt },
|
| 783 |
+
);
|
| 784 |
+
// Navigate back to class page and retry
|
| 785 |
+
await page.goto(
|
| 786 |
+
`https://www.turnitin.com/s_home.asp?lang=en_us`,
|
| 787 |
+
{ waitUntil: 'domcontentloaded', timeout: 60000 },
|
| 788 |
+
);
|
| 789 |
+
continue;
|
| 790 |
+
}
|
| 791 |
+
throw error;
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
if (!viewerPage && !assignmentLaunchUrl) {
|
| 797 |
+
throw new Error('Assignment launch URL was not captured — cannot proceed to similarity wait');
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
// --- 9. Open viewer ---
|
| 801 |
+
if (!viewerPage) {
|
| 802 |
+
await emit(
|
| 803 |
+
onEvent,
|
| 804 |
+
'info',
|
| 805 |
+
'viewer',
|
| 806 |
+
'Opening report viewer',
|
| 807 |
+
);
|
| 808 |
+
if (!similarityResult) {
|
| 809 |
+
throw new Error('Similarity/submission card result was not captured before opening viewer');
|
| 810 |
+
}
|
| 811 |
+
viewerPage = await openReportViewerPage(
|
| 812 |
+
page,
|
| 813 |
+
context,
|
| 814 |
+
similarityResult.scope,
|
| 815 |
+
similarityResult.locator,
|
| 816 |
+
);
|
| 817 |
+
result.viewerUrl = viewerPage.url();
|
| 818 |
+
result.lastCompletedStep = 'viewer';
|
| 819 |
+
await emit(
|
| 820 |
+
onEvent,
|
| 821 |
+
'info',
|
| 822 |
+
'viewer',
|
| 823 |
+
'Report viewer opened',
|
| 824 |
+
{ viewerUrl: result.viewerUrl, resumed: false },
|
| 825 |
+
);
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
if (!viewerPage) {
|
| 829 |
+
throw new Error('Report viewer page was not available after open/resume');
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
const submissionDetails = await readSubmissionDetails(viewerPage);
|
| 833 |
+
if (submissionDetails) {
|
| 834 |
+
result.submissionDetails = submissionDetails;
|
| 835 |
+
await emit(
|
| 836 |
+
onEvent,
|
| 837 |
+
'info',
|
| 838 |
+
'submission_details',
|
| 839 |
+
'Submission details captured',
|
| 840 |
+
submissionDetails as Record<string, unknown>,
|
| 841 |
+
);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
// --- 10. Apply filters ---
|
| 845 |
+
await emit(onEvent, 'info', 'filters', 'Applying filters', {
|
| 846 |
+
filters,
|
| 847 |
+
});
|
| 848 |
+
try {
|
| 849 |
+
await applyFilters(viewerPage, filters);
|
| 850 |
+
} catch (filterError) {
|
| 851 |
+
const filterMessage =
|
| 852 |
+
filterError instanceof Error
|
| 853 |
+
? filterError.message
|
| 854 |
+
: String(filterError);
|
| 855 |
+
|
| 856 |
+
// When the user has enabled at least one filter, filter application
|
| 857 |
+
// is MANDATORY — do NOT proceed to download with an unfiltered report.
|
| 858 |
+
if (hasActiveFilters(filters)) {
|
| 859 |
+
await emit(
|
| 860 |
+
onEvent,
|
| 861 |
+
'error',
|
| 862 |
+
'filters',
|
| 863 |
+
'Filters are enabled but could not be applied; aborting download to prevent unfiltered report',
|
| 864 |
+
{ error: filterMessage, filters },
|
| 865 |
+
);
|
| 866 |
+
throw new Error(
|
| 867 |
+
`Filter application failed with active filters: ${filterMessage}`,
|
| 868 |
+
);
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
// No filters are active — the panel might simply be unavailable;
|
| 872 |
+
// it is safe to continue with the download.
|
| 873 |
+
await emit(
|
| 874 |
+
onEvent,
|
| 875 |
+
'warning',
|
| 876 |
+
'filters',
|
| 877 |
+
'Filter panel was not available (no filters were active); continuing with report download',
|
| 878 |
+
{ error: filterMessage },
|
| 879 |
+
);
|
| 880 |
+
}
|
| 881 |
+
result.lastCompletedStep = 'filters';
|
| 882 |
+
|
| 883 |
+
// --- 10b. Validate that all active filters were applied before downloading ---
|
| 884 |
+
if (hasActiveFilters(filters)) {
|
| 885 |
+
await emit(
|
| 886 |
+
onEvent,
|
| 887 |
+
'info',
|
| 888 |
+
'filters',
|
| 889 |
+
'Validating applied filters before download',
|
| 890 |
+
{ filters },
|
| 891 |
+
);
|
| 892 |
+
await validateFilters(viewerPage, filters, true /* retryApply */);
|
| 893 |
+
await emit(
|
| 894 |
+
onEvent,
|
| 895 |
+
'info',
|
| 896 |
+
'filters',
|
| 897 |
+
'Filter validation passed',
|
| 898 |
+
{ filters },
|
| 899 |
+
);
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
// Read the similarity score from the viewer after filters are applied.
|
| 903 |
+
// The score may have changed due to filter exclusions; give the UI a
|
| 904 |
+
// moment to re-render before reading.
|
| 905 |
+
await emit(
|
| 906 |
+
onEvent,
|
| 907 |
+
'info',
|
| 908 |
+
'similarity',
|
| 909 |
+
'Reading post-filter similarity score from viewer',
|
| 910 |
+
);
|
| 911 |
+
|
| 912 |
+
const viewerSimilarityPercent =
|
| 913 |
+
await readViewerSimilarityPercent(viewerPage);
|
| 914 |
+
if (viewerSimilarityPercent !== null) {
|
| 915 |
+
result.similarityPercent = viewerSimilarityPercent;
|
| 916 |
+
await emit(
|
| 917 |
+
onEvent,
|
| 918 |
+
'info',
|
| 919 |
+
'similarity',
|
| 920 |
+
`Viewer similarity (post-filter): ${viewerSimilarityPercent}%`,
|
| 921 |
+
{ similarityPercent: viewerSimilarityPercent, filtered: hasActiveFilters(filters) },
|
| 922 |
+
);
|
| 923 |
+
} else {
|
| 924 |
+
await emit(
|
| 925 |
+
onEvent,
|
| 926 |
+
'warning',
|
| 927 |
+
'similarity',
|
| 928 |
+
'Could not read similarity score from viewer; using pre-filter value if available',
|
| 929 |
+
{ preFilteSimilarity: result.similarityPercent ?? null },
|
| 930 |
+
);
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
// --- 11. Download PDF ---
|
| 934 |
+
await emit(
|
| 935 |
+
onEvent,
|
| 936 |
+
'info',
|
| 937 |
+
'download',
|
| 938 |
+
'Downloading PDF report',
|
| 939 |
+
);
|
| 940 |
+
fs.mkdirSync(outputDir, { recursive: true });
|
| 941 |
+
const outputPdfPath = path.join(
|
| 942 |
+
outputDir,
|
| 943 |
+
`turnitin_report_${Date.now()}.pdf`,
|
| 944 |
+
);
|
| 945 |
+
const actualPath = await downloadPdf(
|
| 946 |
+
viewerPage,
|
| 947 |
+
context,
|
| 948 |
+
outputPdfPath,
|
| 949 |
+
);
|
| 950 |
+
result.outputPdfPath = actualPath;
|
| 951 |
+
result.lastCompletedStep = 'download';
|
| 952 |
+
|
| 953 |
+
await emit(
|
| 954 |
+
onEvent,
|
| 955 |
+
'info',
|
| 956 |
+
'download',
|
| 957 |
+
'PDF downloaded successfully',
|
| 958 |
+
{ outputPdfPath: actualPath },
|
| 959 |
+
);
|
| 960 |
+
|
| 961 |
+
if (isModernOnePool) {
|
| 962 |
+
const message =
|
| 963 |
+
'Modern one-use account consumed its single allowed submission. Class will be dropped and account will be permanently limited.';
|
| 964 |
+
let dropClassResult: NonNullable<RunTurnitinJobResult['permanentLimit']>['dropClassResult'] | undefined;
|
| 965 |
+
if (page) {
|
| 966 |
+
await emit(onEvent, 'warning', 'class_cleanup', 'Modern one-use submission completed; dropping class from account', {
|
| 967 |
+
classTitle: assignmentTarget.classTitle,
|
| 968 |
+
});
|
| 969 |
+
dropClassResult = await dropClassByTitle(page, assignmentTarget.classTitle).catch((dropError: unknown) => ({
|
| 970 |
+
attempted: true,
|
| 971 |
+
dropped: false,
|
| 972 |
+
reason: dropError instanceof Error ? dropError.message : String(dropError),
|
| 973 |
+
}));
|
| 974 |
+
await emit(
|
| 975 |
+
onEvent,
|
| 976 |
+
dropClassResult.dropped ? 'info' : 'warning',
|
| 977 |
+
'class_cleanup',
|
| 978 |
+
dropClassResult.dropped
|
| 979 |
+
? 'Modern one-use class dropped or already absent'
|
| 980 |
+
: 'Modern one-use class could not be dropped automatically',
|
| 981 |
+
{ ...dropClassResult },
|
| 982 |
+
);
|
| 983 |
+
}
|
| 984 |
+
result.permanentLimit = {
|
| 985 |
+
message,
|
| 986 |
+
submissionCount: 1,
|
| 987 |
+
dropClassResult,
|
| 988 |
+
};
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
// --- 12. Save storageState ---
|
| 992 |
+
if (storageStatePath) {
|
| 993 |
+
await context
|
| 994 |
+
.storageState({ path: storageStatePath })
|
| 995 |
+
.catch(() => {});
|
| 996 |
+
logger.info('Storage state saved after job', {
|
| 997 |
+
path: storageStatePath,
|
| 998 |
+
});
|
| 999 |
+
}
|
| 1000 |
+
} catch (error) {
|
| 1001 |
+
const message =
|
| 1002 |
+
error instanceof Error ? error.message : String(error);
|
| 1003 |
+
const publicMessage = compactEngineErrorMessage(message);
|
| 1004 |
+
const isQuotaLimit =
|
| 1005 |
+
error instanceof Error &&
|
| 1006 |
+
(error.name === 'SubmissionQuotaLimitError' ||
|
| 1007 |
+
/reached your limit|submission quota limit/i.test(message));
|
| 1008 |
+
if (isQuotaLimit && page) {
|
| 1009 |
+
await dropClassAfterQuotaLimit(message).catch((dropError: unknown) => {
|
| 1010 |
+
logger.warn('Failed to drop class after quota limit', {
|
| 1011 |
+
classTitle: assignmentTarget.classTitle,
|
| 1012 |
+
error:
|
| 1013 |
+
dropError instanceof Error
|
| 1014 |
+
? dropError.message
|
| 1015 |
+
: String(dropError),
|
| 1016 |
+
});
|
| 1017 |
+
});
|
| 1018 |
+
}
|
| 1019 |
+
await emit(onEvent, 'error', 'error', publicMessage, {
|
| 1020 |
+
errorName:
|
| 1021 |
+
error instanceof Error ? error.name : 'UnknownError',
|
| 1022 |
+
});
|
| 1023 |
+
if (error && typeof error === 'object') {
|
| 1024 |
+
(error as any).lastCompletedStep = result.lastCompletedStep;
|
| 1025 |
+
(error as any).viewerUrl = result.viewerUrl;
|
| 1026 |
+
(error as any).similarityPercent = result.similarityPercent;
|
| 1027 |
+
}
|
| 1028 |
+
throw error;
|
| 1029 |
+
} finally {
|
| 1030 |
+
// --- 13. Close context (NOT the browser — it's shared across jobs) ---
|
| 1031 |
+
await context.close().catch(() => {});
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
// --- 14. Return result ---
|
| 1035 |
+
return result;
|
| 1036 |
+
}
|
src/index.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config, validateConfig } from './config';
|
| 2 |
+
import { logger, setWorkerId } from './utils/logger';
|
| 3 |
+
import { createApp } from './server/app';
|
| 4 |
+
import { startWorkerManager, stopWorkerManager } from './worker/manager';
|
| 5 |
+
import { closeBrowser } from './worker/browser-pool';
|
| 6 |
+
import { runQuotaCheckCron } from './cron/quota-check';
|
| 7 |
+
import { runCleanupReportsCron } from './cron/cleanup-reports';
|
| 8 |
+
import { runStaleRecoveryCron } from './cron/stale-recovery';
|
| 9 |
+
import * as cron from 'node-cron';
|
| 10 |
+
|
| 11 |
+
async function main(): Promise<void> {
|
| 12 |
+
// Validate environment
|
| 13 |
+
try {
|
| 14 |
+
validateConfig();
|
| 15 |
+
} catch (err) {
|
| 16 |
+
console.error('Configuration error:', err instanceof Error ? err.message : err);
|
| 17 |
+
process.exit(1);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
setWorkerId(config.workerId);
|
| 21 |
+
logger.info('Turnitin Worker starting', {
|
| 22 |
+
workerId: config.workerId,
|
| 23 |
+
maxWorkers: config.maxWorkers,
|
| 24 |
+
port: config.port,
|
| 25 |
+
enableWorker: config.enableWorker,
|
| 26 |
+
enableCron: config.enableCron,
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
// Start Express server
|
| 30 |
+
const app = createApp();
|
| 31 |
+
const server = app.listen(config.port, () => {
|
| 32 |
+
logger.info(`HTTP server listening on port ${config.port}`);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
if (config.enableWorker) {
|
| 36 |
+
// Start worker manager (polls for jobs)
|
| 37 |
+
startWorkerManager().catch((err) => {
|
| 38 |
+
logger.error('Worker manager fatal error', {
|
| 39 |
+
error: err instanceof Error ? err.message : String(err),
|
| 40 |
+
});
|
| 41 |
+
});
|
| 42 |
+
} else {
|
| 43 |
+
logger.info('Worker manager disabled by ENABLE_WORKER=false');
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (config.enableCron) {
|
| 47 |
+
// Schedule cron jobs
|
| 48 |
+
// Quota check: every N minutes
|
| 49 |
+
const quotaCronExpr = `*/${config.quotaCheckInterval} * * * *`;
|
| 50 |
+
cron.schedule(quotaCronExpr, () => {
|
| 51 |
+
runQuotaCheckCron().catch((err) => {
|
| 52 |
+
logger.error('Quota check cron error', {
|
| 53 |
+
error: err instanceof Error ? err.message : String(err),
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
+
});
|
| 57 |
+
logger.info(`Quota check cron scheduled: ${quotaCronExpr}`);
|
| 58 |
+
|
| 59 |
+
// Report cleanup: every N minutes
|
| 60 |
+
const cleanupCronExpr = `*/${config.cleanupInterval} * * * *`;
|
| 61 |
+
cron.schedule(cleanupCronExpr, () => {
|
| 62 |
+
runCleanupReportsCron().catch((err) => {
|
| 63 |
+
logger.error('Report cleanup cron error', {
|
| 64 |
+
error: err instanceof Error ? err.message : String(err),
|
| 65 |
+
});
|
| 66 |
+
});
|
| 67 |
+
});
|
| 68 |
+
logger.info(`Report cleanup cron scheduled: ${cleanupCronExpr}`);
|
| 69 |
+
|
| 70 |
+
// Stale recovery: every N minutes
|
| 71 |
+
const staleCronExpr = `*/${config.staleRecoveryInterval} * * * *`;
|
| 72 |
+
cron.schedule(staleCronExpr, () => {
|
| 73 |
+
runStaleRecoveryCron().catch((err) => {
|
| 74 |
+
logger.error('Stale recovery cron error', {
|
| 75 |
+
error: err instanceof Error ? err.message : String(err),
|
| 76 |
+
});
|
| 77 |
+
});
|
| 78 |
+
});
|
| 79 |
+
logger.info(`Stale recovery cron scheduled: ${staleCronExpr}`);
|
| 80 |
+
} else {
|
| 81 |
+
logger.info('Cron jobs disabled by ENABLE_CRON=false');
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
logger.info('All systems started successfully');
|
| 85 |
+
|
| 86 |
+
// Graceful shutdown
|
| 87 |
+
const shutdown = async (signal: string) => {
|
| 88 |
+
logger.info(`Received ${signal}, shutting down gracefully...`);
|
| 89 |
+
|
| 90 |
+
// Stop accepting new jobs
|
| 91 |
+
stopWorkerManager();
|
| 92 |
+
|
| 93 |
+
// Close HTTP server
|
| 94 |
+
server.close(() => {
|
| 95 |
+
logger.info('HTTP server closed');
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// Wait for active workers to finish (max 60 seconds)
|
| 99 |
+
const maxWait = 60000;
|
| 100 |
+
const start = Date.now();
|
| 101 |
+
const { getActiveWorkerCount } = await import('./worker/manager');
|
| 102 |
+
while (getActiveWorkerCount() > 0 && Date.now() - start < maxWait) {
|
| 103 |
+
logger.info(`Waiting for ${getActiveWorkerCount()} active workers to finish...`);
|
| 104 |
+
await new Promise((r) => setTimeout(r, 2000));
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// Close browser
|
| 108 |
+
await closeBrowser();
|
| 109 |
+
|
| 110 |
+
logger.info('Shutdown complete');
|
| 111 |
+
process.exit(0);
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
| 115 |
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
| 116 |
+
|
| 117 |
+
// Handle uncaught errors
|
| 118 |
+
process.on('uncaughtException', (err) => {
|
| 119 |
+
logger.error('Uncaught exception', {
|
| 120 |
+
error: err.message,
|
| 121 |
+
stack: err.stack,
|
| 122 |
+
});
|
| 123 |
+
// Don't exit on uncaught exceptions in worker service
|
| 124 |
+
// The worker loop has its own error handling
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
process.on('unhandledRejection', (reason) => {
|
| 128 |
+
logger.error('Unhandled promise rejection', {
|
| 129 |
+
reason: reason instanceof Error ? reason.message : String(reason),
|
| 130 |
+
});
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
main().catch((err) => {
|
| 135 |
+
console.error('Fatal startup error:', err);
|
| 136 |
+
process.exit(1);
|
| 137 |
+
});
|
src/server/app.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import express, { Express, Request, Response, NextFunction } from 'express';
|
| 2 |
+
import { config } from '../config';
|
| 3 |
+
import { logger } from '../utils/logger';
|
| 4 |
+
import healthRouter from './routes/health';
|
| 5 |
+
import submitRouter from './routes/submit';
|
| 6 |
+
import internalRouter from './routes/internal';
|
| 7 |
+
import reportsRouter from './routes/reports';
|
| 8 |
+
import accountsRouter from './routes/accounts';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Create and configure the Express application with all routes and middleware.
|
| 12 |
+
*/
|
| 13 |
+
export function createApp(): Express {
|
| 14 |
+
const app = express();
|
| 15 |
+
|
| 16 |
+
app.disable('x-powered-by');
|
| 17 |
+
|
| 18 |
+
app.use((req: Request, res: Response, next: NextFunction) => {
|
| 19 |
+
const origin = req.headers.origin;
|
| 20 |
+
const allowOrigin =
|
| 21 |
+
origin &&
|
| 22 |
+
(config.allowedOrigins.length === 0 || config.allowedOrigins.includes(origin));
|
| 23 |
+
|
| 24 |
+
if (allowOrigin) {
|
| 25 |
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
| 26 |
+
res.setHeader('Vary', 'Origin');
|
| 27 |
+
}
|
| 28 |
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
| 29 |
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type,Idempotency-Key,X-Admin-Secret');
|
| 30 |
+
res.setHeader('Access-Control-Max-Age', '86400');
|
| 31 |
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
| 32 |
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
| 33 |
+
|
| 34 |
+
if (req.method === 'OPTIONS') {
|
| 35 |
+
res.status(204).end();
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
next();
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// --- Body parsers ---
|
| 43 |
+
app.use(express.json({ limit: '1mb' }));
|
| 44 |
+
|
| 45 |
+
// --- Routes ---
|
| 46 |
+
app.use(healthRouter);
|
| 47 |
+
app.use(submitRouter);
|
| 48 |
+
app.use(reportsRouter);
|
| 49 |
+
app.use(accountsRouter);
|
| 50 |
+
app.use(internalRouter);
|
| 51 |
+
|
| 52 |
+
app.use((req: Request, res: Response) => {
|
| 53 |
+
res.status(404).json({ error: 'Not found' });
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
// --- Global error handler ---
|
| 57 |
+
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
| 58 |
+
logger.error('Unhandled server error', {
|
| 59 |
+
error: err.message,
|
| 60 |
+
stack: err.stack,
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
res.status(500).json({ error: 'Internal server error' });
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
return app;
|
| 67 |
+
}
|
src/server/middleware/admin-secret.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Request, Response, NextFunction } from 'express';
|
| 2 |
+
import { config } from '../../config';
|
| 3 |
+
import { logger } from '../../utils/logger';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Express middleware that gates internal endpoints behind a shared secret.
|
| 7 |
+
* Reads the X-Admin-Secret header and compares against config.internalSecret.
|
| 8 |
+
* Returns 403 if the secret is missing or incorrect.
|
| 9 |
+
*/
|
| 10 |
+
export function requireAdminSecret(
|
| 11 |
+
req: Request,
|
| 12 |
+
res: Response,
|
| 13 |
+
next: NextFunction,
|
| 14 |
+
): void {
|
| 15 |
+
const secret = req.headers['x-admin-secret'];
|
| 16 |
+
|
| 17 |
+
if (!config.internalSecret) {
|
| 18 |
+
logger.error('INTERNAL_SECRET is not configured — rejecting admin request');
|
| 19 |
+
res.status(403).json({ error: 'Admin endpoints are not configured' });
|
| 20 |
+
return;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (!secret || secret !== config.internalSecret) {
|
| 24 |
+
logger.warn('Admin secret mismatch', {
|
| 25 |
+
ip: req.ip,
|
| 26 |
+
path: req.path,
|
| 27 |
+
});
|
| 28 |
+
res.status(403).json({ error: 'Forbidden: invalid admin secret' });
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
next();
|
| 33 |
+
}
|
src/server/middleware/auth.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Request, Response, NextFunction } from 'express';
|
| 2 |
+
import { supabase } from '../../db/client';
|
| 3 |
+
import { logger } from '../../utils/logger';
|
| 4 |
+
|
| 5 |
+
/** Augmented Express Request with authenticated user fields */
|
| 6 |
+
export interface AuthenticatedRequest extends Request {
|
| 7 |
+
userId: string;
|
| 8 |
+
userEmail: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Express middleware that verifies a Supabase JWT from the Authorization header.
|
| 13 |
+
* On success, sets req.userId and req.userEmail for downstream handlers.
|
| 14 |
+
* Returns 401 if the token is missing or invalid.
|
| 15 |
+
*/
|
| 16 |
+
export async function authenticateUser(
|
| 17 |
+
req: Request,
|
| 18 |
+
res: Response,
|
| 19 |
+
next: NextFunction,
|
| 20 |
+
): Promise<void> {
|
| 21 |
+
const authHeader = req.headers.authorization;
|
| 22 |
+
|
| 23 |
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 24 |
+
res.status(401).json({ error: 'Missing or malformed Authorization header' });
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const token = authHeader.slice(7); // Strip "Bearer "
|
| 29 |
+
|
| 30 |
+
try {
|
| 31 |
+
const { data, error } = await supabase.auth.getUser(token);
|
| 32 |
+
|
| 33 |
+
if (error || !data.user) {
|
| 34 |
+
logger.warn('Auth verification failed', { error: error?.message });
|
| 35 |
+
res.status(401).json({ error: 'Invalid or expired token' });
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Attach user info to the request
|
| 40 |
+
(req as AuthenticatedRequest).userId = data.user.id;
|
| 41 |
+
(req as AuthenticatedRequest).userEmail = data.user.email || '';
|
| 42 |
+
|
| 43 |
+
next();
|
| 44 |
+
} catch (err) {
|
| 45 |
+
logger.error('Auth middleware unexpected error', {
|
| 46 |
+
error: err instanceof Error ? err.message : String(err),
|
| 47 |
+
});
|
| 48 |
+
res.status(401).json({ error: 'Authentication failed' });
|
| 49 |
+
}
|
| 50 |
+
}
|
src/server/routes/accounts.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router, Response } from 'express';
|
| 2 |
+
import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
|
| 3 |
+
import { supabase } from '../../db/client';
|
| 4 |
+
import { logger } from '../../utils/logger';
|
| 5 |
+
|
| 6 |
+
const router = Router();
|
| 7 |
+
|
| 8 |
+
type AccountRow = {
|
| 9 |
+
id: string;
|
| 10 |
+
turnitin_status: string | null;
|
| 11 |
+
turnitin_quota_limit: number | null;
|
| 12 |
+
turnitin_quota_remaining: number | null;
|
| 13 |
+
turnitin_quota_message: string | null;
|
| 14 |
+
turnitin_next_retry_at: string | null;
|
| 15 |
+
turnitin_last_checked_at: string | null;
|
| 16 |
+
turnitin_last_error: string | null;
|
| 17 |
+
turnitin_pool_key: string | null;
|
| 18 |
+
created_at: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
async function requireAdmin(userId: string): Promise<boolean> {
|
| 22 |
+
const { data, error } = await supabase
|
| 23 |
+
.from('user_profiles')
|
| 24 |
+
.select('role')
|
| 25 |
+
.eq('id', userId)
|
| 26 |
+
.single();
|
| 27 |
+
|
| 28 |
+
if (error) {
|
| 29 |
+
logger.warn('Failed to verify admin profile for accounts quota', {
|
| 30 |
+
userId,
|
| 31 |
+
error: error.message,
|
| 32 |
+
});
|
| 33 |
+
return false;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return data?.role === 'admin';
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* GET /api/accounts/quota
|
| 41 |
+
*
|
| 42 |
+
* Admin-only quota view for Turnitin account cycling. The browser should not
|
| 43 |
+
* read generated_identities directly because storage/account RLS may hide rows.
|
| 44 |
+
* This endpoint returns sanitized account IDs and quota state only.
|
| 45 |
+
*/
|
| 46 |
+
router.get(
|
| 47 |
+
'/api/accounts/quota',
|
| 48 |
+
authenticateUser,
|
| 49 |
+
async (req, res: Response): Promise<void> => {
|
| 50 |
+
const authReq = req as AuthenticatedRequest;
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
if (!(await requireAdmin(authReq.userId))) {
|
| 54 |
+
res.status(403).json({ error: 'Admin access required' });
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
let { data: accounts, error: accountsError } = await supabase
|
| 59 |
+
.from('generated_identities')
|
| 60 |
+
.select(
|
| 61 |
+
'id, turnitin_status, turnitin_quota_limit, turnitin_quota_remaining, turnitin_quota_message, turnitin_next_retry_at, turnitin_last_checked_at, turnitin_last_error, turnitin_pool_key, created_at',
|
| 62 |
+
)
|
| 63 |
+
.order('created_at', { ascending: false });
|
| 64 |
+
|
| 65 |
+
if (
|
| 66 |
+
accountsError &&
|
| 67 |
+
/turnitin_pool_key|column/i.test(accountsError.message)
|
| 68 |
+
) {
|
| 69 |
+
const fallback = await supabase
|
| 70 |
+
.from('generated_identities')
|
| 71 |
+
.select(
|
| 72 |
+
'id, turnitin_status, turnitin_quota_limit, turnitin_quota_remaining, turnitin_quota_message, turnitin_next_retry_at, turnitin_last_checked_at, turnitin_last_error, created_at',
|
| 73 |
+
)
|
| 74 |
+
.order('created_at', { ascending: false });
|
| 75 |
+
accounts = fallback.data as any;
|
| 76 |
+
accountsError = fallback.error;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (accountsError) throw accountsError;
|
| 80 |
+
|
| 81 |
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
| 82 |
+
const { data: submissions, error: submissionsError } = await supabase
|
| 83 |
+
.from('turnitin_submissions')
|
| 84 |
+
.select('identity_id, submitted_at')
|
| 85 |
+
.gte('submitted_at', since);
|
| 86 |
+
|
| 87 |
+
if (submissionsError) throw submissionsError;
|
| 88 |
+
|
| 89 |
+
const usedByIdentity = new Map<string, number>();
|
| 90 |
+
for (const submission of submissions || []) {
|
| 91 |
+
const identityId = submission.identity_id as string | null;
|
| 92 |
+
if (!identityId) continue;
|
| 93 |
+
usedByIdentity.set(identityId, (usedByIdentity.get(identityId) || 0) + 1);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const sanitized = ((accounts || []) as AccountRow[]).map((account) => {
|
| 97 |
+
const quotaLimit = account.turnitin_pool_key === 'modern_one'
|
| 98 |
+
? 1
|
| 99 |
+
: account.turnitin_pool_key === 'legacy_carta'
|
| 100 |
+
? 4
|
| 101 |
+
: account.turnitin_quota_limit || 3;
|
| 102 |
+
const usedLast24h = usedByIdentity.get(account.id) || 0;
|
| 103 |
+
const hardLimited = ['quota_limited', 'cooling_down'].includes(
|
| 104 |
+
account.turnitin_status || '',
|
| 105 |
+
);
|
| 106 |
+
const quotaRemaining =
|
| 107 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 108 |
+
? account.turnitin_quota_remaining
|
| 109 |
+
: hardLimited
|
| 110 |
+
? 0
|
| 111 |
+
: Math.max(0, quotaLimit - usedLast24h);
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
id: account.id,
|
| 115 |
+
account_label: `Account #${account.id.replace(/-/g, '').slice(0, 8)}`,
|
| 116 |
+
turnitin_pool_key: account.turnitin_pool_key || 'modern_lti',
|
| 117 |
+
turnitin_status: account.turnitin_status || 'available',
|
| 118 |
+
turnitin_quota_limit: quotaLimit,
|
| 119 |
+
turnitin_quota_remaining: Math.max(0, Math.min(quotaLimit, quotaRemaining)),
|
| 120 |
+
turnitin_quota_message: account.turnitin_quota_message,
|
| 121 |
+
turnitin_next_retry_at: account.turnitin_next_retry_at,
|
| 122 |
+
turnitin_last_checked_at: account.turnitin_last_checked_at,
|
| 123 |
+
turnitin_last_error: account.turnitin_last_error,
|
| 124 |
+
used_last_24h: usedLast24h,
|
| 125 |
+
};
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
res.json({ accounts: sanitized });
|
| 129 |
+
} catch (err: unknown) {
|
| 130 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 131 |
+
logger.error('Failed to load Turnitin account quotas', {
|
| 132 |
+
userId: authReq.userId,
|
| 133 |
+
error: message,
|
| 134 |
+
});
|
| 135 |
+
res.status(500).json({ error: 'Failed to load account quotas' });
|
| 136 |
+
}
|
| 137 |
+
},
|
| 138 |
+
);
|
| 139 |
+
|
| 140 |
+
export default router;
|
src/server/routes/health.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router, Request, Response } from 'express';
|
| 2 |
+
import { config } from '../../config';
|
| 3 |
+
import { getActiveWorkerCount } from '../../worker/manager';
|
| 4 |
+
|
| 5 |
+
const router = Router();
|
| 6 |
+
|
| 7 |
+
const startTime = Date.now();
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* GET /
|
| 11 |
+
* Root endpoint returning status indicator (useful for HF Space pings).
|
| 12 |
+
*/
|
| 13 |
+
router.get('/', (_req: Request, res: Response) => {
|
| 14 |
+
res.json({
|
| 15 |
+
status: 'operational',
|
| 16 |
+
workerId: config.workerId,
|
| 17 |
+
timestamp: new Date().toISOString(),
|
| 18 |
+
});
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* GET /health
|
| 23 |
+
* Basic health check endpoint exposing worker identity and uptime.
|
| 24 |
+
*/
|
| 25 |
+
router.get('/health', (_req: Request, res: Response) => {
|
| 26 |
+
res.json({
|
| 27 |
+
ok: true,
|
| 28 |
+
workerId: config.workerId,
|
| 29 |
+
maxWorkers: config.maxWorkers,
|
| 30 |
+
activeWorkers: getActiveWorkerCount(),
|
| 31 |
+
enableWorker: config.enableWorker,
|
| 32 |
+
enableCron: config.enableCron,
|
| 33 |
+
allowedOriginCount: config.allowedOrigins.length,
|
| 34 |
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
| 35 |
+
});
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
export default router;
|
src/server/routes/internal.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router, Request, Response } from 'express';
|
| 2 |
+
import { config } from '../../config';
|
| 3 |
+
import {
|
| 4 |
+
claimSpecificAccountForManualQuota,
|
| 5 |
+
getTurnitinAccountById,
|
| 6 |
+
} from '../../db/accounts';
|
| 7 |
+
import { requireAdminSecret } from '../middleware/admin-secret';
|
| 8 |
+
import { getJobById } from '../../db/jobs';
|
| 9 |
+
import { runQuotaCheckForAccount } from '../../cron/quota-check';
|
| 10 |
+
import { logger } from '../../utils/logger';
|
| 11 |
+
|
| 12 |
+
const router = Router();
|
| 13 |
+
const MAX_MANUAL_QUOTA_CHECKS_PER_WORKER = 1;
|
| 14 |
+
let activeManualQuotaChecks = 0;
|
| 15 |
+
|
| 16 |
+
// Only /internal routes require the admin secret. Keep this scoped so unknown
|
| 17 |
+
// public paths return 404 instead of noisy "admin secret mismatch" warnings.
|
| 18 |
+
router.use('/internal', requireAdminSecret);
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* POST /internal/jobs/:id/run
|
| 22 |
+
* Manually trigger a job run. Stub implementation — validates the job exists
|
| 23 |
+
* and returns 202 Accepted. Actual worker invocation is handled elsewhere.
|
| 24 |
+
*/
|
| 25 |
+
router.post('/internal/jobs/:id/run', async (req: Request, res: Response): Promise<void> => {
|
| 26 |
+
const id = String(req.params.id);
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
const job = await getJobById(id);
|
| 30 |
+
if (!job) {
|
| 31 |
+
res.status(404).json({ error: 'Job not found' });
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
logger.info('Manual job run requested', { jobId: id });
|
| 36 |
+
|
| 37 |
+
// TODO: Integrate with worker dispatch
|
| 38 |
+
res.status(202).json({ message: 'Job run accepted', jobId: id });
|
| 39 |
+
} catch (err) {
|
| 40 |
+
logger.error('Internal job run error', {
|
| 41 |
+
jobId: id,
|
| 42 |
+
error: err instanceof Error ? err.message : String(err),
|
| 43 |
+
});
|
| 44 |
+
res.status(500).json({ error: 'Internal server error' });
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* POST /internal/accounts/:id/quota-check
|
| 50 |
+
* Manually trigger a quota check for a specific account.
|
| 51 |
+
* The check is started in the background after the account is leased, so the
|
| 52 |
+
* dashboard request is not held open for a full browser session.
|
| 53 |
+
*/
|
| 54 |
+
router.post(
|
| 55 |
+
'/internal/accounts/:id/quota-check',
|
| 56 |
+
async (req: Request, res: Response): Promise<void> => {
|
| 57 |
+
const id = String(req.params.id);
|
| 58 |
+
let handedOffToBackground = false;
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
logger.info('Manual quota check requested', { accountId: id });
|
| 62 |
+
|
| 63 |
+
if (activeManualQuotaChecks >= MAX_MANUAL_QUOTA_CHECKS_PER_WORKER) {
|
| 64 |
+
res.status(429).json({
|
| 65 |
+
error: 'This worker is already running a manual quota check. Try another worker or wait briefly.',
|
| 66 |
+
});
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
activeManualQuotaChecks += 1;
|
| 71 |
+
|
| 72 |
+
const existingAccount = await getTurnitinAccountById(id);
|
| 73 |
+
if (!existingAccount) {
|
| 74 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 75 |
+
res.status(404).json({ error: 'Turnitin account not found' });
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (existingAccount.turnitin_status === 'disabled') {
|
| 80 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 81 |
+
res.status(409).json({ error: 'This account is disabled and cannot be checked.' });
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (existingAccount.turnitin_status === 'login_failed') {
|
| 86 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 87 |
+
res.status(409).json({
|
| 88 |
+
error: 'This account is marked login_failed. Fix the credential before checking quota.',
|
| 89 |
+
});
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (existingAccount.turnitin_pool_key === 'legacy_carta' || existingAccount.turnitin_pool_key === 'modern_one') {
|
| 94 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 95 |
+
res.status(409).json({
|
| 96 |
+
error:
|
| 97 |
+
'Manual quota check supports reusable modern Turnitin accounts only. Legacy and modern_one pools are finalized after submission.',
|
| 98 |
+
});
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const leaseOwner = `${config.workerId}:manual-quota-check`;
|
| 103 |
+
const claimedAccount = await claimSpecificAccountForManualQuota(id, leaseOwner);
|
| 104 |
+
if (!claimedAccount) {
|
| 105 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 106 |
+
res.status(409).json({
|
| 107 |
+
error: 'This account is currently leased by another worker or is not ready for checking.',
|
| 108 |
+
});
|
| 109 |
+
return;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
handedOffToBackground = true;
|
| 113 |
+
void runQuotaCheckForAccount(claimedAccount, {
|
| 114 |
+
source: 'manual',
|
| 115 |
+
fallbackStatus: existingAccount.turnitin_status || 'available',
|
| 116 |
+
})
|
| 117 |
+
.catch((err: unknown) => {
|
| 118 |
+
logger.error('Manual quota check background task failed', {
|
| 119 |
+
accountId: id,
|
| 120 |
+
error: err instanceof Error ? err.message : String(err),
|
| 121 |
+
});
|
| 122 |
+
})
|
| 123 |
+
.finally(() => {
|
| 124 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
res.status(202).json({
|
| 128 |
+
message: 'Quota check started',
|
| 129 |
+
accountId: id,
|
| 130 |
+
workerId: config.workerId,
|
| 131 |
+
});
|
| 132 |
+
} catch (err: unknown) {
|
| 133 |
+
logger.error('Manual quota check request failed', {
|
| 134 |
+
accountId: id,
|
| 135 |
+
error: err instanceof Error ? err.message : String(err),
|
| 136 |
+
});
|
| 137 |
+
if (!handedOffToBackground) {
|
| 138 |
+
activeManualQuotaChecks = Math.max(0, activeManualQuotaChecks - 1);
|
| 139 |
+
}
|
| 140 |
+
res.status(500).json({ error: 'Failed to start quota check' });
|
| 141 |
+
}
|
| 142 |
+
},
|
| 143 |
+
);
|
| 144 |
+
|
| 145 |
+
export default router;
|
src/server/routes/reports.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router, Response } from 'express';
|
| 2 |
+
import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
|
| 3 |
+
import { getJobById } from '../../db/jobs';
|
| 4 |
+
import { createSignedUrl } from '../../db/storage';
|
| 5 |
+
import { config } from '../../config';
|
| 6 |
+
import { logger } from '../../utils/logger';
|
| 7 |
+
|
| 8 |
+
const router = Router();
|
| 9 |
+
|
| 10 |
+
function buildReportDownloadName(inputFileName: string): string {
|
| 11 |
+
const base = String(inputFileName || 'report')
|
| 12 |
+
.replace(/\.[^.]+$/, '')
|
| 13 |
+
.replace(/[\\/:*?"<>|]+/g, ' ')
|
| 14 |
+
.replace(/\s+/g, ' ')
|
| 15 |
+
.trim()
|
| 16 |
+
.slice(0, 140) || 'report';
|
| 17 |
+
return `RelVDev_${base}.pdf`;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function buildReceiptDownloadName(inputFileName: string): string {
|
| 21 |
+
const base = String(inputFileName || 'receipt')
|
| 22 |
+
.replace(/\.[^.]+$/, '')
|
| 23 |
+
.replace(/[\\/:*?"<>|]+/g, ' ')
|
| 24 |
+
.replace(/\s+/g, ' ')
|
| 25 |
+
.trim()
|
| 26 |
+
.slice(0, 132) || 'receipt';
|
| 27 |
+
return `RelVDev_Receipt_${base}.pdf`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* GET /api/jobs/:jobId/report-url
|
| 32 |
+
*
|
| 33 |
+
* Creates a short-lived signed URL for a completed report PDF. The browser
|
| 34 |
+
* should not sign private storage objects directly because storage RLS can hide
|
| 35 |
+
* private files as "not found"; this endpoint verifies job ownership first and
|
| 36 |
+
* then signs with the backend service role.
|
| 37 |
+
*/
|
| 38 |
+
router.get(
|
| 39 |
+
'/api/jobs/:jobId/report-url',
|
| 40 |
+
authenticateUser,
|
| 41 |
+
async (req, res: Response): Promise<void> => {
|
| 42 |
+
const authReq = req as AuthenticatedRequest;
|
| 43 |
+
const jobId = String(authReq.params.jobId || '');
|
| 44 |
+
|
| 45 |
+
try {
|
| 46 |
+
const job = await getJobById(jobId);
|
| 47 |
+
|
| 48 |
+
if (!job) {
|
| 49 |
+
res.status(404).json({ error: 'Job not found' });
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (job.user_id !== authReq.userId) {
|
| 54 |
+
res.status(403).json({ error: 'Forbidden' });
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (!job.output_pdf_path) {
|
| 59 |
+
res.status(404).json({ error: 'Report PDF is not available yet' });
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if (
|
| 64 |
+
job.output_pdf_expires_at &&
|
| 65 |
+
new Date(job.output_pdf_expires_at).getTime() < Date.now()
|
| 66 |
+
) {
|
| 67 |
+
res.status(410).json({ error: 'Report PDF has expired' });
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const signedUrl = await createSignedUrl(
|
| 72 |
+
config.reportBucket,
|
| 73 |
+
job.output_pdf_path,
|
| 74 |
+
300,
|
| 75 |
+
buildReportDownloadName(job.input_file_name),
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
res.json({
|
| 79 |
+
signedUrl,
|
| 80 |
+
fileName: buildReportDownloadName(job.input_file_name),
|
| 81 |
+
expiresIn: 300,
|
| 82 |
+
expiresAt: new Date(Date.now() + 300 * 1000).toISOString(),
|
| 83 |
+
});
|
| 84 |
+
} catch (err: unknown) {
|
| 85 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 86 |
+
logger.error('Failed to create report signed URL', {
|
| 87 |
+
jobId,
|
| 88 |
+
userId: authReq.userId,
|
| 89 |
+
error: message,
|
| 90 |
+
});
|
| 91 |
+
res.status(500).json({ error: 'Failed to create report download URL' });
|
| 92 |
+
}
|
| 93 |
+
},
|
| 94 |
+
);
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* GET /api/jobs/:jobId/receipt-url
|
| 98 |
+
*
|
| 99 |
+
* Creates a short-lived signed URL for a legacy Digital Receipt PDF.
|
| 100 |
+
*/
|
| 101 |
+
router.get(
|
| 102 |
+
'/api/jobs/:jobId/receipt-url',
|
| 103 |
+
authenticateUser,
|
| 104 |
+
async (req, res: Response): Promise<void> => {
|
| 105 |
+
const authReq = req as AuthenticatedRequest;
|
| 106 |
+
const jobId = String(authReq.params.jobId || '');
|
| 107 |
+
|
| 108 |
+
try {
|
| 109 |
+
const job = await getJobById(jobId);
|
| 110 |
+
|
| 111 |
+
if (!job) {
|
| 112 |
+
res.status(404).json({ error: 'Job not found' });
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if (job.user_id !== authReq.userId) {
|
| 117 |
+
res.status(403).json({ error: 'Forbidden' });
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (!job.receipt_pdf_path) {
|
| 122 |
+
res.status(404).json({ error: 'Digital Receipt is not available yet' });
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
if (
|
| 127 |
+
job.receipt_pdf_expires_at &&
|
| 128 |
+
new Date(job.receipt_pdf_expires_at).getTime() < Date.now()
|
| 129 |
+
) {
|
| 130 |
+
res.status(410).json({ error: 'Digital Receipt has expired' });
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const fileName = buildReceiptDownloadName(job.input_file_name);
|
| 135 |
+
const signedUrl = await createSignedUrl(
|
| 136 |
+
config.reportBucket,
|
| 137 |
+
job.receipt_pdf_path,
|
| 138 |
+
300,
|
| 139 |
+
fileName,
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
res.json({
|
| 143 |
+
signedUrl,
|
| 144 |
+
fileName,
|
| 145 |
+
expiresIn: 300,
|
| 146 |
+
expiresAt: new Date(Date.now() + 300 * 1000).toISOString(),
|
| 147 |
+
});
|
| 148 |
+
} catch (err: unknown) {
|
| 149 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 150 |
+
logger.error('Failed to create Digital Receipt signed URL', {
|
| 151 |
+
jobId,
|
| 152 |
+
userId: authReq.userId,
|
| 153 |
+
error: message,
|
| 154 |
+
});
|
| 155 |
+
res.status(500).json({ error: 'Failed to create Digital Receipt download URL' });
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
);
|
| 159 |
+
|
| 160 |
+
export default router;
|
src/server/routes/submit.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Router, Response } from 'express';
|
| 2 |
+
import multer from 'multer';
|
| 3 |
+
import { authenticateUser, AuthenticatedRequest } from '../middleware/auth';
|
| 4 |
+
import { uploadInputFile } from '../../db/storage';
|
| 5 |
+
import {
|
| 6 |
+
createJobWithTicket,
|
| 7 |
+
getJobBySubmissionRequestId,
|
| 8 |
+
getUserProfile,
|
| 9 |
+
} from '../../db/tickets';
|
| 10 |
+
import { logger } from '../../utils/logger';
|
| 11 |
+
import { createHash, randomUUID } from 'crypto';
|
| 12 |
+
|
| 13 |
+
const router = Router();
|
| 14 |
+
|
| 15 |
+
/** Multer configured for in-memory storage (files go to Supabase, not local disk) */
|
| 16 |
+
const upload = multer({
|
| 17 |
+
storage: multer.memoryStorage(),
|
| 18 |
+
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
/** Allowed file extensions for Turnitin submissions */
|
| 22 |
+
const ALLOWED_EXTENSIONS = new Set([
|
| 23 |
+
'.docx',
|
| 24 |
+
'.xlsx',
|
| 25 |
+
'.pptx',
|
| 26 |
+
'.ps',
|
| 27 |
+
'.pdf',
|
| 28 |
+
'.html',
|
| 29 |
+
'.rtf',
|
| 30 |
+
'.odt',
|
| 31 |
+
'.hwp',
|
| 32 |
+
'.txt',
|
| 33 |
+
]);
|
| 34 |
+
const ALLOWED_MODES = new Set(['upload', 'resubmit']);
|
| 35 |
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
| 36 |
+
|
| 37 |
+
const DEFAULT_FILTERS: Record<string, unknown> = {
|
| 38 |
+
excludeBibliography: false,
|
| 39 |
+
excludeQuotes: false,
|
| 40 |
+
excludeCitations: false,
|
| 41 |
+
excludeSmallMatches: true,
|
| 42 |
+
smallMatchMode: 'words',
|
| 43 |
+
smallMatchThreshold: 8,
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
function getFileExtension(filename: string): string {
|
| 47 |
+
const lastDot = filename.lastIndexOf('.');
|
| 48 |
+
return lastDot >= 0 ? filename.slice(lastDot).toLowerCase() : '';
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function existingJobMatchesRequest(
|
| 52 |
+
existingJob: {
|
| 53 |
+
assignment_target_id: string;
|
| 54 |
+
mode: string;
|
| 55 |
+
input_file_name: string;
|
| 56 |
+
input_file_size: number | null;
|
| 57 |
+
input_file_sha256: string | null;
|
| 58 |
+
},
|
| 59 |
+
request: {
|
| 60 |
+
assignmentTargetId: string;
|
| 61 |
+
mode: string;
|
| 62 |
+
inputFileName: string;
|
| 63 |
+
inputFileSize: number;
|
| 64 |
+
inputFileSha256: string;
|
| 65 |
+
},
|
| 66 |
+
): boolean {
|
| 67 |
+
return (
|
| 68 |
+
existingJob.assignment_target_id === request.assignmentTargetId &&
|
| 69 |
+
existingJob.mode === request.mode &&
|
| 70 |
+
existingJob.input_file_name === request.inputFileName &&
|
| 71 |
+
existingJob.input_file_size === request.inputFileSize &&
|
| 72 |
+
existingJob.input_file_sha256 === request.inputFileSha256
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* POST /api/submit
|
| 78 |
+
* Accepts a file upload + metadata, validates, stores the file,
|
| 79 |
+
* creates a job (deducting a ticket), and returns the job ID.
|
| 80 |
+
*/
|
| 81 |
+
router.post(
|
| 82 |
+
'/api/submit',
|
| 83 |
+
authenticateUser,
|
| 84 |
+
upload.single('file'),
|
| 85 |
+
async (req, res: Response): Promise<void> => {
|
| 86 |
+
const authReq = req as AuthenticatedRequest;
|
| 87 |
+
|
| 88 |
+
try {
|
| 89 |
+
// 1. Validate required fields
|
| 90 |
+
const {
|
| 91 |
+
assignment_target_id,
|
| 92 |
+
mode,
|
| 93 |
+
filters: filtersRaw,
|
| 94 |
+
submission_request_id: bodySubmissionRequestId,
|
| 95 |
+
} = authReq.body;
|
| 96 |
+
|
| 97 |
+
if (!assignment_target_id || !mode) {
|
| 98 |
+
res.status(400).json({
|
| 99 |
+
error: 'Missing required fields: assignment_target_id, mode',
|
| 100 |
+
});
|
| 101 |
+
return;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (!ALLOWED_MODES.has(String(mode))) {
|
| 105 |
+
res.status(400).json({
|
| 106 |
+
error: 'Invalid mode. Allowed: upload, resubmit',
|
| 107 |
+
});
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const suppliedRequestId = String(
|
| 112 |
+
bodySubmissionRequestId || authReq.get('Idempotency-Key') || '',
|
| 113 |
+
).trim();
|
| 114 |
+
const submissionRequestId = suppliedRequestId || randomUUID();
|
| 115 |
+
|
| 116 |
+
if (!UUID_PATTERN.test(submissionRequestId)) {
|
| 117 |
+
res.status(400).json({ error: 'Invalid submission request ID' });
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Parse filters (may come as stringified JSON)
|
| 122 |
+
let filters: Record<string, unknown>;
|
| 123 |
+
try {
|
| 124 |
+
const parsedFilters =
|
| 125 |
+
typeof filtersRaw === 'string'
|
| 126 |
+
? JSON.parse(filtersRaw)
|
| 127 |
+
: filtersRaw && typeof filtersRaw === 'object'
|
| 128 |
+
? filtersRaw
|
| 129 |
+
: {};
|
| 130 |
+
filters = { ...DEFAULT_FILTERS, ...parsedFilters };
|
| 131 |
+
} catch {
|
| 132 |
+
res.status(400).json({ error: 'Invalid filters JSON' });
|
| 133 |
+
return;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 2. Validate file exists
|
| 137 |
+
if (!authReq.file) {
|
| 138 |
+
res.status(400).json({ error: 'File is required' });
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// 3. Validate file type
|
| 143 |
+
const ext = getFileExtension(authReq.file.originalname);
|
| 144 |
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
| 145 |
+
res.status(400).json({
|
| 146 |
+
error: `Unsupported file type: ${ext}. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`,
|
| 147 |
+
});
|
| 148 |
+
return;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 4. Validate filters. Legacy Turnitin supports small-match words,
|
| 152 |
+
// percent, or off; modern Turnitin uses word threshold only.
|
| 153 |
+
const smallMatchMode =
|
| 154 |
+
filters.smallMatchMode === 'percent' ||
|
| 155 |
+
filters.smallMatchMode === 'off' ||
|
| 156 |
+
filters.smallMatchMode === 'words'
|
| 157 |
+
? filters.smallMatchMode
|
| 158 |
+
: 'words';
|
| 159 |
+
filters.smallMatchMode = smallMatchMode;
|
| 160 |
+
|
| 161 |
+
if (filters.excludeSmallMatches === true && smallMatchMode !== 'off') {
|
| 162 |
+
let threshold = Number(filters.smallMatchThreshold) || 8;
|
| 163 |
+
const maxThreshold = smallMatchMode === 'percent' ? 100 : 40;
|
| 164 |
+
threshold = Math.max(1, Math.min(maxThreshold, threshold));
|
| 165 |
+
filters.smallMatchThreshold = threshold;
|
| 166 |
+
} else {
|
| 167 |
+
filters.excludeSmallMatches = false;
|
| 168 |
+
filters.smallMatchMode = 'off';
|
| 169 |
+
filters.smallMatchThreshold = null;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// 5. Fingerprint the payload before any state-changing operation.
|
| 173 |
+
const inputFileSha256 = createHash('sha256')
|
| 174 |
+
.update(authReq.file.buffer)
|
| 175 |
+
.digest('hex');
|
| 176 |
+
|
| 177 |
+
const existingJob = await getJobBySubmissionRequestId(
|
| 178 |
+
authReq.userId,
|
| 179 |
+
submissionRequestId,
|
| 180 |
+
);
|
| 181 |
+
|
| 182 |
+
if (existingJob) {
|
| 183 |
+
if (!existingJobMatchesRequest(existingJob, {
|
| 184 |
+
assignmentTargetId: String(assignment_target_id),
|
| 185 |
+
mode: String(mode),
|
| 186 |
+
inputFileName: authReq.file.originalname,
|
| 187 |
+
inputFileSize: authReq.file.size,
|
| 188 |
+
inputFileSha256,
|
| 189 |
+
})) {
|
| 190 |
+
res.status(409).json({
|
| 191 |
+
error: 'Submission request ID was already used for a different file or configuration',
|
| 192 |
+
});
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
const profile = await getUserProfile(authReq.userId);
|
| 197 |
+
logger.info('Idempotent submit replay returned existing job', {
|
| 198 |
+
jobId: existingJob.id,
|
| 199 |
+
userId: authReq.userId,
|
| 200 |
+
submissionRequestId,
|
| 201 |
+
});
|
| 202 |
+
res.status(200).json({
|
| 203 |
+
jobId: existingJob.id,
|
| 204 |
+
ticketBalance: profile?.ticket_balance ?? 0,
|
| 205 |
+
idempotentReplay: true,
|
| 206 |
+
});
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// A deterministic request/hash path makes concurrent retries upload the
|
| 211 |
+
// same bytes to the same object before the database invariant resolves them.
|
| 212 |
+
const storagePath = await uploadInputFile(
|
| 213 |
+
authReq.userId,
|
| 214 |
+
`${submissionRequestId}/${inputFileSha256}`,
|
| 215 |
+
authReq.file.originalname,
|
| 216 |
+
authReq.file.buffer,
|
| 217 |
+
true,
|
| 218 |
+
);
|
| 219 |
+
|
| 220 |
+
// 6. Atomically create one job/ticket ledger entry, or return the job
|
| 221 |
+
// already created by another Space handling this exact request.
|
| 222 |
+
const creation = await createJobWithTicket({
|
| 223 |
+
userId: authReq.userId,
|
| 224 |
+
assignmentTargetId: assignment_target_id,
|
| 225 |
+
mode,
|
| 226 |
+
filters,
|
| 227 |
+
inputFileName: authReq.file.originalname,
|
| 228 |
+
inputStoragePath: storagePath,
|
| 229 |
+
inputFileSize: authReq.file.size,
|
| 230 |
+
inputFileSha256,
|
| 231 |
+
submissionRequestId,
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
// Fetch updated ticket balance
|
| 235 |
+
const profile = await getUserProfile(authReq.userId);
|
| 236 |
+
const ticketBalance = profile?.ticket_balance ?? 0;
|
| 237 |
+
|
| 238 |
+
logger.info(creation.created ? 'Job submitted successfully' : 'Idempotent submit race resolved', {
|
| 239 |
+
jobId: creation.jobId,
|
| 240 |
+
userId: authReq.userId,
|
| 241 |
+
fileName: authReq.file.originalname,
|
| 242 |
+
submissionRequestId,
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// 7. Return success
|
| 246 |
+
res.status(creation.created ? 201 : 200).json({
|
| 247 |
+
jobId: creation.jobId,
|
| 248 |
+
ticketBalance,
|
| 249 |
+
idempotentReplay: !creation.created,
|
| 250 |
+
});
|
| 251 |
+
} catch (err: unknown) {
|
| 252 |
+
const message = err instanceof Error ? err.message : String(err);
|
| 253 |
+
|
| 254 |
+
// Handle specific error cases from the RPC
|
| 255 |
+
if (message.includes('insufficient') || message.includes('ticket')) {
|
| 256 |
+
res.status(402).json({ error: 'Insufficient ticket balance' });
|
| 257 |
+
return;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
if (message.includes('Idempotency key')) {
|
| 261 |
+
res.status(409).json({ error: 'Submission request ID conflict' });
|
| 262 |
+
return;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
logger.error('Submit endpoint error', {
|
| 266 |
+
userId: authReq.userId,
|
| 267 |
+
error: message,
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
res.status(500).json({ error: 'Internal server error' });
|
| 271 |
+
}
|
| 272 |
+
},
|
| 273 |
+
);
|
| 274 |
+
|
| 275 |
+
export default router;
|
src/utils/logger.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Structured logger with worker context */
|
| 2 |
+
|
| 3 |
+
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
| 4 |
+
|
| 5 |
+
interface LogEntry {
|
| 6 |
+
level: LogLevel;
|
| 7 |
+
ts: string;
|
| 8 |
+
worker?: string;
|
| 9 |
+
job?: string;
|
| 10 |
+
account?: string;
|
| 11 |
+
step?: string;
|
| 12 |
+
msg: string;
|
| 13 |
+
[key: string]: unknown;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
let globalWorkerId = 'unknown';
|
| 17 |
+
|
| 18 |
+
export function setWorkerId(id: string): void {
|
| 19 |
+
globalWorkerId = id;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function write(level: LogLevel, msg: string, extra?: Record<string, unknown>): void {
|
| 23 |
+
const entry: LogEntry = {
|
| 24 |
+
level,
|
| 25 |
+
ts: new Date().toISOString(),
|
| 26 |
+
worker: globalWorkerId,
|
| 27 |
+
msg,
|
| 28 |
+
...extra,
|
| 29 |
+
};
|
| 30 |
+
const line = JSON.stringify(entry);
|
| 31 |
+
if (level === 'error') {
|
| 32 |
+
process.stderr.write(line + '\n');
|
| 33 |
+
} else {
|
| 34 |
+
process.stdout.write(line + '\n');
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export const logger = {
|
| 39 |
+
info: (msg: string, extra?: Record<string, unknown>) => write('info', msg, extra),
|
| 40 |
+
warn: (msg: string, extra?: Record<string, unknown>) => write('warn', msg, extra),
|
| 41 |
+
error: (msg: string, extra?: Record<string, unknown>) => write('error', msg, extra),
|
| 42 |
+
debug: (msg: string, extra?: Record<string, unknown>) => write('debug', msg, extra),
|
| 43 |
+
|
| 44 |
+
/** Create a child logger with preset context fields */
|
| 45 |
+
child(context: Record<string, unknown>) {
|
| 46 |
+
return {
|
| 47 |
+
info: (msg: string, extra?: Record<string, unknown>) =>
|
| 48 |
+
write('info', msg, { ...context, ...extra }),
|
| 49 |
+
warn: (msg: string, extra?: Record<string, unknown>) =>
|
| 50 |
+
write('warn', msg, { ...context, ...extra }),
|
| 51 |
+
error: (msg: string, extra?: Record<string, unknown>) =>
|
| 52 |
+
write('error', msg, { ...context, ...extra }),
|
| 53 |
+
debug: (msg: string, extra?: Record<string, unknown>) =>
|
| 54 |
+
write('debug', msg, { ...context, ...extra }),
|
| 55 |
+
};
|
| 56 |
+
},
|
| 57 |
+
};
|
src/utils/retry.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { logger } from './logger';
|
| 2 |
+
|
| 3 |
+
interface RetryOptions {
|
| 4 |
+
maxAttempts: number;
|
| 5 |
+
baseDelayMs?: number;
|
| 6 |
+
maxDelayMs?: number;
|
| 7 |
+
retryIf?: (error: unknown) => boolean;
|
| 8 |
+
label?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Retry a function with exponential backoff.
|
| 13 |
+
* Returns the result on success or throws after all attempts fail.
|
| 14 |
+
*/
|
| 15 |
+
export async function retry<T>(
|
| 16 |
+
fn: () => Promise<T>,
|
| 17 |
+
options: RetryOptions
|
| 18 |
+
): Promise<T> {
|
| 19 |
+
const {
|
| 20 |
+
maxAttempts,
|
| 21 |
+
baseDelayMs = 1000,
|
| 22 |
+
maxDelayMs = 30000,
|
| 23 |
+
retryIf,
|
| 24 |
+
label = 'operation',
|
| 25 |
+
} = options;
|
| 26 |
+
|
| 27 |
+
let lastError: unknown;
|
| 28 |
+
|
| 29 |
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
| 30 |
+
try {
|
| 31 |
+
return await fn();
|
| 32 |
+
} catch (error) {
|
| 33 |
+
lastError = error;
|
| 34 |
+
|
| 35 |
+
if (retryIf && !retryIf(error)) {
|
| 36 |
+
throw error;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (attempt === maxAttempts) {
|
| 40 |
+
break;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const delay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
|
| 44 |
+
const jitter = delay * (0.5 + Math.random() * 0.5);
|
| 45 |
+
|
| 46 |
+
logger.warn(`${label} attempt ${attempt}/${maxAttempts} failed, retrying in ${Math.round(jitter)}ms`, {
|
| 47 |
+
error: error instanceof Error ? error.message : String(error),
|
| 48 |
+
attempt,
|
| 49 |
+
maxAttempts,
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
await sleep(jitter);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
throw lastError;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** Promise-based sleep */
|
| 60 |
+
export function sleep(ms: number): Promise<void> {
|
| 61 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 62 |
+
}
|
src/worker/browser-pool.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { chromium, Browser, BrowserContext } from 'playwright';
|
| 2 |
+
import { config } from '../config';
|
| 3 |
+
import { logger } from '../utils/logger';
|
| 4 |
+
|
| 5 |
+
let browser: Browser | null = null;
|
| 6 |
+
let browserLaunchPromise: Promise<Browser> | null = null;
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Get or launch the shared browser instance.
|
| 10 |
+
* Multiple callers waiting concurrently will share the same launch promise.
|
| 11 |
+
*/
|
| 12 |
+
export async function getBrowser(): Promise<Browser> {
|
| 13 |
+
if (browser && browser.isConnected()) {
|
| 14 |
+
return browser;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
if (browserLaunchPromise) {
|
| 18 |
+
return browserLaunchPromise;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
browserLaunchPromise = launchBrowser();
|
| 22 |
+
|
| 23 |
+
try {
|
| 24 |
+
browser = await browserLaunchPromise;
|
| 25 |
+
return browser;
|
| 26 |
+
} finally {
|
| 27 |
+
browserLaunchPromise = null;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function launchBrowser(): Promise<Browser> {
|
| 32 |
+
logger.info('Launching Chromium browser', { headless: config.headless });
|
| 33 |
+
|
| 34 |
+
const b = await chromium.launch({
|
| 35 |
+
headless: config.headless,
|
| 36 |
+
args: [
|
| 37 |
+
'--no-sandbox',
|
| 38 |
+
'--disable-setuid-sandbox',
|
| 39 |
+
'--disable-dev-shm-usage',
|
| 40 |
+
'--disable-gpu',
|
| 41 |
+
],
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
b.on('disconnected', () => {
|
| 45 |
+
logger.warn('Browser disconnected');
|
| 46 |
+
browser = null;
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
logger.info('Browser launched successfully');
|
| 50 |
+
return b;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* Create a new isolated browser context for a job.
|
| 55 |
+
* Optionally restore storage state from a previous session.
|
| 56 |
+
*/
|
| 57 |
+
export async function createContext(
|
| 58 |
+
storageState?: string
|
| 59 |
+
): Promise<BrowserContext> {
|
| 60 |
+
const b = await getBrowser();
|
| 61 |
+
|
| 62 |
+
const contextOptions: Parameters<Browser['newContext']>[0] = {
|
| 63 |
+
viewport: { width: 1280, height: 720 },
|
| 64 |
+
userAgent:
|
| 65 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
| 66 |
+
acceptDownloads: true,
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
if (storageState) {
|
| 70 |
+
try {
|
| 71 |
+
contextOptions.storageState = JSON.parse(storageState);
|
| 72 |
+
} catch {
|
| 73 |
+
logger.warn('Failed to parse storage state, starting fresh');
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const context = await b.newContext(contextOptions);
|
| 78 |
+
|
| 79 |
+
// Set reasonable timeouts
|
| 80 |
+
context.setDefaultTimeout(30000);
|
| 81 |
+
context.setDefaultNavigationTimeout(60000);
|
| 82 |
+
|
| 83 |
+
return context;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Gracefully close the shared browser.
|
| 88 |
+
*/
|
| 89 |
+
export async function closeBrowser(): Promise<void> {
|
| 90 |
+
if (browser) {
|
| 91 |
+
try {
|
| 92 |
+
await browser.close();
|
| 93 |
+
} catch {
|
| 94 |
+
// Ignore close errors
|
| 95 |
+
}
|
| 96 |
+
browser = null;
|
| 97 |
+
logger.info('Browser closed');
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Get count of active contexts (approximation of active jobs).
|
| 103 |
+
*/
|
| 104 |
+
export async function getActiveContextCount(): Promise<number> {
|
| 105 |
+
if (!browser || !browser.isConnected()) return 0;
|
| 106 |
+
return browser.contexts().length;
|
| 107 |
+
}
|
src/worker/manager.ts
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from '../config';
|
| 2 |
+
import { logger } from '../utils/logger';
|
| 3 |
+
import { sleep } from '../utils/retry';
|
| 4 |
+
import { createContext } from './browser-pool';
|
| 5 |
+
import { getAccountPassword } from '../crypto/password';
|
| 6 |
+
import { supabase } from '../db/client';
|
| 7 |
+
|
| 8 |
+
// DB imports (will be created by subagent)
|
| 9 |
+
import { claimPendingJob, completeJobIfActive, updateJobStatus, updateJobFields, incrementJobAttempt, getJobById, TurnitinJob } from '../db/jobs';
|
| 10 |
+
import {
|
| 11 |
+
claimAvailableAccount,
|
| 12 |
+
claimSpecificAccountForResume,
|
| 13 |
+
countAvailableAccounts,
|
| 14 |
+
getAccountPoolState,
|
| 15 |
+
getTurnitinAccountById,
|
| 16 |
+
releaseAccount,
|
| 17 |
+
updateAccountQuota,
|
| 18 |
+
type TurnitinAccount,
|
| 19 |
+
} from '../db/accounts';
|
| 20 |
+
import { insertJobEvent } from '../db/events';
|
| 21 |
+
import { downloadInputFile, uploadReceiptPdf, uploadReportPdf, downloadStorageState, uploadStorageState } from '../db/storage';
|
| 22 |
+
import { cancelJob, refundFailedJob } from '../db/tickets';
|
| 23 |
+
import { MODERN_ONE_POOL_KEY, runTurnitinJob, RunTurnitinJobInput, RunTurnitinJobResult } from '../engine/turnitin';
|
| 24 |
+
|
| 25 |
+
import * as path from 'path';
|
| 26 |
+
import * as fs from 'fs';
|
| 27 |
+
import * as os from 'os';
|
| 28 |
+
|
| 29 |
+
let activeWorkers = 0;
|
| 30 |
+
let running = false;
|
| 31 |
+
|
| 32 |
+
const ACCOUNT_WAIT_POLL_MS = Number(process.env.ACCOUNT_WAIT_POLL_MS || 15000);
|
| 33 |
+
const ACCOUNT_WAIT_MAX_MS = Number(process.env.ACCOUNT_WAIT_MAX_MS || 30 * 60 * 1000);
|
| 34 |
+
const RESUME_PROTECTED_STEPS = ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'];
|
| 35 |
+
const RESUME_ACCOUNT_RETRY_DELAY_MS = Number(process.env.RESUME_ACCOUNT_RETRY_DELAY_MS || 30000);
|
| 36 |
+
const DEFAULT_ACCOUNT_POOL_KEY = 'modern_lti';
|
| 37 |
+
const LEGACY_ACCOUNT_POOL_KEY = 'legacy_carta';
|
| 38 |
+
const LEGACY_ACCOUNT_QUOTA_LIMIT = 4;
|
| 39 |
+
|
| 40 |
+
function accountQuotaLimitForPool(poolKey: string, account: TurnitinAccount): number {
|
| 41 |
+
if (poolKey === MODERN_ONE_POOL_KEY) return 1;
|
| 42 |
+
if (poolKey === LEGACY_ACCOUNT_POOL_KEY) return LEGACY_ACCOUNT_QUOTA_LIMIT;
|
| 43 |
+
return account.turnitin_quota_limit || 3;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// ---------------------------------------------------------------------------
|
| 47 |
+
// In-memory sets to prevent double processing and double refund.
|
| 48 |
+
// These are per-process so each HF Space worker keeps its own bookkeeping.
|
| 49 |
+
// ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
/** Jobs currently being processed by this worker process. */
|
| 52 |
+
const processingJobs = new Set<string>();
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Jobs that have already been refunded by this worker process.
|
| 56 |
+
* NEW-BUG-4 FIX: Entries are auto-deleted after 1 hour to prevent unbounded growth.
|
| 57 |
+
*/
|
| 58 |
+
const refundedJobs = new Map<string, NodeJS.Timeout>();
|
| 59 |
+
|
| 60 |
+
function markAsRefunded(jobId: string): void {
|
| 61 |
+
// Clear existing timer if re-refunding (shouldn't happen, but be safe)
|
| 62 |
+
const existing = refundedJobs.get(jobId);
|
| 63 |
+
if (existing) clearTimeout(existing);
|
| 64 |
+
// Auto-remove after 1 hour
|
| 65 |
+
const timer = setTimeout(() => refundedJobs.delete(jobId), 60 * 60 * 1000);
|
| 66 |
+
// Prevent timer from keeping the process alive during shutdown
|
| 67 |
+
if (timer.unref) timer.unref();
|
| 68 |
+
refundedJobs.set(jobId, timer);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function wasRefunded(jobId: string): boolean {
|
| 72 |
+
return refundedJobs.has(jobId);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
async function refundFailedTicketOnce(
|
| 76 |
+
jobId: string,
|
| 77 |
+
reason: string,
|
| 78 |
+
jobLog: ReturnType<typeof logger.child>,
|
| 79 |
+
): Promise<boolean> {
|
| 80 |
+
if (wasRefunded(jobId)) {
|
| 81 |
+
jobLog.info('Refund already attempted by this worker process', { jobId });
|
| 82 |
+
return false;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const refunded = await refundFailedJob(jobId, reason);
|
| 87 |
+
markAsRefunded(jobId);
|
| 88 |
+
jobLog.info('Failed job refund checked', { jobId, refunded });
|
| 89 |
+
return refunded;
|
| 90 |
+
} catch (error) {
|
| 91 |
+
jobLog.warn('Failed job refund RPC failed', {
|
| 92 |
+
jobId,
|
| 93 |
+
error: error instanceof Error ? compactWorkerMessage(error.message) : String(error),
|
| 94 |
+
});
|
| 95 |
+
return false;
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
type AssignmentTargetConfig = RunTurnitinJobInput['assignmentTarget'];
|
| 100 |
+
|
| 101 |
+
function normalizeUiVariant(value: unknown): AssignmentTargetConfig['uiVariant'] {
|
| 102 |
+
return value === 'legacy_carta' ? 'legacy_carta' : 'modern_lti';
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function defaultAssignmentTarget(): AssignmentTargetConfig {
|
| 106 |
+
return {
|
| 107 |
+
targetUrl: config.turnitinTargetUrl,
|
| 108 |
+
classTitle: config.turnitinClassTitle,
|
| 109 |
+
assignmentTitle: config.turnitinAssignmentTitle as string | null,
|
| 110 |
+
assignmentLaunchUrl: null,
|
| 111 |
+
uiVariant: 'modern_lti',
|
| 112 |
+
accountPoolKey: DEFAULT_ACCOUNT_POOL_KEY,
|
| 113 |
+
};
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
async function loadAssignmentTarget(
|
| 117 |
+
assignmentTargetId: string,
|
| 118 |
+
jobLog: ReturnType<typeof logger.child>,
|
| 119 |
+
): Promise<AssignmentTargetConfig> {
|
| 120 |
+
const assignmentTarget = defaultAssignmentTarget();
|
| 121 |
+
if (!assignmentTargetId) return assignmentTarget;
|
| 122 |
+
|
| 123 |
+
try {
|
| 124 |
+
const { data, error } = await supabase
|
| 125 |
+
.from('turnitin_assignment_targets')
|
| 126 |
+
.select('*')
|
| 127 |
+
.eq('id', assignmentTargetId)
|
| 128 |
+
.single();
|
| 129 |
+
|
| 130 |
+
if (error) throw error;
|
| 131 |
+
if (!data) return assignmentTarget;
|
| 132 |
+
|
| 133 |
+
const uiVariant = normalizeUiVariant(data.ui_variant);
|
| 134 |
+
assignmentTarget.targetUrl = data.target_url || assignmentTarget.targetUrl;
|
| 135 |
+
assignmentTarget.classTitle = data.class_title || assignmentTarget.classTitle;
|
| 136 |
+
assignmentTarget.assignmentTitle = data.assignment_title || null;
|
| 137 |
+
assignmentTarget.assignmentLaunchUrl = data.assignment_launch_url || null;
|
| 138 |
+
assignmentTarget.uiVariant = uiVariant;
|
| 139 |
+
assignmentTarget.accountPoolKey =
|
| 140 |
+
data.account_pool_key || (uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY);
|
| 141 |
+
|
| 142 |
+
return assignmentTarget;
|
| 143 |
+
} catch (error) {
|
| 144 |
+
jobLog.warn('Failed to fetch assignment target, using defaults', {
|
| 145 |
+
assignmentTargetId,
|
| 146 |
+
error: error instanceof Error ? error.message : String(error),
|
| 147 |
+
});
|
| 148 |
+
return assignmentTarget;
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// ---------------------------------------------------------------------------
|
| 153 |
+
// Job queue with backpressure — prevents thundering herd
|
| 154 |
+
// ---------------------------------------------------------------------------
|
| 155 |
+
|
| 156 |
+
/**
|
| 157 |
+
* Pending account claim resolvers, FIFO.
|
| 158 |
+
* When a job needs an account and none is free, it pushes a resolve callback
|
| 159 |
+
* here. When any job finishes and releases an account, we pop the first waiter
|
| 160 |
+
* to signal it to retry its claim.
|
| 161 |
+
*/
|
| 162 |
+
const accountWaiters: Array<() => void> = [];
|
| 163 |
+
|
| 164 |
+
/** Signal one waiting job that an account may now be available. */
|
| 165 |
+
function notifyNextWaiter(): void {
|
| 166 |
+
const next = accountWaiters.shift();
|
| 167 |
+
if (next) next();
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/** Wait for a notification that an account was released. */
|
| 171 |
+
function waitForAccountRelease(timeoutMs: number): Promise<'notified' | 'timeout'> {
|
| 172 |
+
return new Promise((resolve) => {
|
| 173 |
+
const timer = setTimeout(() => {
|
| 174 |
+
// Remove ourselves from the queue on timeout
|
| 175 |
+
const idx = accountWaiters.indexOf(onNotify);
|
| 176 |
+
if (idx >= 0) accountWaiters.splice(idx, 1);
|
| 177 |
+
resolve('timeout');
|
| 178 |
+
}, timeoutMs);
|
| 179 |
+
if (timer.unref) timer.unref();
|
| 180 |
+
|
| 181 |
+
const onNotify = () => {
|
| 182 |
+
clearTimeout(timer);
|
| 183 |
+
resolve('notified');
|
| 184 |
+
};
|
| 185 |
+
accountWaiters.push(onNotify);
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/**
|
| 190 |
+
* Start the worker polling loop.
|
| 191 |
+
* Claims jobs from Supabase and processes them with Playwright.
|
| 192 |
+
*/
|
| 193 |
+
export async function startWorkerManager(): Promise<void> {
|
| 194 |
+
running = true;
|
| 195 |
+
logger.info('Worker manager started', {
|
| 196 |
+
workerId: config.workerId,
|
| 197 |
+
maxWorkers: config.maxWorkers,
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
// BUG-2 FIX: Stale job recovery is now handled EXCLUSIVELY by stale-recovery.ts
|
| 201 |
+
// cron job, which runs every 5 minutes. Removing the duplicate inline loop here
|
| 202 |
+
// prevents two Space workers from simultaneously resetting a job that the other
|
| 203 |
+
// worker is still actively processing.
|
| 204 |
+
|
| 205 |
+
while (running) {
|
| 206 |
+
try {
|
| 207 |
+
if (activeWorkers >= config.maxWorkers) {
|
| 208 |
+
await sleep(2000);
|
| 209 |
+
continue;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// Try to claim a pending job
|
| 213 |
+
const job = await claimPendingJob(config.workerId);
|
| 214 |
+
|
| 215 |
+
if (!job) {
|
| 216 |
+
// No jobs available, wait before polling again
|
| 217 |
+
const jitter = config.pollIntervalMs + Math.random() * 2000;
|
| 218 |
+
await sleep(jitter);
|
| 219 |
+
continue;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// ── Guard: skip job if already being processed by this worker ──
|
| 223 |
+
if (processingJobs.has(job.id)) {
|
| 224 |
+
logger.warn('Skipping job already in progress on this worker', {
|
| 225 |
+
jobId: job.id,
|
| 226 |
+
});
|
| 227 |
+
// Release the claim — set it back to pending so a healthy worker can take it.
|
| 228 |
+
await updateJobStatus(job.id, 'pending').catch(() => {});
|
| 229 |
+
await sleep(1000);
|
| 230 |
+
continue;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
logger.info('Claimed job', { jobId: job.id, mode: job.mode, userId: job.user_id });
|
| 234 |
+
|
| 235 |
+
// ── BUG-3 FIX: Defensive guard — verify the claimed job actually belongs ──
|
| 236 |
+
// to this worker. If the Supabase RPC `claim_turnitin_job` accidentally
|
| 237 |
+
// returns a job owned by another worker (e.g. one stuck in 'waiting_account'),
|
| 238 |
+
// processing it here would cause double-submit. Re-read the job from DB to
|
| 239 |
+
// ensure worker_id matches before proceeding.
|
| 240 |
+
const freshJob = await getJobById(job.id);
|
| 241 |
+
if (freshJob && freshJob.worker_id && freshJob.worker_id !== config.workerId) {
|
| 242 |
+
logger.warn('Claimed job belongs to a different worker; releasing', {
|
| 243 |
+
jobId: job.id,
|
| 244 |
+
ownWorker: config.workerId,
|
| 245 |
+
actualWorker: freshJob.worker_id,
|
| 246 |
+
});
|
| 247 |
+
await sleep(1000);
|
| 248 |
+
continue;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// Mark as in-progress
|
| 252 |
+
processingJobs.add(job.id);
|
| 253 |
+
|
| 254 |
+
// Spawn async task for this job (non-blocking)
|
| 255 |
+
activeWorkers++;
|
| 256 |
+
processJob(job)
|
| 257 |
+
.catch((err) => {
|
| 258 |
+
logger.error('Unhandled error in job processing', {
|
| 259 |
+
jobId: job.id,
|
| 260 |
+
error: err instanceof Error ? err.message : String(err),
|
| 261 |
+
});
|
| 262 |
+
})
|
| 263 |
+
.finally(() => {
|
| 264 |
+
activeWorkers--;
|
| 265 |
+
processingJobs.delete(job.id);
|
| 266 |
+
});
|
| 267 |
+
} catch (err) {
|
| 268 |
+
logger.error('Worker manager loop error', {
|
| 269 |
+
error: err instanceof Error ? err.message : String(err),
|
| 270 |
+
});
|
| 271 |
+
await sleep(5000);
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
logger.info('Worker manager stopped');
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
/**
|
| 280 |
+
* Stop the worker polling loop gracefully.
|
| 281 |
+
*/
|
| 282 |
+
export function stopWorkerManager(): void {
|
| 283 |
+
running = false;
|
| 284 |
+
logger.info('Worker manager stop requested');
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* Get current active worker count.
|
| 289 |
+
*/
|
| 290 |
+
export function getActiveWorkerCount(): number {
|
| 291 |
+
return activeWorkers;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/**
|
| 295 |
+
* Process a single job end-to-end.
|
| 296 |
+
*/
|
| 297 |
+
async function processJob(job: TurnitinJob): Promise<void> {
|
| 298 |
+
const jobId = job.id;
|
| 299 |
+
const jobLog = logger.child({ job: jobId });
|
| 300 |
+
let identityId: string | null = null;
|
| 301 |
+
let claimedAccount: TurnitinAccount | null = null;
|
| 302 |
+
let initialLastCompletedStep: string | undefined;
|
| 303 |
+
let currentAttemptCount = (job.attempt_count as number) || 0;
|
| 304 |
+
const maxAttempts = (job.max_attempts as number) || 3;
|
| 305 |
+
|
| 306 |
+
// NEW-BUG-3 FIX: Declare tmpDir outside try so the finally block can always clean up.
|
| 307 |
+
const tmpDir = path.join(os.tmpdir(), `turnitin-job-${jobId}`);
|
| 308 |
+
const assignmentTargetId = job.assignment_target_id as string;
|
| 309 |
+
let assignmentTarget = defaultAssignmentTarget();
|
| 310 |
+
let accountPoolKey = DEFAULT_ACCOUNT_POOL_KEY;
|
| 311 |
+
|
| 312 |
+
try {
|
| 313 |
+
// ── Re-check job status before processing ──
|
| 314 |
+
// Another worker may have already picked this job up or cancelled it.
|
| 315 |
+
const freshJob = await getJobById(jobId);
|
| 316 |
+
if (!freshJob || !['claiming_account', 'pending'].includes(freshJob.status)) {
|
| 317 |
+
jobLog.warn('Job no longer claimable; skipping', {
|
| 318 |
+
currentStatus: freshJob?.status ?? 'not_found',
|
| 319 |
+
});
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
// Increment attempt count
|
| 324 |
+
currentAttemptCount = await incrementJobAttempt(jobId);
|
| 325 |
+
|
| 326 |
+
// If a previous attempt already submitted the file, keep retrying with
|
| 327 |
+
// the same Turnitin account. A different account generally cannot access
|
| 328 |
+
// the existing report viewer URL and must not submit the file again.
|
| 329 |
+
initialLastCompletedStep = freshJob.last_completed_step || undefined;
|
| 330 |
+
const resumeNeedsSameAccount = Boolean(
|
| 331 |
+
initialLastCompletedStep &&
|
| 332 |
+
RESUME_PROTECTED_STEPS.includes(initialLastCompletedStep) &&
|
| 333 |
+
freshJob.identity_id,
|
| 334 |
+
);
|
| 335 |
+
assignmentTarget = await loadAssignmentTarget(assignmentTargetId, jobLog);
|
| 336 |
+
accountPoolKey =
|
| 337 |
+
assignmentTarget.accountPoolKey ||
|
| 338 |
+
(assignmentTarget.uiVariant === 'legacy_carta' ? 'legacy_carta' : DEFAULT_ACCOUNT_POOL_KEY);
|
| 339 |
+
|
| 340 |
+
// Claim an available Turnitin account
|
| 341 |
+
await updateJobStatus(jobId, 'claiming_account');
|
| 342 |
+
await emitEvent(jobId, null, 'info', 'claiming_account', 'Looking for available Turnitin account', {
|
| 343 |
+
accountPoolKey,
|
| 344 |
+
uiVariant: assignmentTarget.uiVariant,
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
// Before submission, every retry may safely rotate to another account.
|
| 348 |
+
// Keeping a pre-submit job attached to an account that has since become
|
| 349 |
+
// quota_limited makes it wait forever even when the pool has free accounts.
|
| 350 |
+
// Post-submit checkpoints remain pinned to the original account to avoid
|
| 351 |
+
// uploading the same file again.
|
| 352 |
+
const account = resumeNeedsSameAccount
|
| 353 |
+
? await claimSpecificAccountForResume(freshJob.identity_id as string, config.workerId)
|
| 354 |
+
: await claimAccountForJob(job, identityId, jobLog, accountPoolKey);
|
| 355 |
+
|
| 356 |
+
if (!account) {
|
| 357 |
+
if (resumeNeedsSameAccount) {
|
| 358 |
+
const previousAccount = await getTurnitinAccountById(
|
| 359 |
+
freshJob.identity_id as string,
|
| 360 |
+
).catch(() => null);
|
| 361 |
+
if (
|
| 362 |
+
!previousAccount ||
|
| 363 |
+
['disabled', 'login_failed'].includes(previousAccount.turnitin_status)
|
| 364 |
+
) {
|
| 365 |
+
throw new Error(
|
| 366 |
+
'The Turnitin account that owns the submitted file is no longer available for report recovery.',
|
| 367 |
+
);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const message =
|
| 371 |
+
'Previous submission is still locked by its Turnitin account; retrying shortly.';
|
| 372 |
+
await updateJobStatus(jobId, 'waiting_account', {
|
| 373 |
+
error_message: message,
|
| 374 |
+
attempt_count: Math.max(0, currentAttemptCount - 1),
|
| 375 |
+
next_retry_at: new Date(Date.now() + RESUME_ACCOUNT_RETRY_DELAY_MS).toISOString(),
|
| 376 |
+
});
|
| 377 |
+
await emitEvent(jobId, freshJob.identity_id, 'warning', 'waiting_account', message);
|
| 378 |
+
}
|
| 379 |
+
return;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
claimedAccount = account;
|
| 383 |
+
identityId = account.id;
|
| 384 |
+
jobLog.info('Account claimed', { accountId: identityId, email: account.email });
|
| 385 |
+
|
| 386 |
+
if (
|
| 387 |
+
accountPoolKey === MODERN_ONE_POOL_KEY &&
|
| 388 |
+
(
|
| 389 |
+
account.turnitin_quota_limit !== 1 ||
|
| 390 |
+
typeof account.turnitin_quota_remaining !== 'number' ||
|
| 391 |
+
account.turnitin_quota_remaining > 1
|
| 392 |
+
)
|
| 393 |
+
) {
|
| 394 |
+
await updateAccountQuota(identityId, {
|
| 395 |
+
turnitin_quota_limit: 1,
|
| 396 |
+
turnitin_quota_remaining:
|
| 397 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 398 |
+
? Math.min(1, account.turnitin_quota_remaining)
|
| 399 |
+
: 1,
|
| 400 |
+
}).catch(() => {});
|
| 401 |
+
account.turnitin_quota_limit = 1;
|
| 402 |
+
account.turnitin_quota_remaining =
|
| 403 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 404 |
+
? Math.min(1, account.turnitin_quota_remaining)
|
| 405 |
+
: 1;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
if (
|
| 409 |
+
accountPoolKey === LEGACY_ACCOUNT_POOL_KEY &&
|
| 410 |
+
(
|
| 411 |
+
account.turnitin_quota_limit !== LEGACY_ACCOUNT_QUOTA_LIMIT ||
|
| 412 |
+
typeof account.turnitin_quota_remaining !== 'number' ||
|
| 413 |
+
account.turnitin_quota_remaining > LEGACY_ACCOUNT_QUOTA_LIMIT
|
| 414 |
+
)
|
| 415 |
+
) {
|
| 416 |
+
await updateAccountQuota(identityId, {
|
| 417 |
+
turnitin_quota_limit: LEGACY_ACCOUNT_QUOTA_LIMIT,
|
| 418 |
+
turnitin_quota_remaining:
|
| 419 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 420 |
+
? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining)
|
| 421 |
+
: LEGACY_ACCOUNT_QUOTA_LIMIT,
|
| 422 |
+
}).catch(() => {});
|
| 423 |
+
account.turnitin_quota_limit = LEGACY_ACCOUNT_QUOTA_LIMIT;
|
| 424 |
+
account.turnitin_quota_remaining =
|
| 425 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 426 |
+
? Math.min(LEGACY_ACCOUNT_QUOTA_LIMIT, account.turnitin_quota_remaining)
|
| 427 |
+
: LEGACY_ACCOUNT_QUOTA_LIMIT;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
// Update job with account reference
|
| 431 |
+
await updateJobStatus(jobId, 'running', {
|
| 432 |
+
identity_id: identityId,
|
| 433 |
+
started_at: new Date().toISOString(),
|
| 434 |
+
});
|
| 435 |
+
await emitEvent(jobId, identityId, 'info', 'running', `Starting job with account ${account.email}`);
|
| 436 |
+
|
| 437 |
+
// Create temp directory for this job
|
| 438 |
+
fs.mkdirSync(tmpDir, { recursive: true });
|
| 439 |
+
|
| 440 |
+
// Download input file from Supabase Storage, except for quota_check jobs.
|
| 441 |
+
const localInputPath = job.mode === 'quota_check'
|
| 442 |
+
? ''
|
| 443 |
+
: path.join(tmpDir, job.input_file_name as string);
|
| 444 |
+
if (job.mode !== 'quota_check') {
|
| 445 |
+
await downloadInputFile(job.input_file_path as string, localInputPath);
|
| 446 |
+
await emitEvent(jobId, identityId, 'info', 'file_downloaded', 'Input file downloaded');
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
// Get storage state for session reuse
|
| 450 |
+
let storageStatePath: string | undefined;
|
| 451 |
+
if (account.turnitin_session_storage_path) {
|
| 452 |
+
const storageState = await downloadStorageState(account.turnitin_session_storage_path);
|
| 453 |
+
if (storageState) {
|
| 454 |
+
storageStatePath = path.join(tmpDir, 'storageState.json');
|
| 455 |
+
fs.writeFileSync(storageStatePath, storageState, 'utf-8');
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Get password
|
| 460 |
+
const password = getAccountPassword();
|
| 461 |
+
|
| 462 |
+
// Parse filters from job
|
| 463 |
+
const filters = (job.filters as Record<string, unknown>) || {};
|
| 464 |
+
|
| 465 |
+
// ── Determine resume point ──
|
| 466 |
+
// If a previous attempt already uploaded the file, we must NOT upload
|
| 467 |
+
// again. The engine's `resumeAfterStep` tells it to skip earlier steps.
|
| 468 |
+
const lastCompletedStep = initialLastCompletedStep;
|
| 469 |
+
const effectiveMode =
|
| 470 |
+
lastCompletedStep && ['submitted', 'similarity', 'viewer', 'filters', 'download', 'receipt'].includes(lastCompletedStep)
|
| 471 |
+
? 'resubmit' as const // force resubmit because file is already there
|
| 472 |
+
: (job.mode as 'upload' | 'resubmit' | 'quota_check');
|
| 473 |
+
|
| 474 |
+
if (lastCompletedStep) {
|
| 475 |
+
jobLog.info('Resuming job from previous step', {
|
| 476 |
+
lastCompletedStep,
|
| 477 |
+
effectiveMode,
|
| 478 |
+
});
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Prepare job input
|
| 482 |
+
const jobInput: RunTurnitinJobInput = {
|
| 483 |
+
account: {
|
| 484 |
+
id: identityId,
|
| 485 |
+
email: account.email,
|
| 486 |
+
password,
|
| 487 |
+
quotaLimit: accountQuotaLimitForPool(accountPoolKey, account),
|
| 488 |
+
quotaRemaining:
|
| 489 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 490 |
+
? account.turnitin_quota_remaining
|
| 491 |
+
: null,
|
| 492 |
+
},
|
| 493 |
+
assignmentTarget,
|
| 494 |
+
inputFilePath: localInputPath,
|
| 495 |
+
inputFileName: job.input_file_name as string,
|
| 496 |
+
inputFileSize: job.input_file_size ?? undefined,
|
| 497 |
+
outputDir: tmpDir,
|
| 498 |
+
mode: effectiveMode,
|
| 499 |
+
filters: {
|
| 500 |
+
excludeBibliography: filters.excludeBibliography as boolean | undefined,
|
| 501 |
+
excludeQuotes: filters.excludeQuotes as boolean | undefined,
|
| 502 |
+
excludeCitations: filters.excludeCitations as boolean | undefined,
|
| 503 |
+
excludeSmallMatches: filters.excludeSmallMatches as boolean | undefined,
|
| 504 |
+
smallMatchMode: filters.smallMatchMode as 'words' | 'percent' | 'off' | null | undefined,
|
| 505 |
+
smallMatchThreshold: filters.smallMatchThreshold as number | null | undefined,
|
| 506 |
+
},
|
| 507 |
+
storageStatePath,
|
| 508 |
+
resumeAfterStep: lastCompletedStep,
|
| 509 |
+
resumeViewerUrl: freshJob.viewer_url,
|
| 510 |
+
attemptCount: currentAttemptCount,
|
| 511 |
+
onEvent: async (event) => {
|
| 512 |
+
await emitEvent(jobId, identityId, event.level, event.step, event.message, event.metadata);
|
| 513 |
+
await persistJobProgressFromEvent(jobId, event);
|
| 514 |
+
},
|
| 515 |
+
};
|
| 516 |
+
|
| 517 |
+
// Run the Playwright job
|
| 518 |
+
const result = await runTurnitinJob(jobInput);
|
| 519 |
+
|
| 520 |
+
// Handle quota limit
|
| 521 |
+
if (result.quotaLimit) {
|
| 522 |
+
jobLog.warn('Account quota limited', { limit: result.quotaLimit });
|
| 523 |
+
await updateAccountQuota(identityId, {
|
| 524 |
+
turnitin_status: 'quota_limited',
|
| 525 |
+
turnitin_quota_remaining: 0,
|
| 526 |
+
turnitin_quota_message: result.quotaLimit.message,
|
| 527 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 528 |
+
turnitin_next_retry_at:
|
| 529 |
+
accountPoolKey === MODERN_ONE_POOL_KEY
|
| 530 |
+
? null
|
| 531 |
+
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 532 |
+
});
|
| 533 |
+
await releaseAccount(identityId, 'quota_limited', result.quotaLimit.message);
|
| 534 |
+
|
| 535 |
+
const availableAccounts = await countAvailableAccounts(accountPoolKey);
|
| 536 |
+
// Retry with a different account only when one is immediately available.
|
| 537 |
+
if (currentAttemptCount < maxAttempts && availableAccounts > 0) {
|
| 538 |
+
await updateJobStatus(jobId, 'pending');
|
| 539 |
+
await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep);
|
| 540 |
+
await emitEvent(jobId, identityId, 'warning', 'quota_limited', 'Account quota limited, retrying with different account');
|
| 541 |
+
} else {
|
| 542 |
+
await updateJobStatus(jobId, 'failed', {
|
| 543 |
+
error_message: result.quotaLimit.message,
|
| 544 |
+
finished_at: new Date().toISOString(),
|
| 545 |
+
});
|
| 546 |
+
const refunded = await refundFailedTicketOnce(
|
| 547 |
+
jobId,
|
| 548 |
+
result.quotaLimit.message,
|
| 549 |
+
jobLog,
|
| 550 |
+
);
|
| 551 |
+
await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep);
|
| 552 |
+
await emitEvent(jobId, identityId, 'error', 'quota_limited', result.quotaLimit.message, {
|
| 553 |
+
refunded,
|
| 554 |
+
});
|
| 555 |
+
}
|
| 556 |
+
return;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
// Upload PDF report if available
|
| 560 |
+
let outputPdfPath: string | undefined;
|
| 561 |
+
let outputPdfExpiresAt: string | undefined;
|
| 562 |
+
|
| 563 |
+
if (result.outputPdfPath && fs.existsSync(result.outputPdfPath)) {
|
| 564 |
+
const uploadResult = await uploadReportPdf(
|
| 565 |
+
job.user_id as string,
|
| 566 |
+
jobId,
|
| 567 |
+
result.outputPdfPath
|
| 568 |
+
);
|
| 569 |
+
outputPdfPath = uploadResult.storagePath;
|
| 570 |
+
outputPdfExpiresAt = uploadResult.expiresAt;
|
| 571 |
+
await emitEvent(jobId, identityId, 'info', 'pdf_uploaded', 'PDF report uploaded to storage');
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
let receiptPdfPath: string | undefined;
|
| 575 |
+
let receiptPdfExpiresAt: string | undefined;
|
| 576 |
+
|
| 577 |
+
if (result.receiptPdfPath && fs.existsSync(result.receiptPdfPath)) {
|
| 578 |
+
const uploadResult = await uploadReceiptPdf(
|
| 579 |
+
job.user_id as string,
|
| 580 |
+
jobId,
|
| 581 |
+
result.receiptPdfPath,
|
| 582 |
+
);
|
| 583 |
+
receiptPdfPath = uploadResult.storagePath;
|
| 584 |
+
receiptPdfExpiresAt = uploadResult.expiresAt;
|
| 585 |
+
await emitEvent(
|
| 586 |
+
jobId,
|
| 587 |
+
identityId,
|
| 588 |
+
'info',
|
| 589 |
+
'receipt_uploaded',
|
| 590 |
+
'Digital Receipt uploaded to storage',
|
| 591 |
+
);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
// Save storage state for session reuse
|
| 595 |
+
try {
|
| 596 |
+
// The engine should have saved the storage state; we read and upload it
|
| 597 |
+
const stateFile = path.join(tmpDir, 'storageState.json');
|
| 598 |
+
if (fs.existsSync(stateFile)) {
|
| 599 |
+
const stateJson = fs.readFileSync(stateFile, 'utf-8');
|
| 600 |
+
const storagePath = await uploadStorageState(identityId, stateJson);
|
| 601 |
+
await updateAccountQuota(identityId, {
|
| 602 |
+
turnitin_session_storage_path: storagePath,
|
| 603 |
+
});
|
| 604 |
+
}
|
| 605 |
+
} catch {
|
| 606 |
+
jobLog.warn('Failed to save storage state');
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
// An administrator may fail/cancel a job while Playwright is already in
|
| 610 |
+
// the viewer. Persist completion only if the job is still active so an
|
| 611 |
+
// in-flight callback cannot revive a terminal job.
|
| 612 |
+
const completionPersisted = await completeJobIfActive(jobId, {
|
| 613 |
+
viewer_url: result.viewerUrl,
|
| 614 |
+
similarity_percent: result.similarityPercent,
|
| 615 |
+
output_pdf_path: outputPdfPath,
|
| 616 |
+
output_pdf_expires_at: outputPdfExpiresAt,
|
| 617 |
+
receipt_pdf_path: receiptPdfPath,
|
| 618 |
+
receipt_pdf_expires_at: receiptPdfExpiresAt,
|
| 619 |
+
error_message: null,
|
| 620 |
+
finished_at: new Date().toISOString(),
|
| 621 |
+
});
|
| 622 |
+
if (!completionPersisted) {
|
| 623 |
+
const terminalArtifacts: Partial<TurnitinJob> = {};
|
| 624 |
+
if (outputPdfPath) terminalArtifacts.output_pdf_path = outputPdfPath;
|
| 625 |
+
if (outputPdfExpiresAt) terminalArtifacts.output_pdf_expires_at = outputPdfExpiresAt;
|
| 626 |
+
if (receiptPdfPath) terminalArtifacts.receipt_pdf_path = receiptPdfPath;
|
| 627 |
+
if (receiptPdfExpiresAt) {
|
| 628 |
+
terminalArtifacts.receipt_pdf_expires_at = receiptPdfExpiresAt;
|
| 629 |
+
}
|
| 630 |
+
if (Object.keys(terminalArtifacts).length > 0) {
|
| 631 |
+
await safeUpdateJobFields(jobId, terminalArtifacts);
|
| 632 |
+
}
|
| 633 |
+
}
|
| 634 |
+
if (result.submissionDetails) {
|
| 635 |
+
await safeUpdateJobFields(jobId, {
|
| 636 |
+
submission_details: result.submissionDetails as Record<string, unknown>,
|
| 637 |
+
});
|
| 638 |
+
}
|
| 639 |
+
await safeUpdateLastCompletedStep(jobId, result.lastCompletedStep || 'download');
|
| 640 |
+
|
| 641 |
+
// Insert submission record
|
| 642 |
+
try {
|
| 643 |
+
const { supabase } = await import('../db/client');
|
| 644 |
+
await supabase.from('turnitin_submissions').insert({
|
| 645 |
+
job_id: jobId,
|
| 646 |
+
identity_id: identityId,
|
| 647 |
+
assignment_target_id: assignmentTargetId,
|
| 648 |
+
input_file_name: job.input_file_name as string,
|
| 649 |
+
input_file_size: job.input_file_size,
|
| 650 |
+
input_file_sha256: job.input_file_sha256,
|
| 651 |
+
viewer_url: result.viewerUrl,
|
| 652 |
+
similarity_percent: result.similarityPercent,
|
| 653 |
+
submission_details: result.submissionDetails as Record<string, unknown> | undefined,
|
| 654 |
+
filters_applied: job.filters,
|
| 655 |
+
pdf_path: outputPdfPath,
|
| 656 |
+
pdf_expires_at: outputPdfExpiresAt,
|
| 657 |
+
receipt_pdf_path: receiptPdfPath,
|
| 658 |
+
receipt_pdf_expires_at: receiptPdfExpiresAt,
|
| 659 |
+
submitted_at: result.submittedAt,
|
| 660 |
+
});
|
| 661 |
+
} catch (err) {
|
| 662 |
+
jobLog.error('Failed to insert submission record', {
|
| 663 |
+
error: err instanceof Error ? err.message : String(err),
|
| 664 |
+
});
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
// Release account. A final-submission warning means this successful submit
|
| 668 |
+
// likely consumed the last available submission, so keep the account out of
|
| 669 |
+
// rotation until the cooldown window passes.
|
| 670 |
+
if (result.permanentLimit) {
|
| 671 |
+
await updateAccountQuota(identityId, {
|
| 672 |
+
turnitin_status: 'quota_limited',
|
| 673 |
+
turnitin_quota_limit: accountQuotaLimitForPool(accountPoolKey, account),
|
| 674 |
+
turnitin_quota_remaining: 0,
|
| 675 |
+
turnitin_quota_message: result.permanentLimit.message,
|
| 676 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 677 |
+
turnitin_next_retry_at: null,
|
| 678 |
+
turnitin_last_success_at: new Date().toISOString(),
|
| 679 |
+
});
|
| 680 |
+
await releaseAccount(identityId, 'quota_limited', result.permanentLimit.message);
|
| 681 |
+
} else if (result.quotaCooldown) {
|
| 682 |
+
await updateAccountQuota(identityId, {
|
| 683 |
+
turnitin_status: 'cooling_down',
|
| 684 |
+
turnitin_quota_remaining: null,
|
| 685 |
+
turnitin_quota_message: result.quotaCooldown.message,
|
| 686 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 687 |
+
turnitin_next_retry_at: result.quotaCooldown.nextRetryAt,
|
| 688 |
+
turnitin_last_success_at: new Date().toISOString(),
|
| 689 |
+
});
|
| 690 |
+
await releaseAccount(identityId, 'cooling_down', result.quotaCooldown.message);
|
| 691 |
+
} else if (result.quotaWarning) {
|
| 692 |
+
await updateAccountQuota(identityId, {
|
| 693 |
+
turnitin_status: 'quota_limited',
|
| 694 |
+
turnitin_quota_remaining: 0,
|
| 695 |
+
turnitin_quota_message: result.quotaWarning,
|
| 696 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 697 |
+
turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 698 |
+
});
|
| 699 |
+
await releaseAccount(identityId, 'quota_limited', result.quotaWarning);
|
| 700 |
+
} else {
|
| 701 |
+
const remainingFromSubmissionCount =
|
| 702 |
+
typeof result.submissionCount === 'number'
|
| 703 |
+
? Math.max(0, accountQuotaLimitForPool(accountPoolKey, account) - result.submissionCount)
|
| 704 |
+
: null;
|
| 705 |
+
const currentRemaining =
|
| 706 |
+
typeof account.turnitin_quota_remaining === 'number'
|
| 707 |
+
? account.turnitin_quota_remaining
|
| 708 |
+
: accountQuotaLimitForPool(accountPoolKey, account);
|
| 709 |
+
const nextRemaining =
|
| 710 |
+
remainingFromSubmissionCount ?? Math.max(0, currentRemaining - 1);
|
| 711 |
+
if (assignmentTarget.uiVariant === LEGACY_ACCOUNT_POOL_KEY && nextRemaining <= 0) {
|
| 712 |
+
const cooldownMessage =
|
| 713 |
+
'Legacy Turnitin account reached 4 submissions. Account is permanently limited because the class should be dropped.';
|
| 714 |
+
await updateAccountQuota(identityId, {
|
| 715 |
+
turnitin_status: 'quota_limited',
|
| 716 |
+
turnitin_quota_remaining: 0,
|
| 717 |
+
turnitin_quota_message: cooldownMessage,
|
| 718 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 719 |
+
turnitin_next_retry_at: null,
|
| 720 |
+
turnitin_last_success_at: new Date().toISOString(),
|
| 721 |
+
});
|
| 722 |
+
await releaseAccount(identityId, 'quota_limited', cooldownMessage);
|
| 723 |
+
} else if (accountPoolKey === MODERN_ONE_POOL_KEY && nextRemaining <= 0) {
|
| 724 |
+
const oneUseMessage =
|
| 725 |
+
'Modern one-use account consumed its single allowed submission. Account is permanently limited.';
|
| 726 |
+
await updateAccountQuota(identityId, {
|
| 727 |
+
turnitin_status: 'quota_limited',
|
| 728 |
+
turnitin_quota_limit: 1,
|
| 729 |
+
turnitin_quota_remaining: 0,
|
| 730 |
+
turnitin_quota_message: oneUseMessage,
|
| 731 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 732 |
+
turnitin_next_retry_at: null,
|
| 733 |
+
turnitin_last_success_at: new Date().toISOString(),
|
| 734 |
+
});
|
| 735 |
+
await releaseAccount(identityId, 'quota_limited', oneUseMessage);
|
| 736 |
+
} else {
|
| 737 |
+
await updateAccountQuota(identityId, {
|
| 738 |
+
turnitin_status: 'available',
|
| 739 |
+
turnitin_quota_remaining: nextRemaining,
|
| 740 |
+
turnitin_quota_message: null,
|
| 741 |
+
turnitin_next_retry_at: null,
|
| 742 |
+
turnitin_last_success_at: new Date().toISOString(),
|
| 743 |
+
});
|
| 744 |
+
await releaseAccount(identityId, 'available');
|
| 745 |
+
}
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
if (completionPersisted) {
|
| 749 |
+
await emitEvent(jobId, identityId, 'info', 'completed', `Job completed. Similarity: ${result.similarityPercent ?? 'N/A'}%`);
|
| 750 |
+
jobLog.info('Job completed successfully', {
|
| 751 |
+
similarity: result.similarityPercent,
|
| 752 |
+
viewerUrl: result.viewerUrl,
|
| 753 |
+
});
|
| 754 |
+
} else {
|
| 755 |
+
await emitEvent(
|
| 756 |
+
jobId,
|
| 757 |
+
identityId,
|
| 758 |
+
'warning',
|
| 759 |
+
'terminal_status_preserved',
|
| 760 |
+
'Worker cleanup finished after the job was stopped; terminal status preserved.',
|
| 761 |
+
);
|
| 762 |
+
jobLog.warn('Worker finished after job entered a terminal state; completion was not persisted');
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
// Cleanup temp directory (moved to finally, see below)
|
| 766 |
+
} catch (err) {
|
| 767 |
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
| 768 |
+
const publicErrorMessage = compactWorkerMessage(errorMessage);
|
| 769 |
+
logger.error('Job failed', { jobId, error: publicErrorMessage });
|
| 770 |
+
|
| 771 |
+
// Try to extract lastCompletedStep from the engine result (it's set
|
| 772 |
+
// on the result object even before throwing because the steps record
|
| 773 |
+
// progress incrementally). We also check the error object itself in
|
| 774 |
+
// case the engine attached the step there.
|
| 775 |
+
const failedResultStep: string | undefined =
|
| 776 |
+
(err as any)?.lastCompletedStep ||
|
| 777 |
+
undefined;
|
| 778 |
+
const failedViewerUrl: string | undefined = (err as any)?.viewerUrl || undefined;
|
| 779 |
+
const failedSimilarityPercent: number | undefined =
|
| 780 |
+
typeof (err as any)?.similarityPercent === 'number'
|
| 781 |
+
? (err as any).similarityPercent
|
| 782 |
+
: undefined;
|
| 783 |
+
|
| 784 |
+
const isQuotaLimit =
|
| 785 |
+
err instanceof Error &&
|
| 786 |
+
(err.name === 'SubmissionQuotaLimitError' ||
|
| 787 |
+
/reached your limit|submission quota limit/i.test(errorMessage));
|
| 788 |
+
const isTargetUnavailable =
|
| 789 |
+
/waiting for locator\('td\.class_name a|waiting for locator\('tr\.assignment-row|Class title|class.*not found|assignment.*not found|Summer Reading 2026/i.test(
|
| 790 |
+
errorMessage,
|
| 791 |
+
);
|
| 792 |
+
|
| 793 |
+
// Determine whether the file was already uploaded on THIS attempt.
|
| 794 |
+
// If so, subsequent retries MUST NOT re-upload.
|
| 795 |
+
// We detect this from the job status transitions that went through
|
| 796 |
+
// the emitEvent calls above (e.g. the 'submitted' event).
|
| 797 |
+
let stepToSave: string | null = failedResultStep || null;
|
| 798 |
+
if (!stepToSave) {
|
| 799 |
+
// Fallback: if the error happened after similarity/viewer/download
|
| 800 |
+
// the job status will have been updated through updateJobStatus.
|
| 801 |
+
const latestJob = await getJobById(jobId).catch(() => null);
|
| 802 |
+
stepToSave = latestJob?.last_completed_step || null;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
const failureProgress: Partial<TurnitinJob> = {};
|
| 806 |
+
if (stepToSave) failureProgress.last_completed_step = stepToSave;
|
| 807 |
+
if (failedViewerUrl) failureProgress.viewer_url = failedViewerUrl;
|
| 808 |
+
if (typeof failedSimilarityPercent === 'number') {
|
| 809 |
+
failureProgress.similarity_percent = failedSimilarityPercent;
|
| 810 |
+
}
|
| 811 |
+
if (Object.keys(failureProgress).length > 0) {
|
| 812 |
+
await safeUpdateJobFields(jobId, failureProgress);
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
const isPostSubmitResume = Boolean(
|
| 816 |
+
stepToSave && RESUME_PROTECTED_STEPS.includes(stepToSave),
|
| 817 |
+
);
|
| 818 |
+
const availableAccounts =
|
| 819 |
+
isQuotaLimit || isTargetUnavailable
|
| 820 |
+
? await countAvailableAccounts(accountPoolKey).catch(() => 0)
|
| 821 |
+
: 1;
|
| 822 |
+
const shouldRetry =
|
| 823 |
+
currentAttemptCount < maxAttempts &&
|
| 824 |
+
(!(isQuotaLimit || isTargetUnavailable) || availableAccounts > 0);
|
| 825 |
+
const submissionConsumedThisAttempt = Boolean(
|
| 826 |
+
isPostSubmitResume && !initialLastCompletedStep,
|
| 827 |
+
);
|
| 828 |
+
const knownRemaining =
|
| 829 |
+
typeof claimedAccount?.turnitin_quota_remaining === 'number'
|
| 830 |
+
? claimedAccount.turnitin_quota_remaining
|
| 831 |
+
: null;
|
| 832 |
+
const remainingAfterConsumedSubmit =
|
| 833 |
+
knownRemaining === null ? null : Math.max(0, knownRemaining - 1);
|
| 834 |
+
const remainingAfterJob = submissionConsumedThisAttempt
|
| 835 |
+
? remainingAfterConsumedSubmit
|
| 836 |
+
: knownRemaining;
|
| 837 |
+
const legacyNeedsClassCleanup = Boolean(
|
| 838 |
+
accountPoolKey === LEGACY_ACCOUNT_POOL_KEY &&
|
| 839 |
+
isPostSubmitResume &&
|
| 840 |
+
!shouldRetry &&
|
| 841 |
+
remainingAfterJob === 0,
|
| 842 |
+
);
|
| 843 |
+
|
| 844 |
+
// Release account if claimed
|
| 845 |
+
if (identityId) {
|
| 846 |
+
const isLoginError = errorMessage.toLowerCase().includes('login');
|
| 847 |
+
try {
|
| 848 |
+
if (isQuotaLimit) {
|
| 849 |
+
await updateAccountQuota(identityId, {
|
| 850 |
+
turnitin_status: 'quota_limited',
|
| 851 |
+
turnitin_quota_remaining: 0,
|
| 852 |
+
turnitin_quota_message: errorMessage,
|
| 853 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 854 |
+
turnitin_next_retry_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 855 |
+
turnitin_last_error: errorMessage,
|
| 856 |
+
});
|
| 857 |
+
await releaseAccount(identityId, 'quota_limited', errorMessage);
|
| 858 |
+
} else if (isTargetUnavailable) {
|
| 859 |
+
await updateAccountQuota(identityId, {
|
| 860 |
+
turnitin_status: 'quota_limited',
|
| 861 |
+
turnitin_quota_remaining: 0,
|
| 862 |
+
turnitin_quota_message:
|
| 863 |
+
'Target class or assignment is not available. This account is treated as limit because the class may have been dropped.',
|
| 864 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 865 |
+
turnitin_next_retry_at: null,
|
| 866 |
+
turnitin_last_error: errorMessage,
|
| 867 |
+
});
|
| 868 |
+
await releaseAccount(identityId, 'quota_limited', errorMessage);
|
| 869 |
+
} else if (isLoginError) {
|
| 870 |
+
const loginLimitMessage =
|
| 871 |
+
'Turnitin login failed. Account removed from rotation because the credential is invalid or the account no longer exists.';
|
| 872 |
+
await updateAccountQuota(identityId, {
|
| 873 |
+
turnitin_status: 'quota_limited',
|
| 874 |
+
turnitin_quota_remaining: 0,
|
| 875 |
+
turnitin_quota_message: loginLimitMessage,
|
| 876 |
+
turnitin_quota_detected_at: new Date().toISOString(),
|
| 877 |
+
turnitin_next_retry_at: null,
|
| 878 |
+
turnitin_last_error: errorMessage,
|
| 879 |
+
}).catch(() => {});
|
| 880 |
+
await releaseAccount(identityId, 'quota_limited', errorMessage);
|
| 881 |
+
} else {
|
| 882 |
+
let nextStatus = 'available';
|
| 883 |
+
if (isPostSubmitResume) {
|
| 884 |
+
if (shouldRetry) {
|
| 885 |
+
nextStatus = 'cooling_down';
|
| 886 |
+
} else if (legacyNeedsClassCleanup) {
|
| 887 |
+
nextStatus = 'cooling_down';
|
| 888 |
+
} else if (remainingAfterJob === 0) {
|
| 889 |
+
nextStatus = 'quota_limited';
|
| 890 |
+
} else if (remainingAfterJob === null) {
|
| 891 |
+
nextStatus = 'cooling_down';
|
| 892 |
+
}
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
if (submissionConsumedThisAttempt) {
|
| 896 |
+
await updateAccountQuota(identityId, {
|
| 897 |
+
turnitin_status: nextStatus,
|
| 898 |
+
turnitin_quota_remaining: remainingAfterConsumedSubmit,
|
| 899 |
+
turnitin_quota_message:
|
| 900 |
+
legacyNeedsClassCleanup
|
| 901 |
+
? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.'
|
| 902 |
+
: remainingAfterConsumedSubmit === null
|
| 903 |
+
? 'Quota needs refresh after a failed post-submit attempt.'
|
| 904 |
+
: null,
|
| 905 |
+
turnitin_next_retry_at:
|
| 906 |
+
legacyNeedsClassCleanup
|
| 907 |
+
? new Date().toISOString()
|
| 908 |
+
: nextStatus === 'cooling_down' || nextStatus === 'quota_limited'
|
| 909 |
+
? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
| 910 |
+
: null,
|
| 911 |
+
turnitin_last_error: errorMessage,
|
| 912 |
+
}).catch(() => {});
|
| 913 |
+
} else if (nextStatus === 'cooling_down') {
|
| 914 |
+
await updateAccountQuota(identityId, {
|
| 915 |
+
turnitin_status: 'cooling_down',
|
| 916 |
+
turnitin_next_retry_at: legacyNeedsClassCleanup
|
| 917 |
+
? new Date().toISOString()
|
| 918 |
+
: claimedAccount?.turnitin_next_retry_at,
|
| 919 |
+
turnitin_last_error: errorMessage,
|
| 920 |
+
turnitin_quota_message:
|
| 921 |
+
legacyNeedsClassCleanup
|
| 922 |
+
? 'Legacy final submission was consumed, but report processing failed. Class cleanup is pending.'
|
| 923 |
+
: 'Reserved for retry after submitted file reached report viewer.',
|
| 924 |
+
}).catch(() => {});
|
| 925 |
+
} else if (nextStatus === 'quota_limited') {
|
| 926 |
+
await updateAccountQuota(identityId, {
|
| 927 |
+
turnitin_status: 'quota_limited',
|
| 928 |
+
turnitin_quota_remaining: 0,
|
| 929 |
+
turnitin_quota_message:
|
| 930 |
+
'Submitted file consumed the remaining quota before report processing failed.',
|
| 931 |
+
turnitin_next_retry_at:
|
| 932 |
+
accountPoolKey === MODERN_ONE_POOL_KEY
|
| 933 |
+
? null
|
| 934 |
+
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
| 935 |
+
turnitin_last_error: errorMessage,
|
| 936 |
+
}).catch(() => {});
|
| 937 |
+
}
|
| 938 |
+
await releaseAccount(identityId, nextStatus, errorMessage);
|
| 939 |
+
}
|
| 940 |
+
} catch {
|
| 941 |
+
// Ignore release errors
|
| 942 |
+
}
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
// A manual failure/cancellation can happen while Playwright is still
|
| 946 |
+
// unwinding. Preserve that terminal decision after account cleanup.
|
| 947 |
+
const latestJob = await getJobById(jobId).catch(() => null);
|
| 948 |
+
if (latestJob && ['failed', 'cancelled'].includes(latestJob.status)) {
|
| 949 |
+
jobLog.info('Job already terminal after worker failure; retry status not changed', {
|
| 950 |
+
status: latestJob.status,
|
| 951 |
+
});
|
| 952 |
+
return;
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
// Check if we should retry
|
| 956 |
+
if (shouldRetry) {
|
| 957 |
+
await updateJobStatus(jobId, 'pending', {
|
| 958 |
+
error_message: publicErrorMessage,
|
| 959 |
+
});
|
| 960 |
+
await safeUpdateLastCompletedStep(jobId, stepToSave);
|
| 961 |
+
await emitEvent(jobId, identityId, 'error', 'failed_retry', `Attempt failed, will retry: ${publicErrorMessage}`);
|
| 962 |
+
} else {
|
| 963 |
+
await updateJobStatus(jobId, 'failed', {
|
| 964 |
+
error_message: publicErrorMessage,
|
| 965 |
+
finished_at: new Date().toISOString(),
|
| 966 |
+
});
|
| 967 |
+
const refunded = await refundFailedTicketOnce(jobId, publicErrorMessage, jobLog);
|
| 968 |
+
await safeUpdateLastCompletedStep(jobId, stepToSave);
|
| 969 |
+
await emitEvent(
|
| 970 |
+
jobId,
|
| 971 |
+
identityId,
|
| 972 |
+
'error',
|
| 973 |
+
'failed_final',
|
| 974 |
+
refunded
|
| 975 |
+
? `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}. Ticket refunded.`
|
| 976 |
+
: `Job failed after ${currentAttemptCount} attempt(s): ${publicErrorMessage}`,
|
| 977 |
+
{ refunded },
|
| 978 |
+
);
|
| 979 |
+
}
|
| 980 |
+
} finally {
|
| 981 |
+
// NEW-BUG-3 FIX: Always clean up temp dir regardless of success or error
|
| 982 |
+
try {
|
| 983 |
+
if (fs.existsSync(tmpDir)) {
|
| 984 |
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
| 985 |
+
}
|
| 986 |
+
} catch {
|
| 987 |
+
// Ignore cleanup errors
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
// Notify the next waiting job that an account may have been released.
|
| 991 |
+
// This is part of the backpressure system — instead of all waiters
|
| 992 |
+
// polling simultaneously (thundering herd), they wait in a FIFO queue.
|
| 993 |
+
notifyNextWaiter();
|
| 994 |
+
}
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
// ---------------------------------------------------------------------------
|
| 998 |
+
// Account claiming with bounded wait & single-refund guarantee
|
| 999 |
+
// ---------------------------------------------------------------------------
|
| 1000 |
+
|
| 1001 |
+
async function claimAccountForJob(
|
| 1002 |
+
job: TurnitinJob,
|
| 1003 |
+
identityId: string | null,
|
| 1004 |
+
jobLog: ReturnType<typeof logger.child>,
|
| 1005 |
+
accountPoolKey: string,
|
| 1006 |
+
): Promise<TurnitinAccount | null> {
|
| 1007 |
+
const startedAt = Date.now();
|
| 1008 |
+
let lastWaitingEventAt = 0;
|
| 1009 |
+
|
| 1010 |
+
while (true) {
|
| 1011 |
+
// ── Re-check that the job is still ours ──
|
| 1012 |
+
// CRITICAL: also check worker_id to catch the case where another Space
|
| 1013 |
+
// worker already picked this job while we were sleeping.
|
| 1014 |
+
const freshJob = await getJobById(job.id).catch(() => null);
|
| 1015 |
+
if (!freshJob) {
|
| 1016 |
+
jobLog.warn('Job disappeared while waiting for account', { jobId: job.id });
|
| 1017 |
+
return null;
|
| 1018 |
+
}
|
| 1019 |
+
if (['cancelled', 'completed', 'failed'].includes(freshJob.status)) {
|
| 1020 |
+
jobLog.info('Job moved to terminal state while waiting for account; stopping', {
|
| 1021 |
+
jobId: job.id,
|
| 1022 |
+
status: freshJob.status,
|
| 1023 |
+
});
|
| 1024 |
+
return null;
|
| 1025 |
+
}
|
| 1026 |
+
// If another worker took ownership of this job, stop immediately.
|
| 1027 |
+
if (freshJob.worker_id && freshJob.worker_id !== config.workerId) {
|
| 1028 |
+
jobLog.warn('Job was taken by another worker while waiting for account; stopping', {
|
| 1029 |
+
jobId: job.id,
|
| 1030 |
+
ownedBy: freshJob.worker_id,
|
| 1031 |
+
thisWorker: config.workerId,
|
| 1032 |
+
});
|
| 1033 |
+
return null;
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
const account = await claimAvailableAccount(config.workerId, accountPoolKey);
|
| 1037 |
+
if (account) {
|
| 1038 |
+
// Double-check ownership one final time to prevent double-processing
|
| 1039 |
+
// that can happen when both workers exit the wait loop simultaneously.
|
| 1040 |
+
const jobAfterClaim = await getJobById(job.id).catch(() => null);
|
| 1041 |
+
if (
|
| 1042 |
+
!jobAfterClaim ||
|
| 1043 |
+
['cancelled', 'completed', 'failed'].includes(jobAfterClaim.status) ||
|
| 1044 |
+
(jobAfterClaim.worker_id && jobAfterClaim.worker_id !== config.workerId)
|
| 1045 |
+
) {
|
| 1046 |
+
jobLog.warn('Job ownership lost right after claiming account; releasing account and stopping', {
|
| 1047 |
+
jobId: job.id,
|
| 1048 |
+
status: jobAfterClaim?.status,
|
| 1049 |
+
ownedBy: jobAfterClaim?.worker_id,
|
| 1050 |
+
});
|
| 1051 |
+
// Release the just-claimed account back to the pool
|
| 1052 |
+
await releaseAccount(account.id, 'available').catch(() => {});
|
| 1053 |
+
return null;
|
| 1054 |
+
}
|
| 1055 |
+
return account;
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
const poolState = await getAccountPoolState(accountPoolKey);
|
| 1059 |
+
|
| 1060 |
+
// ── Wait only if there are running accounts AND we haven't timed out ──
|
| 1061 |
+
if (poolState.running > 0 && Date.now() - startedAt < ACCOUNT_WAIT_MAX_MS) {
|
| 1062 |
+
const message = `No free account right now. Waiting for ${poolState.running} running account(s) to finish.`;
|
| 1063 |
+
// Keep status as waiting_account — do NOT reset to claiming_account here.
|
| 1064 |
+
// Resetting to claiming_account would allow another worker's claim_turnitin_job
|
| 1065 |
+
// RPC to pick up this job, causing double processing.
|
| 1066 |
+
await updateJobStatus(job.id, 'waiting_account', {
|
| 1067 |
+
error_message: message,
|
| 1068 |
+
});
|
| 1069 |
+
|
| 1070 |
+
if (Date.now() - lastWaitingEventAt > 60000) {
|
| 1071 |
+
lastWaitingEventAt = Date.now();
|
| 1072 |
+
await emitEvent(job.id, identityId, 'warning', 'waiting_account', message, {
|
| 1073 |
+
poolState,
|
| 1074 |
+
});
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
// BACKPRESSURE: Instead of fixed-interval polling (sleep 15s), wait
|
| 1078 |
+
// in the FIFO queue for a notification from a finishing job. Fall back
|
| 1079 |
+
// to the old sleep interval if no notification arrives.
|
| 1080 |
+
await waitForAccountRelease(ACCOUNT_WAIT_POLL_MS);
|
| 1081 |
+
// Do NOT reset to claiming_account — keep as waiting_account
|
| 1082 |
+
continue;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
// ── No accounts available and nothing running (or timed out) ──
|
| 1086 |
+
// Cancel the job and refund the ticket once. The database RPC is
|
| 1087 |
+
// idempotent, while the local marker only prevents duplicate attempts
|
| 1088 |
+
// after a successful cancellation/refund.
|
| 1089 |
+
if (wasRefunded(job.id)) {
|
| 1090 |
+
jobLog.warn('Refund already issued for this job; skipping duplicate refund', {
|
| 1091 |
+
jobId: job.id,
|
| 1092 |
+
});
|
| 1093 |
+
// Set job to failed terminal state so neither worker picks it up again.
|
| 1094 |
+
await updateJobStatus(job.id, 'failed', {
|
| 1095 |
+
error_message: 'All Turnitin accounts are out of quota or cooling down. Ticket already refunded.',
|
| 1096 |
+
finished_at: new Date().toISOString(),
|
| 1097 |
+
});
|
| 1098 |
+
return null;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
const cancelMessage =
|
| 1102 |
+
poolState.total === 0
|
| 1103 |
+
? 'No Turnitin accounts are configured. Ticket refunded.'
|
| 1104 |
+
: 'All Turnitin accounts are out of quota or cooling down. Ticket refunded.';
|
| 1105 |
+
|
| 1106 |
+
jobLog.warn('No claimable Turnitin accounts; cancelling and refunding ticket', {
|
| 1107 |
+
poolState,
|
| 1108 |
+
});
|
| 1109 |
+
|
| 1110 |
+
try {
|
| 1111 |
+
const cancelled = await cancelJob(job.user_id as string, job.id);
|
| 1112 |
+
if (cancelled) {
|
| 1113 |
+
markAsRefunded(job.id);
|
| 1114 |
+
}
|
| 1115 |
+
await emitEvent(job.id, identityId, 'error', 'no_account', cancelMessage, {
|
| 1116 |
+
poolState,
|
| 1117 |
+
refunded: cancelled,
|
| 1118 |
+
});
|
| 1119 |
+
} catch (error) {
|
| 1120 |
+
const message =
|
| 1121 |
+
error instanceof Error ? compactWorkerMessage(error.message) : String(error);
|
| 1122 |
+
|
| 1123 |
+
// If the cancel RPC failed because the job is in a wrong state (e.g.
|
| 1124 |
+
// already cancelled by the other worker), just mark it failed.
|
| 1125 |
+
jobLog.error('Failed to cancel job', {
|
| 1126 |
+
userId: job.user_id,
|
| 1127 |
+
jobId: job.id,
|
| 1128 |
+
error: message,
|
| 1129 |
+
});
|
| 1130 |
+
|
| 1131 |
+
await updateJobStatus(job.id, 'failed', {
|
| 1132 |
+
error_message: `${cancelMessage} Refund note: ${message}`,
|
| 1133 |
+
finished_at: new Date().toISOString(),
|
| 1134 |
+
}).catch(() => {});
|
| 1135 |
+
const refunded = await refundFailedTicketOnce(
|
| 1136 |
+
job.id,
|
| 1137 |
+
`${cancelMessage} Fallback after cancel error: ${message}`,
|
| 1138 |
+
jobLog,
|
| 1139 |
+
);
|
| 1140 |
+
|
| 1141 |
+
await emitEvent(
|
| 1142 |
+
job.id,
|
| 1143 |
+
identityId,
|
| 1144 |
+
'error',
|
| 1145 |
+
'no_account',
|
| 1146 |
+
refunded
|
| 1147 |
+
? `${cancelMessage} Refund recovered after cancel error.`
|
| 1148 |
+
: `${cancelMessage} Refund note: ${message}`,
|
| 1149 |
+
{ poolState, refunded },
|
| 1150 |
+
);
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
return null;
|
| 1154 |
+
}
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
function compactWorkerMessage(message: string): string {
|
| 1158 |
+
const firstLine = message.split('\n').map((line) => line.trim()).find(Boolean) || message;
|
| 1159 |
+
if (/locator\.waitFor: Timeout/i.test(firstLine)) {
|
| 1160 |
+
const selector = firstLine.match(/locator\('([^']+)'/i)?.[1];
|
| 1161 |
+
return selector
|
| 1162 |
+
? `Turnitin page element did not appear in time: ${selector}`
|
| 1163 |
+
: 'Turnitin page element did not appear in time.';
|
| 1164 |
+
}
|
| 1165 |
+
if (/Upload file was selected, but no Upload\/Confirm\/Submit button was found/i.test(firstLine)) {
|
| 1166 |
+
return 'File was selected, but Turnitin did not show a usable upload confirmation button.';
|
| 1167 |
+
}
|
| 1168 |
+
return firstLine.length > 260 ? `${firstLine.slice(0, 257)}...` : firstLine;
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
/**
|
| 1172 |
+
* Helper to emit a job event.
|
| 1173 |
+
*/
|
| 1174 |
+
async function emitEvent(
|
| 1175 |
+
jobId: string,
|
| 1176 |
+
identityId: string | null,
|
| 1177 |
+
level: 'info' | 'warning' | 'error',
|
| 1178 |
+
step: string,
|
| 1179 |
+
message: string,
|
| 1180 |
+
metadata?: Record<string, unknown>
|
| 1181 |
+
): Promise<void> {
|
| 1182 |
+
try {
|
| 1183 |
+
await insertJobEvent({
|
| 1184 |
+
job_id: jobId,
|
| 1185 |
+
identity_id: identityId,
|
| 1186 |
+
level,
|
| 1187 |
+
step,
|
| 1188 |
+
message,
|
| 1189 |
+
metadata: metadata || {},
|
| 1190 |
+
});
|
| 1191 |
+
} catch (err) {
|
| 1192 |
+
logger.error('Failed to insert job event', {
|
| 1193 |
+
jobId,
|
| 1194 |
+
step,
|
| 1195 |
+
error: err instanceof Error ? err.message : String(err),
|
| 1196 |
+
});
|
| 1197 |
+
}
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
type EngineEvent = {
|
| 1201 |
+
level: 'info' | 'warning' | 'error';
|
| 1202 |
+
step: string;
|
| 1203 |
+
message: string;
|
| 1204 |
+
metadata?: Record<string, unknown>;
|
| 1205 |
+
};
|
| 1206 |
+
|
| 1207 |
+
async function persistJobProgressFromEvent(
|
| 1208 |
+
jobId: string,
|
| 1209 |
+
event: EngineEvent,
|
| 1210 |
+
): Promise<void> {
|
| 1211 |
+
const fields: Partial<TurnitinJob> = {};
|
| 1212 |
+
|
| 1213 |
+
if (event.step === 'submitted' && event.level === 'info') {
|
| 1214 |
+
fields.last_completed_step = 'submitted';
|
| 1215 |
+
} else if (
|
| 1216 |
+
event.step === 'submission_details' &&
|
| 1217 |
+
event.level === 'info' &&
|
| 1218 |
+
event.metadata
|
| 1219 |
+
) {
|
| 1220 |
+
fields.submission_details = event.metadata;
|
| 1221 |
+
} else if (
|
| 1222 |
+
event.step === 'similarity' &&
|
| 1223 |
+
event.level === 'info' &&
|
| 1224 |
+
/^Similarity:/i.test(event.message)
|
| 1225 |
+
) {
|
| 1226 |
+
fields.last_completed_step = 'similarity';
|
| 1227 |
+
if (typeof event.metadata?.similarityPercent === 'number') {
|
| 1228 |
+
fields.similarity_percent = event.metadata.similarityPercent;
|
| 1229 |
+
}
|
| 1230 |
+
} else if (
|
| 1231 |
+
event.step === 'similarity' &&
|
| 1232 |
+
event.level === 'info' &&
|
| 1233 |
+
typeof event.metadata?.similarityPercent === 'number'
|
| 1234 |
+
) {
|
| 1235 |
+
fields.similarity_percent = event.metadata.similarityPercent;
|
| 1236 |
+
} else if (
|
| 1237 |
+
event.step === 'viewer' &&
|
| 1238 |
+
event.level === 'info' &&
|
| 1239 |
+
typeof event.metadata?.viewerUrl === 'string'
|
| 1240 |
+
) {
|
| 1241 |
+
fields.last_completed_step = 'viewer';
|
| 1242 |
+
fields.viewer_url = event.metadata.viewerUrl;
|
| 1243 |
+
} else if (
|
| 1244 |
+
event.step === 'filters' &&
|
| 1245 |
+
event.level === 'info' &&
|
| 1246 |
+
/Filter validation passed/i.test(event.message)
|
| 1247 |
+
) {
|
| 1248 |
+
fields.last_completed_step = 'filters';
|
| 1249 |
+
} else if (
|
| 1250 |
+
event.step === 'download' &&
|
| 1251 |
+
event.level === 'info' &&
|
| 1252 |
+
/PDF downloaded successfully/i.test(event.message)
|
| 1253 |
+
) {
|
| 1254 |
+
fields.last_completed_step = 'download';
|
| 1255 |
+
} else if (
|
| 1256 |
+
event.step === 'receipt' &&
|
| 1257 |
+
event.level === 'info' &&
|
| 1258 |
+
/Digital Receipt downloaded successfully/i.test(event.message)
|
| 1259 |
+
) {
|
| 1260 |
+
fields.last_completed_step = 'receipt';
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
if (Object.keys(fields).length > 0) {
|
| 1264 |
+
await safeUpdateJobFields(jobId, fields);
|
| 1265 |
+
}
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
async function safeUpdateJobFields(
|
| 1269 |
+
jobId: string,
|
| 1270 |
+
fields: Partial<TurnitinJob>,
|
| 1271 |
+
): Promise<void> {
|
| 1272 |
+
try {
|
| 1273 |
+
await updateJobFields(jobId, fields);
|
| 1274 |
+
} catch (err) {
|
| 1275 |
+
logger.warn('safeUpdateJobFields: could not persist job progress', {
|
| 1276 |
+
jobId,
|
| 1277 |
+
fields: Object.keys(fields),
|
| 1278 |
+
error: err instanceof Error ? err.message : String(err),
|
| 1279 |
+
});
|
| 1280 |
+
}
|
| 1281 |
+
}
|
| 1282 |
+
|
| 1283 |
+
/**
|
| 1284 |
+
* Safely persist the last completed step to the job row.
|
| 1285 |
+
* Logs a warning on failure (e.g. column not yet migrated or DB unreachable)
|
| 1286 |
+
* but never throws — step tracking must not crash the main job flow.
|
| 1287 |
+
*/
|
| 1288 |
+
async function safeUpdateLastCompletedStep(
|
| 1289 |
+
jobId: string,
|
| 1290 |
+
step: string | null | undefined,
|
| 1291 |
+
): Promise<void> {
|
| 1292 |
+
if (!step) return;
|
| 1293 |
+
try {
|
| 1294 |
+
const { supabase } = await import('../db/client');
|
| 1295 |
+
const { error } = await supabase
|
| 1296 |
+
.from('turnitin_jobs')
|
| 1297 |
+
.update({ last_completed_step: step, updated_at: new Date().toISOString() })
|
| 1298 |
+
.eq('id', jobId);
|
| 1299 |
+
if (error) {
|
| 1300 |
+
logger.warn('safeUpdateLastCompletedStep: could not persist step (column may not exist yet)', {
|
| 1301 |
+
jobId,
|
| 1302 |
+
step,
|
| 1303 |
+
error: error.message,
|
| 1304 |
+
});
|
| 1305 |
+
}
|
| 1306 |
+
} catch (err) {
|
| 1307 |
+
logger.warn('safeUpdateLastCompletedStep: unexpected error', {
|
| 1308 |
+
jobId,
|
| 1309 |
+
step,
|
| 1310 |
+
error: err instanceof Error ? err.message : String(err),
|
| 1311 |
+
});
|
| 1312 |
+
}
|
| 1313 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"module": "commonjs",
|
| 5 |
+
"lib": ["ES2022", "DOM"],
|
| 6 |
+
"outDir": "./dist",
|
| 7 |
+
"rootDir": "./src",
|
| 8 |
+
"strict": true,
|
| 9 |
+
"esModuleInterop": true,
|
| 10 |
+
"skipLibCheck": true,
|
| 11 |
+
"forceConsistentCasingInFileNames": true,
|
| 12 |
+
"resolveJsonModule": true,
|
| 13 |
+
"declaration": true,
|
| 14 |
+
"declarationMap": true,
|
| 15 |
+
"sourceMap": true,
|
| 16 |
+
"moduleResolution": "node"
|
| 17 |
+
},
|
| 18 |
+
"include": ["src/**/*"],
|
| 19 |
+
"exclude": ["node_modules", "dist", "tests"]
|
| 20 |
+
}
|