├── .gitignore ├── icon.png ├── preview.png ├── assets └── images │ ├── icon-128.png │ ├── icon-16.png │ └── icon-48.png ├── LICENSE ├── scripts ├── background │ └── get-results.js ├── popup.js ├── utils.js ├── main.js ├── third-party │ ├── browser-polyfill.min.js │ └── browser-polyfill.min.js.map └── nlp.js ├── manifest.v3.chrome.json ├── css ├── style.css └── popup.css ├── manifest.v2.json ├── manifest.v3.firefox.json ├── package.ps1 ├── popup.html ├── README.md └── HNRelevant.user.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | output -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imdj/HNRelevant/HEAD/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imdj/HNRelevant/HEAD/preview.png -------------------------------------------------------------------------------- /assets/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imdj/HNRelevant/HEAD/assets/images/icon-128.png -------------------------------------------------------------------------------- /assets/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imdj/HNRelevant/HEAD/assets/images/icon-16.png -------------------------------------------------------------------------------- /assets/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imdj/HNRelevant/HEAD/assets/images/icon-48.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Imad Aldoj 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /scripts/background/get-results.js: -------------------------------------------------------------------------------- 1 | // Only import in the worker's context which has a different global object, one where 'importScripts' exists. 2 | if( 'function' === typeof importScripts) { 3 | importScripts("../third-party/browser-polyfill.min.js"); 4 | } 5 | 6 | async function searchHackerNews(submissionID, searchObject) { 7 | const url = `https://hn.algolia.com/api/v1/search` 8 | + (searchObject.type === 'verbatim' ? `?query=${encodeURIComponent(searchObject.rawQuery)}` : `?similarQuery=${encodeURIComponent(searchObject.query)}`) 9 | + `&tags=story` 10 | + `&hitsPerPage=${searchObject.numOfResults}` 11 | + `&filters=NOT objectID:` + submissionID // exclude current submission 12 | + `&numericFilters=created_at_i>${searchObject.date.start},created_at_i<${searchObject.date.end}` // filter by date 13 | + (searchObject.hidePostswithLowComments ? `,num_comments>=${searchObject.minComments}` : ``) // filter by minimum comments if enabled 14 | ; 15 | 16 | const response = await fetch(url).then(response => response.json()); 17 | return response; 18 | } 19 | 20 | browser.runtime.onMessage.addListener((msg, sender, result) => { 21 | searchHackerNews(msg.id, msg.object).then(response => { 22 | result(response); 23 | }); 24 | return true; 25 | }); -------------------------------------------------------------------------------- /manifest.v3.chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "HNRelevant - Related stories on Hacker News", 4 | "description": "Enhance your Hacker News experience and gain new insights by exploring relevant stories and discussions", 5 | "version": "1.4.0", 6 | "icons": { 7 | "16": "assets/images/icon-16.png", 8 | "48": "assets/images/icon-48.png", 9 | "128": "assets/images/icon-128.png" 10 | }, 11 | "host_permissions": [ 12 | "*://hn.algolia.com/api/*", 13 | "*://news.ycombinator.com/*" 14 | ], 15 | "content_scripts": [ 16 | { 17 | "matches": [ 18 | "*://news.ycombinator.com/item*" 19 | ], 20 | "js": [ 21 | "scripts/third-party/browser-polyfill.min.js", 22 | "scripts/nlp.js", 23 | "scripts/utils.js", 24 | "scripts/main.js" 25 | ], 26 | "css": [ 27 | "css/style.css" 28 | ] 29 | } 30 | ], 31 | "permissions": [ 32 | "storage" 33 | ], 34 | "background": { 35 | "service_worker": "scripts/background/get-results.js" 36 | }, 37 | "action": { 38 | "default_icon": { 39 | "16": "assets/images/icon-16.png", 40 | "48": "assets/images/icon-48.png", 41 | "128": "assets/images/icon-128.png" 42 | }, 43 | "default_popup": "popup.html", 44 | "default_title": "HNRelevant" 45 | } 46 | } -------------------------------------------------------------------------------- /css/style.css: -------------------------------------------------------------------------------- 1 | #hnrelevant-controls-container, #hnrelevant-controls { 2 | display: flex; 3 | gap: 10px 4 | } 5 | 6 | #hnrelevant-controls-container { 7 | flex-direction: column; 8 | } 9 | 10 | #hnrelevant-controls { 11 | flex-direction: row; 12 | flex-wrap: wrap; 13 | align-items: center; 14 | } 15 | 16 | #query-customization-container { 17 | display: flex; 18 | flex-direction: row; 19 | align-items: center; 20 | margin: 5px 0; 21 | padding-right: 10px; 22 | } 23 | 24 | #queryCustomization { 25 | flex-grow: 1; 26 | } 27 | 28 | #hnrelevant-results-list { 29 | list-style: none; 30 | padding: 0; 31 | width: 100%; 32 | display: flex; 33 | flex-direction: column; 34 | } 35 | 36 | #hnrelevant-results-list .result { 37 | padding: 5px 0; 38 | min-width: 280px; 39 | } 40 | 41 | @media screen and (max-width: 1200px) { 42 | #hnrelevant-results { 43 | display: grid; 44 | } 45 | 46 | #hnrelevant-results-list { 47 | flex-direction: row; 48 | gap: 10px; 49 | overflow-x: auto; 50 | } 51 | 52 | #hnrelevant-results-list .result { 53 | display: flex; 54 | flex-direction: column; 55 | justify-content: space-between; 56 | border: 1px solid #d3d3d3; 57 | border-radius: 5px; 58 | padding: 5px; 59 | } 60 | } -------------------------------------------------------------------------------- /manifest.v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "HNRelevant - Related stories on Hacker News", 4 | "description": "Enhance your Hacker News experience and gain new insights by exploring relevant stories and discussions", 5 | "version": "1.4.0", 6 | "icons": { 7 | "16": "./assets/images/icon-16.png", 8 | "48": "./assets/images/icon-48.png", 9 | "128": "./assets/images/icon-128.png" 10 | }, 11 | "permissions": [ 12 | "storage", 13 | "*://hn.algolia.com/api/*", 14 | "*://news.ycombinator.com/*" 15 | ], 16 | "applications": { 17 | "gecko": { 18 | "id": "hnrelevant@extensions.imdj.dev", 19 | "strict_min_version": "79.0" 20 | } 21 | }, 22 | "content_scripts": [ 23 | { 24 | "matches": [ 25 | "*://news.ycombinator.com/item*" 26 | ], 27 | "js": [ 28 | "./scripts/nlp.js", 29 | "./scripts/utils.js", 30 | "./scripts/main.js" 31 | ], 32 | "css": [ 33 | "./css/style.css" 34 | ] 35 | } 36 | ], 37 | "background": { 38 | "scripts": [ 39 | "./scripts/third-party/browser-polyfill.min.js", 40 | "./scripts/background/get-results.js" 41 | ] 42 | }, 43 | "browser_action": { 44 | "default_icon": { 45 | "16": "./assets/images/icon-16.png", 46 | "48": "./assets/images/icon-48.png", 47 | "128": "./assets/images/icon-128.png" 48 | }, 49 | "default_popup": "./popup.html", 50 | "default_title": "HNRelevant" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /manifest.v3.firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "HNRelevant - Related stories on Hacker News", 4 | "description": "Enhance your Hacker News experience and gain new insights by exploring relevant stories and discussions", 5 | "version": "1.4.0", 6 | "icons": { 7 | "16": "./assets/images/icon-16.png", 8 | "48": "./assets/images/icon-48.png", 9 | "128": "./assets/images/icon-128.png" 10 | }, 11 | "host_permissions": [ 12 | "*://hn.algolia.com/api/*", 13 | "*://news.ycombinator.com/*" 14 | ], 15 | "content_scripts": [ 16 | { 17 | "matches": [ 18 | "*://news.ycombinator.com/item*" 19 | ], 20 | "js": [ 21 | "./scripts/third-party/browser-polyfill.min.js", 22 | "./scripts/nlp.js", 23 | "./scripts/utils.js", 24 | "./scripts/main.js" 25 | ], 26 | "css": [ 27 | "./css/style.css" 28 | ] 29 | } 30 | ], 31 | "permissions": [ 32 | "storage" 33 | ], 34 | "browser_specific_settings": { 35 | "gecko": { 36 | "id": "hnrelevant@extensions.imdj.dev", 37 | "strict_min_version": "109.0" 38 | }, 39 | "gecko_android": { 40 | "strict_min_version": "120.0" 41 | } 42 | }, 43 | "background": { 44 | "scripts": [ 45 | "./scripts/third-party/browser-polyfill.min.js", 46 | "./scripts/background/get-results.js" 47 | ] 48 | }, 49 | "action": { 50 | "default_icon": { 51 | "16": "./assets/images/icon-16.png", 52 | "48": "./assets/images/icon-48.png", 53 | "128": "./assets/images/icon-128.png" 54 | }, 55 | "default_popup": "./popup.html", 56 | "default_title": "HNRelevant" 57 | } 58 | } -------------------------------------------------------------------------------- /package.ps1: -------------------------------------------------------------------------------- 1 | # Set baseDir to the current directory 2 | $baseDir = Get-Location 3 | 4 | # Check for existence of required files and folders 5 | $requiredFiles = @( 6 | "LICENSE" 7 | "manifest.v2.json", 8 | "manifest.v3.chrome.json", 9 | "manifest.v3.firefox.json", 10 | "popup.html", 11 | "scripts", 12 | "assets", 13 | "css", 14 | "icon.png" 15 | ) 16 | $missingFiles = $requiredFiles | Where-Object { -not (Test-Path (Join-Path $baseDir $_)) } 17 | if ($missingFiles) { 18 | Write-Error "The following required files are missing: $missingFiles" 19 | Exit 20 | } 21 | 22 | # Check if WinRAR is installed 23 | # Tried using Compress-Archive but the result file wasn't compatible with Firefox 24 | $winRarPath = "C:\Program Files\WinRAR\WinRAR.exe" # Replace with the actual path to WinRAR.exe 25 | if (-not (Test-Path $winRarPath)) { 26 | Write-Host "WinRAR is not installed. Exiting..." 27 | exit 28 | } 29 | 30 | # Clean output directory if it already exists 31 | $outputDir = Join-Path $baseDir "output" 32 | if (Test-Path $outputDir) { 33 | Remove-Item -Path $outputDir -Recurse -Force 34 | } 35 | New-Item -Path $outputDir -ItemType Directory | Out-Null 36 | 37 | # Read and parse manifest.v3.json 38 | $manifestPath = Join-Path $baseDir "manifest.v3.chrome.json" 39 | $manifestContent = Get-Content $manifestPath -Raw | ConvertFrom-Json 40 | 41 | $version = $manifestContent.version 42 | 43 | # Create packages for manifest v2 and v3 44 | foreach ($manifest in @("manifest.v2.json", "manifest.v3.chrome.json", "manifest.v3.firefox.json")) { 45 | $filesToPackage = @( 46 | "LICENSE" 47 | "popup.html", 48 | "scripts", 49 | "assets", 50 | "css", 51 | "icon.png" 52 | ) 53 | 54 | # Extract version from manifest 55 | $manifestContent = Get-Content (Join-Path $baseDir $manifest) -Raw | ConvertFrom-Json 56 | 57 | $outputPath = Join-Path $outputDir "HNRelevant-$($manifest.Replace('.json', ''))-v$version.zip" 58 | 59 | # Copy the manifest to a temporary file named "manifest.json" 60 | $tempManifestPath = Join-Path $baseDir "manifest.json" 61 | Copy-Item -Path (Join-Path $baseDir $manifest) -Destination $tempManifestPath 62 | 63 | # Add the temporary manifest to files to package 64 | $filesToPackage += "manifest.json" 65 | 66 | # Join paths for each file individually 67 | $pathsToPackage = $filesToPackage | ForEach-Object { Join-Path $baseDir $_ } 68 | 69 | # Use WinRAR to create the zip file 70 | $winRarCommand = "a -afzip -ep1 -r $outputPath $pathsToPackage" 71 | Start-Process -FilePath $winRarPath -ArgumentList $winRarCommand -NoNewWindow -Wait 72 | 73 | # Delete the temporary manifest 74 | Remove-Item -Path $tempManifestPath -Force 75 | 76 | Write-Host "Created " $outputPath 77 | } -------------------------------------------------------------------------------- /css/popup.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: system-ui, sans-serif; 3 | } 4 | 5 | html { 6 | max-height: 600px; 7 | overflow-y: auto; 8 | } 9 | body { 10 | width: 350px; 11 | max-width: 100%; 12 | min-height: 300px; 13 | } 14 | 15 | .relative { 16 | position: relative; 17 | } 18 | 19 | .flex { 20 | display: flex; 21 | } 22 | 23 | .h-full { 24 | height: 100%; 25 | } 26 | 27 | .w-full { 28 | width: 100%; 29 | } 30 | 31 | .flex-col { 32 | flex-direction: column; 33 | } 34 | 35 | .flex-row { 36 | flex-direction: row; 37 | } 38 | 39 | .justify-center { 40 | justify-content: center; 41 | } 42 | 43 | .justify-between { 44 | justify-content: space-between; 45 | } 46 | 47 | .items-baseline { 48 | align-items: baseline; 49 | } 50 | 51 | .items-center { 52 | align-items: center; 53 | } 54 | 55 | [class~="top-0.5"] { 56 | top: 0.125rem; 57 | } 58 | 59 | .ml-2 { 60 | margin-left: 0.5rem; 61 | } 62 | 63 | .ml-4 { 64 | margin-left: 1rem; 65 | } 66 | 67 | .ml-8 { 68 | margin-left: 2rem; 69 | } 70 | 71 | .mt-2 { 72 | margin-top: 0.5rem; 73 | } 74 | 75 | .mt-3 { 76 | margin-top: 0.75rem; 77 | } 78 | 79 | .mt-8 { 80 | margin-top: 2rem; 81 | } 82 | 83 | .mb-auto { 84 | margin-bottom: auto; 85 | } 86 | 87 | .mb-0 { 88 | margin-bottom: 0; 89 | } 90 | 91 | .mb-2 { 92 | margin-bottom: 0.5rem; 93 | } 94 | 95 | .mb-4 { 96 | margin-bottom: 1rem; 97 | } 98 | 99 | .my-2 { 100 | margin: 0.5rem 0; 101 | } 102 | 103 | .my-4 { 104 | margin: 1rem 0; 105 | } 106 | 107 | .my-8 { 108 | margin: 2rem 0; 109 | } 110 | 111 | .mt-auto { 112 | margin-top: auto; 113 | } 114 | 115 | [class~="p-0.5"] { 116 | padding: 0.125rem; 117 | } 118 | 119 | p { 120 | margin: unset; 121 | color: rgba(0, 0, 0, .6); 122 | } 123 | 124 | .btn { 125 | display: flex; 126 | align-items: center; 127 | flex-direction: row; 128 | padding: 0.25rem 0.5rem; 129 | border-radius: 0.5rem; 130 | border: 3px solid #E3E3E3; 131 | cursor: pointer; 132 | color: #000; 133 | background: #fff; 134 | transition: 0.3s; 135 | margin: 0 0.5rem; 136 | } 137 | 138 | a.btn { 139 | text-decoration: none; 140 | } 141 | 142 | .btn:hover, 143 | .btn:focus { 144 | background: #E3E3E3; 145 | } 146 | 147 | .btn svg { 148 | margin-left: 10px; 149 | } 150 | 151 | .main-btn { 152 | border-color: #ff6600; 153 | cursor: pointer; 154 | } 155 | 156 | .main-btn:hover, .main-btn:focus { 157 | background: #ff6600; 158 | color: #fff; 159 | } 160 | 161 | input[type="checkbox"] { 162 | position: relative; 163 | width: 40px; 164 | height: 20px; 165 | -webkit-appearance: none; 166 | appearance: none; 167 | background: red; 168 | outline: none; 169 | border-radius: 2rem; 170 | cursor: pointer; 171 | box-shadow: inset 0 0 5px rgb(0 0 0 / 50%); 172 | } 173 | 174 | input[type="checkbox"]::before { 175 | content: ""; 176 | width: 18px; 177 | height: 18px; 178 | border-radius: 50%; 179 | background: #fff; 180 | position: absolute; 181 | top: 1px; 182 | left: 1px; 183 | transition: 0.5s; 184 | } 185 | 186 | input[type="checkbox"]:checked::before { 187 | transform: translateX(100%); 188 | background: #fff; 189 | } 190 | 191 | input[type="checkbox"]:checked { 192 | background: #00ed64; 193 | } -------------------------------------------------------------------------------- /scripts/popup.js: -------------------------------------------------------------------------------- 1 | const permissionsToRequest = { 2 | origins: ["*://news.ycombinator.com/*", "*://hn.algolia.com/api/*"] 3 | } 4 | 5 | function requestPermission() { 6 | browser.permissions.request(permissionsToRequest); 7 | window.close(); 8 | } 9 | 10 | (async () => { 11 | const permissionStatus = await browser.permissions.contains(permissionsToRequest); 12 | 13 | if (!permissionStatus) { 14 | document.body.innerHTML = ""; 15 | 16 | const description = document.createElement("p"); 17 | description.textContent = "HNRelevant needs some permissions to access Hacker News and Algolia API to fetch results."; 18 | description.style = "padding: 0.5em; margin-bottom: 1em;"; 19 | document.body.appendChild(description); 20 | 21 | const grantPermissionButton = document.createElement("button"); 22 | grantPermissionButton.textContent = "Grant Permission"; 23 | grantPermissionButton.classList = "btn main-btn"; 24 | grantPermissionButton.style = "font-size: 1.5em;"; 25 | grantPermissionButton.addEventListener("click", requestPermission); 26 | 27 | document.body.appendChild(grantPermissionButton); 28 | } 29 | })(); 30 | 31 | 32 | // get references to the input elements 33 | const modeRadioButtons = document.getElementsByName("mode"); 34 | const resultsDropdown = document.getElementById("results"); 35 | const searchType = document.getElementsByName("searchType"); 36 | const hideLowCommentsCheckbox = document.getElementById("hide-low-comments"); 37 | 38 | async function initPopup() { 39 | const preferences = await loadPreferences(); 40 | 41 | // set the input elements to the saved values 42 | for (const radioButton of modeRadioButtons) { 43 | if (radioButton.value === preferences.mode) { 44 | radioButton.checked = true; 45 | break; 46 | } 47 | } 48 | for (const radioButton of searchType) { 49 | if (radioButton.value === preferences.type) { 50 | radioButton.checked = true; 51 | break; 52 | } 53 | } 54 | resultsDropdown.value = preferences.numOfResults; 55 | hideLowCommentsCheckbox.checked = preferences.hidePostswithLowComments; 56 | 57 | // Set review button 58 | const reviewButton = document.getElementById("review-btn"); 59 | if (chrome.runtime.getURL('').startsWith('moz-extension://')) { 60 | reviewButton.textContent = "Review on Firefox Add-ons"; 61 | reviewButton.href = `https://addons.mozilla.org/en-US/firefox/addon/hnrelevant/`; 62 | } 63 | else { 64 | reviewButton.textContent = "Review on Chrome Web Store"; 65 | reviewButton.href = `https://chromewebstore.google.com/detail/hnrelevant-related-storie/iajhnkeiioebplnbfkpnlnggkgblmoln`; 66 | } 67 | 68 | // add event listeners to the input elements 69 | for (const radioButton of modeRadioButtons) { 70 | radioButton.addEventListener("change", (event) => { 71 | preferences.mode = event.target.value; 72 | savePreferences(preferences); 73 | }); 74 | } 75 | resultsDropdown.addEventListener("change", (event) => { 76 | preferences.numOfResults = event.target.value; 77 | savePreferences(preferences); 78 | } 79 | ); 80 | 81 | hideLowCommentsCheckbox.addEventListener("change", (event) => { 82 | preferences.hidePostswithLowComments = event.target.checked; 83 | savePreferences(preferences); 84 | }); 85 | 86 | for (const radioButton of searchType) { 87 | radioButton.addEventListener("change", (event) => { 88 | preferences.type = event.target.value; 89 | savePreferences(preferences); 90 | }); 91 | } 92 | } 93 | 94 | initPopup(); -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HNRelevant Popup 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Select mode

14 |
15 | 16 |
17 | 18 |

Always fetch and show related submissions

19 |
20 |
21 |
22 | 23 |
24 | 25 |

Only fetch results when requested by user manually

26 |
27 |
28 |
29 |
30 |

Results

31 |
32 | 39 | 40 |
41 |
42 |
43 |

Search type

44 |
45 | 46 |
47 | 48 |

Get exact matches

49 |
50 |
51 |
52 | 53 |
54 | 55 |

Find related submissions

56 |
57 |
58 |
59 |

Filters

60 |
61 | 62 | 63 |
64 |
65 |
66 |
67 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
preview of the relevant submissions section on hacker news
2 | 3 | ![Chrome Web Store](https://img.shields.io/chrome-web-store/v/iajhnkeiioebplnbfkpnlnggkgblmoln?logo=googlechrome&logoColor=000000&labelColor=F3F3F3&color=4285F4) 4 | ![Mozilla Add-on Stars](https://img.shields.io/amo/v/hnrelevant?logo=firefoxbrowser&logoColor=FFFFFF&labelColor=2E1068&color=F5541F) 5 | ![Edge Add-ons](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fgetproductdetailsbycrxid%2Flfmnjojalnklnkbfcikabochcjgllngc&query=%24.version&prefix=v&logo=data:image/svg%2bxml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU%2BTWljcm9zb2Z0IEVkZ2U8L3RpdGxlPjxwYXRoIGQ9Ik0yMS44NiAxNy44NnEuMTQgMCAuMjUuMTIuMS4xMy4xLjI1dC0uMTEuMzNsLS4zMi40Ni0uNDMuNTMtLjQ0LjVxLS4yMS4yNS0uMzguNDJsLS4yMi4yM3EtLjU4LjUzLTEuMzQgMS4wNC0uNzYuNTEtMS42LjkxLS44Ni40LTEuNzQuNjR0LTEuNjcuMjRxLS45IDAtMS42OS0uMjgtLjgtLjI4LTEuNDgtLjc4LS42OC0uNS0xLjIyLTEuMTctLjUzLS42Ni0uOTItMS40NC0uMzgtLjc3LS41OC0xLjYtLjItLjgzLS4yLTEuNjcgMC0xIC4zMi0xLjk2LjMzLS45Ny44Ny0xLjguMTQuOTUuNTUgMS43Ny40MS44MiAxLjAyIDEuNS42LjY4IDEuMzggMS4yMS43OC41NCAxLjY0LjkuODYuMzYgMS43Ny41Ni45Mi4yIDEuOC4yIDEuMTIgMCAyLjE4LS4yNCAxLjA2LS4yMyAyLjA2LS43MmwuMi0uMS4yLS4wNXptLTE1LjUtMS4yN3EwIDEuMS4yNyAyLjE1LjI3IDEuMDYuNzggMi4wMy41MS45NiAxLjI0IDEuNzcuNzQuODIgMS42NiAxLjQtMS40Ny0uMi0yLjgtLjc0LTEuMzMtLjU1LTIuNDgtMS4zNy0xLjE1LS44My0yLjA4LTEuOS0uOTItMS4wNy0xLjU4LTIuMzNULjM2IDE0Ljk0UTAgMTMuNTQgMCAxMi4wNnEwLS44MS4zMi0xLjQ5LjMxLS42OC44My0xLjIzLjUzLS41NSAxLjItLjk2LjY2LS40IDEuMzUtLjY2Ljc0LS4yNyAxLjUtLjM5Ljc4LS4xMiAxLjU1LS4xMi43IDAgMS40Mi4xLjcyLjEyIDEuNC4zNS42OC4yMyAxLjMyLjU3LjYzLjM1IDEuMTYuODMtLjM1IDAtLjcuMDctLjMzLjA3LS42NS4yM3YtLjAycS0uNjMuMjgtMS4yLjc0LS41Ny40Ni0xLjA1IDEuMDQtLjQ4LjU4LS44NyAxLjI2LS4zOC42Ny0uNjUgMS4zOS0uMjcuNzEtLjQyIDEuNDQtLjE1LjcyLS4xNSAxLjM4ek0xMS45Ni4wNnExLjcgMCAzLjMzLjM5IDEuNjMuMzggMy4wNyAxLjE1IDEuNDMuNzcgMi42MiAxLjkzIDEuMTggMS4xNiAxLjk4IDIuNy40OS45NC43NiAxLjk2LjI4IDEgLjI4IDIuMDggMCAuODktLjIzIDEuNy0uMjQuOC0uNjkgMS40OC0uNDUuNjgtMS4xIDEuMjItLjY0LjUzLTEuNDUuODgtLjU0LjI0LTEuMTEuMzYtLjU4LjEzLTEuMTYuMTMtLjQyIDAtLjk3LS4wMy0uNTQtLjAzLTEuMS0uMTItLjU1LS4xLTEuMDUtLjI4LS41LS4xOS0uODQtLjUtLjEyLS4wOS0uMjMtLjI0LS4xLS4xNi0uMS0uMzMgMC0uMTUuMTYtLjM1LjE2LS4yLjM1LS41LjItLjI4LjM2LS42OC4xNi0uNC4xNi0uOTUgMC0xLjA2LS40LTEuOTYtLjQtLjkxLTEuMDYtMS42NC0uNjYtLjc0LTEuNTItMS4yOC0uODYtLjU1LTEuNzktLjg5LS44NC0uMy0xLjcyLS40NC0uODctLjE0LTEuNzYtLjE0LTEuNTUgMC0zLjA2LjQ1VC45NCA3LjU1cS43MS0xLjc0IDEuODEtMy4xMyAxLjEtMS4zOCAyLjUyLTIuMzVRNi42OCAxLjEgOC4zNy41OHExLjctLjUyIDMuNTgtLjUyWiIgZmlsbD0id2hpdGUiLz48L3N2Zz4%3D&logoColor=FFFFFF&label=edge%20add-on&labelColor=104e92&color=2bc3d2&link=https%3A%2F%2Fmicrosoftedge.microsoft.com%2Faddons%2Fdetail%2Flfmnjojalnklnkbfcikabochcjgllngc) 6 | 7 | # HNRelevant 8 | A browser extension that adds a "Related" section to Hacker News. Don't miss out on relevant interesting discussions. 9 | 10 | ## Features 11 | - Instant list of related submissions 12 | - Customize search queries in place (search for something different) 13 | - Matches and blend with Hacker News's design like a native feature 14 | - Choose how it works: 15 | - Automatic: Results load when you open a page 16 | - Manual: Fetch on demand 17 | - Available on Firefox for Android™ 18 | - Ability to change defaults to your preference in the extension's popup menu 19 | 20 | ## Installation 21 |
22 | 23 | HNRelevant on Chrome Web Store 24 | 25 | 26 | HNRelevant on Firefox add-ons 27 | 28 | 29 | HNRelevant on Firefox add-ons 30 | 31 |
32 | 33 | Available on: 34 | - **Chrome web store**: [HNRelevant](https://chromewebstore.google.com/detail/hnrelevant-related-storie/iajhnkeiioebplnbfkpnlnggkgblmoln) 35 | - **Firefox (Desktop & Android) add-on**: [HNRelevant](https://addons.mozilla.org/en-US/firefox/addon/hnrelevant/) 36 | - **Microsoft Edge add-ons**: [HNRelevant](https://microsoftedge.microsoft.com/addons/detail/lfmnjojalnklnkbfcikabochcjgllngc) 37 | - As a **Userscript**. This option also has the benefit of supporting further browsers: 38 | 1. Make sure to have one of the userscripts extensions installed e.g: Tampermonkey, Violentmonkey, or Greasemonkey. 39 | 2. The userscript file can be found here [HNRelevant.user.js](https://github.com/imdj/HNRelevant/raw/main/HNRelevant.user.js). 40 | 3. Load the script into the extension of your choice. 41 | 42 | ## How it works 43 | It's based on [HN algolia search API](https://hn.algolia.com/api) and uses the submission title as its initial query 44 | 45 | ## License 46 | Released under the [MIT License](http://www.opensource.org/licenses/MIT). See [LICENSE](LICENSE) file for details. 47 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_PREFERENCES = { 2 | mode: "auto", // "auto" or "manual" 3 | rawQuery: "", 4 | query: "", 5 | type: "similar", // "similar" or "verbatim" 6 | numOfResults: 15, 7 | hidePostswithLowComments: true, 8 | minComments: 3, 9 | date: { 10 | start: 0, 11 | end: Math.floor(new Date().getTime() / 1000) 12 | } 13 | }; 14 | 15 | function getDefaultPreferences() { 16 | return { 17 | ...DEFAULT_PREFERENCES, 18 | date: { 19 | ...DEFAULT_PREFERENCES.date, 20 | end: Math.floor(new Date().getTime() / 1000) 21 | } 22 | }; 23 | } 24 | 25 | async function loadPreferences() { 26 | const stored = await browser.storage.sync.get('hnrelevant'); 27 | const storedPreferences = stored.hnrelevant; 28 | 29 | if (!storedPreferences) { 30 | return getDefaultPreferences(); 31 | } 32 | 33 | const mergedPreferences = { 34 | ...getDefaultPreferences(), 35 | ...storedPreferences, 36 | date: { 37 | ...getDefaultPreferences().date, 38 | ...(storedPreferences.date || {}) 39 | } 40 | }; 41 | 42 | // Save the merged preferences back to ensure new fields are persisted 43 | savePreferences(mergedPreferences); 44 | 45 | return mergedPreferences; 46 | } 47 | 48 | function savePreferences(preferences) { 49 | browser.storage.sync.set({ hnrelevant: preferences }); 50 | return preferences; 51 | } 52 | 53 | function optimizeSearchQuery() { 54 | const textAnalyzer = new TextAnalyzer(); 55 | searchQuery.query = stripYearFromTitle(searchQuery.rawQuery); 56 | 57 | const title = document.querySelector('.fatitem .titleline > a').textContent; 58 | const topLevelComments = document.querySelectorAll('td.ind[indent="0"] + td + td .commtext'); 59 | let keywords = []; 60 | 61 | // Use comments only showing results for the current discussion 62 | if (searchQuery.rawQuery === title) { 63 | keywords = textAnalyzer.extractKeywords(searchQuery.query, topLevelComments); 64 | searchQuery.query = keywords.join(' '); 65 | } 66 | 67 | return searchQuery.query; 68 | } 69 | 70 | // Get relative time from timestamp 71 | function timestampToRelativeTime(timestamp) { 72 | const now = new Date(); 73 | const date = new Date(timestamp); 74 | const diff = now - date; 75 | let rtf = new Intl.RelativeTimeFormat('en', { numeric: 'always' }); 76 | 77 | const units = { 78 | year: 365 * 24 * 60 * 60 * 1000, 79 | month: 30 * 24 * 60 * 60 * 1000, 80 | day: 24 * 60 * 60 * 1000, 81 | hour: 60 * 60 * 1000, 82 | minute: 60 * 1000 83 | }; 84 | 85 | for (const unit in units) { 86 | if (diff > units[unit]) { 87 | const time = Math.round(diff / units[unit]); 88 | return rtf.format(-time, unit); 89 | } 90 | } 91 | 92 | return rtf.format(-Math.round(diff / 1000), 'second'); 93 | } 94 | 95 | // i.e. "Title (2021)" -> "Title" 96 | function stripYearFromTitle(title) { 97 | return title.replace(/\s\(\d{4}\)$/, ''); 98 | } 99 | 100 | // Render dom element for a search result 101 | function displayResult(object) { 102 | const element = document.createElement('li'); 103 | element.className = 'result'; 104 | 105 | const titleContainer = document.createElement('span'); 106 | titleContainer.style.display = 'block'; 107 | titleContainer.classList.add('titleline'); 108 | const link = document.createElement('a'); 109 | link.href = object.url ? object.url : 'item?id=' + object.objectID; 110 | link.textContent = object.title; 111 | link.rel = 'no-referrer'; 112 | 113 | titleContainer.appendChild(link); 114 | 115 | if (object.url) { 116 | const domainContainer = document.createElement('span'); 117 | domainContainer.classList.add('sitebit', 'comhead'); 118 | const domain = document.createElement('a'); 119 | domain.href = 'from?site=' + (new URL(object.url)).hostname.replace('www.', ''); 120 | const domainChild = document.createElement('span'); 121 | domainChild.classList.add('sitestr'); 122 | domainChild.textContent = (new URL(object.url)).hostname.replace('www.', ''); 123 | 124 | domain.appendChild(domainChild); 125 | domainContainer.appendChild(domain); 126 | domain.insertAdjacentText('beforebegin', ' ('); 127 | domain.insertAdjacentText('afterend', ')'); 128 | titleContainer.appendChild(domainContainer); 129 | } 130 | element.appendChild(titleContainer); 131 | 132 | const description = document.createElement('span'); 133 | description.className = 'subtext'; 134 | const author = document.createElement('a'); 135 | author.href = 'user?id=' + object.author; 136 | author.textContent = object.author; 137 | const comments = document.createElement('a'); 138 | comments.href = 'item?id=' + object.objectID; 139 | comments.textContent = object.num_comments + ' comments'; 140 | description.insertAdjacentText('afterbegin', 141 | object.points + ' points ' 142 | + 'by ' 143 | ); 144 | description.appendChild(author); 145 | description.insertAdjacentText('beforeend', ' | '); 146 | 147 | const timeurl = document.createElement('a'); 148 | timeurl.href = 'item?id=' + object.objectID; 149 | timeurl.title = object.created_at; 150 | 151 | const time = document.createElement('time'); 152 | time.dateTime = object.created_at; 153 | time.textContent = timestampToRelativeTime(object.created_at); 154 | timeurl.appendChild(time); 155 | 156 | description.appendChild(timeurl); 157 | 158 | description.insertAdjacentText('beforeend', ' | '); 159 | description.appendChild(comments); 160 | element.appendChild(description); 161 | 162 | return element; 163 | } 164 | 165 | function updateDateRange() { 166 | const dateRange = document.getElementById('dateRangeDropdown').value; 167 | const startDate = document.getElementById('startDate').value; 168 | const endDate = document.getElementById('endDate').value; 169 | 170 | searchQuery.date.end = Math.floor(new Date().getTime() / 1000); 171 | 172 | switch (dateRange) { 173 | case 'Past week': 174 | searchQuery.date.start = searchQuery.date.end - 604800; 175 | break; 176 | case 'Past month': 177 | searchQuery.date.start = searchQuery.date.end - 2592000; 178 | break; 179 | case 'Past year': 180 | searchQuery.date.start = searchQuery.date.end - 31536000; 181 | break; 182 | case 'Custom': 183 | searchQuery.date.start = Math.floor(new Date(startDate).getTime() / 1000) || 0; 184 | searchQuery.date.end = Math.floor(new Date(endDate).getTime() / 1000) || searchQuery.date.end; 185 | break; 186 | default: 187 | searchQuery.date.start = 0; 188 | } 189 | } 190 | 191 | // Update sidebar content 192 | function updateResults() { 193 | document.getElementById('hnrelevant-results').innerHTML = ''; 194 | searchQuery.query = optimizeSearchQuery(); 195 | 196 | browser.runtime.sendMessage({id: itemId, object: searchQuery}).then((result) => { 197 | const list = document.createElement('ul'); 198 | list.id = 'hnrelevant-results-list'; 199 | 200 | // if no results, display a message 201 | if (result.hits.length === 0) { 202 | const element = document.createElement('li'); 203 | element.className = 'result'; 204 | element.style = 'padding: 5px 0; text-align: center; white-space: pre-line;'; 205 | element.textContent = searchQuery.type === 'verbatim' ? 'No matching results found.\r\nTry a different query or switch to \'Similar\' search.' : 'No results found. Try to customize the query.'; 206 | list.appendChild(element); 207 | } 208 | else { 209 | result.hits.forEach(hit => { 210 | const element = displayResult(hit); 211 | list.appendChild(element); 212 | }); 213 | } 214 | document.getElementById('hnrelevant-results').appendChild(list); 215 | }); 216 | } 217 | -------------------------------------------------------------------------------- /scripts/main.js: -------------------------------------------------------------------------------- 1 | let searchQuery = getDefaultPreferences(); 2 | 3 | let itemId = (new URLSearchParams(document.location.search)).get("id"); 4 | 5 | const relevantContent = ` 6 |

Relevant Submissions

7 |
8 |
9 | 10 | 11 |
12 |
13 | The results aren't good? 14 |

Try the following: 15 |

20 |

21 |
22 |
23 |
24 | 25 | 32 |
33 |
34 | 35 | 42 |
43 | 53 |
54 | Search type 55 |
56 | 57 | 58 | 59 |
60 | 61 | 62 | 63 |
64 |
65 |
66 |
67 |
68 | `; 69 | 70 | function updateData(key, value) { 71 | searchQuery[key] = value; 72 | 73 | if (searchQuery.mode === 'auto') { 74 | updateResults(); 75 | } 76 | } 77 | 78 | async function installSection() { 79 | // Submissions and Comments share the same page URL 80 | // Abort if we are not on a submission page 81 | if (!document.querySelector('.fatitem .titleline')) { 82 | return; 83 | } 84 | 85 | // Load preferences from storage 86 | // if not present save the default preferences to storage and use them 87 | searchQuery = await loadPreferences(); 88 | 89 | const hnBody = document.querySelector('#hnmain > tbody'); 90 | let NavbarIndex = 0; 91 | const rows = hnBody.querySelectorAll("tr"); 92 | 93 | // handle special case if death banner is present 94 | if (rows[0].querySelector('td img[src="s.gif"]')) { 95 | rows[0].querySelector("td").setAttribute("colspan", "2"); 96 | NavbarIndex = 1; 97 | } 98 | 99 | // Since we add a new column to the table for the sidebar, we need to make navbar span all columns 100 | const hnNavBar = hnBody.children[NavbarIndex]; 101 | hnNavBar.children[0].setAttribute('colspan', '2'); 102 | 103 | const hnContent = hnBody.children[NavbarIndex + 2]; 104 | searchQuery.rawQuery = hnBody.querySelector('.fatitem .titleline > a').textContent; 105 | 106 | // Make sure all table data elements are aligned to the top 107 | // (they're centered vertically by default which causes problem when coupled with long sidebar) 108 | hnBody.querySelectorAll(':scope > tr > td').forEach(td => td.style.verticalAlign = 'top'); 109 | 110 | if (window.innerWidth < 1200) { 111 | const tr = document.createElement('tr'); 112 | const td = document.createElement('td'); 113 | td.innerHTML = relevantContent; 114 | td.style = 'padding-top: 1rem;'; 115 | tr.innerHTML = ''; 116 | tr.appendChild(td); 117 | const submissionMetadata = hnContent.querySelector('table.fatitem > tbody'); 118 | submissionMetadata.appendChild(tr); 119 | } else { 120 | const td = document.createElement('td'); 121 | td.style = 'min-width: 280px; width: 25%; vertical-align: baseline; padding-left: 10px;'; 122 | td.innerHTML = relevantContent; 123 | hnContent.appendChild(td); 124 | } 125 | 126 | document.getElementById('queryCustomization').placeholder = searchQuery.rawQuery; 127 | document.getElementById('queryCustomization').value = searchQuery.rawQuery; 128 | document.getElementById('numOfResultsDropdown').value = searchQuery.numOfResults; 129 | 130 | document.getElementsByName('searchType').forEach((radio) => { 131 | radio.checked = radio.value === searchQuery.type; 132 | }); 133 | 134 | if (searchQuery.mode === 'auto') { 135 | updateResults(); 136 | } 137 | 138 | document.getElementById('numOfResultsDropdown').addEventListener('change', () => { 139 | updateData('numOfResults', document.getElementById('numOfResultsDropdown').value); 140 | }); 141 | document.getElementById('dateRangeDropdown').addEventListener('change', () => { 142 | updateDateRange(); 143 | updateData('date', searchQuery.date); 144 | }); 145 | document.getElementById('startDate').addEventListener('change', () => { 146 | updateDateRange(); 147 | updateData('date', searchQuery.date); 148 | }); 149 | document.getElementById('endDate').addEventListener('change', () => { 150 | updateDateRange(); 151 | updateData('date', searchQuery.date); 152 | }); 153 | 154 | document.getElementById('dateRangeDropdown').addEventListener('change', (event) => { 155 | if (event.target.value === 'Custom') { 156 | document.getElementById('dateRangeInputContainer').style = 'display: flex; flex-direction: row; gap: 5px;'; 157 | } else { 158 | document.getElementById('dateRangeInputContainer').style.display = 'none'; 159 | } 160 | }); 161 | 162 | document.getElementsByName('searchType').forEach((radio) => { 163 | radio.addEventListener('change', (event) => { 164 | updateData('type', event.target.value); 165 | }); 166 | }); 167 | 168 | document.getElementById('submitCustomization').addEventListener('click', () => { 169 | searchQuery.rawQuery = document.getElementById('queryCustomization').value; 170 | updateResults(); 171 | }); 172 | 173 | document.getElementById('queryCustomization').addEventListener('keyup', (event) => { 174 | if (event.key === 'Enter') { 175 | searchQuery.rawQuery = document.getElementById('queryCustomization').value; 176 | updateResults(); 177 | } 178 | }); 179 | } 180 | 181 | if (document.readyState !== 'complete') { 182 | window.addEventListener('load', installSection); 183 | } else { 184 | installSection(); 185 | } -------------------------------------------------------------------------------- /scripts/third-party/browser-polyfill.min.js: -------------------------------------------------------------------------------- 1 | (function(a,b){if("function"==typeof define&&define.amd)define("webextension-polyfill",["module"],b);else if("undefined"!=typeof exports)b(module);else{var c={exports:{}};b(c),a.browser=c.exports}})("undefined"==typeof globalThis?"undefined"==typeof self?this:self:globalThis,function(a){"use strict";if(!globalThis.chrome?.runtime?.id)throw new Error("This script should only be loaded in a browser extension.");if("undefined"==typeof globalThis.browser||Object.getPrototypeOf(globalThis.browser)!==Object.prototype){a.exports=(a=>{const b={alarms:{clear:{minArgs:0,maxArgs:1},clearAll:{minArgs:0,maxArgs:0},get:{minArgs:0,maxArgs:1},getAll:{minArgs:0,maxArgs:0}},bookmarks:{create:{minArgs:1,maxArgs:1},get:{minArgs:1,maxArgs:1},getChildren:{minArgs:1,maxArgs:1},getRecent:{minArgs:1,maxArgs:1},getSubTree:{minArgs:1,maxArgs:1},getTree:{minArgs:0,maxArgs:0},move:{minArgs:2,maxArgs:2},remove:{minArgs:1,maxArgs:1},removeTree:{minArgs:1,maxArgs:1},search:{minArgs:1,maxArgs:1},update:{minArgs:2,maxArgs:2}},browserAction:{disable:{minArgs:0,maxArgs:1,fallbackToNoCallback:!0},enable:{minArgs:0,maxArgs:1,fallbackToNoCallback:!0},getBadgeBackgroundColor:{minArgs:1,maxArgs:1},getBadgeText:{minArgs:1,maxArgs:1},getPopup:{minArgs:1,maxArgs:1},getTitle:{minArgs:1,maxArgs:1},openPopup:{minArgs:0,maxArgs:0},setBadgeBackgroundColor:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setBadgeText:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setIcon:{minArgs:1,maxArgs:1},setPopup:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setTitle:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0}},browsingData:{remove:{minArgs:2,maxArgs:2},removeCache:{minArgs:1,maxArgs:1},removeCookies:{minArgs:1,maxArgs:1},removeDownloads:{minArgs:1,maxArgs:1},removeFormData:{minArgs:1,maxArgs:1},removeHistory:{minArgs:1,maxArgs:1},removeLocalStorage:{minArgs:1,maxArgs:1},removePasswords:{minArgs:1,maxArgs:1},removePluginData:{minArgs:1,maxArgs:1},settings:{minArgs:0,maxArgs:0}},commands:{getAll:{minArgs:0,maxArgs:0}},contextMenus:{remove:{minArgs:1,maxArgs:1},removeAll:{minArgs:0,maxArgs:0},update:{minArgs:2,maxArgs:2}},cookies:{get:{minArgs:1,maxArgs:1},getAll:{minArgs:1,maxArgs:1},getAllCookieStores:{minArgs:0,maxArgs:0},remove:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}},devtools:{inspectedWindow:{eval:{minArgs:1,maxArgs:2,singleCallbackArg:!1}},panels:{create:{minArgs:3,maxArgs:3,singleCallbackArg:!0},elements:{createSidebarPane:{minArgs:1,maxArgs:1}}}},downloads:{cancel:{minArgs:1,maxArgs:1},download:{minArgs:1,maxArgs:1},erase:{minArgs:1,maxArgs:1},getFileIcon:{minArgs:1,maxArgs:2},open:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},pause:{minArgs:1,maxArgs:1},removeFile:{minArgs:1,maxArgs:1},resume:{minArgs:1,maxArgs:1},search:{minArgs:1,maxArgs:1},show:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0}},extension:{isAllowedFileSchemeAccess:{minArgs:0,maxArgs:0},isAllowedIncognitoAccess:{minArgs:0,maxArgs:0}},history:{addUrl:{minArgs:1,maxArgs:1},deleteAll:{minArgs:0,maxArgs:0},deleteRange:{minArgs:1,maxArgs:1},deleteUrl:{minArgs:1,maxArgs:1},getVisits:{minArgs:1,maxArgs:1},search:{minArgs:1,maxArgs:1}},i18n:{detectLanguage:{minArgs:1,maxArgs:1},getAcceptLanguages:{minArgs:0,maxArgs:0}},identity:{launchWebAuthFlow:{minArgs:1,maxArgs:1}},idle:{queryState:{minArgs:1,maxArgs:1}},management:{get:{minArgs:1,maxArgs:1},getAll:{minArgs:0,maxArgs:0},getSelf:{minArgs:0,maxArgs:0},setEnabled:{minArgs:2,maxArgs:2},uninstallSelf:{minArgs:0,maxArgs:1}},notifications:{clear:{minArgs:1,maxArgs:1},create:{minArgs:1,maxArgs:2},getAll:{minArgs:0,maxArgs:0},getPermissionLevel:{minArgs:0,maxArgs:0},update:{minArgs:2,maxArgs:2}},pageAction:{getPopup:{minArgs:1,maxArgs:1},getTitle:{minArgs:1,maxArgs:1},hide:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setIcon:{minArgs:1,maxArgs:1},setPopup:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},setTitle:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0},show:{minArgs:1,maxArgs:1,fallbackToNoCallback:!0}},permissions:{contains:{minArgs:1,maxArgs:1},getAll:{minArgs:0,maxArgs:0},remove:{minArgs:1,maxArgs:1},request:{minArgs:1,maxArgs:1}},runtime:{getBackgroundPage:{minArgs:0,maxArgs:0},getPlatformInfo:{minArgs:0,maxArgs:0},openOptionsPage:{minArgs:0,maxArgs:0},requestUpdateCheck:{minArgs:0,maxArgs:0},sendMessage:{minArgs:1,maxArgs:3},sendNativeMessage:{minArgs:2,maxArgs:2},setUninstallURL:{minArgs:1,maxArgs:1}},sessions:{getDevices:{minArgs:0,maxArgs:1},getRecentlyClosed:{minArgs:0,maxArgs:1},restore:{minArgs:0,maxArgs:1}},storage:{local:{clear:{minArgs:0,maxArgs:0},get:{minArgs:0,maxArgs:1},getBytesInUse:{minArgs:0,maxArgs:1},remove:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}},managed:{get:{minArgs:0,maxArgs:1},getBytesInUse:{minArgs:0,maxArgs:1}},sync:{clear:{minArgs:0,maxArgs:0},get:{minArgs:0,maxArgs:1},getBytesInUse:{minArgs:0,maxArgs:1},remove:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}}},tabs:{captureVisibleTab:{minArgs:0,maxArgs:2},create:{minArgs:1,maxArgs:1},detectLanguage:{minArgs:0,maxArgs:1},discard:{minArgs:0,maxArgs:1},duplicate:{minArgs:1,maxArgs:1},executeScript:{minArgs:1,maxArgs:2},get:{minArgs:1,maxArgs:1},getCurrent:{minArgs:0,maxArgs:0},getZoom:{minArgs:0,maxArgs:1},getZoomSettings:{minArgs:0,maxArgs:1},goBack:{minArgs:0,maxArgs:1},goForward:{minArgs:0,maxArgs:1},highlight:{minArgs:1,maxArgs:1},insertCSS:{minArgs:1,maxArgs:2},move:{minArgs:2,maxArgs:2},query:{minArgs:1,maxArgs:1},reload:{minArgs:0,maxArgs:2},remove:{minArgs:1,maxArgs:1},removeCSS:{minArgs:1,maxArgs:2},sendMessage:{minArgs:2,maxArgs:3},setZoom:{minArgs:1,maxArgs:2},setZoomSettings:{minArgs:1,maxArgs:2},update:{minArgs:1,maxArgs:2}},topSites:{get:{minArgs:0,maxArgs:0}},webNavigation:{getAllFrames:{minArgs:1,maxArgs:1},getFrame:{minArgs:1,maxArgs:1}},webRequest:{handlerBehaviorChanged:{minArgs:0,maxArgs:0}},windows:{create:{minArgs:0,maxArgs:1},get:{minArgs:1,maxArgs:2},getAll:{minArgs:0,maxArgs:1},getCurrent:{minArgs:0,maxArgs:1},getLastFocused:{minArgs:0,maxArgs:1},remove:{minArgs:1,maxArgs:1},update:{minArgs:2,maxArgs:2}}};if(0===Object.keys(b).length)throw new Error("api-metadata.json has not been included in browser-polyfill");class c extends WeakMap{constructor(a,b=void 0){super(b),this.createItem=a}get(a){return this.has(a)||this.set(a,this.createItem(a)),super.get(a)}}const d=a=>a&&"object"==typeof a&&"function"==typeof a.then,e=(b,c)=>(...d)=>{a.runtime.lastError?b.reject(new Error(a.runtime.lastError.message)):c.singleCallbackArg||1>=d.length&&!1!==c.singleCallbackArg?b.resolve(d[0]):b.resolve(d)},f=a=>1==a?"argument":"arguments",g=(a,b)=>function(c,...d){if(d.lengthb.maxArgs)throw new Error(`Expected at most ${b.maxArgs} ${f(b.maxArgs)} for ${a}(), got ${d.length}`);return new Promise((f,g)=>{if(b.fallbackToNoCallback)try{c[a](...d,e({resolve:f,reject:g},b))}catch(e){console.warn(`${a} API method doesn't seem to support the callback parameter, `+"falling back to call it without a callback: ",e),c[a](...d),b.fallbackToNoCallback=!1,b.noCallback=!0,f()}else b.noCallback?(c[a](...d),f()):c[a](...d,e({resolve:f,reject:g},b))})},h=(a,b,c)=>new Proxy(b,{apply(b,d,e){return c.call(d,a,...e)}});let i=Function.call.bind(Object.prototype.hasOwnProperty);const j=(a,b={},c={})=>{let d=Object.create(null),e=Object.create(a);return new Proxy(e,{has(b,c){return c in a||c in d},get(e,f){if(f in d)return d[f];if(!(f in a))return;let k=a[f];if("function"==typeof k){if("function"==typeof b[f])k=h(a,a[f],b[f]);else if(i(c,f)){let b=g(f,c[f]);k=h(a,a[f],b)}else k=k.bind(a);}else if("object"==typeof k&&null!==k&&(i(b,f)||i(c,f)))k=j(k,b[f],c[f]);else if(i(c,"*"))k=j(k,b[f],c["*"]);else return Object.defineProperty(d,f,{configurable:!0,enumerable:!0,get(){return a[f]},set(b){a[f]=b}}),k;return d[f]=k,k},set(b,c,e){return c in d?d[c]=e:a[c]=e,!0},defineProperty(a,b,c){return Reflect.defineProperty(d,b,c)},deleteProperty(a,b){return Reflect.deleteProperty(d,b)}})},k=a=>({addListener(b,c,...d){b.addListener(a.get(c),...d)},hasListener(b,c){return b.hasListener(a.get(c))},removeListener(b,c){b.removeListener(a.get(c))}}),l=new c(a=>"function"==typeof a?function(b){const c=j(b,{},{getContent:{minArgs:0,maxArgs:0}});a(c)}:a),m=new c(a=>"function"==typeof a?function(b,c,e){let f,g,h=!1,i=new Promise(a=>{f=function(b){h=!0,a(b)}});try{g=a(b,c,f)}catch(a){g=Promise.reject(a)}const j=!0!==g&&d(g);if(!0!==g&&!j&&!h)return!1;const k=a=>{a.then(a=>{e(a)},a=>{let b;b=a&&(a instanceof Error||"string"==typeof a.message)?a.message:"An unexpected error occurred",e({__mozWebExtensionPolyfillReject__:!0,message:b})}).catch(a=>{console.error("Failed to send onMessage rejected reply",a)})};return j?k(g):k(i),!0}:a),n=({reject:b,resolve:c},d)=>{a.runtime.lastError?a.runtime.lastError.message==="The message port closed before a response was received."?c():b(new Error(a.runtime.lastError.message)):d&&d.__mozWebExtensionPolyfillReject__?b(new Error(d.message)):c(d)},o=(a,b,c,...d)=>{if(d.lengthb.maxArgs)throw new Error(`Expected at most ${b.maxArgs} ${f(b.maxArgs)} for ${a}(), got ${d.length}`);return new Promise((a,b)=>{const e=n.bind(null,{resolve:a,reject:b});d.push(e),c.sendMessage(...d)})},p={devtools:{network:{onRequestFinished:k(l)}},runtime:{onMessage:k(m),onMessageExternal:k(m),sendMessage:o.bind(null,"sendMessage",{minArgs:1,maxArgs:3})},tabs:{sendMessage:o.bind(null,"sendMessage",{minArgs:2,maxArgs:3})}},q={clear:{minArgs:1,maxArgs:1},get:{minArgs:1,maxArgs:1},set:{minArgs:1,maxArgs:1}};return b.privacy={network:{"*":q},services:{"*":q},websites:{"*":q}},j(a,p,b)})(chrome)}else a.exports=globalThis.browser}); 2 | //# sourceMappingURL=browser-polyfill.min.js.map 3 | 4 | // webextension-polyfill v.0.10.0 (https://github.com/mozilla/webextension-polyfill) 5 | 6 | /* This Source Code Form is subject to the terms of the Mozilla Public 7 | * License, v. 2.0. If a copy of the MPL was not distributed with this 8 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -------------------------------------------------------------------------------- /scripts/nlp.js: -------------------------------------------------------------------------------- 1 | class TextAnalyzer { 2 | constructor() { 3 | this.HNWords = ['ask hn', 'tell hn', 'show hn', 'launch hn']; 4 | 5 | this.stopWords = [ 6 | 'about', 7 | 'after', 8 | 'all', 9 | 'also', 10 | 'am', 11 | 'an', 12 | 'and', 13 | 'another', 14 | 'any', 15 | 'are', 16 | 'as', 17 | 'at', 18 | 'be', 19 | 'because', 20 | 'been', 21 | 'before', 22 | 'being', 23 | 'between', 24 | 'both', 25 | 'but', 26 | 'by', 27 | 'came', 28 | 'can', 29 | 'come', 30 | 'could', 31 | 'did', 32 | 'do', 33 | 'does', 34 | 'doing', 35 | 'each', 36 | 'finally', 37 | 'for', 38 | 'from', 39 | 'get', 40 | 'got', 41 | 'has', 42 | 'had', 43 | 'he', 44 | 'have', 45 | 'her', 46 | 'here', 47 | 'him', 48 | 'himself', 49 | 'his', 50 | 'how', 51 | 'if', 52 | 'in', 53 | 'into', 54 | 'is', 55 | 'it', 56 | 'just', 57 | 'like', 58 | 'make', 59 | 'many', 60 | 'maybe', 61 | 'me', 62 | 'might', 63 | 'more', 64 | 'most', 65 | 'much', 66 | 'must', 67 | 'my', 68 | 'never', 69 | 'no', 70 | 'not', 71 | 'now', 72 | 'of', 73 | 'on', 74 | 'once', 75 | 'one', 76 | 'only', 77 | 'or', 78 | 'other', 79 | 'our', 80 | 'out', 81 | 'over', 82 | 'said', 83 | 'same', 84 | 'should', 85 | 'since', 86 | 'so', 87 | 'some', 88 | 'still', 89 | 'such', 90 | 'take', 91 | 'than', 92 | 'that', 93 | 'the', 94 | 'their', 95 | 'them', 96 | 'then', 97 | 'there', 98 | 'these', 99 | 'they', 100 | 'thing', 101 | 'think', 102 | 'this', 103 | 'those', 104 | 'through', 105 | 'to', 106 | 'too', 107 | 'under', 108 | 'up', 109 | 'very', 110 | 'want', 111 | 'was', 112 | 'way', 113 | 'we', 114 | 'well', 115 | 'were', 116 | 'what', 117 | 'when', 118 | 'where', 119 | 'which', 120 | 'while', 121 | 'who', 122 | 'will', 123 | 'with', 124 | 'would', 125 | 'you', 126 | 'your', 127 | 'a', 128 | 'i' 129 | ] 130 | 131 | this.contractions = { 132 | "ain't": "is not", 133 | "aren't": "are not", 134 | "can't": "cannot", 135 | "couldn't": "could not", 136 | "didn't": "did not", 137 | "doesn't": "does not", 138 | "don't": "do not", 139 | "hadn't": "had not", 140 | "hasn't": "has not", 141 | "haven't": "have not", 142 | "he'd": "he would", 143 | "he'll": "he will", 144 | "he's": "he is", 145 | "i'd": "i would", 146 | "i'll": "i will", 147 | "i'm": "i am", 148 | "i've": "i have", 149 | "isn't": "is not", 150 | "it's": "it is", 151 | "let's": "let us", 152 | "shouldn't": "should not", 153 | "that's": "that is", 154 | "there's": "there is", 155 | "they'd": "they would", 156 | "they'll": "they will", 157 | "they're": "they are", 158 | "they've": "they have", 159 | "wasn't": "was not", 160 | "we'd": "we would", 161 | "we're": "we are", 162 | "we've": "we have", 163 | "weren't": "were not", 164 | "what's": "what is", 165 | "where's": "where is", 166 | "who's": "who is", 167 | "won't": "will not", 168 | "wouldn't": "would not", 169 | "you'd": "you would", 170 | "you'll": "you will", 171 | "you're": "you are", 172 | "you've": "you have" 173 | }; 174 | } 175 | 176 | cleanText(text) { 177 | return text 178 | .toLowerCase() 179 | .replace(/https?:\/\/[^\s]+/g, ' ') 180 | .replace(/www\.[^\s]+/g, ' ') 181 | .replace(/\b\w+'?\w*\b/g, match => { 182 | return this.contractions[match.toLowerCase()] || match; 183 | }) 184 | .replace(/[^a-z0-9\s]/gi, ' ') 185 | .replace(/\s+/g, ' ') 186 | .trim() 187 | .split(' ') 188 | .filter(token => 189 | token.length >= 2 && 190 | !this.HNWords.includes(token) && 191 | !this.stopWords.includes(token) 192 | ) 193 | .join(' '); 194 | } 195 | 196 | // Find the top n-grams in the comments 197 | // group related ones using LCS or character similarity 198 | findTopNGrams(comments, n = 2, topCount = 10, similarityThreshold = 0.9, title = '') { 199 | const ngramFreq = {}; 200 | 201 | // Extract title keywords for boosting 202 | const titleKeywords = title ? this.cleanText(title).split(' ').filter(token => token.length > 0) : []; 203 | 204 | // Generate n-grams from all comments 205 | for (const comment of comments) { 206 | const cleanedText = this.cleanText(comment.textContent); 207 | const tokens = cleanedText.split(' ').filter(token => token.length > 0); 208 | 209 | // Generate n-grams 210 | for (let i = 0; i <= tokens.length - n; i++) { 211 | const ngram = tokens.slice(i, i + n).join(' '); 212 | if (ngram.trim()) { 213 | let baseFreq = (ngramFreq[ngram] || 0) + 1; 214 | 215 | // Boost frequency if ngram is similar to title keywords 216 | let titleBoost = 0; 217 | for (const keyword of titleKeywords) { 218 | const similarity = this.calculateStringSimilarity(ngram, keyword); 219 | if (similarity >= 0.6) { 220 | titleBoost += similarity * 3; 221 | } 222 | } 223 | 224 | ngramFreq[ngram] = baseFreq + titleBoost; 225 | } 226 | } 227 | } 228 | 229 | // Sort n-grams by frequency 230 | const sortedNgrams = Object.entries(ngramFreq) 231 | .sort(([, a], [, b]) => b - a) 232 | .slice(0, topCount * 4); 233 | 234 | // Group similar n-grams 235 | const groups = []; 236 | const used = new Set(); 237 | 238 | // Group n-grams by similarity 239 | for (const [ngram, freq] of sortedNgrams) { 240 | if (used.has(ngram)) continue; 241 | 242 | const group = { 243 | representative: ngram, 244 | frequency: freq, 245 | similar: [ngram] 246 | }; 247 | used.add(ngram); 248 | 249 | // Find similar n-grams 250 | for (const [otherNgram, otherFreq] of sortedNgrams) { 251 | if (used.has(otherNgram) || ngram === otherNgram) continue; 252 | 253 | if (this.calculateStringSimilarity(ngram, otherNgram) >= similarityThreshold) { 254 | group.similar.push(otherNgram); 255 | group.frequency += otherFreq; 256 | used.add(otherNgram); 257 | } 258 | } 259 | 260 | groups.push(group); 261 | } 262 | 263 | // Sort groups by combined frequency and return top results 264 | return groups 265 | .sort((a, b) => b.frequency - a.frequency) 266 | .slice(0, topCount); 267 | } 268 | 269 | calculateStringSimilarity(str1, str2) { 270 | const norm1 = str1.replace(/\s+/g, '').toLowerCase(); 271 | const norm2 = str2.replace(/\s+/g, '').toLowerCase(); 272 | 273 | if (norm1 === norm2) return 1.0; 274 | 275 | // Check for substring containment 276 | if (norm1.includes(norm2) || norm2.includes(norm1)) { 277 | const shorterLen = Math.min(norm1.length, norm2.length); 278 | const longerLen = Math.max(norm1.length, norm2.length); 279 | return shorterLen / longerLen; 280 | } 281 | 282 | // Check for word-level similarity 283 | const words1 = str1.toLowerCase().split(/\s+/); 284 | const words2 = str2.toLowerCase().split(/\s+/); 285 | const wordSet1 = new Set(words1); 286 | const wordSet2 = new Set(words2); 287 | const wordIntersection = new Set([...wordSet1].filter(x => wordSet2.has(x))); 288 | const wordUnion = new Set([...wordSet1, ...wordSet2]); 289 | const wordJaccard = wordIntersection.size / wordUnion.size; 290 | 291 | if (wordJaccard > 0.3) { 292 | return wordJaccard; 293 | } 294 | 295 | // Character-based similarity using LCS 296 | const minLength = Math.min(norm1.length, norm2.length); 297 | const maxLength = Math.max(norm1.length, norm2.length); 298 | 299 | if (minLength / maxLength < 0.6) { 300 | return 0; 301 | } 302 | 303 | const lcs = this.longestCommonSubsequence(norm1, norm2); 304 | const lcsSimilarity = (2.0 * lcs) / (norm1.length + norm2.length); 305 | 306 | return lcsSimilarity > 0.7 ? lcsSimilarity : 0; 307 | } 308 | 309 | longestCommonSubsequence(str1, str2) { 310 | const m = str1.length; 311 | const n = str2.length; 312 | const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); 313 | 314 | for (let i = 1; i <= m; i++) { 315 | for (let j = 1; j <= n; j++) { 316 | if (str1[i - 1] === str2[j - 1]) { 317 | dp[i][j] = dp[i - 1][j - 1] + 1; 318 | } else { 319 | dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); 320 | } 321 | } 322 | } 323 | 324 | return dp[m][n]; 325 | } 326 | 327 | extractKeywords(title, comments) { 328 | const titleTerms = this.cleanText(title).split(' '); 329 | 330 | const usedTerms = new Set(); 331 | const keywords = []; 332 | 333 | // Add all title keywords 334 | for (const titleTerm of titleTerms) { 335 | if (!usedTerms.has(titleTerm.toLowerCase())) { 336 | keywords.push(titleTerm); 337 | usedTerms.add(titleTerm.toLowerCase()); 338 | } 339 | } 340 | 341 | if (keywords.length <= 5) { 342 | const topNGrams = this.findTopNGrams(comments, 2, 10, 0.9, title); 343 | 344 | // Add one diverse n-gram 345 | let addedDiverseNgram = false; 346 | for (const group of topNGrams.slice(0, 3)) { 347 | if (keywords.length >= 8 || addedDiverseNgram) break; 348 | 349 | const rep = group.representative; 350 | const repWords = rep.split(' '); 351 | 352 | if (usedTerms.has(rep.toLowerCase())) continue; 353 | 354 | // Check if similar to title terms 355 | let isSimilarToTitle = false; 356 | for (const titleTerm of titleTerms) { 357 | const similarity = this.calculateStringSimilarity(rep, titleTerm); 358 | const containsTitle = rep.toLowerCase().includes(titleTerm.toLowerCase()) || 359 | repWords.some(word => word.toLowerCase() === titleTerm.toLowerCase()); 360 | 361 | if (similarity > 0.9 || containsTitle) { // Use consistent threshold 362 | isSimilarToTitle = true; 363 | break; 364 | } 365 | } 366 | 367 | if (!isSimilarToTitle) { 368 | const hasUsedWords = repWords.some(word => usedTerms.has(word.toLowerCase())); 369 | 370 | if (!hasUsedWords) { 371 | keywords.push(rep); 372 | usedTerms.add(rep.toLowerCase()); 373 | repWords.forEach(word => usedTerms.add(word.toLowerCase())); 374 | addedDiverseNgram = true; 375 | } 376 | } 377 | } 378 | } 379 | 380 | return keywords; 381 | } 382 | } -------------------------------------------------------------------------------- /HNRelevant.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name HNRelevant 3 | // @version 1.4.0 4 | // @description Adds a "Related Submissions" section to Hacker News 5 | // @author imdj 6 | // @match *://news.ycombinator.com/item* 7 | // @connect *://hn.algolia.com/* 8 | // @icon https://raw.githubusercontent.com/imdj/HNRelevant/main/icon.png 9 | // @updateURL https://raw.githubusercontent.com/imdj/HNRelevant/main/HNRelevant.user.js 10 | // @downloadURL https://raw.githubusercontent.com/imdj/HNRelevant/main/HNRelevant.user.js 11 | // @license MIT 12 | // @run-at document-start 13 | // @grant none 14 | // @inject-into content 15 | // ==/UserScript== 16 | 17 | let searchQuery = { 18 | mode: "auto", // "auto" or "manual" 19 | rawQuery: "", 20 | query: "", 21 | type: "similar", // "similar" or "verbatim" 22 | numOfResults: 15, 23 | hidePostswithLowComments: true, 24 | minComments: 3, 25 | date: { 26 | start: 0, 27 | end: Math.floor(new Date().getTime() / 1000) 28 | } 29 | }; 30 | 31 | let itemId = (new URLSearchParams(document.location.search)).get("id"); 32 | 33 | class TextAnalyzer { 34 | constructor() { 35 | this.HNWords = ['ask hn', 'tell hn', 'show hn', 'launch hn']; 36 | 37 | this.stopWords = [ 38 | 'about', 39 | 'after', 40 | 'all', 41 | 'also', 42 | 'am', 43 | 'an', 44 | 'and', 45 | 'another', 46 | 'any', 47 | 'are', 48 | 'as', 49 | 'at', 50 | 'be', 51 | 'because', 52 | 'been', 53 | 'before', 54 | 'being', 55 | 'between', 56 | 'both', 57 | 'but', 58 | 'by', 59 | 'came', 60 | 'can', 61 | 'come', 62 | 'could', 63 | 'did', 64 | 'do', 65 | 'does', 66 | 'doing', 67 | 'each', 68 | 'finally', 69 | 'for', 70 | 'from', 71 | 'get', 72 | 'got', 73 | 'has', 74 | 'had', 75 | 'he', 76 | 'have', 77 | 'her', 78 | 'here', 79 | 'him', 80 | 'himself', 81 | 'his', 82 | 'how', 83 | 'if', 84 | 'in', 85 | 'into', 86 | 'is', 87 | 'it', 88 | 'just', 89 | 'like', 90 | 'make', 91 | 'many', 92 | 'maybe', 93 | 'me', 94 | 'might', 95 | 'more', 96 | 'most', 97 | 'much', 98 | 'must', 99 | 'my', 100 | 'never', 101 | 'no', 102 | 'not', 103 | 'now', 104 | 'of', 105 | 'on', 106 | 'once', 107 | 'one', 108 | 'only', 109 | 'or', 110 | 'other', 111 | 'our', 112 | 'out', 113 | 'over', 114 | 'said', 115 | 'same', 116 | 'should', 117 | 'since', 118 | 'so', 119 | 'some', 120 | 'still', 121 | 'such', 122 | 'take', 123 | 'than', 124 | 'that', 125 | 'the', 126 | 'their', 127 | 'them', 128 | 'then', 129 | 'there', 130 | 'these', 131 | 'they', 132 | 'thing', 133 | 'think', 134 | 'this', 135 | 'those', 136 | 'through', 137 | 'to', 138 | 'too', 139 | 'under', 140 | 'up', 141 | 'very', 142 | 'want', 143 | 'was', 144 | 'way', 145 | 'we', 146 | 'well', 147 | 'were', 148 | 'what', 149 | 'when', 150 | 'where', 151 | 'which', 152 | 'while', 153 | 'who', 154 | 'will', 155 | 'with', 156 | 'would', 157 | 'you', 158 | 'your', 159 | 'a', 160 | 'i' 161 | ] 162 | 163 | this.contractions = { 164 | "ain't": "is not", 165 | "aren't": "are not", 166 | "can't": "cannot", 167 | "couldn't": "could not", 168 | "didn't": "did not", 169 | "doesn't": "does not", 170 | "don't": "do not", 171 | "hadn't": "had not", 172 | "hasn't": "has not", 173 | "haven't": "have not", 174 | "he'd": "he would", 175 | "he'll": "he will", 176 | "he's": "he is", 177 | "i'd": "i would", 178 | "i'll": "i will", 179 | "i'm": "i am", 180 | "i've": "i have", 181 | "isn't": "is not", 182 | "it's": "it is", 183 | "let's": "let us", 184 | "shouldn't": "should not", 185 | "that's": "that is", 186 | "there's": "there is", 187 | "they'd": "they would", 188 | "they'll": "they will", 189 | "they're": "they are", 190 | "they've": "they have", 191 | "wasn't": "was not", 192 | "we'd": "we would", 193 | "we're": "we are", 194 | "we've": "we have", 195 | "weren't": "were not", 196 | "what's": "what is", 197 | "where's": "where is", 198 | "who's": "who is", 199 | "won't": "will not", 200 | "wouldn't": "would not", 201 | "you'd": "you would", 202 | "you'll": "you will", 203 | "you're": "you are", 204 | "you've": "you have" 205 | }; 206 | } 207 | 208 | cleanText(text) { 209 | return text 210 | .toLowerCase() 211 | .replace(/https?:\/\/[^\s]+/g, ' ') 212 | .replace(/www\.[^\s]+/g, ' ') 213 | .replace(/\b\w+'?\w*\b/g, match => { 214 | return this.contractions[match.toLowerCase()] || match; 215 | }) 216 | .replace(/[^a-z0-9\s]/gi, ' ') 217 | .replace(/\s+/g, ' ') 218 | .trim() 219 | .split(' ') 220 | .filter(token => 221 | token.length >= 2 && 222 | !this.HNWords.includes(token) && 223 | !this.stopWords.includes(token) 224 | ) 225 | .join(' '); 226 | } 227 | 228 | // Find the top n-grams in the comments 229 | // group related ones using LCS or character similarity 230 | findTopNGrams(comments, n = 2, topCount = 10, similarityThreshold = 0.9, title = '') { 231 | const ngramFreq = {}; 232 | 233 | // Extract title keywords for boosting 234 | const titleKeywords = title ? this.cleanText(title).split(' ').filter(token => token.length > 0) : []; 235 | 236 | // Generate n-grams from all comments 237 | for (const comment of comments) { 238 | const cleanedText = this.cleanText(comment.textContent); 239 | const tokens = cleanedText.split(' ').filter(token => token.length > 0); 240 | 241 | // Generate n-grams 242 | for (let i = 0; i <= tokens.length - n; i++) { 243 | const ngram = tokens.slice(i, i + n).join(' '); 244 | if (ngram.trim()) { 245 | let baseFreq = (ngramFreq[ngram] || 0) + 1; 246 | 247 | // Boost frequency if ngram is similar to title keywords 248 | let titleBoost = 0; 249 | for (const keyword of titleKeywords) { 250 | const similarity = this.calculateStringSimilarity(ngram, keyword); 251 | if (similarity >= 0.6) { 252 | titleBoost += similarity * 3; 253 | } 254 | } 255 | 256 | ngramFreq[ngram] = baseFreq + titleBoost; 257 | } 258 | } 259 | } 260 | 261 | // Sort n-grams by frequency 262 | const sortedNgrams = Object.entries(ngramFreq) 263 | .sort(([, a], [, b]) => b - a) 264 | .slice(0, topCount * 4); 265 | 266 | // Group similar n-grams 267 | const groups = []; 268 | const used = new Set(); 269 | 270 | // Group n-grams by similarity 271 | for (const [ngram, freq] of sortedNgrams) { 272 | if (used.has(ngram)) continue; 273 | 274 | const group = { 275 | representative: ngram, 276 | frequency: freq, 277 | similar: [ngram] 278 | }; 279 | used.add(ngram); 280 | 281 | // Find similar n-grams 282 | for (const [otherNgram, otherFreq] of sortedNgrams) { 283 | if (used.has(otherNgram) || ngram === otherNgram) continue; 284 | 285 | if (this.calculateStringSimilarity(ngram, otherNgram) >= similarityThreshold) { 286 | group.similar.push(otherNgram); 287 | group.frequency += otherFreq; 288 | used.add(otherNgram); 289 | } 290 | } 291 | 292 | groups.push(group); 293 | } 294 | 295 | // Sort groups by combined frequency and return top results 296 | return groups 297 | .sort((a, b) => b.frequency - a.frequency) 298 | .slice(0, topCount); 299 | } 300 | 301 | calculateStringSimilarity(str1, str2) { 302 | const norm1 = str1.replace(/\s+/g, '').toLowerCase(); 303 | const norm2 = str2.replace(/\s+/g, '').toLowerCase(); 304 | 305 | if (norm1 === norm2) return 1.0; 306 | 307 | // Check for substring containment 308 | if (norm1.includes(norm2) || norm2.includes(norm1)) { 309 | const shorterLen = Math.min(norm1.length, norm2.length); 310 | const longerLen = Math.max(norm1.length, norm2.length); 311 | return shorterLen / longerLen; 312 | } 313 | 314 | // Check for word-level similarity 315 | const words1 = str1.toLowerCase().split(/\s+/); 316 | const words2 = str2.toLowerCase().split(/\s+/); 317 | const wordSet1 = new Set(words1); 318 | const wordSet2 = new Set(words2); 319 | const wordIntersection = new Set([...wordSet1].filter(x => wordSet2.has(x))); 320 | const wordUnion = new Set([...wordSet1, ...wordSet2]); 321 | const wordJaccard = wordIntersection.size / wordUnion.size; 322 | 323 | if (wordJaccard > 0.3) { 324 | return wordJaccard; 325 | } 326 | 327 | // Character-based similarity using LCS 328 | const minLength = Math.min(norm1.length, norm2.length); 329 | const maxLength = Math.max(norm1.length, norm2.length); 330 | 331 | if (minLength / maxLength < 0.6) { 332 | return 0; 333 | } 334 | 335 | const lcs = this.longestCommonSubsequence(norm1, norm2); 336 | const lcsSimilarity = (2.0 * lcs) / (norm1.length + norm2.length); 337 | 338 | return lcsSimilarity > 0.7 ? lcsSimilarity : 0; 339 | } 340 | 341 | longestCommonSubsequence(str1, str2) { 342 | const m = str1.length; 343 | const n = str2.length; 344 | const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); 345 | 346 | for (let i = 1; i <= m; i++) { 347 | for (let j = 1; j <= n; j++) { 348 | if (str1[i - 1] === str2[j - 1]) { 349 | dp[i][j] = dp[i - 1][j - 1] + 1; 350 | } else { 351 | dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); 352 | } 353 | } 354 | } 355 | 356 | return dp[m][n]; 357 | } 358 | 359 | extractKeywords(title, comments) { 360 | const titleTerms = this.cleanText(title).split(' '); 361 | 362 | const usedTerms = new Set(); 363 | const keywords = []; 364 | 365 | // Add all title keywords 366 | for (const titleTerm of titleTerms) { 367 | if (!usedTerms.has(titleTerm.toLowerCase())) { 368 | keywords.push(titleTerm); 369 | usedTerms.add(titleTerm.toLowerCase()); 370 | } 371 | } 372 | 373 | if (keywords.length <= 5) { 374 | const topNGrams = this.findTopNGrams(comments, 2, 10, 0.9, title); 375 | 376 | // Add one diverse n-gram 377 | let addedDiverseNgram = false; 378 | for (const group of topNGrams.slice(0, 3)) { 379 | if (keywords.length >= 8 || addedDiverseNgram) break; 380 | 381 | const rep = group.representative; 382 | const repWords = rep.split(' '); 383 | 384 | if (usedTerms.has(rep.toLowerCase())) continue; 385 | 386 | // Check if similar to title terms 387 | let isSimilarToTitle = false; 388 | for (const titleTerm of titleTerms) { 389 | const similarity = this.calculateStringSimilarity(rep, titleTerm); 390 | const containsTitle = rep.toLowerCase().includes(titleTerm.toLowerCase()) || 391 | repWords.some(word => word.toLowerCase() === titleTerm.toLowerCase()); 392 | 393 | if (similarity > 0.9 || containsTitle) { // Use consistent threshold 394 | isSimilarToTitle = true; 395 | break; 396 | } 397 | } 398 | 399 | if (!isSimilarToTitle) { 400 | const hasUsedWords = repWords.some(word => usedTerms.has(word.toLowerCase())); 401 | 402 | if (!hasUsedWords) { 403 | keywords.push(rep); 404 | usedTerms.add(rep.toLowerCase()); 405 | repWords.forEach(word => usedTerms.add(word.toLowerCase())); 406 | addedDiverseNgram = true; 407 | } 408 | } 409 | } 410 | } 411 | 412 | return keywords; 413 | } 414 | } 415 | 416 | function optimizeSearchQuery() { 417 | const textAnalyzer = new TextAnalyzer(); 418 | searchQuery.query = stripYearFromTitle(searchQuery.rawQuery); 419 | 420 | const title = document.querySelector('.fatitem .titleline > a').textContent; 421 | const topLevelComments = document.querySelectorAll('td.ind[indent="0"] + td + td .commtext'); 422 | let keywords = []; 423 | 424 | // Use comments only showing results for the current discussion 425 | if (searchQuery.rawQuery === title) { 426 | keywords = textAnalyzer.extractKeywords(searchQuery.query, topLevelComments); 427 | searchQuery.query = keywords.join(' '); 428 | } 429 | 430 | return searchQuery.query; 431 | } 432 | 433 | async function searchHackerNews() { 434 | searchQuery.query = optimizeSearchQuery(); 435 | const url = `https://hn.algolia.com/api/v1/search` 436 | + (searchQuery.type === 'verbatim' ? `?query=${encodeURIComponent(searchQuery.rawQuery)}` : `?similarQuery=${encodeURIComponent(searchQuery.query)}`) 437 | + `&tags=story` 438 | + `&hitsPerPage=${searchQuery.numOfResults}` 439 | + `&filters=NOT objectID:` + itemId // exclude current submission 440 | + `&numericFilters=created_at_i>${searchQuery.date.start},created_at_i<${searchQuery.date.end}` // filter by date 441 | + (searchQuery.hidePostswithLowComments ? `,num_comments>=${searchQuery.minComments}` : ``) // filter by minimum comments if enabled 442 | ; 443 | 444 | const response = await fetch(url).then(response => response.json()); 445 | return response; 446 | } 447 | 448 | // Get relative time from timestamp 449 | function timestampToRelativeTime(timestamp) { 450 | const now = new Date(); 451 | const date = new Date(timestamp); 452 | const diff = now - date; 453 | let rtf = new Intl.RelativeTimeFormat('en', { numeric: 'always' }); 454 | 455 | const units = { 456 | year: 365 * 24 * 60 * 60 * 1000, 457 | month: 30 * 24 * 60 * 60 * 1000, 458 | day: 24 * 60 * 60 * 1000, 459 | hour: 60 * 60 * 1000, 460 | minute: 60 * 1000 461 | }; 462 | 463 | for (const unit in units) { 464 | if (diff > units[unit]) { 465 | const time = Math.round(diff / units[unit]); 466 | return rtf.format(-time, unit); 467 | } 468 | } 469 | 470 | return rtf.format(-Math.round(diff / 1000), 'second'); 471 | } 472 | 473 | // i.e. "Title (2021)" -> "Title" 474 | function stripYearFromTitle(title) { 475 | return title.replace(/\s\(\d{4}\)$/, ''); 476 | } 477 | 478 | // Render dom element for a search result 479 | function displayResult(object) { 480 | const element = document.createElement('li'); 481 | element.className = 'result'; 482 | 483 | const titleContainer = document.createElement('span'); 484 | titleContainer.style.display = 'block'; 485 | titleContainer.classList.add('titleline'); 486 | const link = document.createElement('a'); 487 | link.href = object.url ? object.url : 'item?id=' + object.objectID; 488 | link.textContent = object.title; 489 | link.rel = 'no-referrer'; 490 | 491 | titleContainer.appendChild(link); 492 | 493 | if (object.url) { 494 | const domainContainer = document.createElement('span'); 495 | domainContainer.classList.add('sitebit', 'comhead'); 496 | const domain = document.createElement('a'); 497 | domain.href = 'from?site=' + (new URL(object.url)).hostname.replace('www.', ''); 498 | const domainChild = document.createElement('span'); 499 | domainChild.classList.add('sitestr'); 500 | domainChild.textContent = (new URL(object.url)).hostname.replace('www.', ''); 501 | 502 | domain.appendChild(domainChild); 503 | domainContainer.appendChild(domain); 504 | domain.insertAdjacentText('beforebegin', ' ('); 505 | domain.insertAdjacentText('afterend', ')'); 506 | titleContainer.appendChild(domainContainer); 507 | } 508 | element.appendChild(titleContainer); 509 | 510 | const description = document.createElement('span'); 511 | description.className = 'subtext'; 512 | const author = document.createElement('a'); 513 | author.href = 'user?id=' + object.author; 514 | author.textContent = object.author; 515 | const comments = document.createElement('a'); 516 | comments.href = 'item?id=' + object.objectID; 517 | comments.textContent = object.num_comments + ' comments'; 518 | description.insertAdjacentText('afterbegin', 519 | object.points + ' points ' 520 | + 'by ' 521 | ); 522 | description.appendChild(author); 523 | description.insertAdjacentText('beforeend', ' | '); 524 | 525 | const timeurl = document.createElement('a'); 526 | timeurl.href = 'item?id=' + object.objectID; 527 | timeurl.title = object.created_at; 528 | 529 | const time = document.createElement('time'); 530 | time.dateTime = object.created_at; 531 | time.textContent = timestampToRelativeTime(object.created_at); 532 | timeurl.appendChild(time); 533 | 534 | description.appendChild(timeurl); 535 | 536 | description.insertAdjacentText('beforeend', ' | '); 537 | description.appendChild(comments); 538 | element.appendChild(description); 539 | 540 | return element; 541 | } 542 | 543 | function updateDateRange() { 544 | const dateRange = document.getElementById('dateRangeDropdown').value; 545 | const startDate = document.getElementById('startDate').value; 546 | const endDate = document.getElementById('endDate').value; 547 | 548 | searchQuery.date.end = Math.floor(new Date().getTime() / 1000); 549 | 550 | switch (dateRange) { 551 | case 'Past week': 552 | searchQuery.date.start = searchQuery.date.end - 604800; 553 | break; 554 | case 'Past month': 555 | searchQuery.date.start = searchQuery.date.end - 2592000; 556 | break; 557 | case 'Past year': 558 | searchQuery.date.start = searchQuery.date.end - 31536000; 559 | break; 560 | case 'Custom': 561 | searchQuery.date.start = Math.floor(new Date(startDate).getTime() / 1000) || 0; 562 | searchQuery.date.end = Math.floor(new Date(endDate).getTime() / 1000) || searchQuery.date.end; 563 | break; 564 | default: 565 | searchQuery.date.start = 0; 566 | } 567 | } 568 | 569 | // Update sidebar content 570 | function updateResults() { 571 | document.getElementById('hnrelevant-results').innerHTML = ''; 572 | 573 | searchHackerNews().then((result) => { 574 | const list = document.createElement('ul'); 575 | list.id = 'hnrelevant-results-list'; 576 | 577 | // if no results, display a message 578 | if (result.hits.length === 0) { 579 | const element = document.createElement('li'); 580 | element.className = 'result'; 581 | element.style = 'padding: 5px 0; text-align: center; white-space: pre-line;'; 582 | element.textContent = searchQuery.type === 'verbatim' ? 'No matching results found.\r\nTry a different query or switch to \'Similar\' search.' : 'No results found. Try to customize the query.'; 583 | list.appendChild(element); 584 | } 585 | else { 586 | result.hits.forEach(hit => { 587 | const element = displayResult(hit); 588 | list.appendChild(element); 589 | }); 590 | } 591 | document.getElementById('hnrelevant-results').appendChild(list); 592 | }); 593 | } 594 | 595 | const style = ` 596 | #hnrelevant-controls-container, #hnrelevant-controls { 597 | display: flex; 598 | gap: 10px 599 | } 600 | 601 | #hnrelevant-controls-container { 602 | flex-direction: column; 603 | } 604 | 605 | #hnrelevant-controls { 606 | flex-direction: row; 607 | flex-wrap: wrap; 608 | align-items: center; 609 | } 610 | 611 | #query-customization-container { 612 | display: flex; 613 | flex-direction: row; 614 | align-items: center; 615 | margin: 5px 0; 616 | padding-right: 10px; 617 | } 618 | 619 | #queryCustomization { 620 | flex-grow: 1; 621 | } 622 | 623 | #hnrelevant-results-list { 624 | list-style: none; 625 | padding: 0; 626 | width: 100%; 627 | display: flex; 628 | flex-direction: column; 629 | } 630 | 631 | #hnrelevant-results-list .result { 632 | padding: 5px 0; 633 | min-width: 280px; 634 | } 635 | 636 | @media screen and (max-width: 1200px) { 637 | #hnrelevant-results { 638 | display: grid; 639 | } 640 | 641 | #hnrelevant-results-list { 642 | flex-direction: row; 643 | gap: 10px; 644 | overflow-x: auto; 645 | } 646 | 647 | #hnrelevant-results-list .result { 648 | display: flex; 649 | flex-direction: column; 650 | justify-content: space-between; 651 | border: 1px solid #d3d3d3; 652 | border-radius: 5px; 653 | padding: 5px; 654 | } 655 | } 656 | `; 657 | 658 | const relevantContent = ` 659 |

Relevant Submissions

660 |
661 |
662 | 663 | 664 |
665 |
666 | The results aren't good? 667 |

Try the following: 668 |

    669 |
  • Omit years and numbers
  • 670 |
  • Remove irrelevant words to avoid noise
  • 671 |
  • Scrap the title and use a custom query instead
  • 672 |
673 |

674 |
675 |
676 |
677 | 678 | 685 |
686 |
687 | 688 | 695 |
696 | 706 |
707 | Search type 708 |
709 | 710 | 711 | 712 |
713 | 714 | 715 | 716 |
717 |
718 |
719 |
720 |
721 | `; 722 | 723 | function updateData(key, value) { 724 | searchQuery[key] = value; 725 | 726 | if (searchQuery.mode === 'auto') { 727 | updateResults(); 728 | } 729 | } 730 | 731 | function installSection() { 732 | // Submissions and Comments share the same page URL 733 | // Abort if we are not on a submission page 734 | if (!document.querySelector('.fatitem .titleline')) { 735 | return; 736 | } 737 | 738 | const hnBody = document.querySelector('#hnmain > tbody'); 739 | let NavbarIndex = 0; 740 | const rows = hnBody.querySelectorAll("tr"); 741 | 742 | // handle special case if death banner is present 743 | if (rows[0].querySelector('td img[src="s.gif"]')) { 744 | rows[0].querySelector("td").setAttribute("colspan", "2"); 745 | NavbarIndex = 1; 746 | } 747 | 748 | // Since we add a new column to the table for the sidebar, we need to make navbar span all columns 749 | const hnNavBar = hnBody.children[NavbarIndex]; 750 | hnNavBar.children[0].setAttribute('colspan', '2'); 751 | 752 | const hnContent = hnBody.children[NavbarIndex + 2]; 753 | searchQuery.rawQuery = hnBody.querySelector('.fatitem .titleline > a').textContent; 754 | 755 | // Make sure all table data elements are aligned to the top 756 | // (they're centered vertically by default which causes problem when coupled with long sidebar) 757 | hnBody.querySelectorAll(':scope > tr > td').forEach(td => td.style.verticalAlign = 'top'); 758 | 759 | if (window.innerWidth < 1200) { 760 | const tr = document.createElement('tr'); 761 | const td = document.createElement('td'); 762 | td.innerHTML = relevantContent; 763 | td.style = 'padding-top: 1rem;'; 764 | tr.innerHTML = ''; 765 | tr.appendChild(td); 766 | const submissionMetadata = hnContent.querySelector('table.fatitem > tbody'); 767 | submissionMetadata.appendChild(tr); 768 | } else { 769 | const td = document.createElement('td'); 770 | td.style = 'min-width: 280px; width: 25%; vertical-align: baseline; padding-left: 10px;'; 771 | td.innerHTML = relevantContent; 772 | hnContent.appendChild(td); 773 | } 774 | 775 | // inject styles 776 | const styleElement = document.createElement('style'); 777 | styleElement.textContent = style; 778 | document.head.appendChild(styleElement); 779 | 780 | document.getElementById('queryCustomization').placeholder = searchQuery.rawQuery; 781 | document.getElementById('queryCustomization').value = searchQuery.rawQuery; 782 | document.getElementById('numOfResultsDropdown').value = searchQuery.numOfResults; 783 | 784 | document.getElementsByName('searchType').forEach((radio) => { 785 | radio.checked = radio.value === searchQuery.type; 786 | }); 787 | 788 | if (searchQuery.mode === 'auto') { 789 | updateResults(); 790 | } 791 | 792 | document.getElementById('numOfResultsDropdown').addEventListener('change', () => { 793 | updateData('numOfResults', document.getElementById('numOfResultsDropdown').value); 794 | }); 795 | document.getElementById('dateRangeDropdown').addEventListener('change', () => { 796 | updateDateRange(); 797 | updateData('date', searchQuery.date); 798 | }); 799 | document.getElementById('startDate').addEventListener('change', () => { 800 | updateDateRange(); 801 | updateData('date', searchQuery.date); 802 | }); 803 | document.getElementById('endDate').addEventListener('change', () => { 804 | updateDateRange(); 805 | updateData('date', searchQuery.date); 806 | }); 807 | 808 | document.getElementById('dateRangeDropdown').addEventListener('change', (event) => { 809 | if (event.target.value === 'Custom') { 810 | document.getElementById('dateRangeInputContainer').style = 'display: flex; flex-direction: row; gap: 5px;'; 811 | } else { 812 | document.getElementById('dateRangeInputContainer').style.display = 'none'; 813 | } 814 | }); 815 | 816 | document.getElementsByName('searchType').forEach((radio) => { 817 | radio.addEventListener('change', (event) => { 818 | updateData('type', event.target.value); 819 | }); 820 | }); 821 | 822 | document.getElementById('submitCustomization').addEventListener('click', () => { 823 | searchQuery.rawQuery = document.getElementById('queryCustomization').value; 824 | updateResults(); 825 | }); 826 | 827 | document.getElementById('queryCustomization').addEventListener('keyup', (event) => { 828 | if (event.key === 'Enter') { 829 | searchQuery.rawQuery = document.getElementById('queryCustomization').value; 830 | updateResults(); 831 | } 832 | }); 833 | } 834 | 835 | window.addEventListener('load', () => { 836 | 'use strict'; 837 | installSection(); 838 | }); -------------------------------------------------------------------------------- /scripts/third-party/browser-polyfill.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"browser-polyfill.min.js","mappings":"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","names":["globalThis","chrome","runtime","id","Error","browser","Object","getPrototypeOf","prototype","module","exports","wrapAPIs","extensionAPIs","apiMetadata","keys","length","DefaultWeakMap","WeakMap","constructor","createItem","items","get","key","has","set","isThenable","value","then","makeCallback","promise","metadata","callbackArgs","lastError","reject","message","singleCallbackArg","resolve","pluralizeArguments","numArgs","wrapAsyncFunction","name","target","args","minArgs","maxArgs","Promise","fallbackToNoCallback","cbError","console","warn","noCallback","wrapMethod","method","wrapper","Proxy","apply","targetMethod","thisObj","call","hasOwnProperty","Function","bind","wrapObject","wrappers","cache","create","proxyTarget","prop","defineProperty","configurable","enumerable","desc","Reflect","deleteProperty","wrapEvent","wrapperMap","addListener","listener","hasListener","removeListener","onRequestFinishedWrappers","req","wrappedReq","getContent","onMessageWrappers","sender","sendResponse","wrappedSendResponse","result","didCallSendResponse","sendResponsePromise","response","err","isResultThenable","sendPromisedResult","msg","error","__mozWebExtensionPolyfillReject__","catch","wrappedSendMessageCallback","reply","wrappedSendMessage","apiNamespaceObj","wrappedCb","push","sendMessage","staticWrappers","devtools","network","onRequestFinished","onMessage","onMessageExternal","tabs","settingMetadata","clear","privacy","services","websites"],"sources":["browser-polyfill.js"],"sourcesContent":["/* webextension-polyfill - v0.10.0 - Fri Aug 12 2022 19:42:44 */\n/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */\n/* vim: set sts=2 sw=2 et tw=80: */\n/* This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at http://mozilla.org/MPL/2.0/. */\n\"use strict\";\n\nif (!globalThis.chrome?.runtime?.id) {\n throw new Error(\"This script should only be loaded in a browser extension.\");\n}\n\nif (typeof globalThis.browser === \"undefined\" || Object.getPrototypeOf(globalThis.browser) !== Object.prototype) {\n const CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE = \"The message port closed before a response was received.\";\n\n // Wrapping the bulk of this polyfill in a one-time-use function is a minor\n // optimization for Firefox. Since Spidermonkey does not fully parse the\n // contents of a function until the first time it's called, and since it will\n // never actually need to be called, this allows the polyfill to be included\n // in Firefox nearly for free.\n const wrapAPIs = extensionAPIs => {\n // NOTE: apiMetadata is associated to the content of the api-metadata.json file\n // at build time by replacing the following \"include\" with the content of the\n // JSON file.\n const apiMetadata = {\n \"alarms\": {\n \"clear\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"clearAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"get\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"bookmarks\": {\n \"create\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"get\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getChildren\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getRecent\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getSubTree\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getTree\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"move\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeTree\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"search\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"update\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n }\n },\n \"browserAction\": {\n \"disable\": {\n \"minArgs\": 0,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"enable\": {\n \"minArgs\": 0,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"getBadgeBackgroundColor\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getBadgeText\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getPopup\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getTitle\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"openPopup\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"setBadgeBackgroundColor\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"setBadgeText\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"setIcon\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"setPopup\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"setTitle\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n }\n },\n \"browsingData\": {\n \"remove\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n },\n \"removeCache\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeCookies\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeDownloads\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeFormData\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeHistory\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeLocalStorage\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removePasswords\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removePluginData\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"settings\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"commands\": {\n \"getAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"contextMenus\": {\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"update\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n }\n },\n \"cookies\": {\n \"get\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getAll\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getAllCookieStores\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"set\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"devtools\": {\n \"inspectedWindow\": {\n \"eval\": {\n \"minArgs\": 1,\n \"maxArgs\": 2,\n \"singleCallbackArg\": false\n }\n },\n \"panels\": {\n \"create\": {\n \"minArgs\": 3,\n \"maxArgs\": 3,\n \"singleCallbackArg\": true\n },\n \"elements\": {\n \"createSidebarPane\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n }\n }\n },\n \"downloads\": {\n \"cancel\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"download\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"erase\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getFileIcon\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"open\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"pause\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeFile\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"resume\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"search\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"show\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n }\n },\n \"extension\": {\n \"isAllowedFileSchemeAccess\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"isAllowedIncognitoAccess\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"history\": {\n \"addUrl\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"deleteAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"deleteRange\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"deleteUrl\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getVisits\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"search\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"i18n\": {\n \"detectLanguage\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getAcceptLanguages\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"identity\": {\n \"launchWebAuthFlow\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"idle\": {\n \"queryState\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"management\": {\n \"get\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"getSelf\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"setEnabled\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n },\n \"uninstallSelf\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n }\n },\n \"notifications\": {\n \"clear\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"create\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"getAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"getPermissionLevel\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"update\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n }\n },\n \"pageAction\": {\n \"getPopup\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getTitle\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"hide\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"setIcon\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"setPopup\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"setTitle\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n },\n \"show\": {\n \"minArgs\": 1,\n \"maxArgs\": 1,\n \"fallbackToNoCallback\": true\n }\n },\n \"permissions\": {\n \"contains\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"request\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"runtime\": {\n \"getBackgroundPage\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"getPlatformInfo\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"openOptionsPage\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"requestUpdateCheck\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"sendMessage\": {\n \"minArgs\": 1,\n \"maxArgs\": 3\n },\n \"sendNativeMessage\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n },\n \"setUninstallURL\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"sessions\": {\n \"getDevices\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getRecentlyClosed\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"restore\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n }\n },\n \"storage\": {\n \"local\": {\n \"clear\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"get\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getBytesInUse\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"set\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"managed\": {\n \"get\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getBytesInUse\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n }\n },\n \"sync\": {\n \"clear\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"get\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getBytesInUse\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"set\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n }\n },\n \"tabs\": {\n \"captureVisibleTab\": {\n \"minArgs\": 0,\n \"maxArgs\": 2\n },\n \"create\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"detectLanguage\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"discard\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"duplicate\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"executeScript\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"get\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getCurrent\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n },\n \"getZoom\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getZoomSettings\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"goBack\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"goForward\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"highlight\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"insertCSS\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"move\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n },\n \"query\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"reload\": {\n \"minArgs\": 0,\n \"maxArgs\": 2\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"removeCSS\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"sendMessage\": {\n \"minArgs\": 2,\n \"maxArgs\": 3\n },\n \"setZoom\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"setZoomSettings\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"update\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n }\n },\n \"topSites\": {\n \"get\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"webNavigation\": {\n \"getAllFrames\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"getFrame\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n }\n },\n \"webRequest\": {\n \"handlerBehaviorChanged\": {\n \"minArgs\": 0,\n \"maxArgs\": 0\n }\n },\n \"windows\": {\n \"create\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"get\": {\n \"minArgs\": 1,\n \"maxArgs\": 2\n },\n \"getAll\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getCurrent\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"getLastFocused\": {\n \"minArgs\": 0,\n \"maxArgs\": 1\n },\n \"remove\": {\n \"minArgs\": 1,\n \"maxArgs\": 1\n },\n \"update\": {\n \"minArgs\": 2,\n \"maxArgs\": 2\n }\n }\n };\n\n if (Object.keys(apiMetadata).length === 0) {\n throw new Error(\"api-metadata.json has not been included in browser-polyfill\");\n }\n\n /**\n * A WeakMap subclass which creates and stores a value for any key which does\n * not exist when accessed, but behaves exactly as an ordinary WeakMap\n * otherwise.\n *\n * @param {function} createItem\n * A function which will be called in order to create the value for any\n * key which does not exist, the first time it is accessed. The\n * function receives, as its only argument, the key being created.\n */\n class DefaultWeakMap extends WeakMap {\n constructor(createItem, items = undefined) {\n super(items);\n this.createItem = createItem;\n }\n\n get(key) {\n if (!this.has(key)) {\n this.set(key, this.createItem(key));\n }\n\n return super.get(key);\n }\n }\n\n /**\n * Returns true if the given object is an object with a `then` method, and can\n * therefore be assumed to behave as a Promise.\n *\n * @param {*} value The value to test.\n * @returns {boolean} True if the value is thenable.\n */\n const isThenable = value => {\n return value && typeof value === \"object\" && typeof value.then === \"function\";\n };\n\n /**\n * Creates and returns a function which, when called, will resolve or reject\n * the given promise based on how it is called:\n *\n * - If, when called, `chrome.runtime.lastError` contains a non-null object,\n * the promise is rejected with that value.\n * - If the function is called with exactly one argument, the promise is\n * resolved to that value.\n * - Otherwise, the promise is resolved to an array containing all of the\n * function's arguments.\n *\n * @param {object} promise\n * An object containing the resolution and rejection functions of a\n * promise.\n * @param {function} promise.resolve\n * The promise's resolution function.\n * @param {function} promise.reject\n * The promise's rejection function.\n * @param {object} metadata\n * Metadata about the wrapped method which has created the callback.\n * @param {boolean} metadata.singleCallbackArg\n * Whether or not the promise is resolved with only the first\n * argument of the callback, alternatively an array of all the\n * callback arguments is resolved. By default, if the callback\n * function is invoked with only a single argument, that will be\n * resolved to the promise, while all arguments will be resolved as\n * an array if multiple are given.\n *\n * @returns {function}\n * The generated callback function.\n */\n const makeCallback = (promise, metadata) => {\n return (...callbackArgs) => {\n if (extensionAPIs.runtime.lastError) {\n promise.reject(new Error(extensionAPIs.runtime.lastError.message));\n } else if (metadata.singleCallbackArg ||\n (callbackArgs.length <= 1 && metadata.singleCallbackArg !== false)) {\n promise.resolve(callbackArgs[0]);\n } else {\n promise.resolve(callbackArgs);\n }\n };\n };\n\n const pluralizeArguments = (numArgs) => numArgs == 1 ? \"argument\" : \"arguments\";\n\n /**\n * Creates a wrapper function for a method with the given name and metadata.\n *\n * @param {string} name\n * The name of the method which is being wrapped.\n * @param {object} metadata\n * Metadata about the method being wrapped.\n * @param {integer} metadata.minArgs\n * The minimum number of arguments which must be passed to the\n * function. If called with fewer than this number of arguments, the\n * wrapper will raise an exception.\n * @param {integer} metadata.maxArgs\n * The maximum number of arguments which may be passed to the\n * function. If called with more than this number of arguments, the\n * wrapper will raise an exception.\n * @param {boolean} metadata.singleCallbackArg\n * Whether or not the promise is resolved with only the first\n * argument of the callback, alternatively an array of all the\n * callback arguments is resolved. By default, if the callback\n * function is invoked with only a single argument, that will be\n * resolved to the promise, while all arguments will be resolved as\n * an array if multiple are given.\n *\n * @returns {function(object, ...*)}\n * The generated wrapper function.\n */\n const wrapAsyncFunction = (name, metadata) => {\n return function asyncFunctionWrapper(target, ...args) {\n if (args.length < metadata.minArgs) {\n throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`);\n }\n\n if (args.length > metadata.maxArgs) {\n throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`);\n }\n\n return new Promise((resolve, reject) => {\n if (metadata.fallbackToNoCallback) {\n // This API method has currently no callback on Chrome, but it return a promise on Firefox,\n // and so the polyfill will try to call it with a callback first, and it will fallback\n // to not passing the callback if the first call fails.\n try {\n target[name](...args, makeCallback({resolve, reject}, metadata));\n } catch (cbError) {\n console.warn(`${name} API method doesn't seem to support the callback parameter, ` +\n \"falling back to call it without a callback: \", cbError);\n\n target[name](...args);\n\n // Update the API method metadata, so that the next API calls will not try to\n // use the unsupported callback anymore.\n metadata.fallbackToNoCallback = false;\n metadata.noCallback = true;\n\n resolve();\n }\n } else if (metadata.noCallback) {\n target[name](...args);\n resolve();\n } else {\n target[name](...args, makeCallback({resolve, reject}, metadata));\n }\n });\n };\n };\n\n /**\n * Wraps an existing method of the target object, so that calls to it are\n * intercepted by the given wrapper function. The wrapper function receives,\n * as its first argument, the original `target` object, followed by each of\n * the arguments passed to the original method.\n *\n * @param {object} target\n * The original target object that the wrapped method belongs to.\n * @param {function} method\n * The method being wrapped. This is used as the target of the Proxy\n * object which is created to wrap the method.\n * @param {function} wrapper\n * The wrapper function which is called in place of a direct invocation\n * of the wrapped method.\n *\n * @returns {Proxy}\n * A Proxy object for the given method, which invokes the given wrapper\n * method in its place.\n */\n const wrapMethod = (target, method, wrapper) => {\n return new Proxy(method, {\n apply(targetMethod, thisObj, args) {\n return wrapper.call(thisObj, target, ...args);\n },\n });\n };\n\n let hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty);\n\n /**\n * Wraps an object in a Proxy which intercepts and wraps certain methods\n * based on the given `wrappers` and `metadata` objects.\n *\n * @param {object} target\n * The target object to wrap.\n *\n * @param {object} [wrappers = {}]\n * An object tree containing wrapper functions for special cases. Any\n * function present in this object tree is called in place of the\n * method in the same location in the `target` object tree. These\n * wrapper methods are invoked as described in {@see wrapMethod}.\n *\n * @param {object} [metadata = {}]\n * An object tree containing metadata used to automatically generate\n * Promise-based wrapper functions for asynchronous. Any function in\n * the `target` object tree which has a corresponding metadata object\n * in the same location in the `metadata` tree is replaced with an\n * automatically-generated wrapper function, as described in\n * {@see wrapAsyncFunction}\n *\n * @returns {Proxy}\n */\n const wrapObject = (target, wrappers = {}, metadata = {}) => {\n let cache = Object.create(null);\n let handlers = {\n has(proxyTarget, prop) {\n return prop in target || prop in cache;\n },\n\n get(proxyTarget, prop, receiver) {\n if (prop in cache) {\n return cache[prop];\n }\n\n if (!(prop in target)) {\n return undefined;\n }\n\n let value = target[prop];\n\n if (typeof value === \"function\") {\n // This is a method on the underlying object. Check if we need to do\n // any wrapping.\n\n if (typeof wrappers[prop] === \"function\") {\n // We have a special-case wrapper for this method.\n value = wrapMethod(target, target[prop], wrappers[prop]);\n } else if (hasOwnProperty(metadata, prop)) {\n // This is an async method that we have metadata for. Create a\n // Promise wrapper for it.\n let wrapper = wrapAsyncFunction(prop, metadata[prop]);\n value = wrapMethod(target, target[prop], wrapper);\n } else {\n // This is a method that we don't know or care about. Return the\n // original method, bound to the underlying object.\n value = value.bind(target);\n }\n } else if (typeof value === \"object\" && value !== null &&\n (hasOwnProperty(wrappers, prop) ||\n hasOwnProperty(metadata, prop))) {\n // This is an object that we need to do some wrapping for the children\n // of. Create a sub-object wrapper for it with the appropriate child\n // metadata.\n value = wrapObject(value, wrappers[prop], metadata[prop]);\n } else if (hasOwnProperty(metadata, \"*\")) {\n // Wrap all properties in * namespace.\n value = wrapObject(value, wrappers[prop], metadata[\"*\"]);\n } else {\n // We don't need to do any wrapping for this property,\n // so just forward all access to the underlying object.\n Object.defineProperty(cache, prop, {\n configurable: true,\n enumerable: true,\n get() {\n return target[prop];\n },\n set(value) {\n target[prop] = value;\n },\n });\n\n return value;\n }\n\n cache[prop] = value;\n return value;\n },\n\n set(proxyTarget, prop, value, receiver) {\n if (prop in cache) {\n cache[prop] = value;\n } else {\n target[prop] = value;\n }\n return true;\n },\n\n defineProperty(proxyTarget, prop, desc) {\n return Reflect.defineProperty(cache, prop, desc);\n },\n\n deleteProperty(proxyTarget, prop) {\n return Reflect.deleteProperty(cache, prop);\n },\n };\n\n // Per contract of the Proxy API, the \"get\" proxy handler must return the\n // original value of the target if that value is declared read-only and\n // non-configurable. For this reason, we create an object with the\n // prototype set to `target` instead of using `target` directly.\n // Otherwise we cannot return a custom object for APIs that\n // are declared read-only and non-configurable, such as `chrome.devtools`.\n //\n // The proxy handlers themselves will still use the original `target`\n // instead of the `proxyTarget`, so that the methods and properties are\n // dereferenced via the original targets.\n let proxyTarget = Object.create(target);\n return new Proxy(proxyTarget, handlers);\n };\n\n /**\n * Creates a set of wrapper functions for an event object, which handles\n * wrapping of listener functions that those messages are passed.\n *\n * A single wrapper is created for each listener function, and stored in a\n * map. Subsequent calls to `addListener`, `hasListener`, or `removeListener`\n * retrieve the original wrapper, so that attempts to remove a\n * previously-added listener work as expected.\n *\n * @param {DefaultWeakMap} wrapperMap\n * A DefaultWeakMap object which will create the appropriate wrapper\n * for a given listener function when one does not exist, and retrieve\n * an existing one when it does.\n *\n * @returns {object}\n */\n const wrapEvent = wrapperMap => ({\n addListener(target, listener, ...args) {\n target.addListener(wrapperMap.get(listener), ...args);\n },\n\n hasListener(target, listener) {\n return target.hasListener(wrapperMap.get(listener));\n },\n\n removeListener(target, listener) {\n target.removeListener(wrapperMap.get(listener));\n },\n });\n\n const onRequestFinishedWrappers = new DefaultWeakMap(listener => {\n if (typeof listener !== \"function\") {\n return listener;\n }\n\n /**\n * Wraps an onRequestFinished listener function so that it will return a\n * `getContent()` property which returns a `Promise` rather than using a\n * callback API.\n *\n * @param {object} req\n * The HAR entry object representing the network request.\n */\n return function onRequestFinished(req) {\n const wrappedReq = wrapObject(req, {} /* wrappers */, {\n getContent: {\n minArgs: 0,\n maxArgs: 0,\n },\n });\n listener(wrappedReq);\n };\n });\n\n const onMessageWrappers = new DefaultWeakMap(listener => {\n if (typeof listener !== \"function\") {\n return listener;\n }\n\n /**\n * Wraps a message listener function so that it may send responses based on\n * its return value, rather than by returning a sentinel value and calling a\n * callback. If the listener function returns a Promise, the response is\n * sent when the promise either resolves or rejects.\n *\n * @param {*} message\n * The message sent by the other end of the channel.\n * @param {object} sender\n * Details about the sender of the message.\n * @param {function(*)} sendResponse\n * A callback which, when called with an arbitrary argument, sends\n * that value as a response.\n * @returns {boolean}\n * True if the wrapped listener returned a Promise, which will later\n * yield a response. False otherwise.\n */\n return function onMessage(message, sender, sendResponse) {\n let didCallSendResponse = false;\n\n let wrappedSendResponse;\n let sendResponsePromise = new Promise(resolve => {\n wrappedSendResponse = function(response) {\n didCallSendResponse = true;\n resolve(response);\n };\n });\n\n let result;\n try {\n result = listener(message, sender, wrappedSendResponse);\n } catch (err) {\n result = Promise.reject(err);\n }\n\n const isResultThenable = result !== true && isThenable(result);\n\n // If the listener didn't returned true or a Promise, or called\n // wrappedSendResponse synchronously, we can exit earlier\n // because there will be no response sent from this listener.\n if (result !== true && !isResultThenable && !didCallSendResponse) {\n return false;\n }\n\n // A small helper to send the message if the promise resolves\n // and an error if the promise rejects (a wrapped sendMessage has\n // to translate the message into a resolved promise or a rejected\n // promise).\n const sendPromisedResult = (promise) => {\n promise.then(msg => {\n // send the message value.\n sendResponse(msg);\n }, error => {\n // Send a JSON representation of the error if the rejected value\n // is an instance of error, or the object itself otherwise.\n let message;\n if (error && (error instanceof Error ||\n typeof error.message === \"string\")) {\n message = error.message;\n } else {\n message = \"An unexpected error occurred\";\n }\n\n sendResponse({\n __mozWebExtensionPolyfillReject__: true,\n message,\n });\n }).catch(err => {\n // Print an error on the console if unable to send the response.\n console.error(\"Failed to send onMessage rejected reply\", err);\n });\n };\n\n // If the listener returned a Promise, send the resolved value as a\n // result, otherwise wait the promise related to the wrappedSendResponse\n // callback to resolve and send it as a response.\n if (isResultThenable) {\n sendPromisedResult(result);\n } else {\n sendPromisedResult(sendResponsePromise);\n }\n\n // Let Chrome know that the listener is replying.\n return true;\n };\n });\n\n const wrappedSendMessageCallback = ({reject, resolve}, reply) => {\n if (extensionAPIs.runtime.lastError) {\n // Detect when none of the listeners replied to the sendMessage call and resolve\n // the promise to undefined as in Firefox.\n // See https://github.com/mozilla/webextension-polyfill/issues/130\n if (extensionAPIs.runtime.lastError.message === CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE) {\n resolve();\n } else {\n reject(new Error(extensionAPIs.runtime.lastError.message));\n }\n } else if (reply && reply.__mozWebExtensionPolyfillReject__) {\n // Convert back the JSON representation of the error into\n // an Error instance.\n reject(new Error(reply.message));\n } else {\n resolve(reply);\n }\n };\n\n const wrappedSendMessage = (name, metadata, apiNamespaceObj, ...args) => {\n if (args.length < metadata.minArgs) {\n throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`);\n }\n\n if (args.length > metadata.maxArgs) {\n throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`);\n }\n\n return new Promise((resolve, reject) => {\n const wrappedCb = wrappedSendMessageCallback.bind(null, {resolve, reject});\n args.push(wrappedCb);\n apiNamespaceObj.sendMessage(...args);\n });\n };\n\n const staticWrappers = {\n devtools: {\n network: {\n onRequestFinished: wrapEvent(onRequestFinishedWrappers),\n },\n },\n runtime: {\n onMessage: wrapEvent(onMessageWrappers),\n onMessageExternal: wrapEvent(onMessageWrappers),\n sendMessage: wrappedSendMessage.bind(null, \"sendMessage\", {minArgs: 1, maxArgs: 3}),\n },\n tabs: {\n sendMessage: wrappedSendMessage.bind(null, \"sendMessage\", {minArgs: 2, maxArgs: 3}),\n },\n };\n const settingMetadata = {\n clear: {minArgs: 1, maxArgs: 1},\n get: {minArgs: 1, maxArgs: 1},\n set: {minArgs: 1, maxArgs: 1},\n };\n apiMetadata.privacy = {\n network: {\"*\": settingMetadata},\n services: {\"*\": settingMetadata},\n websites: {\"*\": settingMetadata},\n };\n\n return wrapObject(extensionAPIs, staticWrappers, apiMetadata);\n };\n\n // The build process adds a UMD wrapper around this file, which makes the\n // `module` variable available.\n module.exports = wrapAPIs(chrome);\n} else {\n module.exports = globalThis.browser;\n}\n"]} --------------------------------------------------------------------------------