File size: 5,042 Bytes
4f843e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
import type { Page, Frame } from 'playwright';
import { logger } from '../../utils/logger';
import { readScopeDeepText } from '../selectors';

type Scope = Page | Frame;

// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------

/**
 * Extract a similarity percentage from text.
 *
 * Handles multiple formats found across Turnitin UI versions:
 *  - "45% Overall Similarity"
 *  - "Similarity: 45%"
 *  - "Overall Similarity 45 %"
 *  - Plain "45" or "45%" (when extracted directly from a score element)
 */
function parseSimilarityPercent(text: string): number | null {
  const normalized = String(text || '').replace(/\s+/g, ' ').trim();

  // Pattern 1: "45% Overall Similarity"
  const labelled = normalized.match(
    /(\d{1,3})\s*%\s*Overall Similarity/i,
  );
  // Pattern 2: "Similarity: 45%"  or  "Overall Similarity: 45%"
  const generic = normalized.match(
    /(?:Overall\s+)?Similarity[:\s]+(\d{1,3})\s*%/i,
  );
  // Pattern 3: "Overall Similarity 45 %" (label before value)
  const reversed = normalized.match(
    /Overall Similarity\s+(\d{1,3})\s*%/i,
  );
  // Pattern 4: Plain number from a score element — "45" or "45%"
  const plain = normalized.match(/^(\d{1,3})\s*%?$/);

  const value = Number(
    labelled?.[1] ?? generic?.[1] ?? reversed?.[1] ?? plain?.[1] ?? NaN,
  );
  if (!Number.isFinite(value)) return null;
  return Math.max(0, Math.min(100, value));
}

// ---------------------------------------------------------------------------
// CSS selectors for the similarity score in the report viewer
// ---------------------------------------------------------------------------

const SCORE_SELECTORS = [
  // Primary — Turnitin viewer overview score
  '.tii-SimilarityHeader__heading-content .tii-OverviewScore__Value',
  '.tii-OverviewScore__Value',
  'span.tii-OverviewScore__Value',
  // Heading area with live region
  'h1[aria-live="polite"] span',
  'h1[aria-live="polite"]',
  // Alternative UI variants
  '.tii-SimilarityReportPanelHeader__OverviewScore span',
  '.similarity-score',
  '.overall-similarity-score',
  '[data-testid="similarity-score"]',
  // Badge-style display
  'span[part="tii-grn-badge-label"]',
];

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Read the current overall similarity score from the Turnitin report viewer.
 *
 * Handles:
 *  - Multiple CSS selector strategies for different Turnitin UI versions
 *  - Shadow DOM deep text fallback
 *  - Automatic retry with short wait to handle post-filter score updates
 *
 * This is used both as a fallback for assignment pages that still show
 * similarity as pending, and as the authoritative read after filter
 * application (where the displayed score may differ from the pre-filter value).
 */
export async function readViewerSimilarityPercent(
  page: Scope,
): Promise<number | null> {
  // Allow up to 3 attempts with a short stabilization wait between each.
  // After filters are applied the score element may take a moment to update.
  const MAX_READ_ATTEMPTS = 3;

  for (let attempt = 1; attempt <= MAX_READ_ATTEMPTS; attempt++) {
    // Small wait for DOM to stabilize (especially after filter application)
    if (attempt > 1) {
      await page.waitForTimeout(2000);
    }

    // --- Strategy 1: CSS selector scan ---
    for (const selector of SCORE_SELECTORS) {
      const element = page.locator(selector).first();

      const rawText = await element
        .textContent({ timeout: 2000 })
        .catch(() => null);

      if (rawText !== null && rawText.trim() !== '') {
        // Try parsing as-is first (may already contain "Overall Similarity")
        let parsed = parseSimilarityPercent(rawText.trim());
        // Fallback: append context so the labelled regex can match plain numbers
        if (parsed === null) {
          parsed = parseSimilarityPercent(`${rawText.trim()} Overall Similarity`);
        }

        if (parsed !== null) {
          logger.info('Read similarity score from viewer selector', {
            selector,
            rawText: rawText.trim(),
            similarityPercent: parsed,
            attempt,
          });
          return parsed;
        }
      }
    }

    // --- Strategy 2: Deep text scan (traverses shadow DOM) ---
    const deepText = await readScopeDeepText(page);
    if (deepText) {
      const parsed = parseSimilarityPercent(deepText);
      if (parsed !== null) {
        logger.info('Read similarity score from viewer deep text', {
          similarityPercent: parsed,
          attempt,
        });
        return parsed;
      }
    }

    if (attempt < MAX_READ_ATTEMPTS) {
      logger.debug(`Similarity score not found on attempt ${attempt}/${MAX_READ_ATTEMPTS}; retrying`);
    }
  }

  logger.warn('Could not read similarity score from viewer after all attempts');
  return null;
}