relv-turnitin-backup / src /engine /steps /quota-detect.ts
RelVDev2's picture
Deploy pre-receipt backup worker
d5c9d40
Raw
History Blame Contribute Delete
17.2 kB
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;
}