UNPKG

@medv/finder

Version:
294 lines (293 loc) 8.55 kB
// License: MIT // Author: Anton Medvedev <anton@medv.io> // Source: https://github.com/antonmedv/finder const acceptedAttrNames = new Set(['role', 'name', 'aria-label', 'rel', 'href']); /** Check if attribute name and value are word-like. */ export function attr(name, value) { let nameIsOk = acceptedAttrNames.has(name); nameIsOk ||= name.startsWith('data-') && wordLike(name); let valueIsOk = wordLike(value) && value.length < 100; valueIsOk ||= value.startsWith('#') && wordLike(value.slice(1)); return nameIsOk && valueIsOk; } /** Check if id name is word-like. */ export function idName(name) { return wordLike(name); } /** Check if class name is word-like. */ export function className(name) { return wordLike(name); } /** Check if tag name is word-like. */ export function tagName(name) { return true; } /** Finds unique CSS selectors for the given element. */ export function finder(input, options) { if (input.nodeType !== Node.ELEMENT_NODE) { throw new Error(`Can't generate CSS selector for non-element node type.`); } if (input.tagName.toLowerCase() === 'html') { return 'html'; } const defaults = { root: document.body, idName: idName, className: className, tagName: tagName, attr: attr, timeoutMs: 1000, seedMinLength: 3, optimizedMinLength: 2, maxNumberOfPathChecks: Infinity, }; const startTime = new Date(); const config = { ...defaults, ...options }; const rootDocument = findRootDocument(config.root, defaults); let foundPath; let count = 0; for (const candidate of search(input, config, rootDocument)) { const elapsedTimeMs = new Date().getTime() - startTime.getTime(); if (elapsedTimeMs > config.timeoutMs || count >= config.maxNumberOfPathChecks) { const fPath = fallback(input, rootDocument); if (!fPath) { throw new Error(`Timeout: Can't find a unique selector after ${config.timeoutMs}ms`); } return selector(fPath); } count++; if (unique(candidate, rootDocument)) { foundPath = candidate; break; } } if (!foundPath) { throw new Error(`Selector was not found.`); } const optimized = [ ...optimize(foundPath, input, config, rootDocument, startTime), ]; optimized.sort(byPenalty); if (optimized.length > 0) { return selector(optimized[0]); } return selector(foundPath); } function* search(input, config, rootDocument) { const stack = []; let paths = []; let current = input; let i = 0; while (current && current !== rootDocument) { const level = tie(current, config); for (const node of level) { node.level = i; } stack.push(level); current = current.parentElement; i++; paths.push(...combinations(stack)); if (i >= config.seedMinLength) { paths.sort(byPenalty); for (const candidate of paths) { yield candidate; } paths = []; } } paths.sort(byPenalty); for (const candidate of paths) { yield candidate; } } function wordLike(name) { if (/^[a-z\-]{3,}$/i.test(name)) { const words = name.split(/-|[A-Z]/); for (const word of words) { if (word.length <= 2) { return false; } if (/[^aeiou]{4,}/i.test(word)) { return false; } } return true; } return false; } function tie(element, config) { const level = []; const elementId = element.getAttribute('id'); if (elementId && config.idName(elementId)) { level.push({ name: '#' + CSS.escape(elementId), penalty: 0, }); } for (let i = 0; i < element.classList.length; i++) { const name = element.classList[i]; if (config.className(name)) { level.push({ name: '.' + CSS.escape(name), penalty: 1, }); } } for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i]; if (config.attr(attr.name, attr.value)) { level.push({ name: `[${CSS.escape(attr.name)}="${CSS.escape(attr.value)}"]`, penalty: 2, }); } } const tagName = element.tagName.toLowerCase(); if (config.tagName(tagName)) { level.push({ name: tagName, penalty: 5, }); const index = indexOf(element, tagName); if (index !== undefined) { level.push({ name: nthOfType(tagName, index), penalty: 10, }); } } const nth = indexOf(element); if (nth !== undefined) { level.push({ name: nthChild(tagName, nth), penalty: 50, }); } return level; } function selector(path) { let node = path[0]; let query = node.name; for (let i = 1; i < path.length; i++) { const level = path[i].level || 0; if (node.level === level - 1) { query = `${path[i].name} > ${query}`; } else { query = `${path[i].name} ${query}`; } node = path[i]; } return query; } function penalty(path) { return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0); } function byPenalty(a, b) { return penalty(a) - penalty(b); } function indexOf(input, tagName) { const parent = input.parentNode; if (!parent) { return undefined; } let child = parent.firstChild; if (!child) { return undefined; } let i = 0; while (child) { if (child.nodeType === Node.ELEMENT_NODE && (tagName === undefined || child.tagName.toLowerCase() === tagName)) { i++; } if (child === input) { break; } child = child.nextSibling; } return i; } function fallback(input, rootDocument) { let i = 0; let current = input; const path = []; while (current && current !== rootDocument) { const tagName = current.tagName.toLowerCase(); const index = indexOf(current, tagName); if (index === undefined) { return; } path.push({ name: nthOfType(tagName, index), penalty: NaN, level: i, }); current = current.parentElement; i++; } if (unique(path, rootDocument)) { return path; } } function nthChild(tagName, index) { if (tagName === 'html') { return 'html'; } return `${tagName}:nth-child(${index})`; } function nthOfType(tagName, index) { if (tagName === 'html') { return 'html'; } return `${tagName}:nth-of-type(${index})`; } function* combinations(stack, path = []) { if (stack.length > 0) { for (let node of stack[0]) { yield* combinations(stack.slice(1, stack.length), path.concat(node)); } } else { yield path; } } function findRootDocument(rootNode, defaults) { if (rootNode.nodeType === Node.DOCUMENT_NODE) { return rootNode; } if (rootNode === defaults.root) { return rootNode.ownerDocument; } return rootNode; } function unique(path, rootDocument) { const css = selector(path); switch (rootDocument.querySelectorAll(css).length) { case 0: throw new Error(`Can't select any node with this selector: ${css}`); case 1: return true; default: return false; } } function* optimize(path, input, config, rootDocument, startTime) { if (path.length > 2 && path.length > config.optimizedMinLength) { for (let i = 1; i < path.length - 1; i++) { const elapsedTimeMs = new Date().getTime() - startTime.getTime(); if (elapsedTimeMs > config.timeoutMs) { return; } const newPath = [...path]; newPath.splice(i, 1); if (unique(newPath, rootDocument) && rootDocument.querySelector(selector(newPath)) === input) { yield newPath; yield* optimize(newPath, input, config, rootDocument, startTime); } } } }