Spaces:
Sleeping
Sleeping
| import type { Page, Frame } from 'playwright'; | |
| import { logger } from '../../utils/logger'; | |
| import { | |
| QUOTA_CONFIRM_LABELS, | |
| SELECTORS, | |
| readScopeDeepText, | |
| readScopeVisibleDeepText, | |
| } from '../selectors'; | |
| // --------------------------------------------------------------------------- | |
| // Types | |
| // --------------------------------------------------------------------------- | |
| export interface QuotaLimitResult { | |
| limited: true; | |
| limit: number; | |
| retryText: string; | |
| message: string; | |
| } | |
| export interface QuotaWarningResult { | |
| warning: true; | |
| message: string; | |
| } | |
| export interface QuotaCheckResult { | |
| quotaLimited: boolean; | |
| limit: number | null; | |
| retryText: string | null; | |
| message: string | null; | |
| warning: string | null; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Parsers | |
| // --------------------------------------------------------------------------- | |
| function parseSubmissionQuotaLimit(text: string): QuotaLimitResult | null { | |
| const normalized = String(text || '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| const match = normalized.match( | |
| /You have reached your limit of\s+(\d+)\s+submissions\.\s+You can submit again\s+(.+?)(?:\.|$)/i, | |
| ); | |
| if (!match) return null; | |
| return { | |
| limited: true, | |
| limit: Number(match[1]), | |
| retryText: match[2], | |
| message: match[0], | |
| }; | |
| } | |
| function parseSubmissionQuotaWarning( | |
| text: string, | |
| ): QuotaWarningResult | null { | |
| const normalized = String(text || '') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| const patterns = [ | |
| /(?:only\s+)?(?:1|one)\s+submissions?\s+remaining/i, | |
| /(?:only\s+)?(?:1|one)\s+remaining\s+submissions?/i, | |
| /(?:only\s+)?(?:1|one)\s+more\s+submissions?\s+left/i, | |
| /final\s+submission/i, | |
| /last\s+submission/i, | |
| /remaining\s+submissions?/i, | |
| /limited\s+number\s+of\s+submissions/i, | |
| ]; | |
| if (!patterns.some((p) => p.test(normalized))) return null; | |
| return { | |
| warning: true, | |
| message: normalized.slice(0, 500), | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Scope helpers | |
| // --------------------------------------------------------------------------- | |
| type Scope = Page | Frame; | |
| const QUOTA_ACTION_SELECTOR = [ | |
| 'button', | |
| 'input[type="button"]', | |
| 'input[type="submit"]', | |
| 'tdl-button', | |
| 'tii-grn-button', | |
| '[role="button"]', | |
| '[slot="accept-button"]', | |
| '[part*="button"]', | |
| '[data-px*="Continue"]', | |
| '[with-data-px*="Continue"]', | |
| ].join(', '); | |
| function getScopes(page: Page): Scope[] { | |
| return [ | |
| page, | |
| ...page.frames().filter((f) => !f.url().includes('cookie-shim')), | |
| ]; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Public API | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Detect whether the page shows a hard quota limit (all submissions used). | |
| */ | |
| export async function detectQuotaLimit( | |
| page: Page, | |
| ): Promise<QuotaLimitResult | null> { | |
| for (const scope of getScopes(page)) { | |
| const text = await readScopeDeepText(scope); | |
| const result = parseSubmissionQuotaLimit(text); | |
| if (result) return result; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Detect whether the page shows a quota warning (e.g. 1 submission remaining). | |
| */ | |
| export async function detectQuotaWarning( | |
| page: Page, | |
| ): Promise<QuotaWarningResult | null> { | |
| for (const scope of getScopes(page)) { | |
| const text = await readScopeVisibleDeepText(scope); | |
| const result = parseSubmissionQuotaWarning(text); | |
| if (result) return result; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Attempt to confirm/dismiss a quota warning popup by clicking a continue | |
| * button. Returns true if a warning was found and confirmed. | |
| * | |
| * Improvements: | |
| * - Skips if the warning text is just background info (no active dialog/modal) | |
| * - Uses dialog.close() as a nuclear fallback to guarantee modal closure | |
| */ | |
| export async function confirmQuotaWarning( | |
| page: Page, | |
| ): Promise<boolean> { | |
| for (const scope of getScopes(page)) { | |
| const text = await readScopeVisibleDeepText(scope); | |
| const warning = parseSubmissionQuotaWarning(text); | |
| if (!warning) continue; | |
| // Check if there is ACTUALLY an interactive modal/dialog open. | |
| // Without this check, background informational text ("You have 1 more | |
| // submission left") keeps triggering the handler even when no dialog exists. | |
| const hasActionableDialog = await scope | |
| .evaluate(() => { | |
| const roots: (Document | ShadowRoot)[] = [document]; | |
| const seen = new Set<Element>(); | |
| for (let i = 0; i < roots.length; i++) { | |
| const root = roots[i]; | |
| for (const el of Array.from(root.querySelectorAll('*'))) { | |
| if (seen.has(el)) continue; | |
| seen.add(el); | |
| if ((el as any).shadowRoot) roots.push((el as any).shadowRoot); | |
| } | |
| } | |
| const isVisible = (el: Element): boolean => { | |
| if (!(el instanceof HTMLElement)) return false; | |
| const s = window.getComputedStyle(el); | |
| const r = el.getBoundingClientRect(); | |
| return s.visibility !== 'hidden' && s.display !== 'none' && r.width > 0 && r.height > 0; | |
| }; | |
| const hasText = (el: Element, pattern: RegExp): boolean => | |
| pattern.test((el as HTMLElement).innerText || el.textContent || ''); | |
| for (const root of roots) { | |
| // Native <dialog open> | |
| for (const d of Array.from(root.querySelectorAll('dialog[open]'))) { | |
| if (isVisible(d) && hasText(d, /more submission|file upload|continue/i)) return true; | |
| } | |
| // Custom modal/overlay elements | |
| for (const el of Array.from( | |
| root.querySelectorAll('[role="dialog"], [aria-modal="true"], tii-modal, .tii-modal, [id*="modal"]'), | |
| )) { | |
| if (isVisible(el) && hasText(el, /more submission|file upload|continue/i)) return true; | |
| } | |
| // tii-grn-button with "Continue to File Upload" visible => quota dialog must be open | |
| for (const el of Array.from(root.querySelectorAll('tii-grn-button'))) { | |
| if (!isVisible(el)) continue; | |
| const t = ((el as HTMLElement).innerText || el.textContent || '').toLowerCase(); | |
| if (t.includes('continue to file upload')) return true; | |
| } | |
| } | |
| return false; | |
| }) | |
| .catch(() => false); | |
| if (!hasActionableDialog) { | |
| // Only background informational text — no interactive dialog to dismiss. | |
| logger.info('Quota warning text is background-only (no active dialog); skipping dismissal'); | |
| continue; | |
| } | |
| logger.info('Submission quota warning dialog detected; attempting to dismiss', { | |
| message: warning.message, | |
| }); | |
| const clicked = await clickQuotaWarningContinue(scope); | |
| if (!clicked) { | |
| logger.warn('Submission quota warning continue button was not found'); | |
| } | |
| await page.waitForTimeout(1500); | |
| // If warning is still visible, try coordinate-based click | |
| const stillVisible = parseSubmissionQuotaWarning( | |
| await readScopeVisibleDeepText(scope), | |
| ); | |
| if (stillVisible) { | |
| await clickQuotaWarningContinueByCoordinates(page, scope); | |
| await page.waitForTimeout(1500); | |
| } | |
| // Nuclear fallback: forcibly close any open dialog with quota/upload text | |
| const stillVisibleAfter = parseSubmissionQuotaWarning( | |
| await readScopeVisibleDeepText(scope), | |
| ); | |
| if (stillVisibleAfter) { | |
| logger.warn('Quota warning still visible; using dialog.close() nuclear fallback'); | |
| await scope | |
| .evaluate(() => { | |
| const roots: (Document | ShadowRoot)[] = [document]; | |
| const seen = new Set<Element>(); | |
| for (let i = 0; i < roots.length; i++) { | |
| const root = roots[i]; | |
| for (const el of Array.from(root.querySelectorAll('*'))) { | |
| if (seen.has(el)) continue; | |
| seen.add(el); | |
| if ((el as any).shadowRoot) roots.push((el as any).shadowRoot); | |
| } | |
| } | |
| for (const root of roots) { | |
| for (const d of Array.from(root.querySelectorAll('dialog[open], dialog'))) { | |
| if (d instanceof HTMLDialogElement) { | |
| const t = (d as HTMLElement).innerText || d.textContent || ''; | |
| if (/more submission|file upload|continue/i.test(t)) { | |
| d.close(); | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| .catch(() => {}); | |
| await page.waitForTimeout(1000); | |
| } | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Run a complete quota check: detect limit and/or warning. | |
| */ | |
| export async function runQuotaCheck( | |
| page: Page, | |
| ): Promise<QuotaCheckResult> { | |
| const limit = await detectQuotaLimit(page); | |
| if (limit) { | |
| return { | |
| quotaLimited: true, | |
| limit: limit.limit, | |
| retryText: limit.retryText, | |
| message: limit.message, | |
| warning: null, | |
| }; | |
| } | |
| const warning = await detectQuotaWarning(page); | |
| return { | |
| quotaLimited: false, | |
| limit: null, | |
| retryText: null, | |
| message: null, | |
| warning: warning?.message || null, | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Internal: click quota warning continue buttons | |
| // --------------------------------------------------------------------------- | |
| async function clickQuotaWarningContinue( | |
| scope: Scope, | |
| ): Promise<boolean> { | |
| // Try each preferred label in order | |
| for (const label of QUOTA_CONFIRM_LABELS) { | |
| const escaped = label.replace(/"/g, '\\"'); | |
| const button = scope | |
| .locator( | |
| `tii-grn-button:has-text("${escaped}"), button:has-text("${escaped}"), tdl-button:has-text("${escaped}"), [role="button"]:has-text("${escaped}")`, | |
| ) | |
| .last(); | |
| if (await button.isVisible({ timeout: 1000 }).catch(() => false)) { | |
| await button.click({ force: true }); | |
| return true; | |
| } | |
| const textTarget = scope.getByText(label, { exact: false }).last(); | |
| if ( | |
| await textTarget.isVisible({ timeout: 1000 }).catch(() => false) | |
| ) { | |
| await textTarget.click({ force: true }); | |
| return true; | |
| } | |
| } | |
| // Try filled action slot button | |
| const filledAction = scope | |
| .locator(SELECTORS.quota.warningConfirmButton) | |
| .last(); | |
| if ( | |
| await filledAction.isVisible({ timeout: 1000 }).catch(() => false) | |
| ) { | |
| await filledAction.click({ force: true }); | |
| return true; | |
| } | |
| // Deep shadow DOM scan fallback. Turnitin often renders the visible label in | |
| // a shadow/slot child, while the actual clickable target is the host element. | |
| return scope | |
| .evaluate( | |
| ({ | |
| labels, | |
| selector, | |
| }: { | |
| labels: string[]; | |
| selector: string; | |
| }) => { | |
| const roots: (Document | ShadowRoot)[] = [document]; | |
| const seen = new Set<Element>(); | |
| for (let i = 0; i < roots.length; i++) { | |
| const root = roots[i]; | |
| for (const el of Array.from(root.querySelectorAll('*'))) { | |
| if (seen.has(el)) continue; | |
| seen.add(el); | |
| if (el.shadowRoot) roots.push(el.shadowRoot); | |
| } | |
| } | |
| const isVisible = (el: Element): boolean => { | |
| if (!(el instanceof HTMLElement)) return false; | |
| const style = window.getComputedStyle(el); | |
| const rect = el.getBoundingClientRect(); | |
| return ( | |
| style.visibility !== 'hidden' && | |
| style.display !== 'none' && | |
| rect.width > 0 && | |
| rect.height > 0 | |
| ); | |
| }; | |
| const isDisabled = (el: Element): boolean => { | |
| if (!(el instanceof HTMLElement)) return true; | |
| return ( | |
| (el as HTMLButtonElement).disabled === true || | |
| el.getAttribute('disabled') !== null || | |
| el.getAttribute('aria-disabled') === 'true' || | |
| el.getAttribute('with-disabled') === 'true' || | |
| el.closest( | |
| '[disabled], [aria-disabled="true"], [with-disabled="true"], .disabled, .is-disabled', | |
| ) !== null | |
| ); | |
| }; | |
| const readText = (el: Element): string => | |
| [ | |
| (el as HTMLElement).innerText, | |
| el.textContent, | |
| el.getAttribute('aria-label'), | |
| el.getAttribute('value'), | |
| el.getAttribute('part'), | |
| el.getAttribute('slot'), | |
| el.getAttribute('data-px'), | |
| el.getAttribute('with-data-px'), | |
| el.getAttribute('with-px-label'), | |
| ] | |
| .filter(Boolean) | |
| .join(' ') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| .toLowerCase(); | |
| const resolveClickable = (el: Element): HTMLElement | null => { | |
| const direct = (el as HTMLElement).closest?.( | |
| 'button, a, [role="button"], tdl-button, tii-grn-button, [slot="accept-button"]', | |
| ) as HTMLElement | null; | |
| const root = el.getRootNode(); | |
| const host = | |
| root instanceof ShadowRoot ? (root.host as HTMLElement) : null; | |
| const clickable = direct || host || (el as HTMLElement); | |
| const innerButton = clickable.shadowRoot?.querySelector( | |
| 'button:not([disabled]), input[type="submit"]:not([disabled]), input[type="button"]:not([disabled])', | |
| ); | |
| return innerButton instanceof HTMLElement | |
| ? innerButton | |
| : clickable; | |
| }; | |
| const candidates: Element[] = []; | |
| for (const root of roots) { | |
| candidates.push(...Array.from(root.querySelectorAll(selector))); | |
| candidates.push(...Array.from(root.querySelectorAll('*'))); | |
| } | |
| const normalizedLabels = labels.map((l) => l.toLowerCase()); | |
| const target = candidates | |
| .filter(isVisible) | |
| .map((el) => { | |
| const rect = el.getBoundingClientRect(); | |
| const text = readText(el); | |
| const labelIndex = normalizedLabels.findIndex((l) => | |
| text.includes(l), | |
| ); | |
| const clickable = resolveClickable(el); | |
| return { | |
| element: el, | |
| clickable, | |
| labelIndex, | |
| area: rect.width * rect.height, | |
| disabled: clickable ? isDisabled(clickable) : true, | |
| }; | |
| }) | |
| .filter((c) => c.labelIndex >= 0 && !c.disabled && c.clickable) | |
| .sort( | |
| (a, b) => a.labelIndex - b.labelIndex || a.area - b.area, | |
| )[0]?.clickable; | |
| if (!target) return false; | |
| target.scrollIntoView({ block: 'center', inline: 'center' }); | |
| target.click(); | |
| return true; | |
| }, | |
| { labels: QUOTA_CONFIRM_LABELS, selector: QUOTA_ACTION_SELECTOR }, | |
| ) | |
| .catch(() => false); | |
| } | |
| async function clickQuotaWarningContinueByCoordinates( | |
| page: Page, | |
| scope: Scope, | |
| ): Promise<boolean> { | |
| // Try filled action slot button first | |
| const filledAction = scope | |
| .locator(SELECTORS.quota.warningConfirmButton) | |
| .last(); | |
| if ( | |
| await filledAction.isVisible({ timeout: 1000 }).catch(() => false) | |
| ) { | |
| await filledAction.click({ force: true }); | |
| return true; | |
| } | |
| // Try coordinate-based click on "Continue to File Upload" | |
| const label = 'Continue to File Upload'; | |
| const textTarget = scope.getByText(label, { exact: false }).last(); | |
| const box = await textTarget | |
| .boundingBox({ timeout: 1000 }) | |
| .catch(() => null); | |
| if (box) { | |
| await page.mouse | |
| .click(box.x + box.width / 2, box.y + box.height / 2) | |
| .catch(() => {}); | |
| return true; | |
| } | |
| // Deep shadow DOM coordinate search | |
| const point = await scope | |
| .evaluate(() => { | |
| const roots: (Document | ShadowRoot)[] = [document]; | |
| const seen = new Set<Element>(); | |
| for (let i = 0; i < roots.length; i++) { | |
| const root = roots[i]; | |
| for (const el of Array.from(root.querySelectorAll('*'))) { | |
| if (seen.has(el)) continue; | |
| seen.add(el); | |
| if (el.shadowRoot) roots.push(el.shadowRoot); | |
| } | |
| } | |
| const isVisible = (el: Element): boolean => { | |
| if (!(el instanceof HTMLElement)) return false; | |
| const style = window.getComputedStyle(el); | |
| const rect = el.getBoundingClientRect(); | |
| return ( | |
| style.visibility !== 'hidden' && | |
| style.display !== 'none' && | |
| rect.width > 0 && | |
| rect.height > 0 | |
| ); | |
| }; | |
| const target = roots | |
| .flatMap((r) => Array.from(r.querySelectorAll('*'))) | |
| .filter(isVisible) | |
| .map((el) => { | |
| const rect = el.getBoundingClientRect(); | |
| const text = [ | |
| (el as HTMLElement).innerText, | |
| el.textContent, | |
| ] | |
| .filter(Boolean) | |
| .join(' ') | |
| .replace(/\s+/g, ' ') | |
| .trim() | |
| .toLowerCase(); | |
| return { rect, text, area: rect.width * rect.height }; | |
| }) | |
| .filter((c) => c.text.includes('continue to file upload')) | |
| .sort((a, b) => a.area - b.area)[0]; | |
| if (!target) return null; | |
| return { | |
| x: target.rect.x + target.rect.width / 2, | |
| y: target.rect.y + target.rect.height / 2, | |
| }; | |
| }) | |
| .catch(() => null); | |
| if (point) { | |
| await page.mouse.click(point.x, point.y).catch(() => {}); | |
| return true; | |
| } | |
| // Final fallback: click approximate center of common dialog area | |
| await page.mouse.click(890, 670).catch(() => {}); | |
| return true; | |
| } | |