├── .babelrc ├── .browserslistrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── css └── styles.css ├── html ├── links.html └── options.html ├── images ├── link_go_128.png ├── link_go_16.png ├── link_go_19.png ├── link_go_32.png └── link_go_48.png ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── components │ ├── LinkList.css │ ├── LinkList.js │ ├── LinkListEmpty.js │ ├── LinkListExpired.js │ ├── Options.css │ └── Options.js ├── contentscript.js ├── links.js ├── options.js └── service_worker.js └── vendor └── bootstrap ├── LICENSE ├── css ├── bootstrap-theme.css └── bootstrap.css └── fonts ├── glyphicons-halflings-regular.eot ├── glyphicons-halflings-regular.svg ├── glyphicons-halflings-regular.ttf ├── glyphicons-halflings-regular.woff └── glyphicons-halflings-regular.woff2 /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", {"runtime": "automatic"}] 5 | ] 6 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 Chrome versions, Firefox ESR 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "react": { 4 | "version": "detect" 5 | } 6 | }, 7 | "parser": "@babel/eslint-parser", 8 | "plugins": [ 9 | "react" 10 | ], 11 | "env": { 12 | "browser": true, 13 | "node": true 14 | }, 15 | "globals": { 16 | "chrome": true, 17 | }, 18 | "extends": "eslint:recommended", 19 | "rules": { 20 | "comma-dangle": [1, "always-multiline"], 21 | "jsx-quotes": [2, "prefer-double"], 22 | "react/display-name": [2, {"ignoreTranspilerName": false}], 23 | "react/sort-comp": 2, 24 | "react/jsx-curly-spacing": [2, "never"], 25 | "react/jsx-no-duplicate-props": 2, 26 | "react/jsx-no-undef": 2, 27 | "react/jsx-uses-react": 1, 28 | "react/jsx-uses-vars": 1, 29 | "react/jsx-wrap-multilines": 2, 30 | "react/no-deprecated": 2, 31 | "react/no-unknown-property": 2, 32 | "semi": [2, "always"], 33 | "strict": [2, "global"], 34 | "quotes": [2, "single"], 35 | "no-console": 1, 36 | "no-unused-vars": [2, {"args": "none"}] 37 | }, 38 | "parserOptions": { 39 | "ecmaFeatures": { 40 | "modules": true, 41 | "jsx": true 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-project 2 | *.sublime-workspace 3 | dist/ 4 | js/ 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2012-2014 Don Tong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: watch 2 | watch: clean 3 | npm run watch 4 | 5 | .PHONY: package 6 | package: build 7 | mkdir -p dist 8 | zip -x\*.DS_Store dist/linkgrabber.zip -r css html images js vendor manifest.json 9 | 10 | .PHONY: build 11 | build: clean 12 | npm run build 13 | 14 | .PHONY: lint 15 | lint: 16 | npm exec eslint src 17 | 18 | .PHONY: clean 19 | clean: 20 | rm -rf js 21 | rm -rf dist 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Hello ### 2 | 3 | https://chrome.google.com/webstore/detail/link-grabber/caodelkhipncidmoebgbbeemedohcdma 4 | 5 | Link Grabber is an extension for Google Chrome that extracts links from an 6 | HTML page and displays them in another tab. 7 | 8 | ### Licenses ### 9 | 10 | This project is open source software that also bundles other open source 11 | software. 12 | 13 | Unless otherwise noted, the MIT License applies. 14 | 15 | Icon files in images/ are derived from icons by FatCow 16 | (http://www.fatcow.com/free-icons) and licensed under the Creative Commons 17 | Attribution 3.0 License 18 | 19 | Files in vendor/bootstrap are licensed under the apache-2.0 license 20 | (vendor/bootstrap/LICENSE) 21 | -------------------------------------------------------------------------------- /css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-bottom: 1em; 3 | overflow-y: scroll; 4 | } 5 | 6 | a:visited { 7 | color: #551a8b; 8 | } 9 | 10 | /* horizontal alignment */ 11 | .txtC, table .txtC, table tr .txtC{text-align:center;} 12 | .txtL, table .txtL, table tr .txtL{text-align:left;} 13 | .txtR, table .txtR, table tr .txtR{text-align:right;} 14 | 15 | /* vertical alignment */ 16 | .txtT, table .txtT, table tr .txtT{vertical-align:top;} 17 | .txtB, table .txtB, table tr .txtB{vertical-align:bottom;} 18 | .txtM, table .txtM, table tr .txtM{vertical-align:middle;} 19 | 20 | .table tbody > tr > td.txtM { 21 | vertical-align: middle; 22 | } 23 | 24 | .d-flex { 25 | display: flex; 26 | } 27 | 28 | .flex-grow-0 { 29 | flex-grow: 0; 30 | } 31 | 32 | .flex-grow-1 { 33 | flex-grow: 1; 34 | } 35 | 36 | .align-items-center { 37 | align-items: center; 38 | } -------------------------------------------------------------------------------- /html/links.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Extracted Links 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /html/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Link Grabber Options 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /images/link_go_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/7fffffff/linkgrabber/8d77e25b3ec003c6a511ceaf381c4a6a2e72953b/images/link_go_128.png -------------------------------------------------------------------------------- /images/link_go_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/7fffffff/linkgrabber/8d77e25b3ec003c6a511ceaf381c4a6a2e72953b/images/link_go_16.png -------------------------------------------------------------------------------- /images/link_go_19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/7fffffff/linkgrabber/8d77e25b3ec003c6a511ceaf381c4a6a2e72953b/images/link_go_19.png -------------------------------------------------------------------------------- /images/link_go_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/7fffffff/linkgrabber/8d77e25b3ec003c6a511ceaf381c4a6a2e72953b/images/link_go_32.png -------------------------------------------------------------------------------- /images/link_go_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/7fffffff/linkgrabber/8d77e25b3ec003c6a511ceaf381c4a6a2e72953b/images/link_go_48.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Link Grabber", 3 | "manifest_version": 3, 4 | "version": "0.6.1", 5 | "description": "An easy to use extractor or grabber for hyperlinks on an HTML page", 6 | "permissions": [ 7 | "activeTab", 8 | "clipboardWrite", 9 | "contextMenus", 10 | "scripting", 11 | "storage" 12 | ], 13 | "incognito": "split", 14 | "options_ui": { 15 | "browser_style": false, 16 | "page": "/html/options.html" 17 | }, 18 | "icons": { 19 | "16": "/images/link_go_16.png", 20 | "32": "/images/link_go_32.png", 21 | "48": "/images/link_go_48.png", 22 | "128": "/images/link_go_128.png" 23 | }, 24 | "background": { 25 | "service_worker": "/js/service_worker.js" 26 | }, 27 | "action": { 28 | "default_icon": "/images/link_go_19.png", 29 | "default_title": "Extract the links on this page" 30 | }, 31 | "browser_specific_settings": { 32 | "gecko": { 33 | "id": "linkgrabber@7fffffff.com" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "classnames": "~2.3.2", 4 | "lodash.debounce": "~4.0.8", 5 | "react": "~18.2.0", 6 | "react-dom": "~18.2.0" 7 | }, 8 | "devDependencies": { 9 | "@babel/core": "~7.24.0", 10 | "@babel/eslint-parser": "~7.23.10", 11 | "@babel/preset-env": "~7.24.0", 12 | "@babel/preset-react": "~7.23.3", 13 | "esbuild": "0.20.1", 14 | "eslint": "~8.57.0", 15 | "eslint-plugin-react": "~7.34.0" 16 | }, 17 | "scripts": { 18 | "build": "esbuild src/*.js --bundle --loader:.js=jsx --outdir=js --sourcemap --splitting --format=esm", 19 | "watch": "esbuild src/*.js --bundle --loader:.js=jsx --outdir=js --sourcemap --splitting --format=esm --watch" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/LinkList.css: -------------------------------------------------------------------------------- 1 | .LinkPageHeader { 2 | white-space: nowrap; 3 | line-height: normal; 4 | overflow-x: hidden; 5 | text-overflow: ellipsis; 6 | } 7 | 8 | .LinkPageStatus { 9 | float: right; 10 | } 11 | 12 | .LinkList { 13 | list-style: none; 14 | padding-left: 0; 15 | } 16 | 17 | .LinkListItem { 18 | border-bottom: 1px solid #ddd; 19 | padding-left: 0.5em; 20 | padding-right: 0.5em; 21 | word-wrap: break-word; 22 | } 23 | 24 | .LinkListItem:nth-child(odd) { 25 | background-color: #F5F5F5; 26 | } 27 | 28 | .LinkListItem--blocked > a { 29 | color: #a94442; 30 | } 31 | 32 | .LinkListItem--duplicate > a { 33 | color: #aaa; 34 | } 35 | 36 | .LinkListItem--blocked.LinkListItem--duplicate > a { 37 | color: #ebccd1; 38 | } 39 | 40 | .LinkPageOptionsForm .form-group { 41 | margin-bottom: 1em; 42 | } 43 | 44 | .LinkPageOptionsForm > .form-group + .form-group { 45 | margin-left: 1em; 46 | } 47 | @media (max-width: 767px) { 48 | .LinkPageOptionsForm { 49 | margin-bottom: 0em; 50 | } 51 | .LinkPageOptionsForm > .form-group + .form-group { 52 | margin-left: 0; 53 | } 54 | } 55 | 56 | .LinkPageOptionsForm .checkbox-inline { 57 | user-select: none; 58 | } -------------------------------------------------------------------------------- /src/components/LinkList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useEffect, useRef, useState} from 'react'; 3 | import cx from 'classnames'; 4 | import debounce from 'lodash.debounce'; 5 | import LinkListEmpty from './LinkListEmpty'; 6 | import LinkListExpired from './LinkListExpired'; 7 | import './LinkList.css'; 8 | 9 | function copyLinks(element) { 10 | const selection = window.getSelection(); 11 | const prevRange = selection.rangeCount ? selection.getRangeAt(0).cloneRange() : null; 12 | const tmp = document.createElement('div'); 13 | const links = element.querySelectorAll('a'); 14 | for (let i = 0; i < links.length; i++) { 15 | const clone = links[i].cloneNode(true); 16 | delete (clone.dataset.reactid); 17 | tmp.appendChild(clone); 18 | tmp.appendChild(document.createElement('br')); 19 | } 20 | document.body.appendChild(tmp); 21 | const copyFrom = document.createRange(); 22 | copyFrom.selectNodeContents(tmp); 23 | selection.removeAllRanges(); 24 | selection.addRange(copyFrom); 25 | document.execCommand('copy'); 26 | document.body.removeChild(tmp); 27 | selection.removeAllRanges(); 28 | if (prevRange) { 29 | selection.addRange(prevRange); 30 | } 31 | } 32 | 33 | function groupLinksByDomain(links) { 34 | const indexes = new Array(links.length); 35 | const rh = new Array(links.length); 36 | for (let i = 0; i < links.length; i++) { 37 | indexes[i] = i; 38 | rh[i] = links[i].hostname.toLowerCase().split('.').reverse().join('.'); 39 | } 40 | indexes.sort((i, j) => { 41 | if (rh[i] < rh[j]) { 42 | return -1; 43 | } 44 | if (rh[i] > rh[j]) { 45 | return 1; 46 | } 47 | return i - j; 48 | }); 49 | return indexes.map(i => links[i]); 50 | } 51 | 52 | function mapBlocked(links, blockedDomains) { 53 | blockedDomains = new Set(blockedDomains); 54 | return links.map(link => { 55 | let hostname = link.hostname.toLowerCase(); 56 | const dots = []; 57 | for (let i = 0; i < hostname.length; i++) { 58 | if (hostname[i] === '.') { 59 | dots.push(i); 60 | } 61 | } 62 | if (blockedDomains.has(hostname)) { 63 | return true; 64 | } 65 | for (const dot of dots) { 66 | if (blockedDomains.has(hostname.substr(dot + 1))) { 67 | blockedDomains.add(hostname); 68 | return true; 69 | } 70 | } 71 | return false; 72 | }); 73 | } 74 | 75 | function mapDuplicates(links) { 76 | const uniq = new Set(); 77 | return links.map(link => { 78 | if (uniq.has(link.href)) { 79 | return true; 80 | } 81 | uniq.add(link.href); 82 | return false; 83 | }); 84 | } 85 | 86 | function rejectSameOrigin(links, sourceUrl) { 87 | if (!sourceUrl) { 88 | return links; 89 | } 90 | if (!sourceUrl.startsWith('http://') && !sourceUrl.startsWith('https://')) { 91 | return links; 92 | } 93 | const parser = document.createElement('a'); 94 | parser.href = sourceUrl; 95 | if (!parser.origin) { 96 | return links; 97 | } 98 | return links.filter(link => link.origin !== parser.origin); 99 | } 100 | 101 | export default function LinkList(props) { 102 | const linkListRef = useRef(null); 103 | 104 | const [filter, setFilter] = useState(''); 105 | const [nextFilter, setNextFilter] = useState(''); 106 | const [groupByDomain, setGroupByDomain] = useState(false); 107 | const [hideBlockedDomains, setHideBlockedDomains] = useState(true); 108 | const [hideDuplicates, setHideDuplicates] = useState(true); 109 | const [hideSameOrigin, setHideSameOrigin] = useState(false); 110 | 111 | const applyFilter = debounce(() => setFilter(nextFilter), 100, {trailing: true}); 112 | const filterChanged = (event) => setNextFilter(event.target.value); 113 | const toggleBlockedLinks = () => setHideBlockedDomains(x => !x); 114 | const toggleDedup = () => setHideDuplicates(x => !x); 115 | const toggleGroupByDomain = () => setGroupByDomain(x => !x); 116 | const toggleHideSameOrigin = () => setHideSameOrigin(x => !x); 117 | 118 | useEffect(() => { 119 | const h = (event) => { 120 | const selection = window.getSelection(); 121 | if (selection.type === 'None' || selection.type === 'Caret') { 122 | copyLinks(); 123 | } 124 | }; 125 | window.document.addEventListener('copy', h); 126 | return () => { 127 | window.document.removeEventListener('copy', h); 128 | }; 129 | }, []); 130 | 131 | useEffect(applyFilter, [nextFilter]); 132 | 133 | if (props.expired) { 134 | return (); 135 | } 136 | 137 | let links = props.links.slice(0); 138 | if (links.length === 0) { 139 | return (); 140 | } 141 | 142 | if (hideSameOrigin) { 143 | links = rejectSameOrigin(links, props.source); 144 | } 145 | if (groupByDomain) { 146 | links = groupLinksByDomain(links); 147 | } 148 | 149 | const blocked = mapBlocked(links, props.blockedDomains); 150 | const duplicates = mapDuplicates(links); 151 | const filterLowerCase = filter.trim().toLowerCase(); 152 | const items = links.reduce((memo, link, index) => { 153 | if (hideDuplicates && duplicates[index]) { 154 | return memo; 155 | } 156 | if (hideBlockedDomains && blocked[index]) { 157 | return memo; 158 | } 159 | if (filterLowerCase) { 160 | const lowerHref = link.href.toLowerCase(); 161 | if (lowerHref.indexOf(filterLowerCase) < 0) { 162 | return memo; 163 | } 164 | } 165 | const itemClassName = cx('LinkListItem', { 166 | 'LinkListItem--blocked': blocked[index], 167 | 'LinkListItem--duplicate': duplicates[index], 168 | }); 169 | memo.push( 170 |
  • 171 | {link.href} 172 |
  • 173 | ); 174 | return memo; 175 | }, []); 176 | 177 | return ( 178 |
    179 |

    {props.source}

    180 |
    181 |
    182 |
    183 | 186 | 189 | 192 | 195 |
    196 |
    197 | 198 |
    199 |
    200 | 203 |
    204 |
    205 |
    206 |
      207 | {items} 208 |
    209 |
    210 | ); 211 | } 212 | -------------------------------------------------------------------------------- /src/components/LinkListEmpty.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function LinkListEmpty (props) { 4 | return ( 5 |
    6 |

    {props.source}

    7 |

    8 | No links were found. 9 |

    10 |
    11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/LinkListExpired.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function LinkListExpired (props) { 4 | return ( 5 |
    6 |

    Expired

    7 |

    8 | Link information has expired and is no longer available. 9 | Please close this tab and try again. 10 |

    11 |
    12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Options.css: -------------------------------------------------------------------------------- 1 | .BadDomainsActionCol { 2 | width: 5em; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Options.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import './Options.css'; 3 | 4 | export default function Options(props) { 5 | return ( 6 |
    7 |
    8 |
    9 |

    Blocked Domains

    10 | 11 | 15 | 16 |
    17 |
    18 |
    19 | ); 20 | } 21 | 22 | function BlockedDomainsEditor(props) { 23 | const [saved, setSaved] = useState(false); 24 | 25 | const onSubmit = (event) => { 26 | event.preventDefault(); 27 | setSaved(false); 28 | const formData = new FormData(event.target); 29 | props.setBlockedDomains(formData.get('blockedDomains').split('\n')); 30 | setSaved(true); 31 | }; 32 | 33 | const blockedDomainsText = props.blockedDomains.join('\n'); 34 | 35 | return ( 36 |
    37 |

    38 | Links from blocked domains will be hidden by default 39 |
    Enter one domain per line 40 |
    Lines starting with # will be ignored 41 |
    example.com will also block www.example.com 42 |

    43 |
    44 |