UNPKG

ai-locators

Version:

AI-powered element locators for Playwright

419 lines (363 loc) 16.9 kB
/* eslint-disable no-unexpected-multiline */ /* eslint-disable no-unused-expressions */ { // Constants LLM_API_URL: '<PLACEHOLDER>', LLM_MODEL: '<PLACEHOLDER>', LLM_API_KEY: '<PLACEHOLDER>', // Will be replaced at runtime // Store failed suggestions per root+description combination _failedSuggestions: {}, // Store successful suggestions per root+description combination _successfulSuggestions: {}, // Store invalid suggestions (syntactically incorrect) per root+description combination _invalidSuggestions: {}, _cleanup(root) { console.log("Filtering non-relevant elements"); var ret = this._filterNonRelevantElements(root); console.log("Removing comments"); ret = this._removeComments(ret); console.log("Removing non-important attributes"); ret = this._removeNonImportantAttributes(ret); return ret; }, // Helper function to filter out non-relevant elements from the root _filterNonRelevantElements(root) { // Clone the root element to preserve the original const clonedRoot = root.cloneNode(true); console.log('Original length', root.getHTML().length); // Remove <script>, <style>, and <noscript> elements in the clone const unwantedTags = ['script', 'style', 'noscript', 'link', 'meta', 'iframe', 'object', 'embed']; unwantedTags.forEach(tag => { const elements = clonedRoot.querySelectorAll(tag); elements.forEach(element => element.remove()); }); console.log('Parsed length', clonedRoot.getHTML().length); return clonedRoot; }, // Helper function to remove comments from the root _removeComments(root) { // Clone the root element to preserve the original const clonedRoot = root.cloneNode(true); console.log('Original length', root.getHTML().length); // Remove comments from the clone const comments = clonedRoot.querySelectorAll('*'); comments.forEach(element => { if (element.nodeType === Node.COMMENT_NODE) { element.remove(); } }); console.log('Parsed length', clonedRoot.getHTML().length); return clonedRoot; }, // Helper function to non important attributes _removeNonImportantAttributes(root) { // Clone the root element to preserve the original const clonedRoot = root.cloneNode(true); console.log('Original length', root.getHTML().length); // Remove non important attributes from the clone const importantAttributes = [ 'id', 'class', 'name', 'data-*', 'role', 'type', 'aria-*' ]; // Remove all attributes except the important ones const elements = clonedRoot.querySelectorAll('*'); elements.forEach(element => { const attributes = Array.from(element.attributes); attributes.forEach(attr => { if (!importantAttributes.some(ia => new RegExp(ia).test(attr.name))) { element.removeAttribute(attr.name); } }); }); console.log('Parsed length', clonedRoot.getHTML().length); return clonedRoot; }, // Helper function to get a unique key for root+description combination _getKey(content, description) { return `${content}:::${description}`; }, // Helper function to record a failed suggestion _recordFailedSuggestion(content, description, selector) { const key = this._getKey(content, description); if (!this._failedSuggestions[key]) { this._failedSuggestions[key] = new Set(); } this._failedSuggestions[key].add(selector); }, // Helper function to record a successful suggestion _recordSuccessfulSuggestion(content, description, selector) { const key = this._getKey(content, description); if (!this._successfulSuggestions[key]) { this._successfulSuggestions[key] = new Set(); } this._successfulSuggestions[key].add(selector); }, // Helper function to record an invalid suggestion _recordInvalidSuggestion(content, description, selector) { const key = this._getKey(content, description); if (!this._invalidSuggestions[key]) { this._invalidSuggestions[key] = new Set(); } this._invalidSuggestions[key].add(selector); }, // Helper function to get failed suggestions _getFailedSuggestions(content, description) { const key = this._getKey(content, description); return Array.from(this._failedSuggestions[key] || []); }, _getSuccessfulSuggestions(content, description) { const key = this._getKey(content, description); return Array.from(this._successfulSuggestions[key] || []); }, // Helper function to get invalid suggestions _getInvalidSuggestions(content, description) { const key = this._getKey(content, description); return Array.from(this._invalidSuggestions[key] || []); }, // Helper function to generate messages for the LLM _getMessages(description, content) { const failedSuggestions = this._getFailedSuggestions(content, description); const invalidSuggestions = this._getInvalidSuggestions(content, description); const messages = [{ role: 'user', content: ` You will be given a description of an element and an HTML snippet. Your job is to locate the node or nodes that match the description and generate a CSS selector. The description could be about a specific element or a group of elements. For example: * "The main heading of the page" # This is a specific element * "The login button" # This is a specific element * "The product images" # This is a group of elements * "The navigation links" # This is a group of elements Your instructions are to: * Locate the node or nodes in the HTML that match the description, then * If you found the node or nodes, generate the most specific CSS selector for the element or elements. * If you cannot locate the node or nodes, output 'NULL'. <CSS_SELECTOR> For the CSS selector, follow these guidelines: * Be specific. The selector should match only the node or nodes that match the description. * Do not go higher in the DOM tree than necessary. * Prioritize content nodes over layout nodes if possible. * The selector MUST NOT use the following CSS selectors: :has, :has-text, :text, :visible, :is or any non native CSS selector. * Use nth-child, nth-of-type, first-child, last-child, etc. to locate elements by index * Use :disabled, :checked, :enabled to locate nodes by state </CSS_SELECTOR> The selector will be used to "query()" or "queryAll()" on the root element of the page to locate the element or elements, so make sure the selector is valid. <OUTPUT> You should output the selector or 'NULL' if no selector is found. Do not include any additional text, comments or instructions in the output. Do not quote your response with backticks or any other characters. Just output the selector string or 'NULL'. </OUTPUT> <EXAMPLES> These are all examples of valid selectors that could be generated: * #main-content form input[name="username"] * #unique-id * .product-list li:nth-of-type(1) * [role="main"] section h1:nth-of-type(1) * .product-list li:nth-of-type(1) img * input[placeholder="Enter your username"] * button:contains("Submit") * .product-list:nth-child(4) * #main-form input[role="textbox"] * [data-qa="username-input"] * [aria-label="Username Input"] * input[type="checkbox"]:disabled * input[type="checkbox"]:checked </EXAMPLES>` }, { role: 'user', content: `The description is: ${description}` }, { role: 'user', content: `The HTML snippet is:\n<PAGE>\n${content}\n</PAGE>` }]; // Add invalid suggestions first as they represent syntax errors if (invalidSuggestions.length > 0) { messages.push({ role: 'assistant', content: invalidSuggestions[0] }); messages.push({ role: 'user', content: 'This selector was not valid. Try a different one.' }); // Add remaining invalid suggestions as alternating assistant/user messages for (let i = 1; i < invalidSuggestions.length; i++) { messages.push({ role: 'assistant', content: invalidSuggestions[i] }); messages.push({ role: 'user', content: 'This selector was also not valid. Try a different one.' }); } console.log('Invalid suggestions:', invalidSuggestions); } // Add failed suggestions after invalid ones if (failedSuggestions.length > 0) { messages.push({ role: 'assistant', content: failedSuggestions[0] }); messages.push({ role: 'user', content: 'That selector did not work. Please try a different approach.' }); // Add remaining failed suggestions as alternating assistant/user messages for (let i = 1; i < failedSuggestions.length; i++) { messages.push({ role: 'assistant', content: failedSuggestions[i] }); messages.push({ role: 'user', content: 'That selector also did not work. Please try a different approach.' }); } console.log('Failed suggestions:', failedSuggestions); } // Add the final assistant message messages.push({ role: 'assistant', content: 'The generated selector is:' }); return messages; }, // Helper function to make sync HTTP request to LLM _llm(messages) { // Check if we have any successful suggestions first const xhr = new XMLHttpRequest(); xhr.open('POST', this.LLM_API_URL, false); // false makes it synchronous xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Authorization', `Bearer ${this.LLM_API_KEY}`); const data = { model: this.LLM_MODEL, max_tokens: 100, temperature: 1.5, messages, stream: false }; try { xhr.send(JSON.stringify(data)); if (xhr.status === 200) { const response = JSON.parse(xhr.responseText); const llm_selector = response.choices[0].message.content.trim(); console.log('Generated selector:', llm_selector); return llm_selector; } else { console.error('API request failed:', xhr.status, xhr.responseText); return null; } } catch (e) { console.error('Error making API request:', e); return null; } }, // Returns the first element matching given selector in the root's subtree. query(root, selector) { console.log('%c AI Selector: Starting query', 'background: #222; color: #bada55'); console.log('Root:', root); console.log('Selector:', selector); try { // Get the root content instead of entire DOM const elem = root.documentElement ? root.documentElement.querySelector('body') : root; const content = this._cleanup(elem).getHTML(); // Check if we have any successful suggestions first const successfulSuggestions = this._getSuccessfulSuggestions(content, selector); if (successfulSuggestions.length > 0) { console.log('Using cached successful selector:', successfulSuggestions[0]); return successfulSuggestions[0]; } // Get selectors from LLM const messages = this._getMessages(selector, content); const llm_selector = this._llm(messages); if (!llm_selector || llm_selector === 'NULL') { console.error('No selector generated'); return null; } try { console.log('Trying selector:', llm_selector); const element = root.querySelector(llm_selector); if (element) { console.log('%c Found element!', 'background: green; color: white', { selector: llm_selector, element: element }); this._recordSuccessfulSuggestion(content, selector, llm_selector); return element; } console.log('No element found with selector:', llm_selector); // Record the failed suggestion this._recordFailedSuggestion(content, selector, llm_selector); return null; } catch (e) { console.error('Error with selector:', llm_selector, e); // Record the invalid suggestion since it caused an error this._recordInvalidSuggestion(content, selector, llm_selector); return null; } } catch (e) { console.error('Error in query:', e); return null; } }, // Returns all elements matching given selector in the root's subtree. queryAll(root, selector) { console.log('%c AI Selector: Starting queryAll', 'background: #222; color: #bada55'); console.log('Root:', root); console.log('Selector:', selector); try { // Get the root content instead of entire DOM const elem = root.documentElement ? root.documentElement.querySelector('body') : root; const content = this._cleanup(elem).getHTML(); // Check if we have any successful suggestions first const successfulSuggestions = this._getSuccessfulSuggestions(content, selector); if (successfulSuggestions.length > 0) { console.log('Using cached successful selector:', successfulSuggestions[0]); this._recordSuccessfulSuggestion(content, selector, successfulSuggestions[0]); return successfulSuggestions[0]; } // Get selectors from LLM const messages = this._getMessages(selector, content); const llm_selector = this._llm(messages); if (!llm_selector || llm_selector === 'NULL') { console.error('No selector generated'); return []; } const elements = new Set(); try { console.log('Trying selector:', llm_selector); const found = root.querySelectorAll(llm_selector); if (found.length > 0) { console.log('%c Found elements!', 'background: green; color: white', { selector: llm_selector, count: found.length }); found.forEach(el => elements.add(el)); } else { console.log('No elements found with selector:', llm_selector); // Record the failed suggestion this._recordFailedSuggestion(content, selector, llm_selector); } return Array.from(elements); } catch (e) { console.error('Error with selector:', llm_selector, e); // Record the invalid suggestion since it caused an error this._recordInvalidSuggestion(content, selector, llm_selector); return []; } } catch (e) { console.error('Error in queryAll:', e); return []; } } }