@odavl/guardian 0.1.0-rc1 → 1.0.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/CHANGELOG.md +146 -0
- package/README.md +155 -97
- package/bin/guardian.js +1544 -55
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +26 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +587 -12
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +85 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +50 -8
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +38 -0
- package/src/guardian/cli-summary.js +167 -67
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +54 -0
- package/src/guardian/flag-validator.js +111 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +357 -82
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +27 -18
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +1612 -115
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +201 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +69 -3
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +181 -0
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust Selector Discovery with Fallbacks
|
|
3
|
+
*
|
|
4
|
+
* Attempts to find elements using multiple strategies:
|
|
5
|
+
* 1. data-guardian attributes (highest priority if site is instrumented)
|
|
6
|
+
* 2. Semantic input types (email/password/text/submit)
|
|
7
|
+
* 3. Autocomplete attributes (email/current-password/new-password)
|
|
8
|
+
* 4. Name/ID contains patterns
|
|
9
|
+
* 5. Role and ARIA attributes
|
|
10
|
+
* 6. Link href patterns
|
|
11
|
+
* 7. Visible text matching (case-insensitive)
|
|
12
|
+
* 8. Heuristic form/button detection
|
|
13
|
+
*
|
|
14
|
+
* Returns: { element, strategy, selector, confidence, discoverySignals }
|
|
15
|
+
* discoverySignals includes: formsCounted, linksFound, buttonsCounted, finalSelection
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build selector chain for a specific element type
|
|
20
|
+
* @param {string} goalType - email_input, password_input, submit_button, login_link, contact_link, language_toggle, etc.
|
|
21
|
+
* @returns {string[]} Array of selectors in priority order
|
|
22
|
+
*/
|
|
23
|
+
function buildSelectorChain(goalType) {
|
|
24
|
+
const chains = {
|
|
25
|
+
email_input: [
|
|
26
|
+
// data-guardian (site instrumented)
|
|
27
|
+
'[data-guardian="email"], [data-guardian="login-email"], [data-guardian="signup-email"]',
|
|
28
|
+
// semantic type
|
|
29
|
+
'input[type="email"]',
|
|
30
|
+
// autocomplete
|
|
31
|
+
'input[autocomplete="email"], input[autocomplete*="email"]',
|
|
32
|
+
// name/id patterns
|
|
33
|
+
'input[name*="email" i], input[id*="email" i]',
|
|
34
|
+
'input[name*="mail" i], input[id*="mail" i]',
|
|
35
|
+
'input[name*="user" i], input[id*="user" i]',
|
|
36
|
+
// placeholder
|
|
37
|
+
'input[placeholder*="email" i], input[placeholder*="mail" i]',
|
|
38
|
+
// aria-label
|
|
39
|
+
'input[aria-label*="email" i], input[aria-label*="mail" i]',
|
|
40
|
+
// broad form field with label
|
|
41
|
+
'form input[type="text"]:first-of-type'
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
password_input: [
|
|
45
|
+
'[data-guardian="password"], [data-guardian="login-password"], [data-guardian="signup-password"]',
|
|
46
|
+
'input[type="password"]',
|
|
47
|
+
'input[autocomplete="current-password"], input[autocomplete="new-password"]',
|
|
48
|
+
'input[name*="pass" i], input[id*="pass" i]',
|
|
49
|
+
'input[name*="pwd" i], input[id*="pwd" i]',
|
|
50
|
+
'input[placeholder*="pass" i], input[placeholder*="pwd" i]',
|
|
51
|
+
'input[aria-label*="pass" i], input[aria-label*="pwd" i]'
|
|
52
|
+
],
|
|
53
|
+
|
|
54
|
+
submit_button: [
|
|
55
|
+
'[data-guardian="submit"], [data-guardian="login-submit"], [data-guardian="signup-submit"], [data-guardian="signup-account-submit"]',
|
|
56
|
+
'button[type="submit"]',
|
|
57
|
+
'button:has-text("Sign in") >> nth=0',
|
|
58
|
+
'button:has-text("Sign up") >> nth=0',
|
|
59
|
+
'button:has-text("Log in") >> nth=0',
|
|
60
|
+
'button:has-text("Register") >> nth=0',
|
|
61
|
+
'button:has-text("Submit") >> nth=0',
|
|
62
|
+
'button:has-text("Send") >> nth=0',
|
|
63
|
+
'button:has-text("Create account") >> nth=0',
|
|
64
|
+
'button[name*="submit" i], button[id*="submit" i]',
|
|
65
|
+
'input[type="submit"], input[type="button"][value*="Submit" i], input[type="button"][value*="Sign" i]',
|
|
66
|
+
'button:not([type="reset"]):not([type="button"]):visible >> nth=0'
|
|
67
|
+
],
|
|
68
|
+
|
|
69
|
+
contact_form: [
|
|
70
|
+
'[data-guardian="contact-form"]',
|
|
71
|
+
'form[name*="contact" i]',
|
|
72
|
+
'form[id*="contact" i]',
|
|
73
|
+
'form[class*="contact" i]',
|
|
74
|
+
'[role="form"][aria-label*="contact" i]',
|
|
75
|
+
'form >> nth=0' // Fallback: first form on page
|
|
76
|
+
],
|
|
77
|
+
|
|
78
|
+
contact_link: [
|
|
79
|
+
'[data-guardian="contact-link"]',
|
|
80
|
+
'a[data-testid="contact-link"]',
|
|
81
|
+
'a:has-text("Contact")',
|
|
82
|
+
'a:has-text("Contact Us")',
|
|
83
|
+
'a:has-text("Get in Touch")',
|
|
84
|
+
'a[href*="/contact" i]',
|
|
85
|
+
'a[href*="/contact-us" i]',
|
|
86
|
+
'a[href*="/support" i]',
|
|
87
|
+
'a[href="#contact" i]',
|
|
88
|
+
'nav a:has-text("Contact")',
|
|
89
|
+
'[role="navigation"] a:has-text("Contact")'
|
|
90
|
+
],
|
|
91
|
+
|
|
92
|
+
login_link: [
|
|
93
|
+
'[data-guardian="account-login-link"]',
|
|
94
|
+
'a:has-text("Log in")',
|
|
95
|
+
'a:has-text("Login")',
|
|
96
|
+
'a:has-text("Sign in")',
|
|
97
|
+
'a[href*="/login" i]',
|
|
98
|
+
'a[href*="/signin" i]',
|
|
99
|
+
'a[href*="/auth" i]',
|
|
100
|
+
'a[href*="/account" i]',
|
|
101
|
+
'nav a:has-text("Log in")'
|
|
102
|
+
],
|
|
103
|
+
|
|
104
|
+
signup_link: [
|
|
105
|
+
'[data-guardian="account-signup-link"]',
|
|
106
|
+
'a:has-text("Sign up")',
|
|
107
|
+
'a:has-text("Signup")',
|
|
108
|
+
'a:has-text("Register")',
|
|
109
|
+
'a:has-text("Create account")',
|
|
110
|
+
'a[href*="/signup" i]',
|
|
111
|
+
'a[href*="/sign-up" i]',
|
|
112
|
+
'a[href*="/register" i]',
|
|
113
|
+
'a[href*="/join" i]',
|
|
114
|
+
'nav a:has-text("Sign up")'
|
|
115
|
+
],
|
|
116
|
+
|
|
117
|
+
checkout_link: [
|
|
118
|
+
'[data-guardian="checkout-link"]',
|
|
119
|
+
'a:has-text("Checkout")',
|
|
120
|
+
'a:has-text("Check out")',
|
|
121
|
+
'a:has-text("Cart")',
|
|
122
|
+
'a[href*="/checkout" i]',
|
|
123
|
+
'a[href*="/cart" i]',
|
|
124
|
+
'a[href*="/order" i]',
|
|
125
|
+
'a[href*="/buy" i]'
|
|
126
|
+
],
|
|
127
|
+
|
|
128
|
+
checkout_button: [
|
|
129
|
+
'[data-guardian="checkout-place-order"]',
|
|
130
|
+
'button:has-text("Checkout")',
|
|
131
|
+
'button:has-text("Place order")',
|
|
132
|
+
'button:has-text("Place Order")',
|
|
133
|
+
'button:has-text("Complete purchase")',
|
|
134
|
+
'button:has-text("Buy now")',
|
|
135
|
+
'button[name*="checkout" i]'
|
|
136
|
+
],
|
|
137
|
+
|
|
138
|
+
language_toggle: [
|
|
139
|
+
'[data-guardian="lang-toggle"]',
|
|
140
|
+
'button:has-text("Language")',
|
|
141
|
+
'button:has-text("Lang")',
|
|
142
|
+
'button[aria-label*="language" i]',
|
|
143
|
+
'button[aria-label*="lang" i]',
|
|
144
|
+
'select[name*="lang" i]',
|
|
145
|
+
'select[id*="language" i]',
|
|
146
|
+
'a[hreflang]:first-of-type'
|
|
147
|
+
],
|
|
148
|
+
|
|
149
|
+
language_option_de: [
|
|
150
|
+
'[data-guardian="lang-option-de"]',
|
|
151
|
+
'button:has-text("DE")',
|
|
152
|
+
'button:has-text("Deutsch")',
|
|
153
|
+
'a[hreflang="de"]',
|
|
154
|
+
'[lang="de"] >> nth=0'
|
|
155
|
+
],
|
|
156
|
+
|
|
157
|
+
success_message: [
|
|
158
|
+
'[data-guardian="success"]',
|
|
159
|
+
'[data-testid="success"]',
|
|
160
|
+
'.success-message',
|
|
161
|
+
'.alert-success',
|
|
162
|
+
'[role="alert"]:has-text("success")',
|
|
163
|
+
'[role="alert"]:has-text("submitted")',
|
|
164
|
+
'[role="alert"]:has-text("confirmed")',
|
|
165
|
+
'div:has-text("success")'
|
|
166
|
+
]
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return chains[goalType] || [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Attempt to find element using selector chain
|
|
174
|
+
* @param {Page} page - Playwright page
|
|
175
|
+
* @param {string[]} selectorChain - Array of selectors to try
|
|
176
|
+
* @param {Object} options - { timeout, requireVisible }
|
|
177
|
+
* @returns {Promise<{element, strategy, selector, confidence, discoverySignals}>}
|
|
178
|
+
*/
|
|
179
|
+
async function findElement(page, selectorChain, options = {}) {
|
|
180
|
+
const { timeout = 5000, requireVisible = true } = options;
|
|
181
|
+
const discoverySignals = {
|
|
182
|
+
formsCounted: 0,
|
|
183
|
+
linksCounted: 0,
|
|
184
|
+
buttonsCounted: 0,
|
|
185
|
+
candidatesEvaluated: [],
|
|
186
|
+
finalSelection: null
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (!selectorChain || selectorChain.length === 0) {
|
|
190
|
+
return {
|
|
191
|
+
element: null,
|
|
192
|
+
strategy: 'EMPTY_CHAIN',
|
|
193
|
+
selector: null,
|
|
194
|
+
confidence: 0,
|
|
195
|
+
discoverySignals
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Quick scan for discovery signals
|
|
200
|
+
try {
|
|
201
|
+
discoverySignals.formsCounted = (await page.locator('form').count()).toString();
|
|
202
|
+
discoverySignals.linksCounted = (await page.locator('a').count()).toString();
|
|
203
|
+
discoverySignals.buttonsCounted = (await page.locator('button').count()).toString();
|
|
204
|
+
} catch (_) {
|
|
205
|
+
// Non-critical
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Try each selector in chain
|
|
209
|
+
for (let i = 0; i < selectorChain.length; i++) {
|
|
210
|
+
const selector = selectorChain[i];
|
|
211
|
+
try {
|
|
212
|
+
const locator = page.locator(selector);
|
|
213
|
+
const count = await locator.count();
|
|
214
|
+
|
|
215
|
+
if (count > 0) {
|
|
216
|
+
// Found candidate(s)
|
|
217
|
+
discoverySignals.candidatesEvaluated.push({
|
|
218
|
+
strategy: i,
|
|
219
|
+
selector,
|
|
220
|
+
found: count
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Use first match
|
|
224
|
+
const element = locator.first();
|
|
225
|
+
|
|
226
|
+
// Check visibility if required
|
|
227
|
+
if (requireVisible) {
|
|
228
|
+
const isVisible = await element.isVisible().catch(() => false);
|
|
229
|
+
if (!isVisible) {
|
|
230
|
+
discoverySignals.candidatesEvaluated[discoverySignals.candidatesEvaluated.length - 1].visible = false;
|
|
231
|
+
continue; // Try next selector
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
discoverySignals.finalSelection = {
|
|
236
|
+
strategy: i,
|
|
237
|
+
selector,
|
|
238
|
+
found: count,
|
|
239
|
+
used: 'first'
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
element,
|
|
244
|
+
strategy: `FALLBACK_${i}`,
|
|
245
|
+
selector,
|
|
246
|
+
confidence: 0.95 - (i * 0.05), // Decrease confidence for later strategies
|
|
247
|
+
discoverySignals
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Selector parse error or timeout - try next
|
|
252
|
+
discoverySignals.candidatesEvaluated.push({
|
|
253
|
+
strategy: i,
|
|
254
|
+
selector,
|
|
255
|
+
error: err.message
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// No element found
|
|
262
|
+
return {
|
|
263
|
+
element: null,
|
|
264
|
+
strategy: 'NOT_FOUND',
|
|
265
|
+
selector: null,
|
|
266
|
+
confidence: 0,
|
|
267
|
+
discoverySignals
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Detect if a specific feature is present on the page
|
|
273
|
+
* @param {Page} page - Playwright page
|
|
274
|
+
* @param {string} featureType - 'login', 'signup', 'checkout', 'contact_form', 'newsletter', 'language_switch'
|
|
275
|
+
* @returns {Promise<{present: boolean, confidence: number, evidence: string[]}>}
|
|
276
|
+
*/
|
|
277
|
+
async function detectFeature(page, featureType) {
|
|
278
|
+
const evidence = [];
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
switch (featureType) {
|
|
282
|
+
case 'login': {
|
|
283
|
+
// Look for login form, login link, or auth elements
|
|
284
|
+
const hasLoginLink = (await page.locator('a:has-text("Log in"), a:has-text("Login"), a:has-text("Sign in")').count()) > 0;
|
|
285
|
+
const hasLoginForm = (await page.locator('input[type="email"] + input[type="password"]').count()) > 0;
|
|
286
|
+
const hasAuthSection = (await page.locator('[role="form"][aria-label*="login" i]').count()) > 0;
|
|
287
|
+
|
|
288
|
+
if (hasLoginLink) evidence.push('login_link_found');
|
|
289
|
+
if (hasLoginForm) evidence.push('auth_form_found');
|
|
290
|
+
if (hasAuthSection) evidence.push('auth_section_found');
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
present: hasLoginLink || hasLoginForm || hasAuthSection,
|
|
294
|
+
confidence: evidence.length / 3,
|
|
295
|
+
evidence
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'signup': {
|
|
300
|
+
const hasSignupLink = (await page.locator('a:has-text("Sign up"), a:has-text("Signup"), a:has-text("Register")').count()) > 0;
|
|
301
|
+
const hasSignupForm = (await page.locator('input[type="email"]').count()) > 0; // Weak signal
|
|
302
|
+
const hasCreateText = (await page.locator('text=/create.*account/i').count()) > 0;
|
|
303
|
+
|
|
304
|
+
if (hasSignupLink) evidence.push('signup_link_found');
|
|
305
|
+
if (hasSignupForm) evidence.push('email_field_found');
|
|
306
|
+
if (hasCreateText) evidence.push('create_account_text_found');
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
present: hasSignupLink || (hasSignupForm && hasCreateText),
|
|
310
|
+
confidence: evidence.length / 3,
|
|
311
|
+
evidence
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case 'checkout': {
|
|
316
|
+
const hasCheckoutLink = (await page.locator('a:has-text("Checkout"), a:has-text("Cart"), a[href*="/checkout" i]').count()) > 0;
|
|
317
|
+
const hasPriceElements = (await page.locator('text=/\\$|€|¥|£/').count()) > 0;
|
|
318
|
+
const hasAddToCart = (await page.locator('button:has-text("Add to cart"), a:has-text("Add to cart")').count()) > 0;
|
|
319
|
+
|
|
320
|
+
if (hasCheckoutLink) evidence.push('checkout_link_found');
|
|
321
|
+
if (hasPriceElements) evidence.push('price_indicators_found');
|
|
322
|
+
if (hasAddToCart) evidence.push('add_to_cart_found');
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
present: hasCheckoutLink || (hasPriceElements && hasAddToCart),
|
|
326
|
+
confidence: evidence.length / 3,
|
|
327
|
+
evidence
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
case 'contact_form': {
|
|
332
|
+
const hasContactForm = (await page.locator('form[name*="contact" i], form[id*="contact" i], [role="form"][aria-label*="contact"]').count()) > 0;
|
|
333
|
+
const hasContactLink = (await page.locator('a:has-text("Contact")').count()) > 0;
|
|
334
|
+
const hasEmailField = (await page.locator('input[type="email"]').count()) > 0;
|
|
335
|
+
const hasMessageField = (await page.locator('textarea').count()) > 0;
|
|
336
|
+
|
|
337
|
+
if (hasContactForm) evidence.push('contact_form_found');
|
|
338
|
+
if (hasContactLink) evidence.push('contact_link_found');
|
|
339
|
+
if (hasEmailField && hasMessageField) evidence.push('contact_form_elements_found');
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
present: hasContactForm || hasContactLink || (hasEmailField && hasMessageField),
|
|
343
|
+
confidence: evidence.length / 3,
|
|
344
|
+
evidence
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
case 'newsletter': {
|
|
349
|
+
const hasNewsletterForm = (await page.locator('form[name*="newsletter" i], [aria-label*="newsletter" i]').count()) > 0;
|
|
350
|
+
const hasSubscribeButton = (await page.locator('button:has-text("Subscribe"), button:has-text("Sign up")').count()) > 0;
|
|
351
|
+
const hasNewsletterText = (await page.locator('text=/newsletter|subscribe/i').count()) > 0;
|
|
352
|
+
|
|
353
|
+
if (hasNewsletterForm) evidence.push('newsletter_form_found');
|
|
354
|
+
if (hasSubscribeButton) evidence.push('subscribe_button_found');
|
|
355
|
+
if (hasNewsletterText) evidence.push('newsletter_text_found');
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
present: hasNewsletterForm || (hasSubscribeButton && hasNewsletterText),
|
|
359
|
+
confidence: evidence.length / 3,
|
|
360
|
+
evidence
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
case 'language_switch': {
|
|
365
|
+
const hasLangButton = (await page.locator('button[aria-label*="language" i], button:has-text("Language")').count()) > 0;
|
|
366
|
+
const hasLangSelect = (await page.locator('select[name*="lang" i]').count()) > 0;
|
|
367
|
+
const hasMultipleLangs = (await page.locator('a[hreflang]').count()) > 1;
|
|
368
|
+
const hasLangIndicator = (await page.locator('text=/en|de|fr|es|it|pt/i').count()) > 0;
|
|
369
|
+
|
|
370
|
+
if (hasLangButton) evidence.push('lang_button_found');
|
|
371
|
+
if (hasLangSelect) evidence.push('lang_select_found');
|
|
372
|
+
if (hasMultipleLangs) evidence.push('hreflang_links_found');
|
|
373
|
+
if (hasLangIndicator) evidence.push('lang_codes_found');
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
present: hasLangButton || hasLangSelect || hasMultipleLangs,
|
|
377
|
+
confidence: evidence.length / 4,
|
|
378
|
+
evidence
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
default:
|
|
383
|
+
return { present: false, confidence: 0, evidence: [] };
|
|
384
|
+
}
|
|
385
|
+
} catch (err) {
|
|
386
|
+
return { present: false, confidence: 0, evidence: [`error: ${err.message}`] };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
buildSelectorChain,
|
|
392
|
+
findElement,
|
|
393
|
+
detectFeature
|
|
394
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Contact Detection
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, multilingual detection of contact links and elements.
|
|
5
|
+
* Returns ranked candidates with source, confidence, and matched tokens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { getTokensForTarget, normalizeText, getMatchedToken } = require('./semantic-targets');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Confidence levels
|
|
12
|
+
*/
|
|
13
|
+
const CONFIDENCE = {
|
|
14
|
+
HIGH: 'high',
|
|
15
|
+
MEDIUM: 'medium',
|
|
16
|
+
LOW: 'low'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detection sources
|
|
21
|
+
*/
|
|
22
|
+
const DETECTION_SOURCE = {
|
|
23
|
+
DATA_GUARDIAN: 'data-guardian',
|
|
24
|
+
ARIA: 'aria',
|
|
25
|
+
HREF: 'href',
|
|
26
|
+
TEXT: 'text',
|
|
27
|
+
NAV_FOOTER: 'nav/footer',
|
|
28
|
+
HEURISTIC: 'heuristic'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect contact candidates on page
|
|
33
|
+
*
|
|
34
|
+
* @param {Page} page - Playwright page object
|
|
35
|
+
* @param {string} baseUrl - Base URL for relative link resolution
|
|
36
|
+
* @returns {Promise<Array>} Array of contact candidates, ranked by confidence
|
|
37
|
+
*/
|
|
38
|
+
async function detectContactCandidates(page, baseUrl = '') {
|
|
39
|
+
const candidates = [];
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const pageData = await page.evaluate(async () => {
|
|
43
|
+
const results = [];
|
|
44
|
+
|
|
45
|
+
// Find all clickable/linkable elements
|
|
46
|
+
const elements = document.querySelectorAll('a, button, [role="link"], [role="button"], [data-guardian], .nav a, footer a');
|
|
47
|
+
|
|
48
|
+
for (const el of elements) {
|
|
49
|
+
const data = {
|
|
50
|
+
tagName: el.tagName.toLowerCase(),
|
|
51
|
+
text: el.textContent?.trim() || '',
|
|
52
|
+
href: el.href || el.getAttribute('href') || '',
|
|
53
|
+
dataGuardian: el.getAttribute('data-guardian') || '',
|
|
54
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
55
|
+
title: el.getAttribute('title') || '',
|
|
56
|
+
className: el.className,
|
|
57
|
+
isInNav: !!el.closest('nav, [role="navigation"]'),
|
|
58
|
+
isInFooter: !!el.closest('footer, [role="contentinfo"]')
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
results.push(data);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return results;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Process each element
|
|
68
|
+
for (const element of pageData) {
|
|
69
|
+
const contactCandidates = evaluateElement(element, baseUrl);
|
|
70
|
+
candidates.push(...contactCandidates);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Sort by confidence (high > medium > low) and then by detection order
|
|
74
|
+
candidates.sort((a, b) => {
|
|
75
|
+
const confidenceOrder = { high: 0, medium: 1, low: 2 };
|
|
76
|
+
return confidenceOrder[a.confidence] - confidenceOrder[b.confidence];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Remove duplicates (same href or same text)
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const deduplicated = [];
|
|
82
|
+
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
const key = `${candidate.matchedText}:${candidate.href}`;
|
|
85
|
+
if (!seen.has(key)) {
|
|
86
|
+
seen.add(key);
|
|
87
|
+
deduplicated.push(candidate);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return deduplicated;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn(`Failed to detect contact candidates: ${error.message}`);
|
|
94
|
+
return candidates;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Evaluate a single element for contact relevance
|
|
100
|
+
*/
|
|
101
|
+
function evaluateElement(element, baseUrl = '') {
|
|
102
|
+
const candidates = [];
|
|
103
|
+
const contactTokens = getTokensForTarget('contact');
|
|
104
|
+
|
|
105
|
+
// Rule A: data-guardian attribute (highest priority)
|
|
106
|
+
if (element.dataGuardian) {
|
|
107
|
+
const normalized = normalizeText(element.dataGuardian);
|
|
108
|
+
if (normalized.includes('contact')) {
|
|
109
|
+
candidates.push({
|
|
110
|
+
selector: buildSelector(element),
|
|
111
|
+
matchedText: element.text || element.dataGuardian,
|
|
112
|
+
matchedToken: 'contact',
|
|
113
|
+
source: DETECTION_SOURCE.DATA_GUARDIAN,
|
|
114
|
+
confidence: CONFIDENCE.HIGH,
|
|
115
|
+
href: element.href,
|
|
116
|
+
ariaLabel: element.ariaLabel
|
|
117
|
+
});
|
|
118
|
+
return candidates;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Rule B: href-based detection
|
|
123
|
+
if (element.href) {
|
|
124
|
+
const normalizedHref = normalizeText(element.href);
|
|
125
|
+
const matchedToken = getMatchedToken(normalizedHref, contactTokens);
|
|
126
|
+
|
|
127
|
+
if (matchedToken) {
|
|
128
|
+
candidates.push({
|
|
129
|
+
selector: buildSelector(element),
|
|
130
|
+
matchedText: element.text || element.href,
|
|
131
|
+
matchedToken: matchedToken,
|
|
132
|
+
source: DETECTION_SOURCE.HREF,
|
|
133
|
+
confidence: CONFIDENCE.HIGH,
|
|
134
|
+
href: element.href,
|
|
135
|
+
ariaLabel: element.ariaLabel
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Rule C: visible text-based detection
|
|
141
|
+
if (element.text) {
|
|
142
|
+
const normalizedText = normalizeText(element.text);
|
|
143
|
+
const matchedToken = getMatchedToken(normalizedText, contactTokens);
|
|
144
|
+
|
|
145
|
+
if (matchedToken) {
|
|
146
|
+
// Higher confidence if in nav or footer
|
|
147
|
+
let confidence = CONFIDENCE.MEDIUM;
|
|
148
|
+
let source = DETECTION_SOURCE.TEXT;
|
|
149
|
+
|
|
150
|
+
if (element.isInNav || element.isInFooter) {
|
|
151
|
+
confidence = CONFIDENCE.HIGH;
|
|
152
|
+
source = element.isInNav ? DETECTION_SOURCE.NAV_FOOTER : DETECTION_SOURCE.NAV_FOOTER;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
candidates.push({
|
|
156
|
+
selector: buildSelector(element),
|
|
157
|
+
matchedText: element.text,
|
|
158
|
+
matchedToken: matchedToken,
|
|
159
|
+
source: source,
|
|
160
|
+
confidence: confidence,
|
|
161
|
+
href: element.href,
|
|
162
|
+
ariaLabel: element.ariaLabel
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Rule D: aria-label or title attribute
|
|
168
|
+
if (element.ariaLabel || element.title) {
|
|
169
|
+
const textToCheck = element.ariaLabel || element.title;
|
|
170
|
+
const normalizedText = normalizeText(textToCheck);
|
|
171
|
+
const matchedToken = getMatchedToken(normalizedText, contactTokens);
|
|
172
|
+
|
|
173
|
+
if (matchedToken) {
|
|
174
|
+
candidates.push({
|
|
175
|
+
selector: buildSelector(element),
|
|
176
|
+
matchedText: textToCheck,
|
|
177
|
+
matchedToken: matchedToken,
|
|
178
|
+
source: DETECTION_SOURCE.ARIA,
|
|
179
|
+
confidence: CONFIDENCE.MEDIUM,
|
|
180
|
+
href: element.href,
|
|
181
|
+
ariaLabel: element.ariaLabel
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return candidates;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build a CSS selector for an element
|
|
191
|
+
*/
|
|
192
|
+
function buildSelector(element) {
|
|
193
|
+
// Prefer data-guardian if available
|
|
194
|
+
if (element.dataGuardian) {
|
|
195
|
+
return `[data-guardian="${element.dataGuardian}"]`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// For links/buttons, use href or text
|
|
199
|
+
if (element.tagName === 'a' && element.href) {
|
|
200
|
+
// Use href in selector
|
|
201
|
+
return `a[href*="${normalizeHrefForSelector(element.href)}"]`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (element.ariaLabel) {
|
|
205
|
+
return `${element.tagName}[aria-label*="${element.ariaLabel}"]`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Fallback
|
|
209
|
+
return `${element.tagName}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Normalize href for use in CSS selector
|
|
214
|
+
*/
|
|
215
|
+
function normalizeHrefForSelector(href) {
|
|
216
|
+
// Extract path portion
|
|
217
|
+
try {
|
|
218
|
+
const url = new URL(href, 'http://localhost');
|
|
219
|
+
return url.pathname.split('/').filter(p => p)[0] || '';
|
|
220
|
+
} catch {
|
|
221
|
+
// If URL parsing fails, extract first path component
|
|
222
|
+
return href.split('/')[1] || '';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Format detection result for human-readable output
|
|
228
|
+
*/
|
|
229
|
+
function formatDetectionResult(candidate, language = 'unknown') {
|
|
230
|
+
const languageStr = language !== 'unknown' ? `lang=${language}` : 'lang=unknown';
|
|
231
|
+
const parts = [
|
|
232
|
+
`Contact detected`,
|
|
233
|
+
`(${languageStr}`,
|
|
234
|
+
`source=${candidate.source}`,
|
|
235
|
+
`token=${candidate.matchedToken}`,
|
|
236
|
+
`confidence=${candidate.confidence})`
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
return parts.join(', ');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get hint message if contact not found
|
|
244
|
+
*/
|
|
245
|
+
function getNoContactFoundHint() {
|
|
246
|
+
return 'No contact found. Consider adding a stable marker like data-guardian="contact" or ensure contact link text/href is recognizable.';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
detectContactCandidates,
|
|
251
|
+
formatDetectionResult,
|
|
252
|
+
getNoContactFoundHint,
|
|
253
|
+
CONFIDENCE,
|
|
254
|
+
DETECTION_SOURCE
|
|
255
|
+
};
|