├── assets └── icon.png ├── firefox-mv3-prod └── manifest.json ├── postcss.config.js ├── tailwind.config.js ├── src ├── style.css ├── content.ts └── popup.tsx ├── tsconfig.json ├── .gitignore ├── .prettierrc.mjs ├── .github └── workflows │ └── submit.yml ├── package.json └── README.md /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zan-keith/cherry-pick/HEAD/assets/icon.png -------------------------------------------------------------------------------- /firefox-mv3-prod/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "applications": { 4 | "gecko": { 5 | "id": "your-extension-id@example.com" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('postcss').ProcessOptions} 3 | */ 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {} 8 | } 9 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | mode: "jit", 4 | darkMode: "class", 5 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 6 | plugins: [] 7 | } -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | code { 6 | @apply font-mono text-sm; 7 | background-color: theme('colors.gray.100'); 8 | padding: 0.5rem 1rem; 9 | margin: 1rem 0; 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": [ 4 | "node_modules" 5 | ], 6 | "include": [ 7 | ".plasmo/index.d.ts", 8 | "./**/*.ts", 9 | "./**/*.tsx" 10 | ], 11 | "compilerOptions": { 12 | "paths": { 13 | "~*": [ 14 | "./*" 15 | ] 16 | }, 17 | "baseUrl": "." 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # misc 13 | .DS_Store 14 | *.pem 15 | 16 | # debug 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | .pnpm-debug.log* 21 | 22 | # local env files 23 | .env*.local 24 | .env 25 | 26 | out/ 27 | build/ 28 | dist/ 29 | 30 | # plasmo 31 | .plasmo 32 | 33 | # typescript 34 | .tsbuildinfo 35 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | export default { 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: false, 9 | singleQuote: false, 10 | trailingComma: "none", 11 | bracketSpacing: true, 12 | bracketSameLine: true, 13 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 14 | importOrder: [ 15 | "", // Node.js built-in modules 16 | "", // Imports not matched by other special words or groups. 17 | "", // Empty line 18 | "^@plasmo/(.*)$", 19 | "", 20 | "^@plasmohq/(.*)$", 21 | "", 22 | "^~(.*)$", 23 | "", 24 | "^[./]" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/submit.yml: -------------------------------------------------------------------------------- 1 | name: "Submit to Web Store" 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Cache pnpm modules 11 | uses: actions/cache@v3 12 | with: 13 | path: ~/.pnpm-store 14 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 15 | restore-keys: | 16 | ${{ runner.os }}- 17 | - uses: pnpm/action-setup@v2.2.4 18 | with: 19 | version: latest 20 | run_install: true 21 | - name: Use Node.js 16.x 22 | uses: actions/setup-node@v3.4.1 23 | with: 24 | node-version: 16.x 25 | cache: "pnpm" 26 | - name: Build the extension 27 | run: pnpm build 28 | - name: Package the extension into a zip artifact 29 | run: pnpm package 30 | - name: Browser Platform Publish 31 | uses: PlasmoHQ/bpp@v3 32 | with: 33 | keys: ${{ secrets.SUBMIT_KEYS }} 34 | artifact: build/chrome-mv3-prod.zip 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cherry-pick", 3 | "displayName": "Cherry pick", 4 | "version": "1.2.0", 5 | "description": "Handpick scraping assistance", 6 | "author": "zan-keith", 7 | "scripts": { 8 | "dev": "plasmo dev", 9 | "build": "plasmo build", 10 | "package": "plasmo package" 11 | }, 12 | "dependencies": { 13 | "@plasmohq/storage": "^1.15.0", 14 | "@tailwindcss/postcss": "^4.1.13", 15 | "hook": "link:@plasmohq/storage/hook", 16 | "lucide-react": "^0.543.0", 17 | "plasmo": "0.90.5", 18 | "react": "18.2.0", 19 | "react-dom": "18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@ianvs/prettier-plugin-sort-imports": "4.1.1", 23 | "@types/chrome": "0.0.258", 24 | "@types/node": "20.11.5", 25 | "@types/react": "18.2.48", 26 | "@types/react-dom": "18.2.18", 27 | "autoprefixer": "^10.4.21", 28 | "postcss": "^8.5.6", 29 | "prettier": "3.2.4", 30 | "tailwindcss": "^3.4.17", 31 | "typescript": "5.3.3" 32 | }, 33 | "manifest": { 34 | "manifest_version": 3, 35 | "version": "1.2.0", 36 | "key": "$CRX_KEY", 37 | "host_permissions": [ 38 | "https://*/*" 39 | ], 40 | "browser_specific_settings": { 41 | "gecko": { 42 | "id": "cherry-pick@zan-keith.com", 43 | "data_collection_permissions": { 44 | "required": [ 45 | "websiteContent" 46 | ], 47 | "optional": [] 48 | } 49 | } 50 | } 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cherry-Pick Scraper Extension 2 | 3 | > A Chrome extension for quick scraping of common elements from web pages. 4 | 5 | > This is just part of a hobby project of mine. I found this made my life easier and hopefully this will help someone else. 6 | 7 | ### Features 8 | - **Automatic Identifier Detection:** Finds common classes, attributes, or tags among selected elements. 9 | - **JavaScript Snippet Generation:** Creates a JS snippet to select and scrape the elements. 10 | - **One-Click Scraping:** Press the "Scrape" button to copy the data from all matched elements. 11 | --- 12 | ### Chrome Installation 13 | 1. [Download the ZIP file from the Releases section](https://github.com/zan-keith/cherry-pick/releases). 14 | 2. Goto `chrome://extensions/` and Enable "Developer Mode" on top right corner 15 | 3. Drag and drop the ZIP file onto the Chrome Extensions page (`chrome://extensions`) to install the extension. 16 | 17 | ### Firefox Installation 18 | 1. [Install Directly From Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/cherry-pick/) 19 | 20 | ### How It Works 21 | 1. **Activate the Extension:** Click the Cherry-Pick icon in your browser. 22 | 2. **Select Elements:** Use the mouse to highlight and select elements you want to scrape. 23 | 24 | 25 | 26 | - Often different elements might come in the way when selecting. You can use the `spacebar` key to cycle between the selectable elements. 27 | - Try to pinpoint the exact element you need to scrape and click! 28 | - Select atleast two similar elements for best results.. 29 | 3. **Scrape Elements:** Click the "Scrape Elements" button in the popup. The extension will: 30 | - Find common identifiers. 31 | - Generate a JS selector snippet. 32 | - Copy the scraped data to your clipboard. 33 | 34 | ##### Example Use Case 35 | Scrape all product names, prices, or links from a listing page by simply selecting a few examples. 36 | 37 | https://github.com/user-attachments/assets/bf8d60a5-946d-4b82-8749-fdba30ef4ef2 38 | 39 | --- 40 | ### Developer Installation 41 | 1. Clone repo 42 | `git clone https://github.com/zan-keith/cherry-pick.git`. 43 | 2. Run `pnpm install` to install dependencies. 44 | 3. Run `pnpm dev` to build the extension. 45 | 4. Load the `build/chrome-mv3-dev` folder as an unpacked extension in Chrome. 46 | 47 | ### Build Instruction 48 | - `plasmo build --zip` 49 | - `plasmo build --target=firefox-mv2 --zip` 50 | 51 | 52 | 53 | ###### Development 54 | - Built with [Plasmo](https://docs.plasmo.com/) and [React](https://react.dev/). 55 | - Uses [Tailwind CSS](https://tailwindcss.com/) for styling. 56 | 57 | #### Contributing 58 | Pull requests and suggestions are welcome! 59 | 60 | #### License 61 | MIT 62 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "@plasmohq/storage" 2 | import { useStorage } from "@plasmohq/storage/hook" 3 | 4 | 5 | 6 | let lastHovered: HTMLElement | null = null; 7 | let selectionAllowed = false; 8 | let sendSelectionCallback: ((element: HTMLElement) => void) | null = null; 9 | let mouseX = 0; 10 | let mouseY = 0; 11 | let elementsUnderPointer: HTMLElement[] = []; 12 | let currentElementIndex = 0; 13 | 14 | function safeElementSerializer(element: HTMLElement): string { 15 | // Create a safe serialization without using innerHTML 16 | const clone = element.cloneNode(true) as HTMLElement; 17 | // Remove any potentially dangerous attributes 18 | clone.removeAttribute('onclick'); 19 | clone.removeAttribute('onload'); 20 | clone.removeAttribute('onerror'); 21 | // Remove script tags 22 | const scripts = clone.querySelectorAll('script'); 23 | scripts.forEach(script => script.remove()); 24 | return clone.outerHTML; 25 | } 26 | 27 | function handleClick(e: MouseEvent) { 28 | if (!selectionAllowed) return; 29 | if (lastHovered instanceof HTMLElement) { 30 | console.log('Clicked element:', lastHovered); 31 | setSelectedElements(safeElementSerializer(lastHovered)); 32 | if (sendSelectionCallback) { 33 | sendSelectionCallback(lastHovered); 34 | sendSelectionCallback = null; 35 | } 36 | lastHovered.style.outline = ''; 37 | lastHovered.style.backgroundColor = ''; 38 | lastHovered = null; 39 | selectionAllowed = false; 40 | e.stopPropagation(); 41 | e.preventDefault(); 42 | removeListeners(); 43 | } 44 | } 45 | 46 | function handleMouseOver(e: MouseEvent) { 47 | if (!selectionAllowed) return; 48 | const target = e.target as HTMLElement; 49 | if (lastHovered && lastHovered !== target) { 50 | lastHovered.style.outline = ''; 51 | lastHovered.style.backgroundColor = ''; 52 | } 53 | if (target instanceof HTMLElement) { 54 | target.style.outline = '2px solid #ff0000'; 55 | target.style.backgroundColor = 'rgba(255, 255, 0, 0.5)'; 56 | lastHovered = target; 57 | } 58 | mouseX = e.clientX; 59 | mouseY = e.clientY; 60 | updateElementsUnderPointer(); 61 | } 62 | 63 | function handleMouseOut(e: MouseEvent) { 64 | if (!selectionAllowed) return; 65 | const target = e.target as HTMLElement; 66 | if (target instanceof HTMLElement) { 67 | target.style.outline = ''; 68 | target.style.backgroundColor = ''; 69 | if (lastHovered === target) { 70 | lastHovered = null; 71 | } 72 | } 73 | } 74 | 75 | function handleEscapeKey(e: KeyboardEvent) { 76 | if (!selectionAllowed) return; 77 | if (e.key === "Escape") { 78 | // Clear highlight 79 | if (lastHovered) { 80 | lastHovered.style.outline = ''; 81 | lastHovered.style.backgroundColor = ''; 82 | lastHovered = null; 83 | } 84 | selectionAllowed = false; 85 | sendSelectionCallback = null; 86 | removeListeners(); 87 | e.stopPropagation(); 88 | e.preventDefault(); 89 | } 90 | } 91 | 92 | function addListeners() { 93 | document.body.addEventListener('mouseover', handleMouseOver, true); 94 | document.body.addEventListener('mouseout', handleMouseOut, true); 95 | document.body.addEventListener('click', handleClick, true); 96 | document.addEventListener('keydown', handleEscapeKey, true); 97 | document.addEventListener('keydown', handleSpacebarCycle, true); 98 | document.body.addEventListener('mousemove', handleMouseMove, true); 99 | } 100 | 101 | function removeListeners() { 102 | document.body.removeEventListener('mouseover', handleMouseOver, true); 103 | document.body.removeEventListener('mouseout', handleMouseOut, true); 104 | document.body.removeEventListener('click', handleClick, true); 105 | document.removeEventListener('keydown', handleEscapeKey, true); 106 | document.removeEventListener('keydown', handleSpacebarCycle, true); 107 | document.body.removeEventListener('mousemove', handleMouseMove, true); 108 | } 109 | 110 | function handleMouseMove(e: MouseEvent) { 111 | if (!selectionAllowed) return; 112 | mouseX = e.clientX; 113 | mouseY = e.clientY; 114 | updateElementsUnderPointer(); 115 | } 116 | 117 | function updateElementsUnderPointer() { 118 | const elements = document.elementsFromPoint(mouseX, mouseY).filter(el => el instanceof HTMLElement) as HTMLElement[]; 119 | elementsUnderPointer = elements; 120 | currentElementIndex = 0; 121 | highlightCurrentElement(); 122 | } 123 | 124 | function highlightCurrentElement() { 125 | if (lastHovered) { 126 | lastHovered.style.outline = ''; 127 | lastHovered.style.backgroundColor = ''; 128 | } 129 | if (elementsUnderPointer.length > 0) { 130 | const el = elementsUnderPointer[currentElementIndex]; 131 | el.style.outline = '2px solid #ff0000'; 132 | el.style.backgroundColor = 'rgba(255, 255, 0, 0.5)'; 133 | lastHovered = el; 134 | } else { 135 | lastHovered = null; 136 | } 137 | } 138 | 139 | function handleSpacebarCycle(e: KeyboardEvent) { 140 | if (!selectionAllowed) return; 141 | if (e.key === ' ' || e.code === 'Space') { 142 | if (elementsUnderPointer.length > 1) { 143 | currentElementIndex = (currentElementIndex + 1) % elementsUnderPointer.length; 144 | highlightCurrentElement(); 145 | e.preventDefault(); 146 | e.stopPropagation(); 147 | } 148 | } 149 | } 150 | 151 | async function setSelectedElements(element) { 152 | const storage = new Storage({ 153 | area: "local" 154 | }) 155 | let prevElements = await storage.get("selectedElements") || []; 156 | storage.set("selectedElements", [...prevElements, element]); 157 | console.log("Element saved to storage:", element); 158 | } 159 | chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { 160 | 161 | if (message.type === "ALLOW_SELECTION") { 162 | selectionAllowed = true; 163 | sendSelectionCallback = (element: HTMLElement) => { 164 | sendResponse({ status: "selected", elementHTML: safeElementSerializer(element) }); 165 | }; 166 | addListeners(); 167 | return true; // Keep sendResponse alive for async 168 | } 169 | else if (message.type === "CLEAR_ALL_HIGHLIGHTS") { 170 | console.log("Clearing all highlights"); 171 | document.querySelectorAll('*').forEach(el => { 172 | if (el instanceof HTMLElement) { 173 | el.style.outline = ''; 174 | el.style.backgroundColor = ''; 175 | } 176 | }); 177 | sendResponse({ status: "highlights_cleared" }); 178 | return true; // Keep sendResponse alive for async 179 | } 180 | else if (message.type === "SELECT_ELEMENTS") { 181 | console.log("Selecting elements with selector:", message.selector); 182 | const selector = message.selector; 183 | const elements = document.querySelectorAll(selector); 184 | const elementsArray = Array.from(elements).map(el => 185 | el instanceof HTMLElement ? safeElementSerializer(el) : el.outerHTML 186 | ); 187 | console.log(`Elements matching selector "${selector}":`, elementsArray); 188 | 189 | // Highlight the selected elements 190 | elements.forEach(el => { 191 | if (el instanceof HTMLElement) { 192 | el.style.outline = '2px solid #00ff00'; 193 | el.style.backgroundColor = 'rgba(0, 255, 0, 0.3)'; 194 | } 195 | }); 196 | sendResponse({ status: "elements_selected", elements: elementsArray }); 197 | return true; // Keep sendResponse alive for async 198 | } 199 | 200 | 201 | return false; 202 | 203 | function handleEscapeKey(e: KeyboardEvent) { 204 | if (!selectionAllowed) return; 205 | if (e.key === "Escape") { 206 | // Clear highlight 207 | if (lastHovered) { 208 | lastHovered.style.outline = ''; 209 | lastHovered.style.backgroundColor = ''; 210 | lastHovered = null; 211 | } 212 | selectionAllowed = false; 213 | sendSelectionCallback = null; 214 | removeListeners(); 215 | e.stopPropagation(); 216 | e.preventDefault(); 217 | } 218 | } 219 | }); 220 | -------------------------------------------------------------------------------- /src/popup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react" 2 | import { Storage } from "@plasmohq/storage" 3 | import { useStorage } from "@plasmohq/storage/hook" 4 | import "./style.css" 5 | import { BadgeQuestionMark, ChartNoAxesGantt, ChevronDown, ChevronDownSquare, ChevronUp, ChevronUpSquare, ClipboardList, CodeXml } from "lucide-react" 6 | import icon from "../assets/icon.png" 7 | function IndexPopup() { 8 | 9 | const [liveSelectionStorageData, setLiveSelectionStorageData] = useStorage({key: "selectedElements",instance: new Storage({ 10 | area: "local" 11 | }) 12 | }) 13 | const [sampleSelectionResult, setSampleSelectionResult] = useStorage({key: "sampleSelectionResult",instance: new Storage({ 14 | area: "local" 15 | }) 16 | }) 17 | 18 | const [selectElementView, setSelectElementView] = useState(true) 19 | const [sampleSelectionView, setSampleSelectionView] = useState(false) 20 | const [commonIdentifiers, setCommonIdentifiers] = useState({ tags: [], classes: [], ids: [], attributes: {} }) 21 | 22 | React.useEffect(() => { 23 | if(sampleSelectionResult && sampleSelectionResult.length > 0) { 24 | setSampleSelectionView(true) 25 | setSelectElementView(false) 26 | } 27 | },[sampleSelectionResult]) 28 | React.useEffect(() => { 29 | getCommonIdentifiers(liveSelectionStorageData || []) 30 | }, [liveSelectionStorageData]) 31 | 32 | function getCommonIdentifiers(elements) { 33 | if (elements.length === 0) return { tags: [], classes: [], ids: [], attributes: {} } 34 | const tagCount = {} 35 | const classCount = {} 36 | const idCount = {} 37 | const attributeCount = {} 38 | 39 | elements.forEach((htmlString) => { 40 | try { 41 | console.log("Processing HTML string:", htmlString) 42 | 43 | // Use DOMParser which handles table elements better 44 | const parser = new DOMParser() 45 | const doc = parser.parseFromString(htmlString, 'text/html') 46 | 47 | console.log("Parsed document body:", doc.body.innerHTML) 48 | console.log("All elements in document:", Array.from(doc.querySelectorAll('*')).map(el => `${el.tagName}${el.className ? '.' + el.className : ''}${el.id ? '#' + el.id : ''}`)) 49 | 50 | // Find the actual target element 51 | let element: HTMLElement | null = null 52 | 53 | // Look for elements with our selection styles first 54 | const elementsWithOutline = doc.querySelectorAll('[style*="outline"]') 55 | console.log("Elements with outline:", elementsWithOutline.length, Array.from(elementsWithOutline).map(el => el.tagName)) 56 | 57 | if (elementsWithOutline.length > 0) { 58 | element = elementsWithOutline[0] as HTMLElement 59 | } else { 60 | // For table elements, they get wrapped in proper table structure 61 | // Look for the specific tag type we're interested in 62 | const allElements = doc.querySelectorAll('*') 63 | console.log("All elements found:", Array.from(allElements).map(el => el.tagName)) 64 | 65 | for (const el of allElements) { 66 | // Skip structural HTML elements 67 | if (!['HTML', 'HEAD', 'BODY', 'TABLE', 'TBODY', 'THEAD', 'TFOOT', 'TR', 'COLGROUP', 'COL'].includes(el.tagName)) { 68 | element = el as HTMLElement 69 | console.log("Selected element:", el.tagName, el.className, el.id) 70 | break 71 | } 72 | } 73 | } 74 | 75 | console.log("Found element:", element?.tagName, element?.className, element?.id) 76 | 77 | if (!element) { 78 | console.warn("No element found for HTML string, trying manual parsing:", htmlString) 79 | 80 | // Fallback: manual parsing of the HTML string 81 | const tagMatch = htmlString.match(/<(\w+)/) 82 | if (tagMatch) { 83 | const tagName = tagMatch[1].toLowerCase() 84 | console.log("Manually extracted tag:", tagName) 85 | tagCount[tagName] = (tagCount[tagName] || 0) + 1 86 | 87 | // Extract classes 88 | const classMatch = htmlString.match(/class=["']([^"']*)["']/) 89 | if (classMatch) { 90 | const classes = classMatch[1].split(/\s+/).filter(cls => cls.length > 0) 91 | classes.forEach(cls => { 92 | classCount[cls] = (classCount[cls] || 0) + 1 93 | console.log("Manually extracted class:", cls) 94 | }) 95 | } 96 | 97 | // Extract ID 98 | const idMatch = htmlString.match(/id=["']([^"']*)["']/) 99 | if (idMatch) { 100 | const id = idMatch[1] 101 | idCount[id] = (idCount[id] || 0) + 1 102 | console.log("Manually extracted ID:", id) 103 | } 104 | 105 | // Extract other attributes (excluding our injected styles) 106 | const attrRegex = /(\w+)=["']([^"']*)["']/g 107 | let attrMatch 108 | while ((attrMatch = attrRegex.exec(htmlString)) !== null) { 109 | const [, name, value] = attrMatch 110 | if (!name.startsWith('on') && 111 | !['href', 'src'].includes(name)) { 112 | if (name === 'style') { 113 | // Skip if it contains our selection styles 114 | if (!value.includes('outline: rgb(255, 0, 0)') && 115 | !value.includes('background-color: rgba(255, 255, 0')) { 116 | const key = `${name}=${value}` 117 | attributeCount[key] = (attributeCount[key] || 0) + 1 118 | console.log("Manually extracted attribute:", key) 119 | } 120 | } else { 121 | const key = `${name}=${value}` 122 | attributeCount[key] = (attributeCount[key] || 0) + 1 123 | console.log("Manually extracted attribute:", key) 124 | } 125 | } 126 | } 127 | } 128 | return 129 | } 130 | 131 | // Tag name 132 | const tagName = element.tagName.toLowerCase() 133 | tagCount[tagName] = (tagCount[tagName] || 0) + 1 134 | console.log("Tag name:", tagName, "Count:", tagCount[tagName]) 135 | 136 | // Classes 137 | element.classList.forEach((cls) => { 138 | classCount[cls] = (classCount[cls] || 0) + 1 139 | console.log("Class:", cls, "Count:", classCount[cls]) 140 | }) 141 | 142 | // IDs 143 | if (element.id) { 144 | idCount[element.id] = (idCount[element.id] || 0) + 1 145 | console.log("ID:", element.id, "Count:", idCount[element.id]) 146 | } 147 | 148 | // Other attributes (excluding potentially dangerous ones and our injected styles) 149 | Array.from(element.attributes).forEach(attr => { 150 | // Skip event handlers, script-related attributes, and our added styles 151 | if (!attr.name.startsWith('on') && 152 | !['href', 'src'].includes(attr.name)) { 153 | // For style attribute, only include if it doesn't contain our selection styles 154 | if (attr.name === 'style') { 155 | const styleValue = attr.value 156 | // Skip if it contains our selection outline or background color 157 | if (!styleValue.includes('outline: rgb(255, 0, 0)') && 158 | !styleValue.includes('background-color: rgba(255, 255, 0')) { 159 | const key = `${attr.name}=${attr.value}` 160 | attributeCount[key] = (attributeCount[key] || 0) + 1 161 | console.log("Attribute:", key, "Count:", attributeCount[key]) 162 | } 163 | } else { 164 | const key = `${attr.name}=${attr.value}` 165 | attributeCount[key] = (attributeCount[key] || 0) + 1 166 | console.log("Attribute:", key, "Count:", attributeCount[key]) 167 | } 168 | } 169 | }) 170 | } catch (error) { 171 | console.error("Error parsing HTML element:", htmlString, error) 172 | return 173 | } 174 | }) 175 | 176 | const total = elements.length 177 | console.log("Total elements:", total) 178 | console.log("Tag counts:", tagCount) 179 | console.log("Class counts:", classCount) 180 | console.log("ID counts:", idCount) 181 | console.log("Attribute counts:", attributeCount) 182 | 183 | const commonTags = Object.keys(tagCount).filter(tag => tagCount[tag] === total) 184 | const commonClasses = Object.keys(classCount).filter(cls => classCount[cls] === total) 185 | const commonIds = Object.keys(idCount).filter(id => idCount[id] === total) 186 | 187 | // Find common attributes and their values 188 | const commonAttributes = {} 189 | Object.keys(attributeCount).forEach(key => { 190 | if (attributeCount[key] === total) { 191 | const [name, value] = key.split("=") 192 | commonAttributes[name] = value 193 | } 194 | }) 195 | 196 | const result = { tags: commonTags, classes: commonClasses, ids: commonIds, attributes: commonAttributes } 197 | console.log("Final common identifiers:", result) 198 | 199 | setCommonIdentifiers(result) 200 | return result 201 | } 202 | 203 | const clearSamples = useCallback(() => { 204 | console.log("Clearing sample selection results") 205 | setSampleSelectionResult([]) 206 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 207 | if (tabs[0]?.id) { 208 | chrome.tabs.sendMessage( 209 | tabs[0].id, 210 | { type: "CLEAR_ALL_HIGHLIGHTS" }, 211 | (response) => { 212 | console.log("Response from content script:", response) 213 | } 214 | ) 215 | } 216 | }) 217 | 218 | }, []) 219 | 220 | 221 | async function handleClearAll() { 222 | console.log("Clearing all selected elements") 223 | setLiveSelectionStorageData([]) 224 | setCommonIdentifiers({ tags: [], classes: [], ids: [], attributes: {} }) 225 | // storage.set("selectedElements", []); 226 | // setSelectedElements([]) 227 | } 228 | 229 | function generateSelector(identifiers) { 230 | let selector = "" 231 | if (identifiers.tags.length > 0) { 232 | selector += identifiers.tags[0] 233 | } else { 234 | selector += "*" 235 | } 236 | if (identifiers.classes.length > 0) { 237 | selector += identifiers.classes.map(cls => `.${cls.replace(/:/g, "\\:")}`).join("") 238 | } 239 | if (identifiers.ids.length > 0) { 240 | selector += identifiers.ids.map(id => `#${id}`).join("") 241 | } 242 | Object.entries(identifiers.attributes || {}).forEach(([name, value]) => { 243 | // Only include attributes that are valid for selectors (skip style) 244 | if (name !== "style") { 245 | // Check for valid CSS attribute value (no semicolons, colons, etc.) 246 | if (/^[^:;]+$/.test(String(value))) { 247 | selector += `[${name}="${value}"]` 248 | } 249 | } 250 | }) 251 | console.log("Constructed selector:", selector) 252 | return selector 253 | } 254 | 255 | 256 | const selectCommonElements = useCallback(() => { 257 | if (!liveSelectionStorageData || liveSelectionStorageData.length === 0) return 258 | const identifiers = getCommonIdentifiers(liveSelectionStorageData) 259 | console.log("Selecting common elements with identifiers:", identifiers) 260 | let selector = generateSelector(identifiers) 261 | 262 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 263 | if (tabs[0]?.id) { 264 | chrome.tabs.sendMessage( 265 | tabs[0].id, 266 | { type: "SELECT_ELEMENTS", selector }, 267 | (response) => { 268 | console.log("Response from content script:", response) 269 | if (response && response.elements) { 270 | console.log("Elements received:", response.elements) 271 | setSampleSelectionResult(response.elements) 272 | } else { 273 | setSampleSelectionResult([]) 274 | } 275 | } 276 | ) 277 | } 278 | }) 279 | }, [liveSelectionStorageData]) 280 | 281 | 282 | 283 | const handleClick = useCallback(() => { 284 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 285 | if (tabs[0]?.id) { 286 | chrome.tabs.sendMessage( 287 | tabs[0].id, 288 | { type: "ALLOW_SELECTION" }, 289 | (response) => { 290 | console.log("Response from content script:", response) 291 | 292 | } 293 | ) 294 | } 295 | }) 296 | window.close(); 297 | }, []) 298 | function removeSelectedElement(index) { 299 | const updatedElements = [...(liveSelectionStorageData || [])] 300 | updatedElements.splice(index, 1) 301 | setLiveSelectionStorageData(updatedElements) 302 | getCommonIdentifiers(updatedElements) 303 | } 304 | function innerText(htmlString) { 305 | const doc = new DOMParser().parseFromString(htmlString, "text/html") 306 | return doc.body.textContent || '' 307 | } 308 | 309 | 310 | function copyToClipboard(txt) { 311 | if (!txt) return 312 | navigator.clipboard.writeText(txt).then(() => { 313 | console.log("Data copied to clipboard") 314 | }).catch((err) => { 315 | console.error("Could not copy text: ", err) 316 | }) 317 | } 318 | function generateJS() { 319 | if (!liveSelectionStorageData || liveSelectionStorageData.length === 0) return 320 | const identifiers = getCommonIdentifiers(liveSelectionStorageData) 321 | let selector = generateSelector(identifiers) 322 | if (!selector || selector === "") selector = "*" 323 | const jsCode = `document.querySelectorAll("${selector}")` 324 | return jsCode 325 | } 326 | 327 | return ( 328 |
329 |
330 | 331 |
332 | 333 |
334 | 335 |

336 | Cherrypick . 337 |

338 |

Select elements to cherry pick

339 |
340 |
341 | 342 | How To 343 | 344 | 345 |
346 |
347 |
348 | 349 | 352 | 358 |
359 | 360 | {selectElementView && ( 361 |
362 |
363 |
364 | 365 |

Selected Elements: ({(liveSelectionStorageData || []).length})

366 | 367 |
368 |
369 | {(liveSelectionStorageData || []).length === 0 ? ( 370 |

No elements selected yet.

371 | ) : ( 372 |
373 | {(liveSelectionStorageData || []).map((el, index) => ( 374 | 379 | ))} 380 |
381 | )} 382 |
383 | 384 |
385 |

386 | Common Identifiers found: ({commonIdentifiers.tags.length + commonIdentifiers.classes.length + commonIdentifiers.ids.length}) 387 |

388 |
389 | {commonIdentifiers.tags.length > 0 && ( 390 |

Tags: {commonIdentifiers.tags.join(", ")}

391 | )} 392 | {commonIdentifiers.classes.length > 0 && ( 393 |

Classes:

394 | {commonIdentifiers.classes.map((cls, idx) => ( 395 |

{cls}

396 | ))} 397 |
398 | )} 399 | {commonIdentifiers.attributes && Object.keys(commonIdentifiers.attributes).length > 0 && ( 400 |
401 | Attributes: 402 | {Object.entries(commonIdentifiers.attributes).map(([name, value], idx) => ( 403 |

{name}="{String(value)}"

404 | ))} 405 |
406 | )} 407 | 408 | {/* Dont really need ids since they shouldnt be duplicated but, whatever*/} 409 | {commonIdentifiers.ids.length > 0 && ( 410 |

IDs:{commonIdentifiers.ids.join(", ")}

411 | )} 412 |
413 | 414 |
415 |
416 | )} 417 |
418 |
419 | 420 |
421 | 422 | 427 | 433 |
434 | {sampleSelectionView && ( 435 |
436 | 437 |
438 |
439 | 440 |

Found Elements: ({sampleSelectionResult.length})

441 | 442 |
443 |
444 | {sampleSelectionResult.length === 0 ? ( 445 |

No elements selected yet.

446 | ) : ( 447 |
448 | {sampleSelectionResult.map((el, index) => ( 449 |
450 |
451 |                   {innerText(el)}
452 |                 
453 |
454 | ))} 455 |
456 | )} 457 |
458 |
459 | )} 460 |
461 | 468 | {/* */} 473 | 478 | {/* */} 479 |
480 |
481 |
482 | Made with ❤️ by Zan Keith 483 |
484 |
485 | ) 486 | } 487 | 488 | export default IndexPopup 489 | --------------------------------------------------------------------------------