chrometools-mcp
Version:
MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, visual testing, Figma comparison, and design validation. Works seamlessly in WSL, Linux, and macOS.
471 lines (400 loc) • 14.5 kB
JavaScript
/**
* Element Finder Utilities
* Provides intelligent element finding with scoring and context analysis
*/
// Multilingual submit keywords
const SUBMIT_KEYWORDS = {
ru: ['войти', 'отправить', 'подтвердить', 'сохранить', 'зарегистрироваться',
'применить', 'продолжить', 'далее', 'готово', 'ок', 'добавить', 'создать',
'загрузить', 'вход', 'регистрация', 'авторизация', 'логин'],
en: ['submit', 'login', 'send', 'save', 'register', 'sign in', 'sign up',
'continue', 'next', 'confirm', 'apply', 'ok', 'done', 'add', 'create',
'upload', 'go', 'enter', 'join', 'search', 'find'],
es: ['enviar', 'iniciar', 'guardar', 'registrarse', 'continuar', 'confirmar', 'aceptar'],
de: ['senden', 'einloggen', 'speichern', 'registrieren', 'weiter', 'bestätigen', 'ok'],
fr: ['envoyer', 'connexion', 'sauvegarder', 'enregistrer', 'continuer', 'confirmer', 'ok'],
it: ['invia', 'accedi', 'salva', 'registrati', 'continua', 'conferma', 'ok'],
pt: ['enviar', 'entrar', 'salvar', 'registrar', 'continuar', 'confirmar', 'ok'],
zh: ['提交', '登录', '保存', '注册', '继续', '确认', '确定'],
ja: ['送信', 'ログイン', '保存', '登録', '続ける', '確認', 'ok'],
};
// Negative keywords (buttons that are NOT submit)
const NEGATIVE_KEYWORDS = {
ru: ['отмена', 'отменить', 'назад', 'закрыть', 'удалить', 'очистить', 'сбросить', 'выход', 'выйти'],
en: ['cancel', 'back', 'close', 'delete', 'clear', 'reset', 'remove', 'dismiss', 'decline', 'logout', 'exit'],
es: ['cancelar', 'atrás', 'cerrar', 'eliminar', 'borrar', 'restablecer'],
de: ['abbrechen', 'zurück', 'schließen', 'löschen', 'zurücksetzen'],
fr: ['annuler', 'retour', 'fermer', 'supprimer', 'effacer', 'réinitialiser'],
it: ['annulla', 'indietro', 'chiudi', 'elimina', 'cancella', 'ripristina'],
pt: ['cancelar', 'voltar', 'fechar', 'excluir', 'limpar', 'redefinir'],
zh: ['取消', '返回', '关闭', '删除', '清除', '重置'],
ja: ['キャンセル', '戻る', '閉じる', '削除', 'クリア', 'リセット'],
};
// Link/anchor keywords
const LINK_KEYWORDS = {
ru: ['подробнее', 'узнать', 'читать', 'перейти', 'смотреть', 'открыть'],
en: ['learn more', 'read more', 'view', 'see', 'details', 'info', 'about', 'help'],
es: ['más información', 'leer más', 'ver', 'detalles'],
de: ['mehr erfahren', 'weiterlesen', 'ansehen', 'details'],
fr: ['en savoir plus', 'lire plus', 'voir', 'détails'],
it: ['per saperne di più', 'leggi di più', 'vedi', 'dettagli'],
pt: ['saiba mais', 'leia mais', 'ver', 'detalhes'],
zh: ['了解更多', '阅读更多', '查看', '详情'],
ja: ['詳細を見る', 'もっと読む', '表示', '詳細'],
};
// Input field keywords
const INPUT_KEYWORDS = {
email: {
ru: ['email', 'почта', 'эл. почта', 'e-mail', 'электронная почта'],
en: ['email', 'e-mail', 'mail', 'email address'],
all: ['@', 'mail']
},
password: {
ru: ['пароль', 'password'],
en: ['password', 'pwd', 'pass'],
all: ['password', 'pwd']
},
username: {
ru: ['имя пользователя', 'логин', 'username', 'пользователь'],
en: ['username', 'user', 'login', 'account'],
all: ['user', 'login']
},
search: {
ru: ['поиск', 'искать', 'найти'],
en: ['search', 'find', 'query'],
all: ['search']
}
};
/**
* Check if text matches submit keywords
*/
function matchesSubmitKeyword(text, description) {
if (!text) return false;
const textLower = text.toLowerCase();
const descLower = description.toLowerCase();
// Direct match with description
if (textLower.includes(descLower) || descLower.includes(textLower)) {
return true;
}
// Check against submit keywords
for (const lang in SUBMIT_KEYWORDS) {
for (const keyword of SUBMIT_KEYWORDS[lang]) {
if (textLower.includes(keyword)) {
return true;
}
}
}
return false;
}
/**
* Check if text matches negative keywords
*/
function matchesNegativeKeyword(text) {
if (!text) return false;
const textLower = text.toLowerCase();
for (const lang in NEGATIVE_KEYWORDS) {
for (const keyword of NEGATIVE_KEYWORDS[lang]) {
if (textLower.includes(keyword)) {
return true;
}
}
}
return false;
}
/**
* Check if text matches link keywords
*/
function matchesLinkKeyword(text) {
if (!text) return false;
const textLower = text.toLowerCase();
for (const lang in LINK_KEYWORDS) {
for (const keyword of LINK_KEYWORDS[lang]) {
if (textLower.includes(keyword)) {
return true;
}
}
}
return false;
}
/**
* Analyze button context
* This function is designed to be injected into the page context
*/
function analyzeButtonContextInPage(element) {
const context = {
// Element type
isButton: element.tagName === 'BUTTON',
isSubmitInput: element.type === 'submit',
isLink: element.tagName === 'A',
// Role
hasSubmitRole: element.getAttribute('role') === 'button',
// Form context
inForm: element.closest('form') !== null,
isLastInForm: false,
// Classes and ID
hasSubmitClass: /submit|send|login|register|confirm|save|apply|continue|next/i.test(
(element.className || '') + ' ' + (element.id || '')
),
// Attributes
hasSubmitAttr: element.type === 'submit' || element.getAttribute('type') === 'submit',
// Icons (common submit icons)
hasSubmitIcon: /check|arrow-right|send|chevron-right|angle-right|caret-right|play|forward/i.test(
element.innerHTML
),
// Visibility
isVisible: element.offsetWidth > 0 && element.offsetHeight > 0,
// Position
offsetWidth: element.offsetWidth,
offsetHeight: element.offsetHeight,
// Text content
text: element.textContent || element.value || element.getAttribute('aria-label') || '',
// Primary button indicators
isPrimary: /primary|main|btn-primary|button-primary/i.test(element.className || ''),
};
// Check if it's the last button in form
if (context.inForm) {
const form = element.closest('form');
const buttons = form.querySelectorAll('button, input[type="submit"], input[type="button"]');
context.isLastInForm = element === buttons[buttons.length - 1];
context.formButtonCount = buttons.length;
}
return context;
}
/**
* Score element as submit button
* Higher score = more likely to be a submit button
*/
function scoreSubmitButton(element, context, description) {
let score = 0;
const text = context.text.toLowerCase();
const descLower = description.toLowerCase();
// Exact match with description (+50)
if (text.includes(descLower)) {
score += 50;
}
// Keyword matching (+30)
if (matchesSubmitKeyword(context.text, description)) {
score += 30;
}
// Technical submit indicators
if (context.hasSubmitAttr) score += 40; // type="submit"
if (context.inForm) score += 20; // inside form
if (context.isLastInForm) score += 15; // last button in form
if (context.hasSubmitClass) score += 10; // submit in class/id
if (context.hasSubmitIcon) score += 5; // submit icon
if (context.isPrimary) score += 15; // primary button style
// Visibility bonus
if (context.isVisible) score += 10;
// Size bonus (larger buttons are more likely to be submit)
if (context.offsetWidth > 100 && context.offsetHeight > 30) {
score += 5;
}
// Penalty for negative keywords (-30)
if (matchesNegativeKeyword(context.text)) {
score -= 30;
}
// Penalty for links without submit keywords
if (context.isLink && !matchesSubmitKeyword(context.text, description)) {
score -= 20;
}
return score;
}
/**
* Score element as input field
* Higher score = more likely to match the description
*/
function scoreInputField(element, context, description) {
let score = 0;
const descLower = description.toLowerCase();
const elementType = element.type || '';
const elementName = (element.name || '').toLowerCase();
const elementId = (element.id || '').toLowerCase();
const elementPlaceholder = (element.placeholder || '').toLowerCase();
// Type matching with description
if (elementType === 'email' && descLower.includes('email')) {
score += 20;
}
if (elementType === 'password' && descLower.includes('password')) {
score += 20;
}
if (elementType === 'tel' && (descLower.includes('phone') || descLower.includes('tel'))) {
score += 20;
}
if (elementType === 'search' && descLower.includes('search')) {
score += 20;
}
// ID/Name matching with description
if (elementId && descLower.includes(elementId)) {
score += 15;
}
if (elementName && descLower.includes(elementName)) {
score += 15;
}
// Placeholder matching
if (elementPlaceholder && descLower.includes(elementPlaceholder)) {
score += 10;
}
// Form context bonus
if (context.inForm) {
score += 20;
}
// Visibility bonus
if (context.isVisible) {
score += 10;
}
// Required field bonus
if (element.required) {
score += 5;
}
return score;
}
/**
* Generate unique CSS selector for an element
*/
function getUniqueSelectorInPage(element) {
// Try ID first
if (element.id) {
return `#${element.id}`;
}
// Try unique class combination
if (element.className) {
const classes = element.className.split(' ').filter(c => c.trim());
if (classes.length > 0) {
const selector = `${element.tagName.toLowerCase()}.${classes.join('.')}`;
if (document.querySelectorAll(selector).length === 1) {
return selector;
}
// Try with first class only
const firstClassSelector = `${element.tagName.toLowerCase()}.${classes[0]}`;
if (document.querySelectorAll(firstClassSelector).length === 1) {
return firstClassSelector;
}
}
}
// Try name attribute
if (element.name) {
const selector = `${element.tagName.toLowerCase()}[name="${element.name}"]`;
if (document.querySelectorAll(selector).length === 1) {
return selector;
}
}
// Try data attributes
const dataAttrs = Array.from(element.attributes)
.filter(attr => attr.name.startsWith('data-'))
.slice(0, 2);
for (const attr of dataAttrs) {
const selector = `${element.tagName.toLowerCase()}[${attr.name}="${attr.value}"]`;
if (document.querySelectorAll(selector).length === 1) {
return selector;
}
}
// Fallback: nth-child
let current = element;
const path = [];
while (current && current.tagName) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector = `#${current.id}`;
path.unshift(selector);
break;
}
let sibling = current;
let nth = 1;
while (sibling.previousElementSibling) {
sibling = sibling.previousElementSibling;
if (sibling.tagName === current.tagName) {
nth++;
}
}
if (nth > 1) {
selector += `:nth-of-type(${nth})`;
}
path.unshift(selector);
current = current.parentElement;
// Stop at body or after 5 levels
if (!current || current.tagName === 'BODY' || path.length >= 5) {
break;
}
}
return path.join(' > ');
}
/**
* Explain score for debugging
*/
function explainScore(element, context, description, score) {
const reasons = [];
const descLower = description.toLowerCase();
const elementType = element.type || '';
// Input field scoring reasons
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
if (elementType === 'email' && descLower.includes('email')) {
reasons.push('type=email matches description (+20)');
}
if (elementType === 'password' && descLower.includes('password')) {
reasons.push('type=password matches description (+20)');
}
if (elementType === 'tel' && (descLower.includes('phone') || descLower.includes('tel'))) {
reasons.push('type=tel matches description (+20)');
}
if (element.id && descLower.includes(element.id.toLowerCase())) {
reasons.push(`id="${element.id}" matches description (+15)`);
}
if (element.name && descLower.includes(element.name.toLowerCase())) {
reasons.push(`name="${element.name}" matches description (+15)`);
}
if (element.placeholder && descLower.includes(element.placeholder.toLowerCase())) {
reasons.push('placeholder matches (+10)');
}
if (element.required) {
reasons.push('required field (+5)');
}
}
// Button scoring reasons
if (context.hasSubmitAttr) reasons.push('type=submit (+40)');
if (context.hasSubmitClass) reasons.push('submit class (+10)');
if (context.hasSubmitIcon) reasons.push('submit icon (+5)');
if (context.isPrimary) reasons.push('primary style (+15)');
if (context.isLastInForm) reasons.push('last in form (+15)');
if (matchesSubmitKeyword(context.text, description)) {
reasons.push('keyword match (+30)');
}
if (context.text.toLowerCase().includes(description.toLowerCase())) {
reasons.push('exact text match (+50)');
}
if (matchesNegativeKeyword(context.text)) {
reasons.push('negative keyword (-30)');
}
if (context.isLink && !matchesSubmitKeyword(context.text, description)) {
reasons.push('link without submit keyword (-20)');
}
// Common reasons
if (context.inForm) reasons.push('in form (+20)');
if (context.isVisible) reasons.push('visible (+10)');
return reasons.length > 0 ? reasons.join(', ') : 'no matching criteria';
}
/**
* Determine element type from description
*/
function determineElementType(description) {
const lower = description.toLowerCase();
// Check for input fields
for (const type in INPUT_KEYWORDS) {
const keywords = INPUT_KEYWORDS[type];
for (const lang in keywords) {
for (const keyword of keywords[lang]) {
if (lower.includes(keyword)) {
return { type: 'input', inputType: type };
}
}
}
}
// Check for links
if (matchesLinkKeyword(description)) {
return { type: 'link' };
}
// Check for buttons
if (matchesSubmitKeyword('', description)) {
return { type: 'button' };
}
return { type: 'any' };
}