cbrowser 8.10.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/dist/analysis/accessibility-empathy.d.ts +32 -0
- package/dist/analysis/accessibility-empathy.d.ts.map +1 -0
- package/dist/analysis/accessibility-empathy.js +1005 -0
- package/dist/analysis/accessibility-empathy.js.map +1 -0
- package/dist/analysis/agent-ready-audit.d.ts +11 -0
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -0
- package/dist/analysis/agent-ready-audit.js +900 -0
- package/dist/analysis/agent-ready-audit.js.map +1 -0
- package/dist/analysis/competitive-benchmark.d.ts +11 -0
- package/dist/analysis/competitive-benchmark.d.ts.map +1 -0
- package/dist/analysis/competitive-benchmark.js +1122 -0
- package/dist/analysis/competitive-benchmark.js.map +1 -0
- package/dist/analysis/index.d.ts +5 -1
- package/dist/analysis/index.d.ts.map +1 -1
- package/dist/analysis/index.js +5 -1
- package/dist/analysis/index.js.map +1 -1
- package/dist/cli.js +191 -2
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/focus-hierarchies.d.ts +113 -0
- package/dist/cognitive/focus-hierarchies.d.ts.map +1 -0
- package/dist/cognitive/focus-hierarchies.js +500 -0
- package/dist/cognitive/focus-hierarchies.js.map +1 -0
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +100 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/personas.d.ts +14 -0
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +393 -0
- package/dist/personas.js.map +1 -1
- package/dist/types.d.ts +429 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/docs/METHODOLOGY.md +245 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Competitive UX Benchmark Module
|
|
3
|
+
*
|
|
4
|
+
* Run identical cognitive journeys across multiple sites simultaneously.
|
|
5
|
+
* Output: head-to-head comparison with friction analysis.
|
|
6
|
+
*/
|
|
7
|
+
import { chromium } from "playwright";
|
|
8
|
+
import { getPersona, BUILTIN_PERSONAS } from "../personas.js";
|
|
9
|
+
import { getFocusHierarchy, inferTaskTypeFromGoal, calculateFocusPriority, getDistractionIgnoreRate, } from "../cognitive/focus-hierarchies.js";
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Scoring Methodology & Disclaimers
|
|
12
|
+
// ============================================================================
|
|
13
|
+
/**
|
|
14
|
+
* METHODOLOGY DISCLAIMER:
|
|
15
|
+
*
|
|
16
|
+
* CBrowser's competitive benchmark uses HEURISTIC estimates, not precise measurements.
|
|
17
|
+
* Scores are derived from behavioral simulation and pattern detection, calibrated against
|
|
18
|
+
* published UX research where available.
|
|
19
|
+
*
|
|
20
|
+
* Research-backed thresholds used:
|
|
21
|
+
* - Page load abandonment: 53% at 3s (Google/SOASTA 2017)
|
|
22
|
+
* - Form complexity: 7-8 fields optimal (Baymard Institute)
|
|
23
|
+
* - Rage click detection: 3+ clicks in 1-2s indicates frustration (FullStory)
|
|
24
|
+
*
|
|
25
|
+
* Heuristic estimates (interpret as directional, not precise):
|
|
26
|
+
* - Abandonment Risk: Letter grade (A-F) based on friction accumulation
|
|
27
|
+
* - Confusion/Frustration: Relative scale based on detected friction points
|
|
28
|
+
* - Site Score: Weighted composite for RANKING purposes only
|
|
29
|
+
*
|
|
30
|
+
* These scores are useful for comparing sites RELATIVE to each other,
|
|
31
|
+
* not as absolute measurements of user behavior.
|
|
32
|
+
*/
|
|
33
|
+
// 1-10 scale with descriptive labels for abandonment risk
|
|
34
|
+
const ABANDONMENT_RISK_LABELS = [
|
|
35
|
+
{ max: 10, score: 1, label: "Very Low" },
|
|
36
|
+
{ max: 20, score: 2, label: "Very Low" },
|
|
37
|
+
{ max: 30, score: 3, label: "Low" },
|
|
38
|
+
{ max: 40, score: 4, label: "Low-Medium" },
|
|
39
|
+
{ max: 50, score: 5, label: "Medium" },
|
|
40
|
+
{ max: 60, score: 6, label: "Medium" },
|
|
41
|
+
{ max: 70, score: 7, label: "Medium-High" },
|
|
42
|
+
{ max: 80, score: 8, label: "High" },
|
|
43
|
+
{ max: 90, score: 9, label: "Very High" },
|
|
44
|
+
{ max: 100, score: 10, label: "Very High" },
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Simulate a user journey toward a goal
|
|
48
|
+
*/
|
|
49
|
+
async function simulateJourney(page, url, goal, persona, maxSteps, maxTime) {
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
const state = {
|
|
52
|
+
patience: 100,
|
|
53
|
+
frustration: 0,
|
|
54
|
+
confusion: 0,
|
|
55
|
+
steps: 0,
|
|
56
|
+
frictionPoints: [],
|
|
57
|
+
startTime,
|
|
58
|
+
};
|
|
59
|
+
let goalAchieved = false;
|
|
60
|
+
let abandonmentReason;
|
|
61
|
+
const screenshots = {
|
|
62
|
+
start: '',
|
|
63
|
+
end: '',
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
// Navigate to starting URL
|
|
67
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
|
|
68
|
+
await page.waitForTimeout(1000);
|
|
69
|
+
// Take start screenshot
|
|
70
|
+
const startBuffer = await page.screenshot();
|
|
71
|
+
screenshots.start = startBuffer.toString("base64");
|
|
72
|
+
// Check initial page state
|
|
73
|
+
const initialAnalysis = await analyzePageForGoal(page, goal);
|
|
74
|
+
if (initialAnalysis.goalReached) {
|
|
75
|
+
goalAchieved = true;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Simulate exploration toward goal
|
|
79
|
+
for (let step = 0; step < maxSteps && !goalAchieved; step++) {
|
|
80
|
+
// Check time limit
|
|
81
|
+
if (Date.now() - startTime > maxTime * 1000) {
|
|
82
|
+
abandonmentReason = "Time limit exceeded";
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
state.steps++;
|
|
86
|
+
// Analyze current page for actions
|
|
87
|
+
const pageAnalysis = await analyzePageForGoal(page, goal);
|
|
88
|
+
if (pageAnalysis.goalReached) {
|
|
89
|
+
goalAchieved = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
// Check for friction points
|
|
93
|
+
if (pageAnalysis.frictionPoints.length > 0) {
|
|
94
|
+
state.frictionPoints.push(...pageAnalysis.frictionPoints);
|
|
95
|
+
state.patience -= pageAnalysis.frictionPoints.length * 5;
|
|
96
|
+
state.frustration += pageAnalysis.frictionPoints.length * 3;
|
|
97
|
+
}
|
|
98
|
+
// Try to take an action toward the goal
|
|
99
|
+
const action = await findBestAction(page, goal, pageAnalysis);
|
|
100
|
+
if (!action) {
|
|
101
|
+
state.confusion += 10;
|
|
102
|
+
state.patience -= 5;
|
|
103
|
+
if (state.patience <= 20 || state.confusion >= 70) {
|
|
104
|
+
abandonmentReason = "User became confused and lost";
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
// Try scrolling to find more options
|
|
108
|
+
await page.evaluate(() => window.scrollBy(0, 300));
|
|
109
|
+
await page.waitForTimeout(500);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Execute the action
|
|
113
|
+
try {
|
|
114
|
+
await executeAction(page, action);
|
|
115
|
+
await page.waitForTimeout(1000);
|
|
116
|
+
// Patience recovery on success
|
|
117
|
+
state.patience = Math.min(100, state.patience + 2);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
state.frictionPoints.push(`Failed to ${action.type}: ${action.target}`);
|
|
121
|
+
state.frustration += 5;
|
|
122
|
+
state.patience -= 10;
|
|
123
|
+
if (state.patience <= 10) {
|
|
124
|
+
abandonmentReason = "Too many failed interactions";
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Check for patience exhaustion
|
|
129
|
+
if (state.patience <= 0) {
|
|
130
|
+
abandonmentReason = "User ran out of patience";
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Take end screenshot
|
|
136
|
+
const endBuffer = await page.screenshot();
|
|
137
|
+
screenshots.end = endBuffer.toString("base64");
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
abandonmentReason = `Error: ${e.message}`;
|
|
141
|
+
}
|
|
142
|
+
// Extract site name from URL
|
|
143
|
+
const siteName = new URL(url).hostname.replace('www.', '');
|
|
144
|
+
return {
|
|
145
|
+
url,
|
|
146
|
+
siteName,
|
|
147
|
+
goalAchieved,
|
|
148
|
+
abandonmentReason,
|
|
149
|
+
totalTime: Date.now() - startTime,
|
|
150
|
+
stepCount: state.steps,
|
|
151
|
+
frictionPoints: [...new Set(state.frictionPoints)], // Dedupe
|
|
152
|
+
confusionLevel: Math.round(state.confusion),
|
|
153
|
+
frustrationLevel: Math.round(state.frustration),
|
|
154
|
+
abandonmentRisk: calculateAbandonmentRisk(state, goalAchieved),
|
|
155
|
+
screenshots,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Calculate abandonment risk as a letter grade.
|
|
160
|
+
*
|
|
161
|
+
* Research basis:
|
|
162
|
+
* - 53% abandon at 3s load time (Google/SOASTA)
|
|
163
|
+
* - 67% abandon with too many steps (Baymard)
|
|
164
|
+
* - Rage clicks (3+ in 1-2s) correlate with 6.5% frustration rate (FullStory)
|
|
165
|
+
*
|
|
166
|
+
* This is a HEURISTIC estimate for comparison purposes, not a prediction.
|
|
167
|
+
*/
|
|
168
|
+
function calculateAbandonmentRisk(state, goalAchieved) {
|
|
169
|
+
if (goalAchieved)
|
|
170
|
+
return 0;
|
|
171
|
+
// Weighted risk factors (calibrated to research where possible)
|
|
172
|
+
let risk = 0;
|
|
173
|
+
// Patience depletion: primary driver (research shows patience exhaustion = abandonment)
|
|
174
|
+
risk += (100 - state.patience) * 0.4;
|
|
175
|
+
// Frustration accumulation: rage clicks correlate with ~6.5% session abandonment
|
|
176
|
+
risk += state.frustration * 0.3;
|
|
177
|
+
// Confusion: correlates with backtracking and eventual abandonment
|
|
178
|
+
risk += state.confusion * 0.3;
|
|
179
|
+
// Cap at 100
|
|
180
|
+
return Math.min(100, Math.round(risk));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Convert numeric risk to 1-10 scale with descriptive label.
|
|
184
|
+
* Returns { score: 1-10, label: "Low" | "Medium" | "High" etc. }
|
|
185
|
+
*/
|
|
186
|
+
function getAbandonmentRiskRating(risk) {
|
|
187
|
+
for (const tier of ABANDONMENT_RISK_LABELS) {
|
|
188
|
+
if (risk <= tier.max) {
|
|
189
|
+
return { score: tier.score, label: tier.label };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { score: 10, label: "Very High" };
|
|
193
|
+
}
|
|
194
|
+
async function analyzePageForGoal(page, goal) {
|
|
195
|
+
const goalLower = goal.toLowerCase();
|
|
196
|
+
const frictionPoints = [];
|
|
197
|
+
// Extract page content for analysis with position data for focus hierarchy
|
|
198
|
+
const pageData = await page.evaluate(() => {
|
|
199
|
+
const viewportHeight = window.innerHeight;
|
|
200
|
+
const viewportWidth = window.innerWidth;
|
|
201
|
+
// Helper: detect page area from element position and context
|
|
202
|
+
function detectArea(el) {
|
|
203
|
+
const rect = el.getBoundingClientRect();
|
|
204
|
+
const classes = el.className?.toString?.().toLowerCase() || '';
|
|
205
|
+
const id = el.id?.toLowerCase() || '';
|
|
206
|
+
const tagName = el.tagName.toLowerCase();
|
|
207
|
+
// Semantic detection first
|
|
208
|
+
if (el.closest('nav') || classes.includes('nav') || id.includes('nav') || el.closest('[role="navigation"]')) {
|
|
209
|
+
return 'navigation';
|
|
210
|
+
}
|
|
211
|
+
if (el.closest('form') || classes.includes('form')) {
|
|
212
|
+
return 'forms';
|
|
213
|
+
}
|
|
214
|
+
if (el.closest('footer') || classes.includes('footer') || id.includes('footer')) {
|
|
215
|
+
return 'footer';
|
|
216
|
+
}
|
|
217
|
+
if (el.closest('aside') || classes.includes('sidebar') || classes.includes('aside')) {
|
|
218
|
+
return 'sidebar';
|
|
219
|
+
}
|
|
220
|
+
if (tagName.match(/^h[1-6]$/) || el.closest('h1, h2, h3, h4, h5, h6')) {
|
|
221
|
+
return 'headings';
|
|
222
|
+
}
|
|
223
|
+
if (classes.includes('hero') || classes.includes('banner') || classes.includes('jumbotron')) {
|
|
224
|
+
return 'hero';
|
|
225
|
+
}
|
|
226
|
+
if (classes.includes('cta') || classes.includes('call-to-action') ||
|
|
227
|
+
(tagName === 'button' && rect.width > 100) ||
|
|
228
|
+
el.getAttribute?.('role') === 'button') {
|
|
229
|
+
return 'cta';
|
|
230
|
+
}
|
|
231
|
+
if (tagName === 'img' || el.closest('figure, picture')) {
|
|
232
|
+
return 'images';
|
|
233
|
+
}
|
|
234
|
+
if (classes.includes('search') || id.includes('search') || el.type === 'search') {
|
|
235
|
+
return 'search';
|
|
236
|
+
}
|
|
237
|
+
// Position-based detection
|
|
238
|
+
if (rect.top < 100) {
|
|
239
|
+
// Top of page - likely navigation or hero
|
|
240
|
+
return rect.height > 200 ? 'hero' : 'navigation';
|
|
241
|
+
}
|
|
242
|
+
if (rect.top > viewportHeight * 0.8) {
|
|
243
|
+
return 'footer';
|
|
244
|
+
}
|
|
245
|
+
if (rect.left < viewportWidth * 0.2 || rect.right > viewportWidth * 0.8) {
|
|
246
|
+
return 'sidebar';
|
|
247
|
+
}
|
|
248
|
+
// Default to content
|
|
249
|
+
return 'content';
|
|
250
|
+
}
|
|
251
|
+
const data = {
|
|
252
|
+
title: document.title,
|
|
253
|
+
url: window.location.href,
|
|
254
|
+
text: document.body.innerText.slice(0, 5000),
|
|
255
|
+
buttons: Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"]')).map(el => ({
|
|
256
|
+
text: el.textContent?.trim() || el.value || '',
|
|
257
|
+
selector: el.id ? `#${el.id}` : el.className ? `.${el.className.toString().split(' ')[0]}` : el.tagName.toLowerCase(),
|
|
258
|
+
visible: el.getBoundingClientRect().width > 0,
|
|
259
|
+
area: detectArea(el),
|
|
260
|
+
})),
|
|
261
|
+
links: Array.from(document.querySelectorAll('a[href]')).map(el => ({
|
|
262
|
+
text: el.textContent?.trim() || '',
|
|
263
|
+
href: el.href,
|
|
264
|
+
selector: el.id ? `#${el.id}` : `a[href="${el.getAttribute('href')}"]`,
|
|
265
|
+
visible: el.getBoundingClientRect().width > 0,
|
|
266
|
+
area: detectArea(el),
|
|
267
|
+
})).slice(0, 50),
|
|
268
|
+
inputs: Array.from(document.querySelectorAll('input:not([type="hidden"]), textarea, select')).map(el => {
|
|
269
|
+
const input = el;
|
|
270
|
+
return {
|
|
271
|
+
type: input.type || 'text',
|
|
272
|
+
name: input.name,
|
|
273
|
+
placeholder: input.placeholder,
|
|
274
|
+
label: document.querySelector(`label[for="${input.id}"]`)?.textContent?.trim() || '',
|
|
275
|
+
selector: input.id ? `#${input.id}` : input.name ? `[name="${input.name}"]` : input.placeholder ? `[placeholder="${input.placeholder}"]` : 'input',
|
|
276
|
+
visible: el.getBoundingClientRect().width > 0,
|
|
277
|
+
area: detectArea(el),
|
|
278
|
+
};
|
|
279
|
+
}),
|
|
280
|
+
// Check for common friction indicators
|
|
281
|
+
hasPopup: !!document.querySelector('[class*="modal"], [class*="popup"], [role="dialog"]'),
|
|
282
|
+
hasError: !!document.querySelector('[class*="error"], .alert-danger, [role="alert"]'),
|
|
283
|
+
hasCaptcha: !!document.querySelector('[class*="captcha"], [class*="recaptcha"], iframe[src*="captcha"]'),
|
|
284
|
+
formCount: document.querySelectorAll('form').length,
|
|
285
|
+
};
|
|
286
|
+
return data;
|
|
287
|
+
});
|
|
288
|
+
// Detect friction points
|
|
289
|
+
if (pageData.hasPopup) {
|
|
290
|
+
frictionPoints.push("Popup/modal blocking content");
|
|
291
|
+
}
|
|
292
|
+
if (pageData.hasError) {
|
|
293
|
+
frictionPoints.push("Error message displayed");
|
|
294
|
+
}
|
|
295
|
+
if (pageData.hasCaptcha) {
|
|
296
|
+
frictionPoints.push("CAPTCHA blocking progress");
|
|
297
|
+
}
|
|
298
|
+
// Check if goal is reached based on semantic analysis
|
|
299
|
+
const goalKeywords = extractGoalKeywords(goal);
|
|
300
|
+
const pageText = pageData.text.toLowerCase();
|
|
301
|
+
const pageUrl = pageData.url.toLowerCase();
|
|
302
|
+
let goalReached = false;
|
|
303
|
+
// Action-oriented goal patterns (require completion confirmation)
|
|
304
|
+
if (goalLower.includes("sign up") || goalLower.includes("register")) {
|
|
305
|
+
goalReached = pageText.includes("welcome") ||
|
|
306
|
+
pageText.includes("account created") ||
|
|
307
|
+
pageText.includes("verify your email") ||
|
|
308
|
+
pageUrl.includes("dashboard") ||
|
|
309
|
+
pageUrl.includes("welcome");
|
|
310
|
+
}
|
|
311
|
+
else if (goalLower.includes("login") || goalLower.includes("sign in")) {
|
|
312
|
+
goalReached = pageUrl.includes("dashboard") ||
|
|
313
|
+
pageUrl.includes("account") ||
|
|
314
|
+
pageText.includes("welcome back");
|
|
315
|
+
}
|
|
316
|
+
else if (goalLower.includes("checkout") || goalLower.includes("purchase")) {
|
|
317
|
+
goalReached = pageText.includes("order confirmed") ||
|
|
318
|
+
pageText.includes("thank you for your") ||
|
|
319
|
+
pageUrl.includes("confirmation");
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Information-seeking goals: "find X", "search for X", "locate X", or generic
|
|
323
|
+
// Extract the subject being sought (remove action verbs)
|
|
324
|
+
const actionVerbs = ["find", "search", "locate", "look", "get", "see", "view", "check", "learn", "discover"];
|
|
325
|
+
const subjectKeywords = goalKeywords.filter(kw => !actionVerbs.includes(kw));
|
|
326
|
+
// Expand subject keywords with synonyms (e.g., "requirements" -> also check "eligibility", "criteria")
|
|
327
|
+
const expandedKeywords = expandKeywordsWithSynonyms(subjectKeywords);
|
|
328
|
+
// If we have subject keywords, check for their presence and density
|
|
329
|
+
if (subjectKeywords.length > 0) {
|
|
330
|
+
// Count how many expanded keywords (including synonyms) appear in the page
|
|
331
|
+
const keywordsFound = expandedKeywords.filter(kw => pageText.includes(kw));
|
|
332
|
+
// For ratio, compare against original subject count (finding ANY synonym counts)
|
|
333
|
+
// If we find synonyms for a concept, that concept is "found"
|
|
334
|
+
const conceptsFound = new Set();
|
|
335
|
+
for (const kw of subjectKeywords) {
|
|
336
|
+
// Check if original keyword or any of its synonyms appear
|
|
337
|
+
const synonyms = expandKeywordsWithSynonyms([kw]);
|
|
338
|
+
if (synonyms.some(syn => pageText.includes(syn))) {
|
|
339
|
+
conceptsFound.add(kw);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const keywordRatio = conceptsFound.size / subjectKeywords.length;
|
|
343
|
+
// Also check keyword density (multiple mentions = more relevant)
|
|
344
|
+
let totalMentions = 0;
|
|
345
|
+
for (const kw of expandedKeywords) {
|
|
346
|
+
const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
347
|
+
const matches = pageText.match(regex);
|
|
348
|
+
totalMentions += matches ? matches.length : 0;
|
|
349
|
+
}
|
|
350
|
+
// Goal reached if:
|
|
351
|
+
// - At least 50% of subject CONCEPTS are present (via original or synonym), AND
|
|
352
|
+
// - Keywords/synonyms are mentioned at least 3x total (indicates actual content)
|
|
353
|
+
goalReached = keywordRatio >= 0.5 && totalMentions >= 3;
|
|
354
|
+
// Also check if URL contains subject keywords (strong signal)
|
|
355
|
+
const urlHasSubject = expandedKeywords.some(kw => pageUrl.includes(kw));
|
|
356
|
+
if (urlHasSubject && keywordRatio >= 0.4) {
|
|
357
|
+
goalReached = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
// Fallback: all keywords are action verbs, just check if we're on a relevant page
|
|
362
|
+
goalReached = goalKeywords.some(kw => pageUrl.includes(kw));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// Build action candidates
|
|
366
|
+
const availableActions = [];
|
|
367
|
+
// Score buttons by relevance to goal (with area detection)
|
|
368
|
+
for (const btn of pageData.buttons.filter(b => b.visible)) {
|
|
369
|
+
const textLower = btn.text.toLowerCase();
|
|
370
|
+
let relevance = 0;
|
|
371
|
+
for (const keyword of goalKeywords) {
|
|
372
|
+
if (textLower.includes(keyword))
|
|
373
|
+
relevance += 0.3;
|
|
374
|
+
}
|
|
375
|
+
// Common action words
|
|
376
|
+
if (textLower.match(/submit|continue|next|proceed|sign|login|create|register|add|buy|checkout/)) {
|
|
377
|
+
relevance += 0.2;
|
|
378
|
+
}
|
|
379
|
+
if (relevance > 0 || btn.text.length > 0) {
|
|
380
|
+
availableActions.push({
|
|
381
|
+
type: "click",
|
|
382
|
+
target: btn.text || "button",
|
|
383
|
+
selector: btn.selector,
|
|
384
|
+
relevance: Math.min(1, relevance),
|
|
385
|
+
area: btn.area,
|
|
386
|
+
text: btn.text,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Score links by relevance (with area detection)
|
|
391
|
+
for (const link of pageData.links.filter(l => l.visible)) {
|
|
392
|
+
const textLower = link.text.toLowerCase();
|
|
393
|
+
let relevance = 0;
|
|
394
|
+
for (const keyword of goalKeywords) {
|
|
395
|
+
if (textLower.includes(keyword) || link.href.toLowerCase().includes(keyword)) {
|
|
396
|
+
relevance += 0.3;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (relevance > 0) {
|
|
400
|
+
availableActions.push({
|
|
401
|
+
type: "click",
|
|
402
|
+
target: link.text || link.href,
|
|
403
|
+
selector: link.selector,
|
|
404
|
+
relevance: Math.min(1, relevance),
|
|
405
|
+
area: link.area,
|
|
406
|
+
text: link.text,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Score inputs (with area detection)
|
|
411
|
+
for (const input of pageData.inputs.filter(i => i.visible)) {
|
|
412
|
+
const labelLower = (input.label + input.placeholder + input.name).toLowerCase();
|
|
413
|
+
let relevance = 0;
|
|
414
|
+
for (const keyword of goalKeywords) {
|
|
415
|
+
if (labelLower.includes(keyword))
|
|
416
|
+
relevance += 0.2;
|
|
417
|
+
}
|
|
418
|
+
// Common form fields
|
|
419
|
+
if (labelLower.match(/email|password|name|username|search/)) {
|
|
420
|
+
relevance += 0.3;
|
|
421
|
+
}
|
|
422
|
+
if (relevance > 0) {
|
|
423
|
+
availableActions.push({
|
|
424
|
+
type: "fill",
|
|
425
|
+
target: input.label || input.placeholder || input.name || input.type,
|
|
426
|
+
selector: input.selector,
|
|
427
|
+
relevance: Math.min(1, relevance),
|
|
428
|
+
area: input.area,
|
|
429
|
+
text: input.label || input.placeholder,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Sort by relevance (focus hierarchy applied later in findBestAction)
|
|
434
|
+
availableActions.sort((a, b) => b.relevance - a.relevance);
|
|
435
|
+
return {
|
|
436
|
+
goalReached,
|
|
437
|
+
frictionPoints,
|
|
438
|
+
availableActions,
|
|
439
|
+
pageContent: pageData.text.slice(0, 1000),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function extractGoalKeywords(goal) {
|
|
443
|
+
const stopWords = ['a', 'an', 'the', 'to', 'for', 'on', 'in', 'at', 'and', 'or', 'of', 'with'];
|
|
444
|
+
return goal.toLowerCase()
|
|
445
|
+
.split(/\s+/)
|
|
446
|
+
.filter(word => word.length > 2 && !stopWords.includes(word));
|
|
447
|
+
}
|
|
448
|
+
// Synonym map for common goal terms - expands keywords to include related terms
|
|
449
|
+
const KEYWORD_SYNONYMS = {
|
|
450
|
+
requirements: ['requirements', 'eligibility', 'criteria', 'prerequisites', 'qualifications', 'what you need', 'how to apply', 'checklist'],
|
|
451
|
+
application: ['application', 'apply', 'applying', 'submit', 'enrollment', 'enroll'],
|
|
452
|
+
admission: ['admission', 'admissions', 'accepted', 'acceptance', 'enrolled'],
|
|
453
|
+
cost: ['cost', 'tuition', 'fees', 'price', 'pricing', 'financial', 'payment'],
|
|
454
|
+
deadline: ['deadline', 'due date', 'dates', 'when to apply', 'timeline'],
|
|
455
|
+
contact: ['contact', 'email', 'phone', 'reach', 'support', 'help'],
|
|
456
|
+
international: ['international', 'global', 'foreign', 'overseas', 'visa', 'i-20'],
|
|
457
|
+
};
|
|
458
|
+
function expandKeywordsWithSynonyms(keywords) {
|
|
459
|
+
const expanded = new Set();
|
|
460
|
+
for (const kw of keywords) {
|
|
461
|
+
expanded.add(kw);
|
|
462
|
+
// Check if this keyword has synonyms
|
|
463
|
+
if (KEYWORD_SYNONYMS[kw]) {
|
|
464
|
+
for (const syn of KEYWORD_SYNONYMS[kw]) {
|
|
465
|
+
expanded.add(syn);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Also check if keyword is IN a synonym list (reverse lookup)
|
|
469
|
+
for (const [key, syns] of Object.entries(KEYWORD_SYNONYMS)) {
|
|
470
|
+
if (syns.includes(kw)) {
|
|
471
|
+
expanded.add(key);
|
|
472
|
+
for (const syn of syns) {
|
|
473
|
+
expanded.add(syn);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return Array.from(expanded);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Find the best action using focus hierarchy weighting.
|
|
482
|
+
*
|
|
483
|
+
* This applies probabilistic focus patterns to simulate how real users
|
|
484
|
+
* prioritize different page areas based on their task type.
|
|
485
|
+
*
|
|
486
|
+
* Key behaviors:
|
|
487
|
+
* - Information-seeking tasks prioritize headings/navigation
|
|
488
|
+
* - Action-completion tasks prioritize forms/CTAs
|
|
489
|
+
* - Common distractions (newsletter popups, etc.) are filtered
|
|
490
|
+
*/
|
|
491
|
+
async function findBestAction(page, goal, analysis) {
|
|
492
|
+
if (analysis.availableActions.length === 0)
|
|
493
|
+
return null;
|
|
494
|
+
// Infer task type from goal and get appropriate focus hierarchy
|
|
495
|
+
const taskType = inferTaskTypeFromGoal(goal);
|
|
496
|
+
const focusHierarchy = getFocusHierarchy(taskType);
|
|
497
|
+
// Apply focus hierarchy weighting to each action
|
|
498
|
+
const weightedActions = analysis.availableActions.map(action => {
|
|
499
|
+
// Calculate focus priority based on area and distraction filtering
|
|
500
|
+
const focusPriority = calculateFocusPriority({
|
|
501
|
+
area: action.area,
|
|
502
|
+
text: action.text,
|
|
503
|
+
selector: action.selector,
|
|
504
|
+
isRelevantToGoal: action.relevance > 0.3,
|
|
505
|
+
}, focusHierarchy);
|
|
506
|
+
// Check if this action should be filtered as a distraction
|
|
507
|
+
const distractionRate = getDistractionIgnoreRate({ text: action.text, selector: action.selector }, focusHierarchy.distractionFilters);
|
|
508
|
+
// Probabilistic filtering: randomly skip distractions
|
|
509
|
+
const skipAsDistraction = Math.random() < distractionRate;
|
|
510
|
+
// Combined score: relevance * focus priority, with distraction penalty
|
|
511
|
+
const combinedScore = skipAsDistraction
|
|
512
|
+
? 0 // Filtered out as distraction
|
|
513
|
+
: action.relevance * focusPriority;
|
|
514
|
+
return {
|
|
515
|
+
action,
|
|
516
|
+
focusPriority,
|
|
517
|
+
distractionRate,
|
|
518
|
+
combinedScore,
|
|
519
|
+
skipped: skipAsDistraction,
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
// Filter out skipped distractions
|
|
523
|
+
const viableActions = weightedActions.filter(w => !w.skipped);
|
|
524
|
+
if (viableActions.length === 0) {
|
|
525
|
+
// All actions were filtered as distractions - fall back to nav-first
|
|
526
|
+
// This happens when page is full of popups/banners but has some nav
|
|
527
|
+
const navActions = weightedActions.filter(w => w.action.area === 'navigation' || w.action.area === 'headings');
|
|
528
|
+
if (navActions.length > 0) {
|
|
529
|
+
// Force through navigation even if it was a distraction
|
|
530
|
+
navActions.sort((a, b) => b.action.relevance - a.action.relevance);
|
|
531
|
+
return navActions[0].action;
|
|
532
|
+
}
|
|
533
|
+
// Absolute fallback: return highest relevance regardless of focus
|
|
534
|
+
return analysis.availableActions[0];
|
|
535
|
+
}
|
|
536
|
+
// Sort by combined score (focus-weighted relevance)
|
|
537
|
+
viableActions.sort((a, b) => b.combinedScore - a.combinedScore);
|
|
538
|
+
// Apply attention capacity limit (humans don't consider all options)
|
|
539
|
+
const consideredActions = viableActions.slice(0, focusHierarchy.attentionCapacity);
|
|
540
|
+
// Add slight randomness to simulate human variability
|
|
541
|
+
// (Sometimes users pick 2nd or 3rd best option)
|
|
542
|
+
const randomFactor = Math.random();
|
|
543
|
+
if (randomFactor < 0.7 && consideredActions.length > 0) {
|
|
544
|
+
return consideredActions[0].action;
|
|
545
|
+
}
|
|
546
|
+
else if (randomFactor < 0.9 && consideredActions.length > 1) {
|
|
547
|
+
return consideredActions[1].action;
|
|
548
|
+
}
|
|
549
|
+
else if (consideredActions.length > 2) {
|
|
550
|
+
return consideredActions[2].action;
|
|
551
|
+
}
|
|
552
|
+
return consideredActions[0]?.action ?? null;
|
|
553
|
+
}
|
|
554
|
+
async function executeAction(page, action) {
|
|
555
|
+
switch (action.type) {
|
|
556
|
+
case "click":
|
|
557
|
+
await page.click(action.selector, { timeout: 5000 });
|
|
558
|
+
break;
|
|
559
|
+
case "fill":
|
|
560
|
+
// Generate test data based on field type
|
|
561
|
+
const testValue = generateTestValue(action.target);
|
|
562
|
+
await page.fill(action.selector, testValue, { timeout: 5000 });
|
|
563
|
+
break;
|
|
564
|
+
case "scroll":
|
|
565
|
+
await page.evaluate(() => window.scrollBy(0, 500));
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function generateTestValue(fieldHint) {
|
|
570
|
+
const hint = fieldHint.toLowerCase();
|
|
571
|
+
if (hint.includes("email"))
|
|
572
|
+
return "test@example.com";
|
|
573
|
+
if (hint.includes("password"))
|
|
574
|
+
return "TestPassword123!";
|
|
575
|
+
if (hint.includes("name"))
|
|
576
|
+
return "Test User";
|
|
577
|
+
if (hint.includes("phone"))
|
|
578
|
+
return "555-123-4567";
|
|
579
|
+
if (hint.includes("search"))
|
|
580
|
+
return "test search";
|
|
581
|
+
return "test value";
|
|
582
|
+
}
|
|
583
|
+
// ============================================================================
|
|
584
|
+
// Comparison and Ranking
|
|
585
|
+
// ============================================================================
|
|
586
|
+
function calculateSiteScore(result) {
|
|
587
|
+
let score = 0;
|
|
588
|
+
// Goal achieved is worth a lot
|
|
589
|
+
if (result.goalAchieved)
|
|
590
|
+
score += 50;
|
|
591
|
+
// Faster is better (up to 30 points for speed)
|
|
592
|
+
const timeScore = Math.max(0, 30 - (result.totalTime / 1000 / 2));
|
|
593
|
+
score += timeScore;
|
|
594
|
+
// Fewer steps is better (up to 10 points)
|
|
595
|
+
const stepScore = Math.max(0, 10 - result.stepCount);
|
|
596
|
+
score += stepScore;
|
|
597
|
+
// Less friction is better (up to 10 points)
|
|
598
|
+
const frictionScore = Math.max(0, 10 - result.frictionPoints.length * 2);
|
|
599
|
+
score += frictionScore;
|
|
600
|
+
return Math.round(score);
|
|
601
|
+
}
|
|
602
|
+
function generateRankings(results) {
|
|
603
|
+
const scored = results.map(r => ({
|
|
604
|
+
result: r,
|
|
605
|
+
score: calculateSiteScore(r),
|
|
606
|
+
}));
|
|
607
|
+
// Sort by score descending
|
|
608
|
+
scored.sort((a, b) => b.score - a.score);
|
|
609
|
+
return scored.map((item, index) => {
|
|
610
|
+
// Determine strengths and weaknesses
|
|
611
|
+
const strengths = [];
|
|
612
|
+
const weaknesses = [];
|
|
613
|
+
const avgTime = results.reduce((sum, r) => sum + r.totalTime, 0) / results.length;
|
|
614
|
+
const avgSteps = results.reduce((sum, r) => sum + r.stepCount, 0) / results.length;
|
|
615
|
+
const avgFriction = results.reduce((sum, r) => sum + r.frictionPoints.length, 0) / results.length;
|
|
616
|
+
if (item.result.goalAchieved) {
|
|
617
|
+
strengths.push("Goal achieved successfully");
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
weaknesses.push("Failed to achieve goal");
|
|
621
|
+
}
|
|
622
|
+
if (item.result.totalTime < avgTime * 0.8) {
|
|
623
|
+
strengths.push("Faster than average");
|
|
624
|
+
}
|
|
625
|
+
else if (item.result.totalTime > avgTime * 1.2) {
|
|
626
|
+
weaknesses.push("Slower than average");
|
|
627
|
+
}
|
|
628
|
+
if (item.result.stepCount < avgSteps * 0.8) {
|
|
629
|
+
strengths.push("Fewer steps needed");
|
|
630
|
+
}
|
|
631
|
+
else if (item.result.stepCount > avgSteps * 1.2) {
|
|
632
|
+
weaknesses.push("Too many steps required");
|
|
633
|
+
}
|
|
634
|
+
if (item.result.frictionPoints.length < avgFriction * 0.8) {
|
|
635
|
+
strengths.push("Low friction experience");
|
|
636
|
+
}
|
|
637
|
+
else if (item.result.frictionPoints.length > avgFriction * 1.2) {
|
|
638
|
+
weaknesses.push("High friction experience");
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
rank: index + 1,
|
|
642
|
+
site: item.result.siteName,
|
|
643
|
+
score: item.score,
|
|
644
|
+
strengths,
|
|
645
|
+
weaknesses,
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
function generateComparison(results) {
|
|
650
|
+
const sorted = [...results];
|
|
651
|
+
// Find fastest/slowest
|
|
652
|
+
sorted.sort((a, b) => a.totalTime - b.totalTime);
|
|
653
|
+
const fastestSite = sorted[0].siteName;
|
|
654
|
+
const slowestSite = sorted[sorted.length - 1].siteName;
|
|
655
|
+
// Find most/least friction
|
|
656
|
+
sorted.sort((a, b) => a.frictionPoints.length - b.frictionPoints.length);
|
|
657
|
+
const leastFriction = sorted[0].siteName;
|
|
658
|
+
const mostFriction = sorted[sorted.length - 1].siteName;
|
|
659
|
+
// Find highest abandonment risk
|
|
660
|
+
sorted.sort((a, b) => b.abandonmentRisk - a.abandonmentRisk);
|
|
661
|
+
const highestAbandonmentRisk = sorted[0].siteName;
|
|
662
|
+
// Find common friction points
|
|
663
|
+
const frictionCounts = new Map();
|
|
664
|
+
for (const result of results) {
|
|
665
|
+
for (const friction of result.frictionPoints) {
|
|
666
|
+
frictionCounts.set(friction, (frictionCounts.get(friction) || 0) + 1);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const commonFrictionAcrossSites = Array.from(frictionCounts.entries())
|
|
670
|
+
.filter(([_, count]) => count >= results.length / 2)
|
|
671
|
+
.map(([friction, _]) => friction);
|
|
672
|
+
return {
|
|
673
|
+
fastestSite,
|
|
674
|
+
slowestSite,
|
|
675
|
+
mostFriction,
|
|
676
|
+
leastFriction,
|
|
677
|
+
highestAbandonmentRisk,
|
|
678
|
+
commonFrictionAcrossSites,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function generateCompetitiveRecommendations(results, ranking) {
|
|
682
|
+
const recommendations = [];
|
|
683
|
+
const bestSite = ranking[0];
|
|
684
|
+
for (const result of results) {
|
|
685
|
+
if (result.siteName === bestSite.site)
|
|
686
|
+
continue; // Skip the winner
|
|
687
|
+
const thisRanking = ranking.find(r => r.site === result.siteName);
|
|
688
|
+
if (!thisRanking)
|
|
689
|
+
continue;
|
|
690
|
+
// Generate recommendations based on weaknesses
|
|
691
|
+
for (const weakness of thisRanking.weaknesses) {
|
|
692
|
+
let improvement = "";
|
|
693
|
+
let reference = "";
|
|
694
|
+
if (weakness.includes("Failed to achieve")) {
|
|
695
|
+
improvement = "Improve conversion flow to ensure users can complete the goal";
|
|
696
|
+
if (bestSite.strengths.includes("Goal achieved successfully")) {
|
|
697
|
+
reference = `${bestSite.site} successfully guides users to goal completion`;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else if (weakness.includes("Slower")) {
|
|
701
|
+
improvement = "Reduce time-to-completion by streamlining steps";
|
|
702
|
+
const fastest = results.reduce((a, b) => a.totalTime < b.totalTime ? a : b);
|
|
703
|
+
reference = `${fastest.siteName} completes in ${(fastest.totalTime / 1000).toFixed(1)}s`;
|
|
704
|
+
}
|
|
705
|
+
else if (weakness.includes("Too many steps")) {
|
|
706
|
+
improvement = "Reduce the number of steps required";
|
|
707
|
+
const fewestSteps = results.reduce((a, b) => a.stepCount < b.stepCount ? a : b);
|
|
708
|
+
reference = `${fewestSteps.siteName} only requires ${fewestSteps.stepCount} steps`;
|
|
709
|
+
}
|
|
710
|
+
else if (weakness.includes("High friction")) {
|
|
711
|
+
improvement = "Reduce friction points in the user flow";
|
|
712
|
+
const leastFriction = results.reduce((a, b) => a.frictionPoints.length < b.frictionPoints.length ? a : b);
|
|
713
|
+
reference = `${leastFriction.siteName} has fewer friction points`;
|
|
714
|
+
}
|
|
715
|
+
if (improvement) {
|
|
716
|
+
recommendations.push({
|
|
717
|
+
site: result.siteName,
|
|
718
|
+
improvement,
|
|
719
|
+
competitorReference: reference || undefined,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// Add friction-specific recommendations
|
|
724
|
+
for (const friction of result.frictionPoints) {
|
|
725
|
+
recommendations.push({
|
|
726
|
+
site: result.siteName,
|
|
727
|
+
improvement: `Fix: ${friction}`,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return recommendations;
|
|
732
|
+
}
|
|
733
|
+
// ============================================================================
|
|
734
|
+
// Report Generation
|
|
735
|
+
// ============================================================================
|
|
736
|
+
export function formatCompetitiveBenchmarkReport(result) {
|
|
737
|
+
let report = `
|
|
738
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
739
|
+
║ COMPETITIVE UX BENCHMARK ║
|
|
740
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
741
|
+
|
|
742
|
+
Goal: "${result.goal}"
|
|
743
|
+
Persona: ${result.persona}
|
|
744
|
+
Timestamp: ${result.timestamp}
|
|
745
|
+
Total Duration: ${(result.duration / 1000).toFixed(1)}s
|
|
746
|
+
|
|
747
|
+
⚠️ METHODOLOGY NOTE: Scores are heuristic estimates for RELATIVE comparison.
|
|
748
|
+
|
|
749
|
+
ABANDONMENT RISK SCALE (1-10):
|
|
750
|
+
1-2 Very Low │ Users likely to complete. Smooth experience.
|
|
751
|
+
3-4 Low │ Minor friction. Most users persist.
|
|
752
|
+
5-6 Medium │ Noticeable friction. Some users may leave.
|
|
753
|
+
7-8 High │ Significant obstacles. Many users abandon.
|
|
754
|
+
9-10 Very High │ Critical barriers. Most users will leave.
|
|
755
|
+
|
|
756
|
+
See docs/METHODOLOGY.md for research sources.
|
|
757
|
+
|
|
758
|
+
┌────────────────────────────────────────────────────────────────────────────┐
|
|
759
|
+
│ RANKING │
|
|
760
|
+
├────────────────────────────────────────────────────────────────────────────┤
|
|
761
|
+
`;
|
|
762
|
+
const medals = ['🥇', '🥈', '🥉'];
|
|
763
|
+
for (const site of result.ranking) {
|
|
764
|
+
const medal = medals[site.rank - 1] || `#${site.rank}`;
|
|
765
|
+
const siteResult = result.sites.find(s => s.siteName === site.site);
|
|
766
|
+
const riskRating = getAbandonmentRiskRating(siteResult.abandonmentRisk);
|
|
767
|
+
report += `│ ${medal} ${site.site.padEnd(20)} — ${(siteResult.totalTime / 1000).toFixed(1)}s, ${siteResult.stepCount} steps, Risk: ${riskRating.score}/10 (${riskRating.label})\n`;
|
|
768
|
+
}
|
|
769
|
+
report += `└────────────────────────────────────────────────────────────────────────────┘
|
|
770
|
+
|
|
771
|
+
DETAILED COMPARISON
|
|
772
|
+
───────────────────
|
|
773
|
+
`;
|
|
774
|
+
// Build comparison table
|
|
775
|
+
const metrics = ['Time', 'Steps', 'Friction', 'Confusion', 'Goal'];
|
|
776
|
+
const headers = ['Metric', ...result.sites.map(s => s.siteName.slice(0, 15))];
|
|
777
|
+
const colWidth = 16;
|
|
778
|
+
report += headers.map(h => h.padEnd(colWidth)).join('') + '\n';
|
|
779
|
+
report += '─'.repeat(colWidth * headers.length) + '\n';
|
|
780
|
+
// Find best values for highlighting
|
|
781
|
+
const bestTime = Math.min(...result.sites.map(s => s.totalTime));
|
|
782
|
+
const bestSteps = Math.min(...result.sites.map(s => s.stepCount));
|
|
783
|
+
const bestFriction = Math.min(...result.sites.map(s => s.frictionPoints.length));
|
|
784
|
+
const bestConfusion = Math.min(...result.sites.map(s => s.confusionLevel));
|
|
785
|
+
for (const metric of metrics) {
|
|
786
|
+
const row = [metric];
|
|
787
|
+
for (const site of result.sites) {
|
|
788
|
+
let value = '';
|
|
789
|
+
let isBest = false;
|
|
790
|
+
switch (metric) {
|
|
791
|
+
case 'Time':
|
|
792
|
+
value = `${(site.totalTime / 1000).toFixed(1)}s`;
|
|
793
|
+
isBest = site.totalTime === bestTime;
|
|
794
|
+
break;
|
|
795
|
+
case 'Steps':
|
|
796
|
+
value = `${site.stepCount}`;
|
|
797
|
+
isBest = site.stepCount === bestSteps;
|
|
798
|
+
break;
|
|
799
|
+
case 'Friction':
|
|
800
|
+
value = `${site.frictionPoints.length}`;
|
|
801
|
+
isBest = site.frictionPoints.length === bestFriction;
|
|
802
|
+
break;
|
|
803
|
+
case 'Confusion':
|
|
804
|
+
value = `${site.confusionLevel}%`;
|
|
805
|
+
isBest = site.confusionLevel === bestConfusion;
|
|
806
|
+
break;
|
|
807
|
+
case 'Goal':
|
|
808
|
+
value = site.goalAchieved ? '✓' : '✗';
|
|
809
|
+
isBest = site.goalAchieved;
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
row.push(isBest ? `${value} ✓` : value);
|
|
813
|
+
}
|
|
814
|
+
report += row.map(c => c.padEnd(colWidth)).join('') + '\n';
|
|
815
|
+
}
|
|
816
|
+
report += `
|
|
817
|
+
RECOMMENDATIONS
|
|
818
|
+
───────────────
|
|
819
|
+
`;
|
|
820
|
+
const groupedRecs = new Map();
|
|
821
|
+
for (const rec of result.recommendations) {
|
|
822
|
+
if (!groupedRecs.has(rec.site))
|
|
823
|
+
groupedRecs.set(rec.site, []);
|
|
824
|
+
groupedRecs.get(rec.site).push(rec.improvement + (rec.competitorReference ? ` — ${rec.competitorReference}` : ''));
|
|
825
|
+
}
|
|
826
|
+
for (const [site, recs] of groupedRecs) {
|
|
827
|
+
report += `\n${site}:\n`;
|
|
828
|
+
for (const rec of recs.slice(0, 5)) {
|
|
829
|
+
report += ` • ${rec}\n`;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
report += `
|
|
833
|
+
─────────────────────────────────────────────────────────────────────────────
|
|
834
|
+
* Methodology and research sources: docs/METHODOLOGY.md
|
|
835
|
+
Key sources: Google/SOASTA (2017), Baymard Institute, Nielsen Norman Group
|
|
836
|
+
|
|
837
|
+
Generated by CBrowser v8.0.0 - Competitive UX Benchmark
|
|
838
|
+
`;
|
|
839
|
+
return report;
|
|
840
|
+
}
|
|
841
|
+
export function generateCompetitiveBenchmarkHtmlReport(result) {
|
|
842
|
+
const medals = ['🥇', '🥈', '🥉'];
|
|
843
|
+
const rankingRows = result.ranking.map((site, i) => {
|
|
844
|
+
const siteResult = result.sites.find(s => s.siteName === site.site);
|
|
845
|
+
const riskRating = getAbandonmentRiskRating(siteResult.abandonmentRisk);
|
|
846
|
+
const riskClass = riskRating.score <= 3 ? 'risk-low' :
|
|
847
|
+
riskRating.score <= 6 ? 'risk-medium' : 'risk-high';
|
|
848
|
+
return `
|
|
849
|
+
<tr class="${siteResult.goalAchieved ? 'success' : 'failure'}">
|
|
850
|
+
<td>${medals[i] || `#${site.rank}`}</td>
|
|
851
|
+
<td><strong>${site.site}</strong></td>
|
|
852
|
+
<td>${site.score}</td>
|
|
853
|
+
<td>${(siteResult.totalTime / 1000).toFixed(1)}s</td>
|
|
854
|
+
<td>${siteResult.stepCount}</td>
|
|
855
|
+
<td>${siteResult.frictionPoints.length}</td>
|
|
856
|
+
<td><span class="risk-score ${riskClass}">${riskRating.score}/10 <small>${riskRating.label}</small></span></td>
|
|
857
|
+
<td>${siteResult.goalAchieved ? '✓' : '✗'}</td>
|
|
858
|
+
</tr>
|
|
859
|
+
`;
|
|
860
|
+
}).join('');
|
|
861
|
+
const recommendationCards = Array.from(result.recommendations.reduce((map, rec) => {
|
|
862
|
+
if (!map.has(rec.site))
|
|
863
|
+
map.set(rec.site, []);
|
|
864
|
+
map.get(rec.site).push(rec);
|
|
865
|
+
return map;
|
|
866
|
+
}, new Map())).map(([site, recs]) => `
|
|
867
|
+
<div class="rec-site">
|
|
868
|
+
<h4>${site}</h4>
|
|
869
|
+
<ul>
|
|
870
|
+
${recs.slice(0, 5).map(r => `
|
|
871
|
+
<li>
|
|
872
|
+
${r.improvement}
|
|
873
|
+
${r.competitorReference ? `<br><small class="reference">${r.competitorReference}</small>` : ''}
|
|
874
|
+
</li>
|
|
875
|
+
`).join('')}
|
|
876
|
+
</ul>
|
|
877
|
+
</div>
|
|
878
|
+
`).join('');
|
|
879
|
+
return `<!DOCTYPE html>
|
|
880
|
+
<html lang="en">
|
|
881
|
+
<head>
|
|
882
|
+
<meta charset="UTF-8">
|
|
883
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
884
|
+
<title>Competitive UX Benchmark - ${result.goal}</title>
|
|
885
|
+
<style>
|
|
886
|
+
* { box-sizing: border-box; }
|
|
887
|
+
body {
|
|
888
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
889
|
+
max-width: 1400px;
|
|
890
|
+
margin: 0 auto;
|
|
891
|
+
padding: 2rem;
|
|
892
|
+
background: #0f172a;
|
|
893
|
+
color: #e2e8f0;
|
|
894
|
+
}
|
|
895
|
+
h1 {
|
|
896
|
+
color: #f8fafc;
|
|
897
|
+
border-bottom: 3px solid #3b82f6;
|
|
898
|
+
padding-bottom: 0.5rem;
|
|
899
|
+
}
|
|
900
|
+
h2 { color: #94a3b8; margin-top: 2rem; }
|
|
901
|
+
.meta {
|
|
902
|
+
background: #1e293b;
|
|
903
|
+
padding: 1rem;
|
|
904
|
+
border-radius: 8px;
|
|
905
|
+
margin-bottom: 1rem;
|
|
906
|
+
}
|
|
907
|
+
.meta p { margin: 0.25rem 0; }
|
|
908
|
+
table {
|
|
909
|
+
width: 100%;
|
|
910
|
+
border-collapse: collapse;
|
|
911
|
+
background: #1e293b;
|
|
912
|
+
border-radius: 8px;
|
|
913
|
+
overflow: hidden;
|
|
914
|
+
margin: 1rem 0;
|
|
915
|
+
}
|
|
916
|
+
th, td {
|
|
917
|
+
padding: 0.75rem 1rem;
|
|
918
|
+
text-align: left;
|
|
919
|
+
border-bottom: 1px solid #334155;
|
|
920
|
+
}
|
|
921
|
+
th { background: #0f172a; color: #94a3b8; }
|
|
922
|
+
tr.success { background: #064e3b40; }
|
|
923
|
+
tr.failure { background: #7f1d1d40; }
|
|
924
|
+
.comparison-grid {
|
|
925
|
+
display: grid;
|
|
926
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
927
|
+
gap: 1rem;
|
|
928
|
+
margin: 1rem 0;
|
|
929
|
+
}
|
|
930
|
+
.stat-card {
|
|
931
|
+
background: #1e293b;
|
|
932
|
+
padding: 1rem;
|
|
933
|
+
border-radius: 8px;
|
|
934
|
+
text-align: center;
|
|
935
|
+
}
|
|
936
|
+
.stat-card .label {
|
|
937
|
+
font-size: 0.875rem;
|
|
938
|
+
color: #94a3b8;
|
|
939
|
+
}
|
|
940
|
+
.stat-card .value {
|
|
941
|
+
font-size: 1.5rem;
|
|
942
|
+
font-weight: bold;
|
|
943
|
+
color: #3b82f6;
|
|
944
|
+
}
|
|
945
|
+
.rec-site {
|
|
946
|
+
background: #1e293b;
|
|
947
|
+
border-radius: 8px;
|
|
948
|
+
padding: 1rem;
|
|
949
|
+
margin: 1rem 0;
|
|
950
|
+
}
|
|
951
|
+
.rec-site h4 {
|
|
952
|
+
margin-top: 0;
|
|
953
|
+
color: #f8fafc;
|
|
954
|
+
}
|
|
955
|
+
.rec-site ul {
|
|
956
|
+
margin: 0;
|
|
957
|
+
padding-left: 1.5rem;
|
|
958
|
+
}
|
|
959
|
+
.rec-site li {
|
|
960
|
+
margin: 0.5rem 0;
|
|
961
|
+
}
|
|
962
|
+
.reference {
|
|
963
|
+
color: #94a3b8;
|
|
964
|
+
}
|
|
965
|
+
.risk-score {
|
|
966
|
+
display: inline-block;
|
|
967
|
+
padding: 0.25rem 0.5rem;
|
|
968
|
+
border-radius: 4px;
|
|
969
|
+
font-weight: bold;
|
|
970
|
+
}
|
|
971
|
+
.risk-score small {
|
|
972
|
+
font-weight: normal;
|
|
973
|
+
opacity: 0.8;
|
|
974
|
+
}
|
|
975
|
+
.risk-low { background: #10b98133; color: #10b981; }
|
|
976
|
+
.risk-medium { background: #f59e0b33; color: #f59e0b; }
|
|
977
|
+
.risk-high { background: #ef444433; color: #ef4444; }
|
|
978
|
+
.disclaimer {
|
|
979
|
+
background: #1e3a5f;
|
|
980
|
+
border-left: 4px solid #3b82f6;
|
|
981
|
+
padding: 1rem;
|
|
982
|
+
margin: 1rem 0;
|
|
983
|
+
border-radius: 0 8px 8px 0;
|
|
984
|
+
}
|
|
985
|
+
.disclaimer h4 {
|
|
986
|
+
margin: 0 0 0.5rem 0;
|
|
987
|
+
color: #60a5fa;
|
|
988
|
+
}
|
|
989
|
+
.disclaimer p {
|
|
990
|
+
margin: 0.25rem 0;
|
|
991
|
+
font-size: 0.875rem;
|
|
992
|
+
color: #94a3b8;
|
|
993
|
+
}
|
|
994
|
+
</style>
|
|
995
|
+
</head>
|
|
996
|
+
<body>
|
|
997
|
+
<h1>📊 Competitive UX Benchmark</h1>
|
|
998
|
+
|
|
999
|
+
<div class="disclaimer">
|
|
1000
|
+
<h4>⚠️ Methodology Note</h4>
|
|
1001
|
+
<p>Scores are <strong>heuristic estimates</strong> for relative comparison between sites, not precise measurements.</p>
|
|
1002
|
+
<p><strong>Abandonment Risk Scale (1-10):</strong></p>
|
|
1003
|
+
<table style="width: 100%; margin: 0.5rem 0; font-size: 0.875rem;">
|
|
1004
|
+
<tr><td style="width: 80px;"><span class="risk-score risk-low">1-2</span></td><td><strong>Very Low</strong> — Users likely to complete. Smooth experience.</td></tr>
|
|
1005
|
+
<tr><td><span class="risk-score risk-low">3-4</span></td><td><strong>Low</strong> — Minor friction. Most users persist.</td></tr>
|
|
1006
|
+
<tr><td><span class="risk-score risk-medium">5-6</span></td><td><strong>Medium</strong> — Noticeable friction. Some users may leave.</td></tr>
|
|
1007
|
+
<tr><td><span class="risk-score risk-high">7-8</span></td><td><strong>High</strong> — Significant obstacles. Many users abandon.</td></tr>
|
|
1008
|
+
<tr><td><span class="risk-score risk-high">9-10</span></td><td><strong>Very High</strong> — Critical barriers. Most users will leave.</td></tr>
|
|
1009
|
+
</table>
|
|
1010
|
+
</div>
|
|
1011
|
+
|
|
1012
|
+
<div class="meta">
|
|
1013
|
+
<p><strong>Goal:</strong> "${result.goal}"</p>
|
|
1014
|
+
<p><strong>Persona:</strong> ${result.persona}</p>
|
|
1015
|
+
<p><strong>Timestamp:</strong> ${result.timestamp}</p>
|
|
1016
|
+
<p><strong>Duration:</strong> ${(result.duration / 1000).toFixed(1)}s</p>
|
|
1017
|
+
</div>
|
|
1018
|
+
|
|
1019
|
+
<h2>Ranking</h2>
|
|
1020
|
+
<table>
|
|
1021
|
+
<thead>
|
|
1022
|
+
<tr>
|
|
1023
|
+
<th>Rank</th>
|
|
1024
|
+
<th>Site</th>
|
|
1025
|
+
<th>Score</th>
|
|
1026
|
+
<th>Time</th>
|
|
1027
|
+
<th>Steps</th>
|
|
1028
|
+
<th>Friction</th>
|
|
1029
|
+
<th>Abandon Risk</th>
|
|
1030
|
+
<th>Goal</th>
|
|
1031
|
+
</tr>
|
|
1032
|
+
</thead>
|
|
1033
|
+
<tbody>
|
|
1034
|
+
${rankingRows}
|
|
1035
|
+
</tbody>
|
|
1036
|
+
</table>
|
|
1037
|
+
|
|
1038
|
+
<h2>Quick Comparison</h2>
|
|
1039
|
+
<div class="comparison-grid">
|
|
1040
|
+
<div class="stat-card">
|
|
1041
|
+
<div class="label">Fastest</div>
|
|
1042
|
+
<div class="value">${result.comparison.fastestSite}</div>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div class="stat-card">
|
|
1045
|
+
<div class="label">Slowest</div>
|
|
1046
|
+
<div class="value">${result.comparison.slowestSite}</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
<div class="stat-card">
|
|
1049
|
+
<div class="label">Least Friction</div>
|
|
1050
|
+
<div class="value">${result.comparison.leastFriction}</div>
|
|
1051
|
+
</div>
|
|
1052
|
+
<div class="stat-card">
|
|
1053
|
+
<div class="label">Most Friction</div>
|
|
1054
|
+
<div class="value">${result.comparison.mostFriction}</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
|
|
1058
|
+
<h2>Recommendations</h2>
|
|
1059
|
+
${recommendationCards}
|
|
1060
|
+
|
|
1061
|
+
<div class="footnote">
|
|
1062
|
+
<p>* Methodology and research sources: <a href="docs/METHODOLOGY.md" style="color: #60a5fa;">docs/METHODOLOGY.md</a></p>
|
|
1063
|
+
<p>Key sources: Google/SOASTA (2017), Baymard Institute, Nielsen Norman Group, FullStory</p>
|
|
1064
|
+
</div>
|
|
1065
|
+
|
|
1066
|
+
<p style="color: #64748b; text-align: center; margin-top: 2rem;">
|
|
1067
|
+
Generated by CBrowser v8.0.0 - Competitive UX Benchmark
|
|
1068
|
+
</p>
|
|
1069
|
+
</body>
|
|
1070
|
+
</html>`;
|
|
1071
|
+
}
|
|
1072
|
+
// ============================================================================
|
|
1073
|
+
// Main Benchmark Function
|
|
1074
|
+
// ============================================================================
|
|
1075
|
+
export async function runCompetitiveBenchmark(options) {
|
|
1076
|
+
const { sites, goal, persona = "first-timer", maxSteps = 30, maxTime = 180, headless = true, maxConcurrency = 3, } = options;
|
|
1077
|
+
const startTime = Date.now();
|
|
1078
|
+
const personaConfig = getPersona(persona) || BUILTIN_PERSONAS["first-timer"];
|
|
1079
|
+
// Run journeys in parallel (limited concurrency)
|
|
1080
|
+
const results = [];
|
|
1081
|
+
// Process sites in batches
|
|
1082
|
+
for (let i = 0; i < sites.length; i += maxConcurrency) {
|
|
1083
|
+
const batch = sites.slice(i, i + maxConcurrency);
|
|
1084
|
+
const batchResults = await Promise.all(batch.map(async (site) => {
|
|
1085
|
+
let browser = null;
|
|
1086
|
+
try {
|
|
1087
|
+
browser = await chromium.launch({ headless });
|
|
1088
|
+
const context = await browser.newContext({
|
|
1089
|
+
viewport: { width: 1920, height: 1080 },
|
|
1090
|
+
});
|
|
1091
|
+
const page = await context.newPage();
|
|
1092
|
+
const result = await simulateJourney(page, site.url, goal, personaConfig, maxSteps, maxTime);
|
|
1093
|
+
// Override site name if provided
|
|
1094
|
+
if (site.name) {
|
|
1095
|
+
result.siteName = site.name;
|
|
1096
|
+
}
|
|
1097
|
+
return result;
|
|
1098
|
+
}
|
|
1099
|
+
finally {
|
|
1100
|
+
if (browser) {
|
|
1101
|
+
await browser.close();
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}));
|
|
1105
|
+
results.push(...batchResults);
|
|
1106
|
+
}
|
|
1107
|
+
// Generate rankings and comparisons
|
|
1108
|
+
const ranking = generateRankings(results);
|
|
1109
|
+
const comparison = generateComparison(results);
|
|
1110
|
+
const recommendations = generateCompetitiveRecommendations(results, ranking);
|
|
1111
|
+
return {
|
|
1112
|
+
goal,
|
|
1113
|
+
persona,
|
|
1114
|
+
timestamp: new Date().toISOString(),
|
|
1115
|
+
duration: Date.now() - startTime,
|
|
1116
|
+
sites: results,
|
|
1117
|
+
ranking,
|
|
1118
|
+
comparison,
|
|
1119
|
+
recommendations,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
//# sourceMappingURL=competitive-benchmark.js.map
|